Ensure the security of your smart contracts

How to Schedule Automatic Smart Contract Execution in EOS

Author: MixBytes team
Introduction
Software development often involves tasks that should be performed at regular intervals. Unix systems, for instance, have a crond for these purposes.

Before we start talking about our solution, let's first answer why we need cron in the blockchain.

There are quite a few applications, here are some of them:

  • You are a Dapp developer and store data in tables that take some RAM space. Memory costs money, so the application needs to periodically clean up these tables.
  • You are developing a currency converter and your application needs to periodically receive data from oracles.
  • You are a crypto fund manager and from time to time you need to send some profit share to customers.

We often have to audit and write smart contracts, and we regularly encounter the problems described above. Unfortunately, EOS does not allow to create periodic tasks, that's why we tried to fill this gap by writing our cron smart contract (Cronos).
What is Cronos
Cronos is a cron smart contract for EOS that will help DApps to perform periodic tasks.

Of course, you can manually call the contract methods or write a script that will do it for you. However, it is much easier and safer to pay a small fee for periodic cronos calls and stop worrying. Thus, you get some kind of cron-as-a-service :)
Working with Cronos (Memory cleanup)
The service works as follows: the user sends the required amount of EOS tokens to the smart contract. The smart contract then creates tasks using the schedule function. During task execution, a certain amount of EOS tokens will be automatically deducted from the contract.

For example, if you want to clean up smart contract storage every 42 seconds, you can create such tasks using Cronos:
cleos push action cron schedule '["andrew", "mydapp", "cleanup", 42]' -p andrew
Cleanup goes successfully if a developer correctly implements the cleanup action of the contract. An entry with the task and its execution time are created in Cronos table.
Under the hood
Cronos is pretty simple. The account owner once calls run at a given interval (polling_interval). Then, during contract execution, a deferred transaction with the same polling_interval is created.

You can learn more about deferred transactions in the official documentation. Don't forget to look up the use cases.

Mind that in EOS transaction execution time is limited to 30 ms. Therefore, it is reasonable to set the limit for the number of processed records in a single transaction (rows_count).

Here is the repository code with comments:
ACTION run(uint32_t polling_interval, uint32_t rows_count) {
    // Make sure the transaction was created by the current contract
    require_auth(get_self());
    
    // Check whether execution should be stopped
    if (stop_execution.get())
        return;

    // Record processing
    scan_schedules(rows_count);
    // Call run again in polling_interval seconds
    create_transaction(_self, _self, "run", polling_interval, make_tuple(polling_interval, rows_count));
}

template<class ...TParams>
void create_transaction(name payer, name account, const string &action, uint32_t delay,
        const std::tuple<TParams...>& args) {
    // Create a deferred transaction with the required delay
    eosio::transaction t;
    t.actions.emplace_back(
            permission_level(_code, "active"_n),
            account,
            name(action),
            args);

    t.delay_sec = delay;
    
    // You will need a unique id for bug fixing
    auto sender_id = unique_id.get();
    t.send(sender_id, payer);
    unique_id.set(sender_id + 1, _code);
}
The logic of processing tasks in the table is quite simple. Cronos picks the task with the minimum execution time, ensures that it must be executed (i.e. the current time is less than or equal to the execution time), and creates a deferred transaction after making sure that the user has enough EOS tokens on the balance account. At the end of task processing, Cronos automatically recalculates the time of the next call.
// We get the unix-time of the next block creation  
time_point_sec current_time(now());

// We check whether it’s time to execute the task 
if (current_time >= item.next_run) {
    const name& account_from = item.from;

    //  Make sure the user has enough funds on his balance account
    if (get_balance(account_from) >= CALL_PRICE) {
        reduce_balance(account_from, CALL_PRICE);
        create_transaction(account_from, item.account, item.action, item.period, tuple<name>(account_from));
    

    // Refresh the runtime
    cron_table.modify(item, _self, [&](auto& row) {
        row.next_run = item.next_run + item.period;
    });
}
Pitfalls of deferred transactions
I must say that the deferred transaction approach has a significant drawback. For instance, if the current transaction fails for some reason, a deferred transaction will not be created. To avoid this, you need to be careful with bug fixing. Otherwise, the mydapp contract owner will have to call run action manually once again.
Next steps
Our team is going to add the functions that allow to set task rules similar to the original crond Unix ones.
References
Other posts