Example with the Execution API

This is a hands-on tutorial that walks through building a simple refuel automation using a JS script. Perfect for getting familiar with core API types and execution flows.

While the Execution Layer provides the foundational security and access control for automations, the Orchestration Layer enhances it with scheduling capabilities. For complex automation scenarios that go beyond the standard scheduling API, developers can directly utilize the core execution endpoints. Let's explore this flexibility through an example.

A Simple Cross Chain Refuel BOT

An automation built using Console Automation APIs that helps keep ETH balances topped up across different chains.

Users can subscribe by providing:

  • A refuelAddress to monitor

  • minAmount of ETH to maintain

  • List of chains to watch

The automation checks if the refuelAddress drops below minAmount of ETH on any of the specified chains. If it does, it automatically bridges funds to keep the balances above the minimum threshold

Creating the Automation

To create an automation, the executor must first configure the automation parameters.

// Define the EIP-712 message parameters
const msgParams = {
  domain: {
    chainId: 42161
  },
  message: {
    timestamp: 1,
    executor: "0xAE75B29ADe678372D77A8B41225654138a7E6ff1",
    inputTokens: ["0x0000000000000000000000000000000000000000"],
    hopAddresses: ["0x3a23F943181408EAC424116Af7b7790c94Cb97a5"],
    feeInBPS: 0,
    feeToken: "0xaf88d065e77c8cC2239327C5EDb3A432268e5831",
    feeReceiver: "0xAE75B29ADe678372D77A8B41225654138a7E6ff1",
    limitPerExecution: true,
    clientId: "auto-refuel"
  },
  primaryType: "RegisterExecutor",
  types: {
    EIP712Domain: [
      { name: "chainId", type: "uint256" },
      { name: "name", type: "string" },
      { name: "version", type: "string" },
      { name: "verifyingContract", type: "address" }
    ],
    RegisterExecutor: [
      { name: "timestamp", type: "uint256" },
      { name: "executor", type: "address" },
      { name: "inputTokens", type: "address[]" },
      { name: "hopAddresses", type: "address[]" },
      { name: "feeInBPS", type: "uint256" },
      { name: "feeToken", type: "address" },
      { name: "feeReceiver", type: "address" },
      { name: "limitPerExecution", type: "bool" },
      { name: "clientId", type: "string" }
    ]
  }
};

Once we have the automation parameters defined, we can create a helper to sign the parameters, as this signature is required for automation creation.

const provider = new providers.JsonRpcProvider(RPC_URL);
const signer = new Wallet(EXECUTOR_PRIVATE_KEY, provider);

async function signAutomationRegistrationMessage(domain, types, message) {
  try {
    // Create a wallet instance using the private key
    const wallet = new ethers.Wallet(privateKey);

    // Sign the message according to EIP-712
    const signature = await wallet._signTypedData(domain, types, message);

    console.log("Signature:", signature);
    return signature; // Return the generated signature
  } catch (err) {
    console.error("Error signing message:", err);
    throw err;
  }
}

Here, RPC_URL and EXECUTOR_PRIVATE_KEY must be configured with appropriate values.

Once the signature is ready, we can simply call the POST /executor endpoint to create the automation for the executor.

const SVC_BASE_URL = "https://gtw.brahma.fi";
// Function to execute the API call
async function executeApiCall() {
  try {
    // Generate signature
    const signature = await signMessage(
      msgParams.domain,
      msgParams.types,
      msgParams.message
    );

    // Prepare the data for the POST request
    const data = {
      config: {
        feeInBPS: msgParams.message.feeInBPS,
        feeReceiver: msgParams.message.feeReceiver,
        feeToken: msgParams.message.feeToken,
        limitPerExecution: msgParams.message.limitPerExecution,
        inputTokens: msgParams.message.inputTokens,
        hopAddresses: msgParams.message.hopAddresses
      },
      executor: msgParams.message.executor,
      signature: signature, // Use the  generated signature
      chainId: msgParams.domain.chainId,
      timestamp: msgParams.message.timestamp,
      executorMetadata: {
        id: msgParams.message.clientId,
        name: "auto-refuel",
        logo: "https://smolrefuel.com/logo.png",
        metadata: {}
      }
    };

    // Make the POST request using Axios
    const response = await axios.post(
      `${SVC_BASE_URL}/v1/automations/executor`,
      data,
      {
        headers: {
          "Content-Type": "application/json"
        }
      }
    );

    console.log("Create Executor response:", response.data);
  } catch (error) {
    console.error("Error creating Executor response:", error);
  }
}

Fetching subscribers of automation

Once the automation is created, Console users will be able to subscribe to the automation from console's Automation Marketplace. The automation executors can fetch a list of all subscribers and their metadata from the API, to perform further operations.

First, the executor's registryID must be fetched in order to fetch its subscribers.

const fetchExecutorByAddress = async (address, chainId) => {
  try {
    const resp = await Axios.get(
      `${SVC_BASE_URL}/v1/automations/executor/${address}/${chainId}`
    );
    return resp.data.data;
  } catch (err) {
    console.log("err: failed to call fetchExecutorByAddress");
    throw "failed to fetch executor by address";
  }
};

Post fetching, we can also perform further cleanup to filter out the active subscriptions.

const fetchActiveSubscriptions = async (registryID) => {
  try {
    const response = await Axios.get(
      `${SVC_BASE_URL}/v1/automations/executor/${registryID}/subscriptions`
    );

    const subscriptions = response.data.data;

    let activeSubscriptions = [];
    for (let subscription of subscriptions) {
      // filter our if not currently active
      if (subscription.status != CANCELLED_STATUS) {
        activeSubscriptions.push(subscription);
      }
    }

    return activeSubscriptions;
  } catch (err) {
    console.log("err: failed to get subscriptions");
    throw "failed to get executor subscriptions";
  }
};

Checking if the automation should be executed for a subscriber

The automation only has to be executed, if the refuelAddress has a balance lower than the minAmount threshold on any given chain ID from chains. We define a function to perform a check to see if a target address has a low balance-

const canExecute = async (targetChainID, target, threshold) => {
  const provider = new providers.JsonRpcProvider(RPC_URL);

  const bal = await provider.getBalance(target);
  logger.info(
    logMessage(
      "CanExecute Check",
      `Current balance: ${bal.toString()}, Threshold: ${threshold.toString()}`
    )
  );

  if (bal.gt(threshold)) {
    return false;
  }

  return true;
};

Here, RPC_URL needs to be defined

Now we can go through all the subscribers and check if the automation is to be performed.

const metadata = await fetchExecutorByAddress(EXECUTOR_ADDRESS, CHAIN_ID);
let subscriptions = await fetchActiveSubscriptions(metadata.id);
logger.info(
  logMessage("Found Subscriptions", JSON.stringify(subscriptions, "", 4))
);

for (let subscription of subscriptions) {
  const metadata = subscription.metadata;
  let refuel = false;
  let targetChainID = 0;
  let subaccount = subscription.subAccountAddress;
  // Check if refuel is needed for any of the target chains
  for (let targetChain of metadata.chains) {
    if (
      await canExecute(targetChain, metadata.refuelAddress, metadata.minAmount)
    ) {
      refuel = true;
      targetChainID = targetChain;
      break;
    }
  }
}

Here, EXECUTOR_ADDRESS and CHAIN_ID must be defined.

Executing calldata to bridge funds to refuelAddress on required chains from subscriber

Before proceeding with the actual execution, we first need to define some helpers.

  • Signing execution digest

    const buildExecutionDigestSignature = async (
      chainId,
      to,
      value,
      data,
      operation,
      account,
      nonce,
      wallet,
      executor
    ) => {
      const types = {
        ExecutionParams: [
          { name: "operation", type: "uint8" },
          { name: "to", type: "address" },
          { name: "account", type: "address" },
          { name: "executor", type: "address" },
          { name: "gasToken", type: "address" },
          { name: "refundReceiver", type: "address" },
          { name: "value", type: "uint256" },
          { name: "nonce", type: "uint256" },
          { name: "safeTxGas", type: "uint256" },
          { name: "baseGas", type: "uint256" },
          { name: "gasPrice", type: "uint256" },
          { name: "data", type: "bytes" }
        ]
      };
    
      const executorPluginDomain = {
        name: "ExecutorPlugin",
        version: "1.0",
        chainId: chainId,
        verifyingContract: EXECUTOR_PLUGIN_ADDRESS
      };
    
      const executionParamsData = {
        to,
        value,
        data,
        operation,
        account,
        executor: executor,
        nonce,
        gasToken: ethers.constants.AddressZero,
        refundReceiver: ethers.constants.AddressZero,
        safeTxGas: 0,
        baseGas: 0,
        gasPrice: 0
      };
    
      const signature = await wallet._signTypedData(
        executorPluginDomain,
        types,
        executionParamsData
      );
    
      return signature;
    };
    
    const signCallData = async (
      account,
      executor,
      executable,
      wallet,
      chainId,
      rpc
    ) => {
      const provider = new ethers.providers.JsonRpcProvider(rpc);
    
      const executorPluginContract = new ethers.Contract(
        EXECUTOR_PLUGIN_ADDRESS,
        EXECUTOR_PLUGIN_ABI,
        provider
      );
      /// Generate signature for execution digest
      const executorSignature = await buildExecutionDigestSignature(
        chainId,
        executable.to,
        executable.value,
        executable.data,
        executable.callType,
        account,
        (
          await executorPluginContract.executorNonce(account, executor)
        ).toString(),
        wallet,
        executor
      );
    
      return executorSignature;
    };

    Here, EXECUTOR_PLUGIN_ADDRESS and EXECUTOR_PLUGIN_ABI must be assigned to the appropriate values.

  • Helpers to get bridge routes from socket

    async function getQuote(
      fromChainId,
      fromTokenAddress,
      toChainId,
      toTokenAddress,
      fromAmount,
      senderAddress,
      recipient,
      uniqueRoutesPerBridge,
      sort,
      singleTxOnly
    ) {
      const response = await fetch(
        `https://api.socket.tech/v2/quote?fromChainId=${fromChainId}&fromTokenAddress=${fromTokenAddress}&toChainId=${toChainId}&toTokenAddress=${toTokenAddress}&fromAmount=${fromAmount}&userAddress=${senderAddress}&recipient=${recipient}&bridgeWithGas=false&sort=${sort}&singleTxOnly=${singleTxOnly}&isContractCall=true`,
        {
          method: "GET",
          headers: {
            "API-KEY": API_KEY,
            Accept: "application/json",
            "Content-Type": "application/json"
          }
        }
      );
    
      const json = await response.json();
      return json;
    }
    
    async function getRouteTransactionData(route) {
      const response = await fetch("https://api.socket.tech/v2/build-tx", {
        method: "POST",
        headers: {
          "API-KEY": API_KEY,
          Accept: "application/json",
          "Content-Type": "application/json"
        },
        body: JSON.stringify({ route: route })
      });
    
      const json = await response.json();
      return json;
    }

    Here, API_KEY must be assigned to the executor's socket API key.

  • Helper to use the /execute endpoint to execute a task on subscriber

    const executeTask = async ({
      chainId,
      executable,
      subaccount,
      executorSignature,
      executor
    }) => {
      try {
        const { data: relayerResponse } = await Axios.post(
          `${SVC_BASE_URL}/v1/automations/tasks/execute/${chainId}`,
          {
            task: {
              executable,
              subaccount,
              executorSignature,
              executor
            }
          },
          {
            timeout: 50 * 1000
          }
        );
        return relayerResponse;
      } catch (err) {
        console.log(
          "failed to execute task ",
          JSON.stringify(err.response.data, "", 4)
        );
        throw "failed to execute task";
      }
    };

Now that we have all the required helpers, we can combine them for the complete automation execution logic.

for (let subscription of subscriptions) {
  const metadata = subscription.metadata;
  let refuel = false;
  let targetChainID = 0;
  let subaccount = subscription.subAccountAddress;
  // Check if refuel is needed for any of the target chains
  for (let targetChain of metadata.chains) {
    if (
      await canExecute(targetChain, metadata.refuelAddress, metadata.minAmount)
    ) {
      refuel = true;
      targetChainID = targetChain;
      break;
    }
  }
  if (refuel) {
    let amount = metadata.amount;
    logger.info(
      logMessage(
        "withdrawing amount",
        utils.formatEther(BigNumber.from(amount))
      )
    );
    let executable = {};

    logger.info(logMessage("Generating Bridging Quote", ""));
    // Get quote for bridging
    const quote = await getQuote(
      CHAIN_ID,
      "0xeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee",
      targetChainID,
      "0xeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee",
      amount,
      subaccount,
      metadata.refuelAddress,
      true,
      "time",
      true
    );

    const route = quote.result.routes[0];
    logger.info(logMessage("Quote", JSON.stringify(route)));

    // Get transaction data for the route
    const apiReturnData = await getRouteTransactionData(route);
    logger.info(logMessage("Call Data", JSON.stringify(apiReturnData, "", 2)));

    // Prepare executable transaction data
    executable = {
      callType: 0,
      to: apiReturnData.result.txTarget,
      value: BigNumber.from(apiReturnData.result.value).toString(),
      data: apiReturnData.result.txData
    };

    // Sign the call data
    const executorSignature = await signCallData(
      subaccount,
      EXECUTOR_ADDRESS,
      executable,
      signer,
      CHAIN_ID,
      RPC_URL
    );
    logger.info(logMessage("Executor Signature", executorSignature));

    // Execute the task
    const taskExecResponse = await executeTask({
      chainId: CHAIN_ID,
      executable,
      subaccount,
      executorSignature,
      executor: EXECUTOR_ADDRESS
    });

    logger.info(
      logMessage("Executed Task", JSON.stringify(taskExecResponse, "", 4))
    );

    // wait before next run
    logger.info(logMessage("Waiting for intent finalization", "~2 mins"));
    await delay(60 * second);
  } else {
    await delay(5 * second);
  }
}

Here, EXECUTOR_ADDRESS, CHAIN_ID and RPC_URL must be configured with appropriate values.

Automation Demo

Wrapping up

  • Reference for the APIs used in this example can be found here.

  • The complete script for this example automation can be found here.

Last updated