How to build a crypto trading bot that signs its own transactions
A trading bot needs a strategy, a way to build the trade, and a key that signs it. Here is how to run all three as one Lit Action on a schedule, with limits written into its code and no server holding the key.
A trading bot has three parts. There is code that decides when to trade, code that builds the transaction, and a key that signs it. The usual setup keeps the key in an environment variable on the same server as the strategy, so a bug or a break-in can sign anything. On Lit Protocol you can run the whole bot as a Lit Action and have Lit Triggers run it on a schedule. The action checks the price, applies your limits, and signs with a key that only that exact code can use. You do not run a server, and nobody holds the key.
I will build a small take-profit bot. Every hour it checks the price of ETH, and if ETH is above $4,000 it sells 0.01 ETH for USDC on Base.
The bot
A Lit Action is a JavaScript function that runs inside a secure enclave. Every action has its own wallet, and the key for that wallet is derived from a hash of the action's code. If you change one character of the code, you get a different wallet. So the rules in this file are tied to the money in the wallet.
// bot.js
const OWNER = "0xYourOwnWallet..."; // the only address it will withdraw to
const USDC = "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913"; // USDC on Base
const ROUTER = "0xYourAggregatorRouter..."; // the only contract it will call
const SELL_ABOVE = 4000; // USD per ETH
const SELL_ETH = "0.01"; // the most it sells in one run
const main = async (params) => {
const provider = new ethers.providers.StaticJsonRpcProvider("https://mainnet.base.org");
const wallet = new ethers.Wallet(await Lit.Actions.getLitActionPrivateKey(), provider);
if (params.addressOnly) return { address: wallet.address };
if (params.withdraw) {
const usdc = new ethers.Contract(USDC, ["function balanceOf(address) view returns (uint256)", "function transfer(address,uint256) returns (bool)"], wallet);
const usdcBalance = await usdc.balanceOf(wallet.address);
if (usdcBalance.gt(0)) await (await usdc.transfer(OWNER, usdcBalance)).wait();
const reserve = ethers.utils.parseEther("0.0001"); // left behind to pay for the ETH transfer
const balance = await wallet.getBalance();
if (balance.lte(reserve)) return { withdrew: true, usdc: usdcBalance.toString() };
const tx = await wallet.sendTransaction({ to: OWNER, value: balance.sub(reserve) });
return { withdrew: true, usdc: usdcBalance.toString(), hash: tx.hash };
}
const prices = await fetch("https://api.coingecko.com/api/v3/simple/price?ids=ethereum&vs_currencies=usd").then(r => r.json());
const price = prices.ethereum.usd;
if (price < SELL_ABOVE) return { traded: false, price };
const sellWei = ethers.utils.parseEther(SELL_ETH);
const quote = await fetch(`https://quotes.example.com/swap?chain=base&sell=ETH&buy=USDC&amount=${sellWei}&taker=${wallet.address}`)
.then(r => r.json());
if (quote.to.toLowerCase() !== ROUTER.toLowerCase()) return { traded: false, reason: "unexpected contract" };
if (ethers.BigNumber.from(quote.value).gt(sellWei)) return { traded: false, reason: "quote sells too much" };
const minUsdc = Math.floor(price * Number(SELL_ETH) * 0.99 * 1e6); // at least 99% of the price it just read
if (Number(quote.buyAmount) < minUsdc) return { traded: false, reason: "quote too low", price };
const tx = await wallet.sendTransaction({ to: quote.to, data: quote.data, value: quote.value });
return { traded: true, price, hash: tx.hash };
};quotes.example.com stands in for whichever DEX aggregator you use. Most of them return the router address, the calldata, the value to send, and the amount you will get back, which is what the bot checks.
The bot does trust the aggregator's calldata. It checks which contract it calls and how much ETH it sends, but it does not decode the swap itself, so a compromised aggregator could send the USDC somewhere else. For real money, decode the router call inside the action and check that the recipient is the bot's own address and that the minimum output is at least minUsdc.
Notice that the settings are constants at the top of the file. Anyone who has the API key that runs this action can call it with whatever parameters they want. So the owner, the router, the price, and the size are written into the code, where a caller cannot change them. The only two parameters the bot reads are addressOnly and withdraw, and neither one can send money anywhere except OWNER.
A caller with the key can also run the trading part whenever they like, not only once an hour. So SELL_ETH limits each run, not each hour. Keep the usage key as private as you would keep a private key, and only put in the wallet what you are willing to have the bot sell.
Setting it up
Go to the Lit dashboard and click Create an account. Choose API key, fill in your email and an account name, and copy the account API key it gives you. Add some credit with Add Funds. Then get the action's content hash, put it in a group, and make an API key that can only run that group.
LIT=https://api.chipotle.litprotocol.com/core/v1
CID=$(jq -Rs . bot.js | curl -s -X POST "$LIT/get_lit_action_ipfs_id" -H "Content-Type: application/json" -d @- | jq -r .)
GROUP=$(curl -s -X POST "$LIT/add_group" -H "X-Api-Key: $ACCOUNT_KEY" -H "Content-Type: application/json" \
-d '{"group_name":"bot","group_description":"","pkp_ids_permitted":[],"cid_hashes_permitted":[]}' | jq -r .group_id)
curl -s -X POST "$LIT/add_action_to_group" -H "X-Api-Key: $ACCOUNT_KEY" -H "Content-Type: application/json" \
-d "{\"group_id\":$GROUP,\"action_ipfs_cid\":\"$CID\"}"
USAGE_KEY=$(curl -s -X POST "$LIT/add_usage_api_key" -H "X-Api-Key: $ACCOUNT_KEY" -H "Content-Type: application/json" \
-d '{"name":"bot","description":"","can_create_groups":false,"can_delete_groups":false,"can_create_pkps":false,
"manage_ipfs_ids_in_groups":[],"add_pkp_to_groups":[],"remove_pkp_from_groups":[],"execute_in_groups":['"$GROUP"']}' \
| jq -r .usage_api_key)Ask the bot for its wallet address, and send it the ETH you want it to trade.
jq -n --rawfile code bot.js '{code: $code, js_params: {addressOnly: true}}' \
| curl -s -X POST "$LIT/lit_action" -H "X-Api-Key: $USAGE_KEY" -H "Content-Type: application/json" -d @-
# {"response":{"address":"0x..."},...}New permissions can take up to 30 seconds to start working, so keep retrying this call for a minute if it is refused.
Running it every hour
Lit Triggers runs an action on a schedule, when a webhook arrives, or when an event happens on-chain. You authorize it once from your browser. The pairing steps are in the Lit Triggers agent guide at triggers.litprotocol.com/SKILL.md, and you can hand that file to a coding agent to walk through them with you. At the end your token is authorized. Then create a schedule trigger with the bot's code and the API key from the last step.
jq -n --rawfile code bot.js --arg key "$USAGE_KEY" \
'{name: "eth-take-profit", kind: "schedule", action_code: $code, usage_api_key: $key, config: {cron: "0 * * * *"}}' \
| curl -s -X POST https://triggers.litprotocol.com/api/triggers \
-H "authorization: Bearer $TRIGGERS_TOKEN" -H "content-type: application/json" -d @-Each run is recorded with what the bot returned, so you can see every price it read and every trade it made.
curl -s -H "authorization: Bearer $TRIGGERS_TOKEN" "https://triggers.litprotocol.com/api/triggers/<id>/runs?limit=20"Changing the bot
Because the wallet comes from the code, a new version of the bot has a new wallet. Before you switch, call the old version with withdraw set to true, and its ETH and USDC go back to OWNER. Then fund the new version's address. If you would rather keep one address across versions, create a wallet with the API and use Lit.Actions.getPrivateKey instead. That wallet stays the same when you add a new version of the code to its group.
For real money, read the price from more than one source and compare them before you trade. The examples page has a price check that uses several sources.
When this is the wrong setup
Lit Triggers can run a bot as often as every 30 seconds. That works for take-profit, rebalancing, and buying on a schedule. It is too slow for market making or arbitrage, where you sign many times a second from your own server. For that kind of bot, keep the strategy on your server and have it call a Lit Action that checks each trade before signing. The post on signing from your backend without exposing the private key shows how.
Get started
To create an account, go to the Lit dashboard and click Create an account. Lit Triggers is documented at developer.litprotocol.com/lit-triggers.