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 theJobRegistry
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:
The
JobRegistry
contract so we can verify the caller.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