Skip to main content

Periodic Automation of Contract Calls

In this tutorial, you'll learn how to automate periodic function calls to smart contracts.

Prerequisites

You'll need:

  • A Metamask wallet with an active account
  • Some Arbitrum One ETH
  • USDC tokens on Arbitrum One for paying serverless fees
  • Node.js installed

Installation

Ignore following steps if you have already completed the Web3 Request tutorial.

Begin by cloning the Oyster Serverless development tools repository and navigating to the repository directory:

git clone https://github.com/marlinprotocol/oyster-serverless-devtools.git
cd oyster-serverless-devtools/user_contract_builder

Initialize and install the npm dependencies:

npm install

Run the following command to verify your Hardhat installation:

npx hardhat

Building your smart contract

Create a new Solidity file called contracts/AutoTxn.sol with the following code:

// SPDX-License-Identifier: SEE LICENSE IN LICENSE
pragma solidity ^0.8.0;

import "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import "@openzeppelin/contracts/access/Ownable.sol";
import "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol";

contract AutoTxn is Ownable {
using SafeERC20 for IERC20;

address public relaySubscriptionsAddress;

/// @notice refers to USDC token
IERC20 public token;

bytes32 public immutable CODE_HASH;

uint256 public subscriptionId;

struct Task {
bytes callArgs;
address callee;
}

mapping (uint256 => Task) public taskList;

event AutoTxnOwnerEthWithdrawal();
event AutoTxnCreated(uint256 _subscriptionId);
event AutoTxnCalled(uint256 _subscriptionId, bool callbackSuccess);
event AutoTxnCreateFailed();
event AutoTxnOwnerUsdcWithdrawal();

error AutoTxnInvalidCallback();
error AutoTxnEthWithdrawalFailed();

constructor(
address _relaySubscriptionsAddress,
address _token,
address _owner,
bytes32 _codeHash
) Ownable(_owner) {
relaySubscriptionsAddress = _relaySubscriptionsAddress;
token = IERC20(_token);
CODE_HASH = _codeHash;

}

struct JobSubscriptionParams {
uint8 env;
uint256 startTime;
uint256 maxGasPrice;
uint256 usdcDeposit;
uint256 callbackGasLimit;
address callbackContract;
bytes32 codehash;
bytes codeInputs;
uint256 periodicGap;
uint256 terminationTimestamp;
uint256 userTimeout;
address refundAccount;
}

function create(
uint256 _interval,
uint256 _endTime,
address _callee,
bytes memory _callArgs
) external {
uint256 _totalRun = ((_endTime - block.timestamp) / _interval) + 1;
uint256 _usdcDeposit = _totalRun * (100 + 2 * 1001);
token.safeIncreaseAllowance(relaySubscriptionsAddress, _usdcDeposit);
uint256 _maxGasPrice = 2 * tx.gasprice;
uint256 _callbackDeposit = _totalRun * _maxGasPrice * (154530 + 70000);
JobSubscriptionParams memory _jobSubsParams = JobSubscriptionParams(
{
env: 1,
startTime: block.timestamp,
maxGasPrice: _maxGasPrice,
usdcDeposit: _usdcDeposit,
callbackGasLimit: 70000,
callbackContract: address(this),
codehash: CODE_HASH,
codeInputs: '',
periodicGap: _interval,
terminationTimestamp: _endTime,
userTimeout: 1001,
refundAccount: _msgSender()
}
);
(bool success, bytes memory data) = relaySubscriptionsAddress.call{value: _callbackDeposit}(
abi.encodeWithSignature(
"startJobSubscription((uint8,uint256,uint256,uint256,uint256,address,bytes32,bytes,uint256,uint256,uint256,address))",
_jobSubsParams
)
);
if (!success) {
emit AutoTxnCreateFailed();
return;
}
uint256 _subscriptionId = abi.decode(data, (uint256));
_createTask(_subscriptionId, _callee, _callArgs);
emit AutoTxnCreated(_subscriptionId);
}

function _createTask(uint256 _subscriptionId, address _callee, bytes memory _callArgs) internal{
taskList[_subscriptionId] = Task({callee: _callee, callArgs: _callArgs});
}

function oysterResultCall(
uint256 _jobId,
address _jobOwner,
bytes32 _codehash,
bytes calldata _codeInputs,
bytes calldata _output,
uint8 _errorCode
) public {
if (relaySubscriptionsAddress != _msgSender() || taskList[_jobId].callee == address(0)) {
revert AutoTxnInvalidCallback();
}
(bool success, ) = taskList[_jobId].callee.call(taskList[_jobId].callArgs);
emit AutoTxnCalled(_jobId, success);
}

function withdrawEth() external onlyOwner {
(bool success, ) = msg.sender.call{value: address(this).balance}("");
if (!success) revert AutoTxnEthWithdrawalFailed();

emit AutoTxnOwnerEthWithdrawal();
}

function withdrawUsdc() external onlyOwner {
uint256 balance = token.balanceOf(address(this));
token.transfer(owner(), balance);

emit AutoTxnOwnerUsdcWithdrawal();
}

receive() external payable {}
}

Next, create a Solidity file called contracts/Payment.sol with the following code:

// SPDX-License-Identifier: SEE LICENSE IN LICENSE
pragma solidity ^0.8.0;

import "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import "@openzeppelin/contracts/access/Ownable.sol";
import "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol";

contract Payment is Ownable {
using SafeERC20 for IERC20;

/// @notice refers to USDC token
IERC20 public token;

constructor(
address _token,
address _owner
) Ownable(_owner) {
token = IERC20(_token);
}

function pay(address recipient, uint256 amount) external {
token.transfer(recipient, amount);
}
}

Compile the contract by running:

npx hardhat compile

Deploying your contract

Create a deployment script in the script/deploy directory called AutoTxn.ts with the following code:

import { ethers } from "hardhat";

async function main() {
let relaySubAddress = "0x8Fb2C621d6E636063F0E49828f4Da7748135F3cB";
let usdcToken = "0xaf88d065e77c8cC2239327C5EDb3A432268e5831";
// This code hash doesn't exist on chain
let codeHash = "0x37b0b2d9dd58d9130781fc914da456c16ec403010e8d4c27b0ea4657a24c8546";
let owner = await (await ethers.getSigners())[0].getAddress();
const autoTxn = await ethers.deployContract(
"AutoTxn",
[
relaySubAddress,
usdcToken,
owner,
codeHash
]
);
await autoTxn.waitForDeployment();
console.log("AutoTxn Contract is deployed at ", autoTxn.target);
}

main()
.then(() => process.exit(0))
.catch((error) => {
console.error(error);
process.exit(1);
});

Similarly, create a deployment script in the script/deploy directory called Payment.ts with the following code:

import { ethers } from "hardhat";

async function main() {
let usdcToken = "0x186A361FF2361BAbEE9344A2FeC1941d80a7a49C";
let owner = await (await ethers.getSigners())[0].getAddress();
const payment = await ethers.deployContract(
"Payment",
[
usdcToken,
owner
]
);
await payment.waitForDeployment();
console.log("Payment Contract is deployed at ", payment.target);
}

main()
.then(() => process.exit(0))
.catch((error) => {
console.error(error);
process.exit(1);
});

Ensure your Metamask account has sufficient Arbitrum One ETH and obtain your private key. Additionally, obtain an Arbiscan API Key by following their API key generation guide. Both the private key and API key will be needed for deployment.

Create a .env file in the user_contract_builder directory with the following environment variables. Replace the placeholders with your actual values:

ARBITRUM_DEPLOYER_KEY=<deployer-account-private-key>
ARBISCAN_API_KEY=<arbiscan-api-key>

Finally, run the following command to deploy the contract to the Arbitrum One Mainnet. Upon successful deployment, the contract address will be printed to the console:

npx hardhat run script/deploy/AutoTxn.ts --network arb1
npx hardhat run script/deploy/Payment.ts --network arb1

Transfer ETH to deployed AutoTxn contract address to be used as deposit for response callbacks. Follow this Metamask Guide to transfer ETH. User contract address is used as recipient and amount of 0.002 ETH is sufficient for this example.

Transfer USDC tokens to deployed AutoTxn contract address to be used as deposit and payment for serverless job. USDC token contract used here is deployed at address 0xaf88d065e77c8cC2239327C5EDb3A432268e5831. Send 10000000 USDC for this example. Also, send 100 USDC to Payment contract to be used to pay the given account.

Send USDC

Create a serverless request

Start hardhat console by running npx hardhat console --network arb1.

Create a AutoTxn and Payment contract instance by running (replace the address with your deployed contract address):

autoTxn = (await ethers.getContractFactory("AutoTxn")).attach("0xb50dd54d181AFF49C4599D29306D098beCFC0354");
payment = (await ethers.getContractFactory("Payment")).attach("0x95872Ae51C77b09146FA78063e694f489d97027a");

Call the create function to create a serverless request. Replace the recipient address with your own address.

await autoTxn.create(30, Math.floor(Date.now()/1000) + 100, payment.target, payment.interface.encodeFunctionData('pay', [<Your Address>, 1]), {gasLimit: 1700000});

Verify the response callback

After creating the subscription, you can check the USDC balance of the recipient address at each interval. It should increase by 1 USDC accordingly.

Go to the Arbiscan. Click on the Contract tab and then on Read As Proxy within that tab. There, you can find the balanceOf method, click on it and then fill the recipient address. Click on the Query to get the USDC balance of recipient address.

usdc balance

Get back remaining funds

After the subscription ends, the remaining funds can be withdrawn from the AutoTxn contract. Call the withdrawEth function to get back the remaining ETH. Call the withdrawUsdc function to get back the remaining USDC.

await user.withdrawEth();
await user.withdrawUsdc();

Congratulations! You've successfully created a smart contract that makes periodic function calls to another contract. You've learned how to deploy the contract, fund it, execute requests, and withdraw funds.

That's a wrap! These tutorials have given you a good foundation for using Oyster Serverless. For more inspiration and advanced use cases, check out our examples. Feel free to use these examples as templates for building your own serverless applications.