Skip to main content

Serverless Subscription Request

In this tutorial, you'll learn how to create subscription-based serverless requests using 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​

First, create a new Solidity file called contracts/EthRate.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 EthRate 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;

event EthPrice(
bytes price,
uint256 time
);

event Refunded(
uint256 subscriptionId
);

event OwnerEthWithdrawal();
event OwnerUsdcWithdrawal();

error EthRateInvalidCallback();
error EthRateAlreadyRunning();
error EthRateNotRunning();
error EthRateRefundFailed();
error EthWithdrawalFailed();

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 run() external onlyOwner returns (bool) {
if (subscriptionId != 0) {
revert EthRateAlreadyRunning();
}
uint256 _usdcDeposit = 11 * 4100;
uint256 _maxGasPrice = 2 * tx.gasprice;
uint256 _callbackDeposit = 11 * _maxGasPrice * (35000 + 150000 + 4530);
token.safeIncreaseAllowance(relaySubscriptionsAddress, _usdcDeposit);
JobSubscriptionParams memory _jobSubsParams = JobSubscriptionParams(
{
env: 1,
startTime: block.timestamp,
maxGasPrice: _maxGasPrice,
usdcDeposit: _usdcDeposit,
callbackGasLimit: 35000,
callbackContract: address(this),
codehash: CODE_HASH,
codeInputs: '',
periodicGap: 30,
terminationTimestamp: block.timestamp + 200,
userTimeout: 2000,
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
)
);
subscriptionId = abi.decode(data, (uint256));
return success;
}

function oysterResultCall(
uint256 _jobId,
address _jobOwner,
bytes32 _codehash,
bytes calldata _codeInputs,
bytes calldata _output,
uint8 _errorCode
) public {
if (relaySubscriptionsAddress != _msgSender() || _jobId != subscriptionId) {
revert EthRateInvalidCallback();
}
emit EthPrice(_output, block.timestamp);
}

function getSubscriptionRefund() external onlyOwner {
if (subscriptionId == 0) {
revert EthRateNotRunning();
}
(bool success,) = relaySubscriptionsAddress.call(
abi.encodeWithSignature(
"refundJobSubsDeposits(uint256)",
subscriptionId
)
);
if (!success) {
revert EthRateRefundFailed();
}
emit Refunded(subscriptionId);
subscriptionId = 0;

}

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

emit OwnerEthWithdrawal();
}

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

emit OwnerUsdcWithdrawal();
}

receive() external payable {}
}

Compile the contract by running:

npx hardhat compile

Deploying your contract​

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

import { ethers } from "hardhat";

async function main() {
let relaySubAddress = "0x8Fb2C621d6E636063F0E49828f4Da7748135F3cB";
let usdcToken = "0xaf88d065e77c8cC2239327C5EDb3A432268e5831";
let codeHash = "0x1982d24c37d1299cbb237d1383eab40699c01886f7c6022328fb8787920a50a2";
let owner = await (await ethers.getSigners())[0].getAddress();
const ethRate = await ethers.deployContract(
"EthRate",
[
relaySubAddress,
usdcToken,
owner,
codeHash
]
);
await ethRate.waitForDeployment();
console.log("User Contract is deployed at ", ethRate.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/EthRate.ts --network arb1

Transfer ETH to the deployed contract address to be used to pay for gas for response callbacks. Follow this Metamask Guide to transfer ETH. The contract address is to be used as the recipient and amount of 0.002 ETH is sufficient for this example.

Transfer USDC tokens to the deployed contract address to be used as payment for the serverless job. USDC token contract used here is deployed at address 0xaf88d065e77c8cC2239327C5EDb3A432268e5831. Send 10 USDC for this example.

Create a serverless request​

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

Create a EthRate contract instance by running (replace the address with your deployed contract address):

ethRate = (await ethers.getContractFactory("EthRate")).attach("0x369937d39228ec7FBDe88897583b4e89222D27ae");

Call the run() function to create a serverless request.

await ethRate.run({gasLimit: 2000000});

Verify the response callback​

After approximately 30 seconds, verify that the response callback was received by checking the event logs. Run the following command to query for "EthPrice" events:

await ethRate.queryFilter("EthPrice");

Notice that the EthPrice event is getting emitted at an interval of 30 seconds.

Get back remaining funds​

Withdraw the remaining funds from the user contract by calling the withdrawEth or withdrawUsdc functions.

await ethRate.withdrawEth();
await ethRate.withdrawUsdc();

Congratulations! You've successfully created a smart contract that makes subscription-based serverless requests and handles response callbacks. You've learned how to deploy the contract, fund it, execute requests, and withdraw funds. Next, you will learn how to automate periodic function calls to smart contract.