Getting Started with Automation
This page registers an automation task on Devnet end to end with Foundry: a contract whose counter the chain increments on its own, in every block whose number is a multiple of 5. The task’s action runs in every block where its predicate holds, and the task’s owner pays each run’s gas from its native SUPRA balance; step 3 gives the cost of one run. For the model behind each step — cycles, fees, task states — see How Automation Works.
The steps are:
- Find the contracts
- Check that automation is available
- Prepare the account
- Deploy the example contract
- Register a user task
- Watch it run
- Cancel or stop a task
The commands assume a Foundry project set up as in
Foundry:
its foundry.toml targets osaka and names the Devnet endpoint supra_devnet, and the
account’s key is imported into a keystore named deployer:
[profile.default]
evm_version = "osaka"
chain_id = 535600
[rpc_endpoints]
supra_devnet = "https://rpc-devnet.supra.com/rpc/v1/eth/wallet_integration"supra_devnet must name the wallet-integration endpoint: the scripts on this page fail against
/rpc/v1/eth, as Which endpoint to use explains.
1. Find the contracts
Read the automation registry’s and Wrapped SUPRA’s addresses from the
contract registry, under the keys
AutomationRegistry and WrappedSupra. The resource omits leading zero digits, so each
value is left-padded to 40 hex digits:
details=$(curl -s 'https://rpc-devnet.supra.com/rpc/v4/accounts/0x1/resources/0x1%3A%3Aevm_config%3A%3AEvmContractsDetails')
evm_address() {
local hex
hex=$(jq -r --arg k "$1" '.data.details.data[] | select(.key == $k) | .value' <<< "$details")
printf '0x%040s\n' "${hex#0x}" | tr ' ' 0
}
export AUTOMATION_REGISTRY=$(evm_address AutomationRegistry)
export WSUPRA=$(evm_address WrappedSupra)2. Check that automation is available
Registration succeeds only while automation is enabled, registration is enabled, and the
current cycle is STARTED:
cast call "$AUTOMATION_REGISTRY" "isAutomationReadyEnabled()(bool)" --rpc-url supra_devnet
cast call "$AUTOMATION_REGISTRY" "isRegistrationEnabled()(bool)" --rpc-url supra_devnet
# Cycle index, start time, duration in seconds, state (0 READY, 1 STARTED, 2 FINISHED, 3 SUSPENDED).
cast call "$AUTOMATION_REGISTRY" "getCycleInfo()(uint64,uint64,uint64,uint8)" --rpc-url supra_devnetBoth calls must return true and the state must be 1. Otherwise registration reverts with
AutomationNotEnabled, RegistrationDisabled or CycleTransitionInProgress; see
Cycle states.
3. Prepare the account
The task’s owner pays its fees in WSUPRA, so the owner’s EVM account needs native SUPRA, wrapped into WSUPRA, with the automation registry approved to spend it.
-
Fund the EVM account. Devnet has no EVM faucet. Fund a Move account from the Move faucet and transfer to the EVM address from it, as described in Funding an account. Creating the Move account and the transfer with the Supra CLI are in From the Supra CLI.
-
Wrap and approve. The
PrepareFeesscript is inscript/Automation.s.sol, listed in step 5; create that file before running it. The script deposits native SUPRA into WSUPRA and approves the registry for the same amount — 1 SUPRA unlessFEE_BUDGETsets another amount in wei:forge script script/Automation.s.sol:PrepareFees \ --rpc-url supra_devnet --account deployer --broadcast
The budget pays the flat registration fee and the task’s deposit at registration, then the
task’s fee at every cycle transition while it runs. Keep both the WSUPRA balance and the
allowance above each cycle’s fee: a task whose owner cannot be charged is removed, and its
deposit is not refunded. At the fee settings Devnet’s genesis derives from its Move
configuration, without congestion, registering the task below takes about 5.0 × 10¹⁷ wei
(0.5 SUPRA) — the flat fee plus a refundable deposit of 2 × 10¹⁵ wei — and each 600-second
cycle charges 10¹⁵ wei. The default budget of 10¹⁸ wei (1 SUPRA) covers registration and about
6 × 10¹⁵ wei of cycle fees over the task’s default one-hour lifetime, with about 0.49 SUPRA left
over. The settings can change; read the current ones with getConfig(), and a task’s per-cycle
fee with estimateAutomationFee, as in
Values at genesis.
Gas is paid separately, from the owner’s native balance and not from WSUPRA. The task runs in every
block whose number is a multiple of 5, so one 600-second cycle holds 120 × R runs at R blocks
per second, and R can be measured by reading cast block-number twice. The first run of the task,
which sets the counter’s storage slot, uses 45,183 gas, and each later run uses 28,083 gas. At the
minimum gas price of 333,333,300,000 wei a later run costs about 9.4 × 10¹⁵ wei (0.0094 SUPRA), so a
cycle costs about 1.1 × R SUPRA, for example 8.7 SUPRA at 7.7 blocks per second. The price paid is
the lowest gas price included in the block, which is above the minimum when every transaction in the
block offers more. Keep the native balance above FEE_BUDGET plus the runs’ gas for as long as the
task is meant to run, and stop the task in step 7 before the balance
runs out, because StopTask also needs native gas. To spend less, set TASK_LIFETIME_SECS to a
shorter lifetime when registering, still later than the end of the current cycle.
4. Deploy the example contract
src/AutomationCounter.sol holds the task’s action and its predicate:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.31;
/// A target for an automation task: `increment` is the action, and `isBlockMultipleOf5` the
/// predicate that gates it.
contract AutomationCounter {
uint256 public counter;
event Incremented(uint256 blockNumber, uint256 newValue);
/// The task's action.
function increment() external {
counter += 1;
emit Incremented(block.number, counter);
}
/// The predicate: the action runs only in a block whose number is a multiple of 5.
function isBlockMultipleOf5() external view returns (bool) {
return block.number % 5 == 0;
}
}The predicate is evaluated in the block the action would run in, so every Incremented log
emitted by the task’s automated transactions carries a block number that is a multiple of 5.
increment() has no access control, so a log from a transaction that any account sends to the
contract can carry any block number.
forge create src/AutomationCounter.sol:AutomationCounter \
--rpc-url supra_devnet --account deployer --broadcast
export COUNTER=<deployed address>5. Register a user task
script/Automation.s.sol holds every script this page runs — PrepareFees,
RegisterUserTask, and the CancelTask and StopTask scripts of
step 7:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.31;
import {Script, console} from "forge-std/Script.sol";
import {AutomationCounter} from "../src/AutomationCounter.sol";
/// The automation registry entry points these scripts call.
interface IAutomationRegistry {
function register(
bytes memory payloadTx,
bytes memory predicate,
uint64 expiryTime,
uint128 maxGasAmount,
uint128 gasPriceCap,
uint128 automationFeeCapForCycle,
uint64 priority,
bytes[] memory auxData
) external;
function cancelTasks(uint64[] memory taskIndexes) external;
function stopTasks(uint64[] memory taskIndexes) external;
function getNextTaskIndex() external view returns (uint64);
function estimateAutomationFee(uint128 taskOccupancy) external view returns (uint128);
}
interface IWrappedSupra {
function deposit() external payable;
function approve(address spender, uint256 amount) external returns (bool);
}
/// The registry's access-list entry, as encoded into a task's payload.
struct AccessListEntry {
address addr;
bytes32[] storageKeys;
}
/// Stand-in for the transaction-hash precompile, which Forge's local EVM does not have.
contract TxHashStandIn {
fallback(bytes calldata) external returns (bytes memory) {
return abi.encode(bytes32(0));
}
}
/// Stand-in for the EVM gas config precompile, serving the figures it is constructed with.
contract GasConfigStandIn {
uint64 internal immutable TX_GAS_LIMIT_CAP;
uint256 internal immutable MIN_GAS_PRICE;
constructor(uint64 txGasLimitCap_, uint256 minGasPrice_) {
TX_GAS_LIMIT_CAP = txGasLimitCap_;
MIN_GAS_PRICE = minGasPrice_;
}
function txGasLimitCap() external view returns (uint64) {
return TX_GAS_LIMIT_CAP;
}
function minGasPrice() external view returns (uint256) {
return MIN_GAS_PRICE;
}
}
abstract contract AutomationScript is Script {
address constant TX_HASH_PRECOMPILE = 0x0000000000000000000000000000000053555001;
address constant GAS_CONFIG_PRECOMPILE = 0x0000000000000000000000000000000053555005;
/// Gas ceiling for one execution, shared by the predicate and the action.
uint128 constant MAX_GAS_AMOUNT = 100_000;
/// Forge executes the script locally before broadcasting, and registration reads two Supra
/// precompiles that Forge's EVM does not have. Install stand-ins for that local run; the
/// broadcast transaction is executed by the chain against the real precompiles.
function installPrecompileStandIns() internal {
vm.etch(TX_HASH_PRECOMPILE, address(new TxHashStandIn()).code);
GasConfigStandIn gasConfig = new GasConfigStandIn(
uint64(vm.envUint("CHAIN_TX_GAS_LIMIT_CAP")), vm.envUint("CHAIN_MIN_GAS_PRICE")
);
vm.etch(GAS_CONFIG_PRECOMPILE, address(gasConfig).code);
}
function encodePayload(address target, bytes memory callData) internal pure returns (bytes memory) {
return abi.encode(uint128(0), target, callData, new AccessListEntry[](0));
}
function encodePredicate(address target) internal pure returns (bytes memory) {
return abi.encode(target, abi.encodeCall(AutomationCounter.isBlockMultipleOf5, ()));
}
function expiryTime() internal view returns (uint64) {
return uint64(block.timestamp + vm.envOr("TASK_LIFETIME_SECS", uint256(3600)));
}
}
/// Wraps native SUPRA into WSUPRA and approves the automation registry to spend it.
contract PrepareFees is Script {
function run() external {
IWrappedSupra wsupra = IWrappedSupra(vm.envAddress("WSUPRA"));
address registry = vm.envAddress("AUTOMATION_REGISTRY");
uint256 amount = vm.envOr("FEE_BUDGET", uint256(1 ether));
vm.startBroadcast();
wsupra.deposit{value: amount}();
wsupra.approve(registry, amount);
vm.stopBroadcast();
}
}
/// Registers a user task (UST) that calls `increment` whenever the block number is a multiple of 5.
contract RegisterUserTask is AutomationScript {
function run() external {
installPrecompileStandIns();
IAutomationRegistry registry = IAutomationRegistry(vm.envAddress("AUTOMATION_REGISTRY"));
address counter = vm.envAddress("COUNTER");
// Pay up to twice the current per-cycle estimate, as headroom against congestion.
uint128 feeCapForCycle = registry.estimateAutomationFee(MAX_GAS_AMOUNT) * 2;
// Bid above the floor, so the task is not skipped in blocks priced above it.
uint128 gasPriceCap = uint128(vm.envUint("CHAIN_MIN_GAS_PRICE") * 2);
vm.startBroadcast();
uint64 taskIndex = registry.getNextTaskIndex();
registry.register(
encodePayload(counter, abi.encodeCall(AutomationCounter.increment, ())),
encodePredicate(counter),
expiryTime(),
MAX_GAS_AMOUNT,
gasPriceCap,
feeCapForCycle,
0,
new bytes[](0)
);
vm.stopBroadcast();
console.log("Submitted user task; expected task index", taskIndex);
}
}
/// Selects the task named by TASK_INDEX.
abstract contract TaskOwnerScript is Script {
function taskIndexes() internal view returns (uint64[] memory indexes) {
indexes = new uint64[](1);
indexes[0] = uint64(vm.envUint("TASK_INDEX"));
}
}
/// Cancels a task. A PENDING task is removed at once; an ACTIVE task keeps running until the
/// current cycle ends and is removed at the cycle transition.
contract CancelTask is TaskOwnerScript {
function run() external {
IAutomationRegistry registry = IAutomationRegistry(vm.envAddress("AUTOMATION_REGISTRY"));
vm.startBroadcast();
registry.cancelTasks(taskIndexes());
vm.stopBroadcast();
}
}
/// Stops a task, removing it at once whatever its state.
contract StopTask is TaskOwnerScript {
function run() external {
IAutomationRegistry registry = IAutomationRegistry(vm.envAddress("AUTOMATION_REGISTRY"));
vm.startBroadcast();
registry.stopTasks(taskIndexes());
vm.stopBroadcast();
}
}forge script executes a script locally before it broadcasts, and registration reads two
Supra precompiles that Forge’s local EVM does not have: the transaction-hash precompile at
0x…53555001 and the EVM gas config precompile at 0x…53555005. installPrecompileStandIns
installs stand-ins for that local run only. The broadcast transaction is executed by the
chain against the real precompiles. Because the local run differs from the chain, pass
--skip-simulation, which stops Forge from replaying the transactions without the
stand-ins.
The gas-config stand-in serves the figures passed in CHAIN_TX_GAS_LIMIT_CAP and
CHAIN_MIN_GAS_PRICE. Read them from the chain, so the local run accepts what the chain
will:
export CHAIN_TX_GAS_LIMIT_CAP=$(cast call 0x0000000000000000000000000000000053555005 "txGasLimitCap()" --rpc-url supra_devnet)
export CHAIN_MIN_GAS_PRICE=$(cast call 0x0000000000000000000000000000000053555005 "minGasPrice()" --rpc-url supra_devnet)
forge script script/Automation.s.sol:RegisterUserTask \
--rpc-url supra_devnet --account deployer --broadcast --skip-simulationRegisterUserTask calls register with:
| Argument | Value |
|---|---|
_payloadTx | A call to increment() on COUNTER, sending no value |
_predicate | A call to isBlockMultipleOf5() on COUNTER |
_expiryTime | One hour from now, or TASK_LIFETIME_SECS seconds. It must be later than the end of the current cycle |
_maxGasAmount | 100,000, shared by the predicate and the action |
_gasPriceCap | Twice the chain’s minimum gas price, so the task is not skipped in blocks priced above the minimum |
_automationFeeCapForCycle | Twice the registry’s current per-cycle fee estimate, as headroom if congestion raises the fee before the next cycle |
_priority | 0. A user task’s priority is always its task index |
_auxData | Empty |
The encodings follow Encoding the call. If registration reverts, the error names the check that failed; see When calls fail.
6. Watch it run
A newly registered task is PENDING until the current cycle ends; it becomes ACTIVE at
the transition and runs from then on. Find the task and wait for the cycle to turn:
OWNER=$(cast wallet address --account deployer)
cast call "$AUTOMATION_REGISTRY" "getTasksByAddress(address)(uint256[])" "$OWNER" --rpc-url supra_devnet
export TASK_INDEX=<task index>
# Repeat until the cycle index has advanced past the one registration happened in.
cast call "$AUTOMATION_REGISTRY" "getCycleInfo()(uint64,uint64,uint64,uint8)" --rpc-url supra_devnet
cast call "$AUTOMATION_REGISTRY" "hasActiveUserTask(address,uint64)(bool)" "$OWNER" "$TASK_INDEX" \
--rpc-url supra_devnetOnce the task is active, query the logs its action emits over the last 1,000 blocks:
LATEST=$(cast block-number --rpc-url supra_devnet)
cast logs --address "$COUNTER" "Incremented(uint256,uint256)" \
--from-block $((LATEST - 1000)) --to-block "$LATEST" --rpc-url supra_devnet
cast call "$COUNTER" "counter()(uint256)" --rpc-url supra_devnetA node refuses an eth_getLogs request whose block range or result set exceeds its bound with
-32005; see Error codes. To read logs from an
earlier period, query that period in ranges within the bound.
Each log the task emits comes from an automated transaction. Fetching that transaction by its
hash shows "supraTxKind": "automated"; a log from another caller has no supraTxKind. See
Automated Transactions over RPC.
The task runs until its expiry time unless it is cancelled or stopped first; see
step 7. It is also removed when its owner cannot pay a cycle fee,
when its cycle fee exceeds _automationFeeCapForCycle, when the node removes it for one of the
reasons in Removal by the runtime, or when a
new epoch’s gas config no longer admits it. A reverted, halted or discarded
action does not remove it. The task runs with its owner as the transaction sender, and a
sender with code (other than an EIP-7702 delegation) is refused, so a task registered by a
contract is removed at its first predicate evaluation. The owner of this task is the
account that ran RegisterUserTask, which has no code. Its action pays gas from the owner’s
native balance in every block it runs; see
What an execution looks like.
7. Cancel or stop a task
A task’s owner ends it early with cancelTasks or stopTasks. The two differ in when the
task stops running and in what is refunded:
| Call | Task state | Removed | Refund |
|---|---|---|---|
| Cancel | PENDING | At once | Half the deposit |
| Cancel | ACTIVE | At the next cycle transition. Until then the task is CANCELLED and keeps executing | The whole deposit, at that transition |
| Stop | PENDING | At once | Half the deposit |
| Stop | ACTIVE or CANCELLED | At once | The whole deposit, plus half the cycle fee for the rest of the cycle |
The flat registration fee is not refunded in any case.
Both calls work only while the cycle is STARTED with automation enabled. The rules are in
Cancelling versus stopping and
Task lifecycle.
CancelTask and StopTask act on the task given by TASK_INDEX. Neither reads a Supra
precompile, so they need no stand-ins and no --skip-simulation:
export TASK_INDEX=<task index>
forge script script/Automation.s.sol:CancelTask \
--rpc-url supra_devnet --account deployer --broadcast
forge script script/Automation.s.sol:StopTask \
--rpc-url supra_devnet --account deployer --broadcastAn index that does not exist is skipped without an error, so check the result rather than relying on the transaction succeeding.
Checking the result
Whether the task is still registered, and whether it is still in the set the node schedules from:
cast call "$AUTOMATION_REGISTRY" "ifTaskExists(uint64)(bool)" "$TASK_INDEX" --rpc-url supra_devnet
cast call "$AUTOMATION_REGISTRY" "getActiveTaskIds()(uint64[])" --rpc-url supra_devnetA registered task’s state is the twelfth field of getTaskDetails: 0 PENDING, 1
ACTIVE, 2 CANCELLED. The call reverts with TaskDoesNotExist once the task is removed:
cast call "$AUTOMATION_REGISTRY" \
"getTaskDetails(uint64)((uint128,uint128,uint128,uint128,bytes32,uint64,uint64,uint64,uint64,address,uint8,uint8,bytes,bytes,bytes[]))" \
"$TASK_INDEX" --rpc-url supra_devnetThe refund is paid in WSUPRA:
cast call "$WSUPRA" "balanceOf(address)(uint256)" "$OWNER" --rpc-url supra_devnetCancelling versus stopping an active task
With the example task active, the difference shows in its logs:
- After cancelling,
ifTaskExistsstaystrue, the state reads2, the task stays ingetActiveTaskIds, andIncrementedlogs keep appearing until the current cycle ends —getCycleInfo()gives its start time and duration.hasActiveUserTaskalso staystrue, since it reports any task that is notPENDING. At the next cycle transition the task is removed and its whole deposit is refunded. - After stopping,
ifTaskExistsisfalseat once, the task leavesgetActiveTaskIds, and noIncrementedlog appears from the next block on. The refund arrives in the same transaction.
Query a range within the node’s bound that starts shortly before the block of the cancel or stop and ends once the chain has passed it:
cast logs --address "$COUNTER" "Incremented(uint256,uint256)" \
--from-block $((CHANGE_BLOCK - 500)) --to-block $((CHANGE_BLOCK + 500)) \
--rpc-url supra_devnetCHANGE_BLOCK is the block of the cancel or stop transaction.
Related
- How Automation Works — cycles, fees and task states.
- AutomationRegistry — every entry point, error and event of the registry.
- Interfaces and ABIs — downloading the registry’s complete Solidity interfaces, in place of the subset declared in the script.
- Getting Started with SupraEVM — the network, funding an account, and Foundry set-up.
- Gas and Fees — how the chain’s gas figures bound automation tasks.