Creating an automated dApp

How to build your first automated dApp using Gelato

How to create an automated dApp using Gelato

Automation on Ethereum basically means that someone else is executing certain transactions on the user's behalf. This comes in very handy if a user wants to schedule a trade, a token transfer or a debt refinancing at some point in the future, for example before going on holidays.

In this tutorial, we will walk you through the process of scheduling a future transaction step by step and having the Gelato Executor Network execute on your user's behalf.

Tasks

On Gelato, a transaction that should be executed at some point in the future is called a Task. A Task holds information that explains the Gelato Executors: 1) when the transaction should be executed (Condition) 2) what kind of transaction should be executed (Action) 3) who should call this transaction (User Proxy)

Conditions

A Condition is a smart contract that follows the IGelatoCondition standard. It will be called by GelatoCore.sol to check whether the condition, which the user pre-specified, is actually met before the transaction can be executed.

To be used as a condition within the Gelato system, the smart contract requires an ok function, like so:

pragma solidity ^0.6.10;
import {GelatoConditionsStandard} from "../../GelatoConditionsStandard.sol";
contract ConditionTime is GelatoConditionsStandard {
function ok(uint256, bytes calldata _conditionData, uint256)
public
view
virtual
override
returns(string memory)
{
uint256 timestamp = abi.decode(_conditionData, (uint256));
return timeCheck(timestamp);
}
// Specific implementation
function timeCheck(uint256 _timestamp) public view virtual returns(string memory) {
if (_timestamp <= block.timestamp) return OK;
return "NotOkTimestampDidNotPass";
}
}

You can write your own Condition smart contract or re-use one of the existing ones already written by the community. In order to tell gelato which condition it should track on your user's behalf, you have to 1) provide the address of the condition and 2) provide the payload which contains some data (_conditionData) in the above example.

You can define a condition in javascript like so:

const ethers = require("ethers");
const GelatoCoreLib = require("@gelatonetwork/core");
const conditionAddress = "0x63129681c487d231aa9148e1e21837165f38deaf"
const conditionAbi = ["function timeCheck(uint256 _timestamp) view returns(string memory)"]
const iFace = new ethers.utils.Interface(conditionAbi)
const futureTimestamp = 1599800000
// #### Create the condition object
const condition = new GelatoCoreLib.Condition({
inst: conditionAddress,
data: iFace.encodeFunctionData("timeCheck", [
futureTimestamp
]),
})

In this example, the ok function on the condition smart contract will return "OK", if the current timestamp on Ethereum is equal or greater to futureTimestamp .

Actions

An Action in the Gelato system can potentially be any smart contract deployed on Ethereum, it does not necessarily have to follow a specific standard.

To be precise, there are 2 ways to use actions with Gelato. The first kind are actions that you will interact with via a .call. These are contracts like Uniswap Router2 or any ERC20 contract like DAI, where you want to call a function on directly. The second kind of actions are those you will interact with via a .delegatecall. These are more like scripts, which can batch together multiple transactions and have the advantage that data can be passed between them. An example of this kind of actions can be found here.

The folling example shows the same action, calling swapExactTokensForTokens on UniswapV2's Router2 contract, using a .call and using a .delegatecall. Delegatecalls into action scripts allow you greater freedom to add logic which should be conducted before and after the actual function call.

UniswapV2Router02.sol Example

contract UniswapV2Router02 {
...
function swapExactTokensForTokens(
uint amountIn,
uint amountOutMin,
address[] calldata path,
address to,
uint deadline
) external
virtual
override
ensure(deadline)
returns (uint[] memory amounts)
{
amounts = UniswapV2Library.getAmountsOut(factory, amountIn, path);
require(amounts[amounts.length - 1] >= amountOutMin, 'UniswapV2Router: INSUFFICIENT_OUTPUT_AMOUNT');
TransferHelper.safeTransferFrom(
path[0], msg.sender, UniswapV2Library.pairFor(factory, path[0], path[1]), amounts[0]
);
_swap(amounts, path, to);
}
}

Example of a .Call action which calls the function above:

const ethers = require("ethers");
const GelatoCoreLib = require("@gelatonetwork/core");
// Address of UniswapV2Router2
const actionAddress = "0x7a250d5630B4cF539739dF2C5dAcb4c659F2488D"
const actionAbi = [
"function swapExactTokensForTokens(
uint256 amountIn,
uint256 amountOutMin,
address[] calldata path,
address to,
uint256 deadline
) returns (uint256[] memory amounts)"
]
const iFace = new ethers.utils.Interface(actionAbi)
// #### Create the action object
const action = new GelatoCoreLib.Action({
addr: actionAddress,
data: iFace.encodeFunctionData("swapExactTokensForTokens", [
1000000000000000,
5000000000000000,
[
"0x6B175474E89094C44Da98b954EedeAC495271d0F",
"0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2"
],
"0x2464e6E2c963CC1810FAF7c2B3205819C93833f7",
1599800000
]),
operation: GelatoCoreLib.Operation.Call,
dataFlow: GelatoCoreLib.DataFlow.None,
value: 0,
termsOkCheck: false
})

ActionUniswapV2Trade.sol Example

contract ActionUniswapV2Trade {
...
function action(
address _sellToken,
uint256 _sellAmount,
address _buyToken,
uint256 _minBuyAmount,
address _receiver,
address _origin
)
public
virtual
delegatecallOnly("ActionKyberTrade.action")
{
address receiver = _receiver == address(0) ? address(this) : _receiver;
address buyToken = _buyToken;
// If sellToken == ETH, wrap ETH to WETH
// IF ETH, we assume the proxy already has ETH and we dont transferFrom it
if (_sellToken == ETH_ADDRESS) {
_sellToken = address(WETH);
WETH.deposit{value: _sellAmount}();
} else {
if (_origin != address(0) && _origin != address(this)) {
IERC20(_sellToken).safeTransferFrom(
_origin, address(this), _sellAmount, "ActionUniswapV2Trade.safeTransferFrom"
);
}
}
IERC20 sellToken = IERC20(_sellToken);
// Uniswap only knows WETH
if(_buyToken == ETH_ADDRESS) buyToken = address(WETH);
address[] memory tokenPath = getPaths(_sellToken, buyToken);
// UserProxy approves Uniswap Router
sellToken.safeIncreaseAllowance(
address(uniRouter), _sellAmount, "ActionUniswapV2Trade.safeIncreaseAllowance"
);
require(sellToken.allowance(address(this), address(uniRouter)) >= _sellAmount, "Invalid token allowance");
uint256 buyAmount;
try uniRouter.swapExactTokensForTokens(
_sellAmount,
_minBuyAmount,
tokenPath,
address(this),
now + 1
) returns (uint256[] memory buyAmounts) {
buyAmount = buyAmounts[1];
} catch {
revert("ActionUniswapV2Trade.action: trade with ERC20 Error");
}
// If sellToken == ETH, unwrap WETH to ETH
if (_buyToken == ETH_ADDRESS) {
WETH.withdraw(buyAmount);
if (receiver != address(this)) payable(receiver).sendValue(buyAmount);
} else if (receiver != address(this)) IERC20(_buyToken).safeTransfer(receiver, buyAmount, "ActionUniswapV2Trade.safeTransfer");
emit LogGelatoUniswapTrade(
_sellToken,
_sellAmount,
_buyToken,
_minBuyAmount,
buyAmount,
receiver,
_origin
);
}
}

Example of a .Delegatecall action which calls the function above:

const ethers = require("ethers");
const GelatoCoreLib = require("@gelatonetwork/core");
// Address of ActionUniswapV2Trade.sol
const actionAddress = "0x926Ef4Fe67B8d88d2cC2E109B6b7fae4A92cB1c1"
const actionAbi = [
"function action(
address _sellToken,
uint256 _sellAmount,
address _buyToken,
uint256 _minBuyAmount,
address _receiver,
address _origin
)"
]
const iFace = new ethers.utils.Interface(actionAbi)
// #### Create the action object
const action = new GelatoCoreLib.Action({
addr: actionAddress,
data: iFace.encodeFunctionData("action", [
"0x6B175474E89094C44Da98b954EedeAC495271d0F",
1000000000000000,
"0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2",
5000000000000000,
"0x2464e6E2c963CC1810FAF7c2B3205819C93833f7",
"0x0000000000000000000000000000000000000000"
]),
operation: GelatoCoreLib.Operation.Delegatecall,
dataFlow: GelatoCoreLib.DataFlow.None,
value: 0,
termsOkCheck: true
})

Note that an action object in javascript consists of the following field:

  1. addr: The address of the contract which should be called or delegatecalled

  2. data: The encoded data including function signature and payload

  3. operation: Call or Delegatecall

  4. dataFlow: Advanced feature for data passing between multiple action, only use for .delegatecalls!

  5. value: How much ETH should be sent along with the execution. Only works with payable functions

  6. termsOkCheck: Advanced feature for doing action payload checks for actions that follow the GelatoActionsStandard

Combining Conditions and Actions to a Task

Now we can combine the aforementioned condition with an action to create a task. A Task object can consist of multiple conditions, which will be checked sequentially before the execution happens and multiple actions, which will be executed synchronously. This allows for action contracts to be written very modular, passing data between each other and chaining them together.

On top of conditions and actions, a Task also has two more fields: 1. selfProviderGasLimit: How much gas limit the execution should be limited to 2. selfProviderGasPriceCeil: The maximum gas price under which the Task should be executed.

const bre = require("@nomiclabs/buidler");
const { ethers } = bre;
const GelatoCoreLib = require("@gelatonetwork/core");
const task = new GelatoCoreLib.Task({
conditions: [condition],
actions: [action],
selfProviderGasLimit: 0,
selfProviderGasPriceCeil: 0
})

Submitting a Task

GelatoProviders: Defining who will pay for the execution

On Gelato, users can pay for their own Tasks getting executed by pre-depositing some ETH on Gelato, which will be used to pay for the encountered execution costs of the Executor Network. We refer to this as being a Self-Provider. In the self-provider scenario, the gelatoProvider object will look like this:

const bre = require("@nomiclabs/buidler");
const { ethers } = bre;
const GelatoCoreLib = require("@gelatonetwork/core");
// Gelato User Proxy
const gelatoUserProxyAddress = "YOUR_PROXY_ADDRESS"
const providerModuleGelatoUserProxy = "0x4372692C2D28A8e5E15BC2B91aFb62f5f8812b93"
// Gelato provider object
const gelatoProvider = new GelatoCoreLib.GelatoProvider({
addr: gelatoUserProxyAddress,
module: providerModuleGelatoUserProxy
})

If another GelatoProvider pays for the Task, then the addr field should be changed accordingly.

Notice that the GelatoProvider object consists of two addresses: 1. addr: The address of the Proxy Smart Contract of the User (e.g. Gnosis Safe, Gelato Proxy or DS Proxy Address) 2. module: The address of a provider Module that tells Gelato what kind of proxy it is dealing with, e.g. the Provider Module for Gelato User Proxies, which tells Gelato the contract it will call is a Gelato User Proxy

However, you can also let someone else pay for the execution of your Task, namely another registered Provider on Gelato who deposited some ETH. Nevertheless, this External Provider will only pay for your Task, if he/she whitelisted a blueprint of the Task you specified beforehand. This blueprint is what we call a TaskSpec. Providers can whitelist Task Specs and enable other people to submit tasks with their address being in the addr field, enabling them to pay for their Users transactions on Gelato. This comes in handy for example if one of the action involved that some of the Tokens being transferred through it will be sent to the External Provider as a compensation. To whitelist a Task Spec as an External provider, simply call the `provideTaskSpecs function. Note: This is only necessary if you want other users to submit transactions which you will pay for, not if your users pay for their own transactions!

const bre = require("@nomiclabs/buidler");
const { ethers } = bre;
const GelatoCoreLib = require("@gelatonetwork/core");
gelatoCore = await ethers.getContractAt(
GelatoCoreLib.GelatoCore.abi,
bre.network.config.GelatoCore
);
conditions: ["0x63129681c487d231aa9148e1e21837165f38deaf"]
actions: [
new GelatoCoreLib.Action({
addr: actionAddress,
data: ethers.constants.HashZero,
operation: GelatoCoreLib.Operation.Delegatecall,
dataFlow: GelatoCoreLib.DataFlow.None,
value: 0,
termsOkCheck: true
})
]
const taskSpec = new GelatoCoreLib.TaskSpec({
conditions: conditions,
actions: actions,
gasPriceCeil: 0 // 0 for executing it no matter what the gas price
})
await gelatoCore.provideTaskSpecs([taskSpec])

Tasks can be submitted as one-time tasks or as repeating tasks

One time Tasks

These tasks are scheduled and executed only once. An example would be a limit order on Uniswap, where a user would specify a certain price, and if this price is equal to the current Uniswap price, then Gelato will execute a transaction on the user's behalf, once.

const bre = require("@nomiclabs/buidler");
const { ethers } = bre;
const GelatoCoreLib = require("@gelatonetwork/core");
const gelatoUserProxyAddress = "YOUR_PROXY_ADDRESS"
gelatoUserProxy = await ethers.getContractAt(
GelatoCoreLib.GelatoUserProxy.abi,
gelatoUserProxyAddress
);
// Submit one-time task
await gelatoUserProxy.submitTask(gelatoProvider, task, expiryData)

Repeating Tasks

Repeating tasks will get executed more than once, potentially forever (as long as the executor network is being paid to execute transactions). An example would be a trading strategy which buys ETH for DAI every day on Uniswap.

const bre = require("@nomiclabs/buidler");
const { ethers } = bre;
const GelatoCoreLib = require("@gelatonetwork/core");
const gelatoUserProxyAddress = "YOUR_PROXY_ADDRESS"
gelatoUserProxy = await ethers.getContractAt(
GelatoCoreLib.GelatoUserProxy.abi,
gelatoUserProxyAddress
);
// Submit reapting task
await gelatoUserProxy.submitTaskCycle(
gelatoProvider,
[task],
expiryData,
numOfCycle
)

In this case, we have the `numOfCycle variable, which indicates how often the task should be executed. Input "1" to only execute the task once, input "0" to execute it indefinitely and input e.g. "3" to execute it 3 times.

Tasks also have an expiry date, which is a timestamp indicating the time after which the Task will expire.

Before the Task gets executed, your Users have to complete a one-time Gelato Setup

After having submitted the Task, the Executor Network will constantly check whether the transaction can be executed and if so, they will execute it. However, this will only be done, if your users have completed a one-time setup on Gelato. This setup includes: 1. Whitelisting an Executor Network, e.g. the standard Gelato Executor Network 2. Whitelisting a Provider Module to tell Gelato what kind of Proxy the User is 3. Depositing some ETH on Gelato to have a balance from which executions can be paid from All of these steps can be done within a single transaction, using multiProvide()

const bre = require("@nomiclabs/buidler");
const { ethers } = bre;
const GelatoCoreLib = require("@gelatonetwork/core");
const executorNetwork = "0xd70D5fb9582cC3b5B79BBFAECbb7310fd0e3B582"
const gelatoUserProxyProviderModule = "0x4372692C2D28A8e5E15BC2B91aFb62f5f8812b93"
const ethToDeposit = ethers.utils.parseEther("3");
const gelatoUserProxyAddress = "YOUR_PROXY_ADDRESS"
const gelatoCoreAddress = "0x1d681d76ce96E4d70a88A00EBbcfc1E47808d0b8"
gelatoUserProxy = await ethers.getContractAt(
GelatoCoreLib.GelatoUserProxy.abi,
gelatoUserProxyAddress
);
const iFace = new ethers.utils.Interface(GelatoCoreLib.GelatoCore.abi)
// Encode Multiprovide function of GelatoCore.sol
const multiProvideData = iFace.encodeFunctionData("multiProvide", [executorNetwork, [], [gelatoUserProxyProviderModule]]);
const multiProvideAction = new GelatoCoreLib.Action({
addr: gelatoCoreAddress,
data: multiProvideData,
value: ethers.utils.parseEther("1"),
operation: Operation.Call,
dataFlow: GelatoCoreLib.DataFlow.None,
termsOkCheck: false
});
await gelatoUserProxy.execAction(multiProvideAction, {
value: utils.parseEther("1"),
});

Note: All these transactions, such as submitting Tasks or providing ETH on Gelato have to be done via the Users Proxy Smart Contracts (e.g. Gnosis Safe, GelatoUSerProxy, DsProxy), not by their EOA (e.g. Metamask).