Create an application

In this section we will go through how to create an application on EES using a simple example of a contract for automated ERC-20 transfers. Along the way we will explore best practises and design patterns. However, we will not focus on gas optimization.

First, a reminder of the interface we have to implement:

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
interface IApplication {
    function onCreateJob(uint256 _index, bytes1 _executionModule, address _owner, bytes calldata _inputs) external;
    function onDeleteJob(uint256 _index, address _owner) external;
    function onExecuteJob(uint256 _index, address _owner) external;
}

The application we wish to create in this example should fulfill the following specification:

  • onExecute: transfers a specified amount of an ERC-20 token from the job owner to a recipient. Specifications are stored within the contract.

  • onCreateJob: store state data for job index with given specification.

  • onDeleteJob: deletes stored state data for job index.

  • The IApplication callback functions should only be callable by the JobRegistry contract.

The data we need to store for each job are the recipient of the transfer, the amount and the ERC-20 token to be transferred. Note that the owner of the job is given in all the callback functions, removing the necessity to store it. We will also use solmate's SafeTransferLib library for safe ERC-20 token transfers.

using SafeTransferLib for ERC20;
struct TransferData {
    address recipient;
    uint256 amount;
    address token;
}

In our contract, we are going to store a few things:

// JobRegistry contract
JobRegistry public immutable jobRegistry;
// job index to data object
mapping(uint256 => TransferData) public transferDataMapping;

We will also create a modifier, restricting the caller of the implemented callback functions to the address of JobRegistry.

modifier onlyJobRegistry() {
    require(msg.sender == address(jobRegistry), "NotJobRegistry");
    _;
}

First, we store the jobRegistry address so we can make sure that external calls only come from this address. We also store a mapping from job indices to their corresponding TransferData object.

In the constructor we set the jobRegistry variable:

constructor(JobRegistry _jobRegistry) {
    jobRegistry = _jobRegistry;
}

Now we can start implementing the first callback function, onCreateJob:

function onCreateJob(uint256 _index, bytes1 _executionModule, address _owner, bytes calldata _inputs)
    external
    override
    onlyJobRegistry
{
    (address recipient, uint256 amount, address token) = abi.decode(_inputs, (address, uint256, address));
    TransferData memory transferData = TransferData({recipient: recipient, amount: amount, token: token});
    transferDataMapping[_index] = transferData;
}

The first line checks if the given execution module is supported and reverts if not. Then, we unpack the encoded _inputs bytes to the recipient, amount and token values which are stored in a new TransferData object. Finally, we add the transferData object in the transferDataMapping at _index.

Now, let us implement the onDeleteJob callback function:

function onDeleteJob(uint256 _index, address _owner) 
    external 
    override 
    onlyJobRegistry 
{
    delete transferDataMapping[_index];
}

This function doesn't do anything fancy, we simply delete the TransferData object corresponding to the index of the deleted job from transferDataMapping. Finally, let us implement the core logic that is executed upon the call to the onExecuteJob callback function:

function onExecuteJob(uint256 _index, address _owner, uint96 _executionNumber) 
    external 
    override 
    onlyJobRegistry 
{
    TransferData memory transferData = transferDataMapping[_index];
    ERC20(transferData.token).safeTransferFrom(_owner, transferData.recipient, transferData.amount);
}

In here, we are simply doing an ERC-20 token transfer from the owner of the job to the recipient with the specified amount and token saved in transferData. We are not using the _executionNumber argument for anything in this example as we wish to perform the same ERC-20 transfer no matter how many times the job has been executed.

Putting it all together, we get the contract:

// SPDX-License-Identifier: MIT
pragma solidity 0.8.17;

import {ERC20} from "solmate/src/tokens/ERC20.sol";
import {SafeTransferLib} from "solmate/src/utils/SafeTransferLib.sol";
import {IApplication} from "ees/interfaces/IApplication.sol";
import {JobRegistry} from "ees/core/JobRegistry.sol";

contract AutomatedTransfer is IApplication, Owned {
    using SafeTransferLib for ERC20;
    struct TransferData {
        address recipient;
        uint256 amount;
        address token;
    }

    JobRegistry public immutable jobRegistry;
    mapping(uint256 => TransferData) public transferDataMapping;

    modifier onlyJobRegistry() {
        require(msg.sender == address(jobRegistry), "NotJobRegistry");
        _;
    }

    constructor(JobRegistry _jobRegistry) Owned(msg.sender) {
        jobRegistry = _jobRegistry;
    }

    function onCreateJob(uint256 _index, bytes1 _executionModule, address _owner, bytes calldata _inputs)
        external
        override
        onlyJobRegistry
    {
        (address recipient, uint256 amount, address token) = abi.decode(_inputs, (address, uint256, address));
        TransferData memory transferData = TransferData({recipient: recipient, amount: amount, token: token});
        transferDataMapping[_index] = transferData;
    }

    function onDeleteJob(uint256 _index, address _owner) 
        external 
        override 
        onlyJobRegistry
    {
        delete transferDataMapping[_index];
    }

    function onExecuteJob(uint256 _index, address _owner, uint96 _executionNumber) 
        external 
        override 
        onlyJobRegistry
    {
        TransferData memory transferData = transferDataMapping[_index];
        ERC20(transferData.token).safeTransferFrom(_owner, transferData.recipient, transferData.amount);
    }
}

Now we have successfully created an application for automated transfer of ERC-20 tokens checking all the specifications we wanted. By supporting any execution module and fee module, users can make both single scheduled and recurring jobs performing ERC-20 transfers using their own preferred fee structure. The rest of the EES system will take care of the rest and make sure the jobs get executed. Because we keep the set of supported execution modules modifiable, we can progressively support new execution modules with time.

Warning: This contract is not tested and should not be used in production. Always perform extensive testing and auditing on smart contracts containing critical logic.

Last updated