DEV Community

Cover image for Automating Limit Orders on Polygon with TypeScript, 0x, and Terraform
Radzion Chachura
Radzion Chachura

Posted on • Originally published at radzion.com

Automating Limit Orders on Polygon with TypeScript, 0x, and Terraform

🐙 GitHub

In this post, we’ll build a TypeScript service for executing limit orders with the 0x Swap API. To take it further, we’ll deploy the service to AWS Lambda using Terraform. To simplify the setup process, we’ll fork RadzionKit—a repository filled with handy TypeScript utilities.

Defining a Limit Order

Let’s start by defining a LimitOrder. This structure outlines the rules for executing asset swaps when an asset hits a specific targetPrice. Each order specifies whether the swap should occur when the price is more or less than the targetPrice. The swap field defines the relationship between the from and to assets involved in the transaction. Additionally, a unique id ensures that each order can be referenced in the database.

import { EntityWithId } from "@lib/utils/entities/EntityWithId"
import { TransferDirection } from "@lib/utils/TransferDirection"
import { LimitOrderAsset } from "./LimitOrderAsset"

type LimitOrderCondition = "more" | "less"

export type LimitOrder = EntityWithId & {
  asset: LimitOrderAsset
  condition: LimitOrderCondition
  targetPrice: number
  swap: Record<TransferDirection, LimitOrderAsset>
}
Enter fullscreen mode Exit fullscreen mode

Keeping Costs Low with Polygon

To keep transaction costs low, we’ll use Polygon, an Ethereum Layer 2 solution. For this example, we’ll focus on trading two assets: wrapped Ethereum (WETH) and USDC. We’ll specify the ERC20 addresses for these assets and include their price provider IDs to retrieve their prices from Coingecko.

import { Address } from "viem"
import { polygon } from "viem/chains"

export const limitOrderAssets = ["weth", "usdc"] as const

export type LimitOrderAsset = (typeof limitOrderAssets)[number]

export const limitOrderAssetAddress: Record<LimitOrderAsset, Address> = {
  weth: "0x7ceB23fD6bC0adD59E62ac25578270cFf1b9f619",
  usdc: "0x3c499c542cEF5E3811e1192ce70d8cC03d5c3359",
}

export const limitOrderAssetPriceProividerId: Record<LimitOrderAsset, string> =
  {
    weth: "polygon:weth",
    usdc: "polygon:usdc",
  }

export const limitOrderChain = polygon
Enter fullscreen mode Exit fullscreen mode

Storing Limit Orders in DynamoDB

We'll store our limit orders in a DynamoDB table. Using utilities from RadzionKit, we can quickly define the essential functions needed for basic CRUD operations.

import { getPickParams } from "@lib/dynamodb/getPickParams"
import { totalScan } from "@lib/dynamodb/totalScan"
import { LimitOrder } from "../entities/LimitOrder"
import { getEnvVar } from "../getEnvVar"
import { DeleteCommand, PutCommand } from "@aws-sdk/lib-dynamodb"
import { dbDocClient } from "@lib/dynamodb/client"
import { updateItem } from "@lib/dynamodb/updateItem"

const tableName = getEnvVar("LIMIT_ORDERS_TABLE_NAME")

export const getLimitOrderItemParams = (id: string) => ({
  TableName: tableName,
  Key: {
    id,
  },
})

export const getAllLimitOrders = async <T extends (keyof LimitOrder)[]>(
  attributes?: T,
) => {
  return totalScan<Pick<LimitOrder, T[number]>>({
    TableName: tableName,
    ...getPickParams(attributes),
  })
}

export const deleteLimitOrder = (id: string) => {
  const command = new DeleteCommand(getLimitOrderItemParams(id))

  return dbDocClient.send(command)
}

export const deleteAllLimitOrders = async () => {
  const alerts = await getAllLimitOrders(["id"])

  return Promise.all(alerts.map(({ id }) => deleteLimitOrder(id)))
}

export const putLimitOrder = (user: LimitOrder) => {
  const command = new PutCommand({
    TableName: tableName,
    Item: user,
  })

  return dbDocClient.send(command)
}

export const updateLimitOrder = async (
  id: string,
  fields: Partial<LimitOrder>,
) => {
  return updateItem({
    tableName: tableName,
    key: { id },
    fields,
  })
}
Enter fullscreen mode Exit fullscreen mode

Managing Environment Variables

To ensure type-safe access to environment variables, we'll use the getEnvVar utility. This will act as the single source of truth for managing our application's environment variables.

type VariableName =
  | "LIMIT_ORDERS_TABLE_NAME"
  | "SENTRY_KEY"
  | "SECRETS"
  | "TELEGRAM_BOT_CHAT_ID"

export const getEnvVar = <T extends string>(name: VariableName): T => {
  const value = process.env[name]
  if (!value) {
    throw new Error(`Missing ${name} environment variable`)
  }

  return value as T
}
Enter fullscreen mode Exit fullscreen mode

Configuring a Sample Limit Order

Here’s an example of configuring a limit order to sell WETH for USDC when the price of WETH exceeds $3,800. Since this service is tailored for single-user use, we can simplify the process by deleting all existing limit orders and replacing them with the new one.

import { deleteAllLimitOrders, putLimitOrder } from "../db/limitOrders"
import { LimitOrder } from "../entities/LimitOrder"

const partialItems: Omit<LimitOrder, "id">[] = [
  {
    asset: "weth",
    condition: "more",
    targetPrice: 3800,
    swap: {
      from: "weth",
      to: "usdc",
    },
  },
]

const items: LimitOrder[] = partialItems.map((value, index) => ({
  ...value,
  id: index.toString(),
}))

const setLimitOrders = async () => {
  await deleteAllLimitOrders()

  await Promise.all(items.map(putLimitOrder))
}

setLimitOrders()
Enter fullscreen mode Exit fullscreen mode

Adding Notifications with a Telegram Bot

When executing a swap for a limit order, it’s beneficial to receive a notification. An easy and effective way to accomplish this is by integrating a Telegram bot.

import { TransferDirection } from "@lib/utils/TransferDirection"
import { getEnvVar } from "../getEnvVar"
import TelegramBot from "node-telegram-bot-api"
import { LimitOrderAsset } from "../entities/LimitOrderAsset"
import { getSecret } from "../getSercret"

type Input = {
  swap: Record<TransferDirection, LimitOrderAsset>
  asset: LimitOrderAsset
  price: number
}

export const sendSwapNotification = async ({ price, asset, swap }: Input) => {
  const token = await getSecret("telegramBotToken")
  const bot = new TelegramBot(token)

  const message = `Executed a swap from ${swap.from} to ${swap.to} at ${asset} price of ${price}`

  return bot.sendMessage(getEnvVar("TELEGRAM_BOT_CHAT_ID"), message)
}
Enter fullscreen mode Exit fullscreen mode

Securing Sensitive Information with AWS Secrets Manager

To improve security, we’ll store sensitive information, such as the Telegram bot token, in AWS Secrets Manager. This approach centralizes all secrets required by our service. To optimize performance, we’ll memoize the getSecrets function to cache the response, reducing the need for repeated requests. The JSON response is then parsed, and we assert the specific field we need.

import { getEnvVar } from "./getEnvVar"
import {
  SecretsManagerClient,
  GetSecretValueCommand,
} from "@aws-sdk/client-secrets-manager"
import { memoizeAsync } from "@lib/utils/memoizeAsync"
import { shouldBePresent } from "@lib/utils/assert/shouldBePresent"
import { assertField } from "@lib/utils/record/assertField"

type SecretName = "accountPrivateKey" | "zeroXApiKey" | "telegramBotToken"

const getSecrets = memoizeAsync(async () => {
  const client = new SecretsManagerClient({})
  const command = new GetSecretValueCommand({ SecretId: getEnvVar("SECRETS") })
  const { SecretString } = await client.send(command)

  return shouldBePresent(SecretString)
})

export const getSecret = async <T = string>(name: SecretName): Promise<T> => {
  const secrets = await getSecrets()

  return assertField(JSON.parse(secrets), name)
}
Enter fullscreen mode Exit fullscreen mode

Fetching Prices with the CoinGecko API

To fetch cryptocurrency prices, we use the CoinGecko API. The getAssetPrices function accepts an array of asset IDs and an optional fiat currency.

import { addQueryParams } from "@lib/utils/query/addQueryParams"
import { FiatCurrency } from "../FiatCurrency"
import { queryUrl } from "@lib/utils/query/queryUrl"
import { recordMap } from "@lib/utils/record/recordMap"

type Input = {
  ids: string[]
  fiatCurrency?: FiatCurrency
}

type Response = Record<string, Record<FiatCurrency, number>>

const baseUrl = "https://api.coingecko.com/api/v3/simple/price"

export const getAssetPrices = async ({ ids, fiatCurrency = "usd" }: Input) => {
  const url = addQueryParams(baseUrl, {
    ids: ids.join(","),
    vs_currencies: fiatCurrency,
  })

  const result = await queryUrl<Response>(url)

  return recordMap(result, (value) => value[fiatCurrency])
}
Enter fullscreen mode Exit fullscreen mode

Creating a Dedicated Account for Swaps

To enhance security, we’ll avoid storing our primary wallet’s private key on the server. Instead, we’ll create a dedicated account solely for executing swaps. This account will hold only the asset we want to swap and a small amount of POL to cover gas fees. A simple script will generate a private key using viem and derive an account address from it.

import { generatePrivateKey, privateKeyToAccount } from "viem/accounts"

const createAccount = () => {
  const privateKey = generatePrivateKey()

  const { address } = privateKeyToAccount(privateKey)

  console.log("EVM Account Created:")
  console.log("Address:", address)
  console.log("Private Key:", privateKey)
}

createAccount()
Enter fullscreen mode Exit fullscreen mode

Withdrawing Assets to the Primary Account

Once our trading activities are complete, we’ll use the withdraw function to transfer all remaining assets back to our primary account.

import { privateKeyToAddress } from "viem/accounts"
import { getErc20Balance } from "../../../lib/chain/evm/erc20/getErc20Balance"
import {
  limitOrderAssetAddress,
  limitOrderAssets,
  limitOrderChain,
} from "../entities/LimitOrderAsset"
import { getSecret } from "../getSercret"
import { transferErc20Token } from "../../../lib/chain/evm/erc20/transferErc20Token"

const withdraw = (address: `0x${string}`) =>
  Promise.all(
    limitOrderAssets.map(async (asset) => {
      const assetAddress = limitOrderAssetAddress[asset]

      const privateKey = await getSecret<`0x${string}`>(`accountPrivateKey`)

      const amount = await getErc20Balance({
        chain: limitOrderChain,
        accountAddress: privateKeyToAddress(privateKey),
        address: assetAddress,
      })

      if (amount === BigInt(0)) {
        return
      }

      return transferErc20Token({
        chain: limitOrderChain,
        privateKey,
        tokenAddress: assetAddress,
        to: address,
        amount,
      })
    }),
  )

const address = process.argv[2] as `0x${string}`

withdraw(address)
Enter fullscreen mode Exit fullscreen mode

Handling Asset Balances During Withdrawal

The withdraw function iterates over every supported limit order asset, checks the balance, and transfers the entire amount to the specified address. The getErc20Balance and transferErc20Token functions handle this process by interacting with the ERC20 contract methods to read balances and transfer tokens, respectively.

import { Address, Chain, erc20Abi } from "viem"
import { getPublicClient } from "../utils/getPublicClient"

type Input = {
  chain: Chain
  address: Address
  accountAddress: Address
}

export const getErc20Balance = async ({
  chain,
  address,
  accountAddress,
}: Input) => {
  const publicClient = getPublicClient(chain)

  return publicClient.readContract({
    address,
    abi: erc20Abi,
    functionName: "balanceOf",
    args: [accountAddress],
  })
}
Enter fullscreen mode Exit fullscreen mode

Executing Swaps with 0x Swap API

To execute a swap, we’ll leverage the 0x Swap API, which identifies the optimal route for the trade.

import { createClientV2 } from "@0x/swap-ts-sdk"
import { shouldBePresent } from "@lib/utils/assert/shouldBePresent"
import {
  Address,
  Chain,
  concat,
  Hex,
  maxUint256,
  numberToHex,
  size,
} from "viem"
import { TransferDirection } from "@lib/utils/TransferDirection"
import { assertField } from "@lib/utils/record/assertField"
import { privateKeyToAccount } from "viem/accounts"
import { setErc20Allowance } from "./setErc20Allowance"
import { getWalletClient } from "../utils/getWalletClient"
import { getPublicClient } from "../utils/getPublicClient"
import { assertTx } from "../utils/assertTx"

type Input = Record<TransferDirection, Address> & {
  chain: Chain
  zeroXApiKey: string
  amount: bigint
  privateKey: `0x${string}`
}

export const swapErc20Token = async ({
  zeroXApiKey,
  chain,
  from,
  to,
  amount,
  privateKey,
}: Input) => {
  const client = createClientV2({
    apiKey: zeroXApiKey,
  })

  const publicClient = getPublicClient(chain)

  const account = privateKeyToAccount(privateKey)

  const walletClient = getWalletClient({ chain, privateKey })

  const quote = await client.swap.permit2.getQuote.query({
    sellToken: from,
    buyToken: to,
    chainId: chain.id,
    sellAmount: amount.toString(),
    taker: account.address,
  })

  if ("issues" in quote) {
    const { allowance } = quote.issues
    if (allowance) {
      const { spender } = allowance
      await setErc20Allowance({
        chain,
        privateKey,
        tokenAddress: from,
        spender: spender as Address,
        amount: maxUint256,
      })
    }
  }

  const transaction = assertField(quote, "transaction")

  const { eip712 } = assertField(quote, "permit2")

  const signature = await walletClient.signTypedData(eip712 as any)

  const signatureLengthInHex = numberToHex(size(signature), {
    signed: false,
    size: 32,
  })

  transaction.data = concat([
    transaction.data as Hex,
    signatureLengthInHex,
    signature,
  ])

  const nonce = await publicClient.getTransactionCount({
    address: account.address,
  })

  const hash = await walletClient.sendTransaction({
    gas: BigInt(shouldBePresent(transaction.gas, "gas")),
    to: transaction.to as Address,
    data: transaction.data as `0x${string}`,
    value: BigInt(transaction.value),
    gasPrice: BigInt(transaction.gasPrice),
    nonce,
  })

  return assertTx({ publicClient, hash })
}
Enter fullscreen mode Exit fullscreen mode

We’re using Permit2 because it simplifies and streamlines token approvals for different protocols. Instead of needing multiple transactions and approvals, Permit2 consolidates these steps with a single signature, reducing gas costs and making the swap process faster and more straightforward.

Ensuring Sufficient Allowance

We check if there’s an allowance issue in the quote because smart contracts need permission to transfer tokens on your behalf. If you haven’t already approved the contract (spender) to move your tokens, we must set the token allowance so the swap can succeed. This extra step ensures the swap contract has the necessary access, preventing transaction failures due to insufficient allowance.

import { Address, Chain, erc20Abi } from "viem"
import { assertTx } from "../utils/assertTx"
import { getPublicClient } from "../utils/getPublicClient"
import { getWalletClient } from "../utils/getWalletClient"

type Input = {
  chain: Chain
  privateKey: `0x${string}`
  tokenAddress: Address
  spender: Address
  amount: bigint
}

export async function setErc20Allowance({
  chain,
  privateKey,
  tokenAddress,
  spender,
  amount,
}: Input) {
  const walletClient = getWalletClient({ chain, privateKey })

  const hash = await walletClient.writeContract({
    address: tokenAddress,
    abi: erc20Abi,
    functionName: "approve",
    args: [spender, amount],
  })

  return assertTx({ publicClient: getPublicClient(chain), hash })
}
Enter fullscreen mode Exit fullscreen mode

Adding EIP-712 Signatures

We’re appending the EIP-712 signature to the transaction data so the contract can verify that you’ve granted permission for Permit2. By signing the typed data first and then adding the signature (along with its length) to the transaction, the on-chain contract can confirm it’s authorized to move your tokens according to the Permit2 specification, all in a single, seamless step.

Preparing and Sending Transactions

The transaction generated from the quote already includes all the necessary fields. Our task is to convert specific fields, add appropriate types, and include the nonce before sending the transaction.

Verifying Transaction Success

To ensure the transaction is successful before proceeding, we use the assertTx function to wait for the transaction receipt. If the transaction fails, an error is thrown with the transaction’s status.

import { PublicClient } from "viem"

type Input = {
  publicClient: PublicClient
  hash: `0x${string}`
}

export const assertTx = async ({ publicClient, hash }: Input) => {
  const receipt = await publicClient.waitForTransactionReceipt({ hash })

  if (receipt.status !== "success") {
    throw new Error(`Transaction was not successful. Status: ${receipt.status}`)
  }

  return hash
}
Enter fullscreen mode Exit fullscreen mode

Implementing the Core Function for Limit Orders

With all the essential components in place, we can now implement the core function responsible for executing limit orders.

import { match } from "@lib/utils/match"
import { getAssetPrices } from "../../../lib/chain/price/utils/getAssetPrices"
import { deleteLimitOrder, getAllLimitOrders } from "../db/limitOrders"
import { sendSwapNotification } from "./sendSwapNotification"
import { swapErc20Token } from "../../../lib/chain/evm/erc20/swapErc20Token"
import { getSecret } from "../getSercret"
import {
  limitOrderAssetAddress,
  limitOrderChain,
} from "../entities/LimitOrderAsset"
import { getErc20Balance } from "../../../lib/chain/evm/erc20/getErc20Balance"
import { recordMap } from "@lib/utils/record/recordMap"
import { privateKeyToAddress } from "viem/accounts"

export const runLimitOrders = async () => {
  const items = await getAllLimitOrders()

  const assets = items.map((item) => item.asset)

  const assetPrices = await getAssetPrices({ ids: assets })

  await Promise.all(
    items.map(async ({ id, condition, asset, targetPrice, swap }) => {
      const price = assetPrices[asset]
      const isConditionMet = match(condition, {
        more: () => price > targetPrice,
        less: () => price < targetPrice,
      })

      if (!isConditionMet) {
        return
      }

      const zeroXApiKey = await getSecret("zeroXApiKey")

      const privateKey = await getSecret<`0x${string}`>("accountPrivateKey")

      const amount = await getErc20Balance({
        chain: limitOrderChain,
        address: limitOrderAssetAddress[swap.from],
        accountAddress: privateKeyToAddress(privateKey),
      })

      await swapErc20Token({
        zeroXApiKey,
        privateKey,
        amount,
        chain: limitOrderChain,
        ...recordMap(swap, (asset) => limitOrderAssetAddress[asset]),
      })

      await sendSwapNotification({
        swap,
        asset,
        price,
      })

      return deleteLimitOrder(id)
    }),
  )
}
Enter fullscreen mode Exit fullscreen mode

First, we retrieve all limit orders from the database and fetch the current asset prices. For each order, we evaluate whether its condition is met; if not, the order is skipped. If the condition is met, we fetch the 0x API key and the private key from the secrets. We then check the balance of the from asset and invoke the swapErc20Token function to execute the swap. Once the swap is completed, we send a notification and remove the limit order from the database.

Deploying the Service with AWS Lambda

We'll deploy our code as an AWS Lambda function, wrapping it with Sentry to receive notifications about any potential issues.

import { AWSLambda } from "@sentry/serverless"
import { getEnvVar } from "./getEnvVar"
import { runLimitOrders } from "./core/runLimitOrders"

AWSLambda.init({
  dsn: getEnvVar("SENTRY_KEY"),
})

exports.handler = AWSLambda.wrapHandler(runLimitOrders)
Enter fullscreen mode Exit fullscreen mode

Automating with Terraform

To provision the necessary AWS resources for our services, we will use Terraform. To ensure the function runs every 10 minutes, we’ll configure a CloudWatch Event Rule. The Terraform code for this setup is available in the GitHub repository.

Conclusion

By combining the 0x Swap API, AWS Lambda, and Terraform, we’ve built a streamlined and efficient service for executing limit orders on Polygon. With robust notifications, secure secrets management, and automated scheduling, this system is ready to handle trades reliably and effectively.

Top comments (0)