Let’s assume we have a lot of wallets on the Polygon network that belong to us and we would like to gather all the tokens(say, WETH) to one of them. In such a case we need a few simple steps to achieve this!
In general, our steps would be:
Preparation
Basic script structure
Loading private keys from a file
Initialising WETH contract
Getting token balance
Preparing method ABI
Estimating gas for transactions
Signing transactions
Sending transactions
Now it is time to install all the dependencies that will be used in our project.
Make sure you have nodejs and npm installed first. There are a huge amount of guides on the internet.
mkdir collect-erc20 && \
cd collect-erc20 && \
mkdir src \
npm init && \
npm i web3 dotenv
This stuff is pretty strait-forward, we just create a project and install their two libraries, that’s it, we are ready to rock!
To make sure import statements work properly, add the following property to package.json:
"type": "module"
In this section, we will create our script and put there some basic function calls to outline our script structure.
touch src/index.js && touch .env
After creating the files, we put the next structure inside our main and the only script src/index.js.
Note: functions are not implemented and their signatures are missing arguments. But we already can see what our main script flow is.
// Importing all external libraries that we will use.
import Web3 from "web3";
import dotenv from 'dotenv';
// Loading .env variables.
dotenv.config();
// Create web3 object using INFURA rpc.
// INFURA_POLYGON_MAINNET=https://polygon-mainnet.infura.io/v3/{your-infura-id} is a variable defined in .env file.
const web3 = new Web3(new Web3.providers.HttpProvider(process.env.INFURA_POLYGON_MAINNET));
// Here is our main flow in this function.
const run = async () => {
// Loading private keys from a file.
const privateKeys = await loadPrivateKeys();
// Executing actions for each wallet we collect tokens from.
for (let i = 0; i < privateKeys.length; i++) {
let privateKey = privateKeys[i];
let walletAddress = web3.eth.accounts.privateKeyToAccount(privateKey).address;
let wethContractObject = createWethContract(walletAddress);
let balance = await wethContractObject.methods.getBalance(walletAddress);
// Do not send tokens to the same address.
if (walletAddress === toAddress) {
continue;
}
// If balance is 0, then just skip this wallet.
if (balance <= 0) {
continue;
}
// Now we sign transaction and get raw transaction object, because we will use it later.
const rawTransaction = await signTransaction();
//And finally sending transaction to the mempool.
sendTransaction();
}
};
// Executing script.
run();
Having our main script flow in place, it is already much easier to implement functions that do not work so far. So let’s do it!
To read files with our private keys, we will need some more dependencies imported. However, there is nothing to install, since we just need something to read the file and this “something“ is fs module from nodejs. Go to the top of the file and add the following:
import * as fs from 'fs/promises';
We use the asynchronous version of the module to leverage neat async/await syntax.
And here is the function that we put somewhere above our main run function:
// Reading private keys from file.
const loadPrivateKeys = async () => {
// We add another .env config for filepath to our private key file since it may be different on different machines.
const privateKeysFilePath = process.env.PRIVATE_KEYS_FILE_PATH;
let privateKeys = [];
try {
privateKeys = await fs.readFile(privateKeysFilePath, "utf-8")
// We assume that every key is on it's own line, so we split file by new line symbol and remove last empty line using filter.
privateKeys = privateKeys.split('\n').filter(e => e);
} catch (e) {
// If we can't read private keys, exit.
process.exit();
}
return privateKeys;
};
In order to send some non-native tokens on EVM compatible chains, we need to interact with a smart contract which is responsible for its token-related operations. In particular, we are going to interact with WETH(wrapped Ether) smart contract on the Polygon chain(which is a layer-2 chain, operating over the Ethereum chain). We are mostly interested in transfer and balanceOf methods of the contract.
Now it is time to go to polygonscan and get yourself an ABI of the contract. We will just put it inside our index.js(as well as a couple more constants), but it is also might be a good idea to put it to .env file.
const wethContractABI = [{ "inputs": [{ "internalType": "address"......}]
// WETH contract address on polygon.
const wethContractAddress = "0x7ceB23fD6bC0adD59E62ac25578270cFf1b9f619";
// Address we send our tokens to.
const toAddress = "0x...123";
Let’s implement createWethContract, since we already have everything we need!
// If we don't tell our contract, what address we are sending tokens from, it
// will say that we should not send from zero address(even though we
// sign our transaction with private key related to this address.)
const createWethContract = (fromAddress) => {
return new web3.eth.Contract(
wethContractABI,
wethContractAddress,
{from: fromAddress}
)
}
This part has been already implemented in our basic script structure, so let’s go further.
const balance = await wethContractObject.methods.balanceOf(walletAddress);
Due to the fact that we use Infura to communicate with the blockchain, we can’t call our contract method directly with
myContract.methods.myMethod([param1[, param2[, ...]]]).send(options[, callback])
The reason is that Infura is a hosted node(by someone else, not us), so it would need our private key to sign transactions, which of course we can’t allow.
Usually, such kind of syntax is used by someone who runs a local node(see).
In our case, we need to involve data field when signing a transaction. It is not that complicated in the end.
This should be a part of signTransaction function, so let’s implement this function and create Tx object inside, which will use our encoded ABI as data.
const signTransaction = async () => {
const encodedTransferABI = wethContractObject.methods.transfer(toAddress, balance).encodeABI();
const Tx = {
to: wethContractAddress,
gas: estimatedGas,
value: "0x0",
// We set only tip for the miner, baseFeePerGas(which is burned) will be set automatically.
// In order to send legacy transaction(before London fork), you can use gas and gasPrice.
maxPriorityFeePerGas: await web3.eth.getGasPrice(),
data: encodedTransferABI,
};
};
Obviously, we have not finished with this function, so let’s move on!
Now let’s estimate gas, we need only toAddress field, which is WETH contract address and we need our encoded ABI.
const signTransaction = async () => {
const encodedTransferABI = wethContractObject.methods.transfer(toAddress, balance).encodeABI();
const estimatedGas = await wethContractObject.methods.transfer(toAddress, balance).estimateGas({
to: wethContractAddress,
data: encodedTransferABI,
});
const Tx = {
to: wethContractAddress,
gas: estimatedGas,
value: "0x0",
// We set only tip for the miner, baseFeePerGas(which is burned) will be set automatically.
// In order to send legacy transaction(before London fork), you can use gas and gasPrice.
maxPriorityFeePerGas: await web3.eth.getGasPrice(),
data: encodedTransferABI,
};
};
Finally, we can sign our transaction and return rawTransaction data in order to send it. But wait! There are some arguments missing in our signTransaction function. Let’s add them first!
const signTransaction = async (wethContractObject, fromPrivateKey, toAddress, balance) => {
And don’t forget to change it’s signature in our main flow(run function)!
const rawTransaction = signTransaction(privateKey, toAddress, balance);
And here you go, our function is ready:
const signTransaction = async (wethContractObject, fromPrivateKey, toAddress, balance) => {
const encodedTransferABI = wethContractObject.methods.transfer(toAddress, balance).encodeABI();
const estimatedGas = await wethContractObject.methods.transfer(mintAccounts[mintAccounts.length - 1], balance).estimateGas({
to: wethContractAddress,
data: encodedTransferABI,
});
const Tx = {
// We are sending request to WETH contract, asking it to transfer tokens.
to: wethContractAddress,
gas: estimatedGas,
// We send zero native token, which is Matic on Polygon network.
value: "0x0",
// We set only tip for the miner, baseFeePerGas(which is burned) will be set automatically.
// In order to send legacy transaction(before London fork), you can use gas and gasPrice.
maxPriorityFeePerGas: await web3.eth.getGasPrice(),
data: encodedTransferABI,
};
const signTransactionOutput = await web3.eth.accounts.signTransaction(
Tx,
fromPrivateKey
);
return signTransactionOutput.rawTransaction;
};
Wow! That’s the last step!! Congrats! And it will be very very simple:
const sendTransaction = (rawTransaction) => {
web3.eth.sendSignedTransaction(
rawTransaction
).once('transactionHash', (function (hash) { console.log(`Tx hash: ${hash}`) }))
.on('confirmation', function (confNumber, receipt) { console.log(`Confirmation: ${confNumber}`); })
.on('error', async function (error) {
console.log('Something went wrong...', error);
});
}
And we should change it’s call in run function to add an argument:
sendTransaction(rawTransaction);
The complete script with some improvements you may check out on GitHub: https://github.com/cy6erninja/collect-erc-20-tokens
This was not a complicated script, but some parts of it made me search google for hours, due to some hidden errors and misread documentation.
So I’m happy we’ve got here 😀 Hope this would save you some time! That’s all folks!