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.
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>
}
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
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,
})
}
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
}
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()
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)
}
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)
}
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])
}
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()
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)
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],
})
}
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 })
}
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.
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 })
}
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.
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.
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
}
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)
}),
)
}
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.
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)
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.
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.