Implementing a bot to automate contract actions

Implementing a bot to automate contract actions

One of the key principles of Ethereum Virtual Machine (EVM) contracts is that a contract cannot initiate an action on its own. A contract can send a transaction to another contract and a whole chain of commands can be executed after that. However, an external user still has to send a transaction to the original contract to start the chain (and pay the gas fee for the whole transaction).

Therefore, if you want to initiate an action on-chain and don't want to physically do it yourself, you have to write an off-chain program to do it for you.

Server-side Contract Actions

Interacting with the blockchain from the server-side is exactly like doing so from a frontend app, except you have to supply your own network provider. On the client side, you can supply one (for reading data and listening for events) or use the user's (required if you want them to be able to call contract functions and send transactions).

I've been using the ethers.js library for my latest frontend work. It has a cleaner API than web3.js and is supported with an integration into Hardhat. Hardhat is a nice environment for developing server side scripts that interact with your contracts. ethers.js provides a default provider, but it is rate-limited. You can get your own API keys from common node providers such as Infura and Alchemy and provide to ethers to use. If you provide multiple API keys, it can be configured to handle failover between them.

Additionally, you have to load a wallet in your script to call functions and send transactions from. It is important to use a dotenv file or store your account private key in a local environment variable and make sure it is not published anywhere. Hardhat's configuration file makes it easy to register signers (wallets) in the config file, or you can load them directly into the script from the environment variables with dotenv. Once we have the provider, we need to create contract instances with our target contracts' addresses and ABIs. I typically create one instance with just the provider, which are read-only, and then create secondary instances of the contract with the wallet signer to send transactions.

// Create ethers provider, signer, and contract instances
const provider = new ethers.providers.WebSocketProvider(process.env.POLYGON_WSS_URL);
const manager = new Wallet(process.env.PRIVATE_KEY_1, provider);
const raiderSlpVault = new ethers.Contract(raiderSlpVaultAddress, raiderSlpVaultAbi, provider);
const aurumSlpVault = new ethers.Contract(aurumSlpVaultAddress, aurumSlpVaultAbi, provider);
const raiderSlpVaultManager = raiderSlpVault.connect(manager);
const aurumSlpVaultManager = aurumSlpVault.connect(manager);

For my Raider Vault manager bot, the contract function call and transaction I need are:

  • 1compoundFrequency1 - view function (call) to return the current value of the public compoundFrequency variable. This represents the number of seconds to wait between calling the compound function.
  • compound - function that compounds the vault. The manager bot will call this on the interval dictated by the compoundFrequency variable.

Here is a simple snippet of calling the compound function on one of the vault contracts:

console.log(`Compounding RAIDER/WMATIC SLP Vault...`)
raiderCompoundTxn = await raiderSlpVaultManager.compound();
await raiderCompoundTxn.wait();
console.log(`Compound complete for RAIDER/WMATIC SLP Vault.`);

Listening to Events

Now that we have the ability to interact with a contract from our bot, we need to keep track of two things:

  1. When the contract was last compounded
  2. If the compoundFrequency changes to a new value

Luckily, we can write our contract to emit events when certain actions happen that can then be used for concurrence off-chain. My Raider Vault contract emits an event called Compounded(timestamp, amount) when a compound action succeeds successfully.

Here, we're going to listen each time the contract is Compounded and use that as the trigger to check whether the compoundFrequency has been changed. We could create an event to specify when the compoundFrequency variable is changed and just update it then, but we don't want to interrupt an active compound cycle. In this way, the variable can be updated, the current cycle can finish, and then a new one with the updated compoundFrequency can begin.

ethers.js lets you easily subscribe to events emitted by contracts with the .on method.

raiderSlpVault.on('Compounded', async () => {
    console.log('Compounded event observed for RAIDER/WMATIC SLP Vault.');
    newFrequency = await raiderSlpVaultManager.compoundFrequency();
    if (!newFrequency.eq(raiderSlpVaultFrequency)) {
        ...
    } 
});

Timing Actions with node-cron

Now, that we can keep track of the compound status and frequency, we need to translate that into specifying when our compound actions will occur. Most programmers first thought when scheduling a task is to use cron jobs. Cron jobs are great, but I'm not a great bash programmer and these jobs need to be able to be scheduled, stopped, and updated by my node.js module. Enter: node-cron. A lightweight library that implements cron jobs for node.

Cron jobs are scheduled by specifying a frequency in "crontab" format, which is a string representation of second, minute, hour, day, month, year. Without getting into too many details, we need to transform our compoundFrequency in seconds to a crontab string to schedule our jobs. We won't be able to exactly hit every frequency to the second, but we can get close.

const convertToCronTab = (totalSeconds, vaultNumber) => {
    // Provide vault number and use to specify seconds to avoid nonce errors with simultaneous transactions
    let seconds, minutes, hours, days;
    if (totalSeconds < 60) {
        seconds = totalSeconds;
        return `*/${seconds} * * * * *`;
    } else if (totalSeconds < 3600) {
        seconds = vaultNumber * 5;
        minutes = Math.floor(totalSeconds / 60);
        return `${seconds} */${minutes} * * * *`;
    } else if (totalSeconds < 86400) {
        seconds = vaultNumber * 5;
        hours = Math.floor(totalSeconds / 3600);
        return `${seconds} 0 */${hours} * * *`;
    } else {
        seconds = vaultNumber * 5;
        days = Math.floor(totalSeconds / 86400);
        return `${seconds} 0 0 */${days} * *`;
    }
};

Once we have our crontab format, scheduling the job is easy. We wrap the action we want to take at the specified interval in a callback function and provide to the cron.schedule function with the crontab.

raiderSlpVaultJob = cron.schedule(convertToCronTab(raiderSlpVaultFrequency, 1), raiderSlpVaultCallBack);

Also, when we detect a new compoundFrequency in our event listener, we need to stop the current job and create a new one.

raiderSlpVaultJob.stop();
raiderSlpVaultJob = cron.schedule(convertToCronTab(newFrequency, 1), raiderSlpVaultCallBack);

Wrapping into a node.js script to run and further considerations

In order to execute the asynchronous contract actions, the main bot routine needs to be wrapped in an async function and then called at the end of the script.

const main = aysnc () => {
    const provider = ethers.providers.WebSocketProvider(wss_url);
    ...
    const data = await contract.getData();
    const callbackAction = async () => {
        const txn = await contract.doSomething();
        await txn.wait();
    };

    contract.listen('Something Happened', () => {
        ...
    };
    cron.schedule(crontab, callbackAction);
};
main();

While many node scripts are meant to execute and finish, this bot needs to run consistently to be listening for events and send transactions to compound the vault at the right time. This is probably stating the obvious, but many templates, including some of the base Hardhat scripts, come with process.exit calls at the end of the script and we need to remove those. Leaving these off will allow the process to persist and run our bot code!

Congratulations, you are now the master of the machine and will survive humanity's doom by leveraging your power to control them. Just kidding, you'll probably be the first target, but it'll be cool in the near-term.

Once you have something like this built, you need to deploy it to production. I'm going to cover this in a future issue, but some key considerations are:

  • Ensuring auto-restart of the process
  • Monitoring whether compounds are succeeding/failing
  • Ensuring the infrastructure has redundancy and DR/COOP capabilities to avoid excessive downtime.

There are a lot of solutions for spinning up serverless functions and having cloud-based event hubs. I thought that would be more complicated than necessary for this use case and will likely leverage traditional cloud infrastructure, but that may be something to consider depending on how scalable your solution needs to be.