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 onExecuteJob(uint256 _index, address _owner, uint48 _executionNumber) external;
    function onCreateJob(uint256 _index, address _owner, bool _ignoreAppRevert, uint24 _executionWindow, bytes1 _executionModule, bytes calldata _executionModuleInput, bytes calldata _applicationInput) external;
    function onDeleteJob(uint256 _index, address _owner) external;
}

The application we wish to create in this example fulfils 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. This is important because the user gives our contract token permissions, so we want to make sure transfers only happen accordingly to the time restrictions defined in EES.

Storage layout and constructor

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 couple of things:

  1. The JobRegistry contract so we can verify the caller.

  2. A mapping from job index to a TransferData to keep track of the transfer specification for each job.

// 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");
    _;
} 

In the constructor we set the jobRegistry variable:

constructor(JobRegistry _jobRegistry) {
    jobRegistry = _jobRegistry;
}

Job creation

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

function onCreateJob(uint256 _index, address /* _owner */, bool /* _ignoreAppRevert */, uint24 /* _executionWindow */, bytes1 /* _executionModule */, bytes calldata /* _executionModuleInput */, bytes calldata _applicationInput)
    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 _applicationInput 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. Notice that we only use the _index and _applicationInput arguments for this example. Let's quickly go over a few examples where these might be relevant: _owner could be relevant if wanted to do something specific to the creator of the job or simply emit an event that a recurring payment has been created from _owner to the recipient.

_ignoreAppRevert could be checked if we want to make sure that the recurring payment is canceled if it doesn't go through, e.g. by lack of funds.

_executionWindow could be relevant in this case if we want to make sure that payments fall within a certain time frame after they're due.

_executionModule can be used to restrict which execution modules we allow for this application, for example regular time intervals.

_executionModuleInput can be used to get information about the job's input to the execution module. For example if we only allow payments in a 30 day interval, we could enforce this by checking the cooldown parameter of the decoded _executionModuleInput in the case of RegularTimeInterval.

Job deletion

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.

Job execution

Finally, let us implement the core logic that is executed upon the call to the onExecuteJob callback function:

function onExecuteJob(uint256 _index, address _owner, uint48 /* _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.

The full contract

Putting it all together, we get the contract:

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

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, address /* _owner */, bool /* _ignoreAppRevert */, uint24 /* _executionWindow */, bytes1 /* _executionModule */, bytes calldata /* _executionModuleInput */, bytes calldata _applicationInput)
        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, uint48 /* _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 EES protocol 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.

Careful: 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