Web3.js: How to Track ERC-20 Token Transfers (+ Specific Address/Token)
ERC-20 tokens have become an essential part of the Ethereum ecosystem; if you’ve interacted with DeFi before, you’ve almost definitely interacted with ERC-20 tokens.
How can we track these programmatically with hundreds of token transactions happening every minute? By utilizing web3.js and adding some ABI and event tracking magic, we’re able to!
If you’d like to learn more about tracking NFTs instead, read more about Tracking NFT (ERC-721/1155) Transfers. Additionally, you can also read more about Retrieving the Balance of an ERC-20 Token.
Setting Up Our Project
This tutorial makes use of Web3.js v1.x.x. Not all functionality might work with Web3.js v4.
Please create a new folder in which we can work on our project, then install web3js using npm
npm install web3
Ensure you have your Infura account set up and have access to your endpoint URL. Feel free to read more about getting started with Infura.
At the top of our new Javascript file, we can add the following to import the web3js library that we just installed and connect to the Infura websockets endpoint:
const Web3 = require("web3");
const web3 = new Web3("wss://mainnet.infura.io/ws/v3/<YOUR_PROJECT_ID>");
Make sure to replace API_KEY with your actual Infura API KEY.
Reading ERC-20 Events
Almost every fungible token on the Ethereum blockchain uses the ERC-20 token spec, which defines a set of required events and event emitters so interfaces that interact with the Ethereum chain can treat every token the same.
Subscribing to Contract Events
By using the web3.eth.subscribe function in web3.js; we can subscribe to events that these token contracts emit, allowing us to track every new token transfer as they occur.
Whenever an ERC-20 token transfer executes, the following event emits:
Transfer (address from, address to, uint256 value)
To tell web3.eth.subscribe which events we should track, we can add the following filter:
let options = {
topics: [web3.utils.sha3("Transfer(address,address,uint256)")],
};
Then, initiate the subscription by passing along the filter we’ve just set:
let subscription = web3.eth.subscribe('logs', options);
Additionally, we can add the following lines to see whether the subscription started successfully or if any errors occurred:
subscription.on("error", (err) => {
throw err;
});
subscription.on("connected", (nr) =>
console.log("Subscription on ERC-20 started with ID %s", nr),
);
A Quick Word on Topics and Data
Whenever a smart contract emits an event, its log record will consist of topics and data. Topics contain the event parameters (such as address from, address to, uint256 value), while the data include the actual values (such as the recipient address or the transferred value).
If you’d like a more in-depth overview of how event logs work, take a look at Understanding event logs on the Ethereum blockchain.
Reading ERC-20 Transfers
We can set the listener for the subscription we just created:
subscription.on('data', event => {
if (event.topics.length == 3) {
...
}
});
To verify that the Transfer event we catch is an ERC-20 transfer, we put a check to see whether the length of the topics array equals 3. We do this because ERC-721 events also emit a Transfer event but contain four items instead.
If you’d like to add more certainty to ensure that the event originates from an ERC-20 contract, feel free to look at ERC-165.
As we cannot read the event topics on their own, we have to decode them using the ERC-20 ABI:
let transaction = web3.eth.abi.decodeLog(
[
{
type: "address",
name: "from",
indexed: true,
},
{
type: "address",
name: "to",
indexed: true,
},
{
type: "uint256",
name: "value",
indexed: false,
},
],
event.data,
[event.topics[1], event.topics[2], event.topics[3]],
);
We’ll then be able to retrieve the sender address (from), receiving address (to), and the number of tokens transferred (value, though yet to be converted, see further) from the transaction object.
Reading Contract Data
Even though we retrieve a value from the contract, this is not the actual number of tokens transferred. ERC-20 tokens contain a decimal value, which indicates the number of decimals a token should have.
For example, Ether has 18 decimals. Other tokens might also have 18 decimals, but this value could be lower, so we cannot assume this is always 18. We can directly call the decimals method of the smart contract to retrieve the decimal value, after which we can calculate the correct number of tokens sent.
Outside the subscription.on() listener, let’s define a new method that will allow us to collect more information from the smart contract:
async function collectData(contract) {
const [decimals, symbol] = await Promise.all([
contract.methods.decimals().call(),
contract.methods.symbol().call(),
]);
return { decimals, symbol };
}
As we’re already requesting the decimals value from the contract, we can also request the symbol value so we can display the ticker of the token.
Then, inside the listener, let’s call the collectData function every time a new ERC-20 transaction is found. Additionally, we also calculate the correct decimal value:
subscription.on('data', event => {
if (event.topics.length == 3) {
let transaction = web3.eth.abi.decodeLog([{...}])
const contract = new web3.eth.Contract(abi, event.address)
collectData(contract).then(contractData => {
const unit = Object.keys(web3.utils.unitMap).find(key => web3.utils.unitMap[key] === web3.utils.toBN(10).pow(web3.utils.toBN(contractData.decimals)).toString());
console.log(`Transfer of ${web3.utils.fromWei(transaction.value, unit)} ${contractData.symbol} from ${transaction.from} to ${transaction.to}`)
})
}
});
Upon running the script, you’ll notice the following appear in your terminal:
Transfer of 100.010001 USDC from 0x048917c72734B97dB03a92b9e37649BB6a9C89a6 to 0x157DA967D621cF7A086ed2A90eD6C4F42e8d551a
Transfer of 184.583283 USDT from 0x651B28f41A70742eF74Adc8BB24Ce450c0D3Ef21 to 0xF9977FCe2A0CE0eaBd51B0251b9d67E304A3991c
Transfer of 1.5 MILADY from 0x33d5CC43deBE407d20dD360F4853385135f97E9d to 0x15A8E38942F9e353BEc8812763fb3C104c89eCf4
Transfer of 1.255882219500739994 WETH from 0x15A8E38942F9e353BEc8812763fb3C104c89eCf4 to 0x33d5CC43deBE407d20dD360F4853385135f97E9d
Transfer of 1435 USDT from 0x651B28f41A70742eF74Adc8BB24Ce450c0D3Ef21 to 0x5336dEC72db2662F8bB4f3f2905cAA76aa1D3f15
Transfer of 69.41745 USDT from 0x16147b424423b6fae48161a27962CAFED51fD5B8 to 0x0d4a11d5EEaaC28EC3F61d100daF4d40471f1852
Tracking a Specific Address
It ’s possible to track a specific sender address by reading the from value of the decoded transaction object. If you wish, you can add the following to the listener we’ve just created to do so:
if (transaction.from == "0x495f947276749ce646f68ac8c248420045cb7b5e") {
console.log("Specified address sent an ERC-20 token!");
}
Additionally, it’s also possible to track a specific recipient address receiving any tokens by tracking the transaction.to value:
if (transaction.to == "0x495f947276749ce646f68ac8c248420045cb7b5e") {
console.log("Specified address received an ERC-20 token!");
}
Tracking a Specific Token
In case you’d like to track a specific address sending a specific ERC-20 token, you can check for both transaction.from (the token sender) and event.address (the ERC-20 smart contract):
if (
transaction.from == "0xBC4CA0EdA7647A8aB7C2061c2E118A18a936f13D" &&
event.address == "0x6b175474e89094c44da98b954eedeac495271d0f"
) {
console.log("Specified address transferred specified token!");
} // event.address contains the contract address
Additionally, it’s also possible to track any transactions for a specific ERC-20 token, regardless of sender/recipient:
if (event.address == "0x6b175474e89094c44da98b954eedeac495271d0f") {
console.log("Specified ERC-20 transfer!");
}
Complete code overview
const Web3 = require("web3");
const web3 = new Web3("wss://mainnet.infura.io/ws/v3/<YOUR_PROJECT_ID>");
let options = {
topics: [web3.utils.sha3("Transfer(address,address,uint256)")],
};
const abi = [
{
constant: true,
inputs: [],
name: "symbol",
outputs: [
{
name: "",
type: "string",
},
],
payable: false,
stateMutability: "view",
type: "function",
},
{
constant: true,
inputs: [],
name: "decimals",
outputs: [
{
name: "",
type: "uint8",
},
],
payable: false,
stateMutability: "view",
type: "function",
},
];
let subscription = web3.eth.subscribe("logs", options);
async function collectData(contract) {
const [decimals, symbol] = await Promise.all([
contract.methods.decimals().call(),
contract.methods.symbol().call(),
]);
return { decimals, symbol };
}
subscription.on("data", (event) => {
if (event.topics.length == 3) {
let transaction = web3.eth.abi.decodeLog(
[
{
type: "address",
name: "from",
indexed: true,
},
{
type: "address",
name: "to",
indexed: true,
},
{
type: "uint256",
name: "value",
indexed: false,
},
],
event.data,
[event.topics[1], event.topics[2], event.topics[3]],
);
const contract = new web3.eth.Contract(abi, event.address);
collectData(contract).then((contractData) => {
const unit = Object.keys(web3.utils.unitMap).find(
(key) =>
web3.utils.unitMap[key] ===
web3.utils
.toBN(10)
.pow(web3.utils.toBN(contractData.decimals))
.toString(),
);
console.log(
`Transfer of ${web3.utils.fromWei(transaction.value, unit)} ${
contractData.symbol
} from ${transaction.from} to ${transaction.to}`,
);
if (transaction.from == "0x495f947276749ce646f68ac8c248420045cb7b5e") {
console.log("Specified address sent an ERC-20 token!");
}
if (transaction.to == "0x495f947276749ce646f68ac8c248420045cb7b5e") {
console.log("Specified address received an ERC-20 token!");
}
if (
transaction.from == "0xBC4CA0EdA7647A8aB7C2061c2E118A18a936f13D" &&
event.address == "0x6b175474e89094c44da98b954eedeac495271d0f"
) {
console.log("Specified address transferred specified token!");
} // event.address contains the contract address
if (event.address == "0x6b175474e89094c44da98b954eedeac495271d0f") {
console.log("Specified ERC-20 transfer!");
}
});
}
});
subscription.on("error", (err) => {
throw err;
});
subscription.on("connected", (nr) =>
console.log("Subscription on ERC-20 started with ID %s", nr),
);