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

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 YOUR_PROJECT_ID with your actual Infura project ID.

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));
3 Likes

Thank you. But using this code consumes lot of requests. Is there a way to reduce the number of requests and get realtime transfers for only specific tokens?

2 Likes

There are some errors on some contracts.

I don’t know all the specific reason, but By using try-catch, wss subscription is continuous.

async function collectData(contract) {
  let decimals, symbol;
  try {
    [decimals, symbol] = await Promise.all([
      contract.methods.decimals().call(),
      contract.methods.symbol().call(),
    ]);
  } catch (e) {
    console.log(e);
  }

  return { decimals, symbol };
}

For example, makerDAO 's symbol is byte32 type, not string type of ERC20 symbols.

And It seems also Blur occur error too with other reason.

maybe some other contract is actually not fulfill ERC20 standard

2 Likes

Nice catch (pun intended)! Some ERC-20 tokens do indeed not fully comply with the ERC-20 standard, which can be a headache. I took a closer look, and it does appear that the MakerDAO token uses a byte value for its symbol, though I wasn’t quite able to see what would go wrong with Blur, at least from taking a look at their smart contract (it uses a string value).

Funnily enough, the symbol parameter is not actually required to comply with ERC-20. See the Ethereum EIP page for more info. This means that, perhaps unfortunately for us, token developers can really do whatever they want, such as not even including a symbol parameter at all, or using any type they want.

What you could do, is check whether there is a symbol function present at all on the contract. If so, you could have various checks to see whether it is a string (most commonly), a bytes array, or whatever else.

3 Likes

Would be amazing to have this written out in python @wtzb - Can’t seem to find a good tutorial for this based on web3.py.

1 Like

Great suggestion – stay tuned! :wink:

1 Like

Yes this is inefficient if required a filtered view.

For filter on a specific token or by specific from/to address, then the options can be set like the following:

let options = {
    address: tokenAddress, // Limit to specific token
    topics: [
        web3.utils.sha3('Transfer(address,address,uint256)'),
        web3.eth.abi.encodeParameter('address', senderAddress),
        web3.eth.abi.encodeParameter('address', receiverAddress)
    ]
}
let subscription = web3.eth.subscribe('logs', options);

This should reduce request consumption. You can even filter to multiple sender/receiver addresses by using an array of addresses instead of a single address. If you only need receiver address then you can also pass null in as the second topic to allow any sender.