A relayer that runs out of gas does not fail loudly. Transactions stop landing, a queue grows somewhere, and the first sign is usually a user asking why their withdrawal is stuck. The message you want instead looks like this, and it arrives while there is still time to top the wallet up:
:warning: ops-2 on BNB Chain (0xd8dA6B) is down to 0.1701 BNB, minimum is 0.2TL;DR
One TypeScript file reads the native balance of every operational wallet on each chain with a single evmquery request per chain, compares it to that chain’s minimum, and posts to a Slack incoming webhook only when a wallet crosses the line in either direction. Cron runs it every five minutes.
Prerequisites
- Node 22.18 or newer (Node 24 works too), which runs
.tsfiles directly withnode --env-file=.env alert.ts. - An evmquery API key from the free signup.
- A Slack incoming webhook URL for the channel that should receive alerts.
- The addresses of the wallets that pay for gas: relayers, keepers, deployers, bridge or payment hot wallets. Only the addresses, never keys.
Set up the project:
npm init -ynpm pkg set type=modulenpm install @evmquery/sdkPut the secrets in a .env file next to the script:
EVMQUERY_API_KEY=your-keySLACK_WEBHOOK_URL=https://hooks.slack.com/services/...Reading every wallet on a chain in one request
The native balance of an address is a method on the address itself. If w is an address, w.balance() returns its balance in wei, and formatUnits(..., 18) turns that into whole tokens. All four chains use 18 decimals for their native token, so the 18 can stay a literal.
To read several wallets at once, declare a list<sol_address> context variable and map over it:
const { data } = await evmquery.query({ throwOnError: true, body: { chain: "evm_base", schema: { contracts: {}, context: { wallets: "list<sol_address>" } }, context: { wallets: ["0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045", "0x6887246668a3b87F54DeB3b94Ba47a6f63F32985"] }, expression: "wallets.map(w, formatUnits(w.balance(), 18))", },});// data.result.value -> [3.1288531903613066, 0]The result is an array in the same order as the input, so index i of the balances belongs to index i of your wallet list. A list literal also works, for example [formatUnits(solAddress("0x...").balance(), 18), ...], but it needs every address pasted into the expression string. The context variable keeps the expression constant and the addresses in your config.
The addresses here are real public ones, picked because they hold different amounts on each chain. They are stand-ins, not wallets of ours. One of them is vitalik.eth, which shows up on several chains because it is the address everyone already has memorized.
Deciding the minimum for each chain
The threshold is the only judgment call in the script, and a number picked by feel is how you end up with an alert that fires constantly or never. Size it from spend:
- Pull the last two weeks of outgoing transactions for the wallet from a block explorer and find the busiest day.
- Multiply that day’s transaction count by the typical gas used per transaction and the typical gas price. That is the native token the wallet burns on a bad day.
- Multiply by the number of days your team needs to notice, approve and send a top-up. Count a weekend if one can happen in between.
An example with made-up numbers: a relayer on Ethereum sends 40 transactions on its busiest day at about 100,000 gas each and 15 gwei. That is 40 × 100,000 × 15e-9 = 0.06 ETH a day. If a refill takes a team two working days plus a weekend buffer, a minimum of 0.5 ETH is roughly eight days of runway, which is generous. On Base the same relayer might send 300 transactions at 120,000 gas and 0.5 gwei, about 0.018 ETH a day, so a minimum of 0.05 ETH is just under three days. That is why the thresholds in the script differ so much per chain: they are days of spend, not a dollar value.
Keep the minimum in the native token. Converting to dollars adds a price feed to a script whose whole job is to be boring, and a gas wallet needs tokens, not dollars.
The configuration
Each chain carries its own id, display name, native symbol, minimum and wallet list. Ethereum and Base both use ETH, BNB Chain uses BNB, and Polygon uses POL, so the symbol is config data rather than something the script can infer.
import { createEvmqueryClient } from "@evmquery/sdk";import { existsSync, readFileSync, writeFileSync } from "node:fs";
const evmquery = createEvmqueryClient({ apiKey: process.env.EVMQUERY_API_KEY });
type Wallet = { name: string; address: string };type ChainConfig = { chain: string; name: string; symbol: string; min: number; // alert below this many native tokens wallets: Wallet[];};
const CHAINS: ChainConfig[] = [ { chain: "evm_ethereum", name: "Ethereum", symbol: "ETH", min: 0.5, wallets: [ { name: "ops-1", address: "0x5050F69a9786F081509234F1a7F4684b5E5b76C9" }, { name: "ops-2", address: "0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045" }, ], }, { chain: "evm_base", name: "Base", symbol: "ETH", min: 0.05, wallets: [ { name: "ops-1", address: "0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045" }, { name: "ops-2", address: "0x6887246668a3b87F54DeB3b94Ba47a6f63F32985" }, ], }, { chain: "evm_bnb_mainnet", name: "BNB Chain", symbol: "BNB", min: 0.2, wallets: [ { name: "ops-1", address: "0x8894E0a0c962CB723c1976a4421c95949bE2D4E3" }, { name: "ops-2", address: "0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045" }, ], }, { chain: "evm_polygon", name: "Polygon", symbol: "POL", min: 50, wallets: [ { name: "ops-1", address: "0xe7804c37c13166fF0b37F5aE0BB07A3aEbb6e245" }, { name: "ops-2", address: "0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045" }, ], },];The client takes its key from the environment. It throws an EvmqueryError on any non-2xx response, which the loop below catches per chain.
State and the Slack call
Alerting on every run would post the same warning every five minutes until someone refills the wallet, and people mute channels like that within a day. The script stores the last known status of each wallet in state.json, keyed by chain and address, and speaks only when the status changes.
const STATE_FILE = new URL("state.json", import.meta.url);type State = Record<string, "ok" | "alert">;
async function sendSlack(text: string): Promise<void> { const res = await fetch(process.env.SLACK_WEBHOOK_URL!, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ text }), }); if (!res.ok) throw new Error(`Slack returned ${res.status}`);}
const state: State = existsSync(STATE_FILE) ? JSON.parse(readFileSync(STATE_FILE, "utf8")) : {};const lines: string[] = [];A wallet with no entry counts as ok. That means a wallet that is already low the first time you run the script gets an alert immediately.
Comparing balances and detecting transitions
The loop does one read per chain, then walks the wallets in order.
for (const { chain, name, symbol, min, wallets } of CHAINS) { let balances: number[]; try { const { data } = await evmquery.query({ throwOnError: true, body: { chain, schema: { contracts: {}, context: { wallets: "list<sol_address>" } }, context: { wallets: wallets.map((w) => w.address) }, expression: "wallets.map(w, formatUnits(w.balance(), 18))", }, }); balances = data.result.value as number[]; } catch (err) { console.error(`${name}: read failed`, err); process.exitCode = 1; continue; // keep the old state for this chain }
wallets.forEach((wallet, i) => { const key = `${chain}:${wallet.address}`; const balance = balances[i]; const now = balance < min ? "alert" : "ok"; const before = state[key] ?? "ok"; const label = `${wallet.name} on ${name} (${wallet.address.slice(0, 8)})`; if (now === "alert" && before === "ok") { lines.push(`:warning: ${label} is down to ${balance.toFixed(4)} ${symbol}, minimum is ${min}`); } else if (now === "ok" && before === "alert") { lines.push(`:white_check_mark: ${label} is back at ${balance.toFixed(4)} ${symbol}`); } state[key] = now; });}
if (lines.length > 0) await sendSlack(lines.join("\n"));writeFileSync(STATE_FILE, JSON.stringify(state, null, 2));Two details in there matter. A failed read for one chain skips that chain without touching its stored state and sets a non-zero exit code, so a flaky chain neither wipes your state nor hides the other three. And sendSlack runs before writeFileSync: if Slack is down, the script throws, the state file is not updated, and the next run sends the same alert again instead of losing it.
All alerts from one run go out as a single Slack message. If a bridge drains three wallets at once you get one message with three lines.
What it printed
I ran the file against the live chains with a local HTTP server standing in for Slack. The first run, with no state.json, found three wallets under their minimums and sent one message:
:warning: ops-2 on Base (0x688724) is down to 0.0000 ETH, minimum is 0.05:warning: ops-2 on BNB Chain (0xd8dA6B) is down to 0.1701 BNB, minimum is 0.2:warning: ops-1 on Polygon (0xe7804c) is down to 0.8019 POL, minimum is 50A second run a few seconds later sent nothing, because all three were already in the alert state. To test recovery I edited state.json by hand to mark a healthy wallet as alert, ran the script again, and got:
:white_check_mark: ops-2 on Ethereum (0xd8dA6B) is back at 5.7938 ETHBalances move, so the digits you see will differ. The shape of the three runs is the part to compare.
Running it from cron
Node can load the .env file itself with --env-file, so the crontab line does not need a wrapper script. Cron has no shell profile and a minimal PATH, so use the absolute path to node (which node shows it):
*/5 * * * * cd /opt/wallet-alerts && /usr/local/bin/node --env-file=.env alert.ts >> alert.log 2>&1The cd makes the .env file and alert.log resolve; state.json is found from the script’s own location. Each run makes one request per chain, four in total, and each response reported units.consumed of 1 when this was written. At one run every five minutes that is about 48 units an hour, well under the free tier’s 700 units an hour. The developers page covers the plain REST route if you prefer fetch over the SDK. The same read through viem and Multicall3 also works, but you assemble the batch and decimals yourself, as the Multicall3 guide shows.
Or skip the cron: run it as a monitor
The minimum check also fits an evmquery monitor, which replaces the crontab and state.json. A monitor reads one chain, so create one per chain and wallet. This is the Base relayer from the first example against the 0.05 ETH minimum:
formatUnits(solAddress("0x6887246668a3b87F54DeB3b94Ba47a6f63F32985").balance(), 18) < 0.05Use a condition trigger. It fires once when the balance drops below the minimum and stays quiet until it has risen above it and dropped again. Point the webhook channel at a small receiver that reuses the Slack call from this post. To hear about recovery too, add a second monitor with the opposite comparison. evmquery retries failed deliveries and pauses the monitor with an alert after repeated errors.
When the balance is fine but the wallet is stuck
A wallet with enough ETH can still be stuck, for example by a nonce gap where one pending transaction blocks every later one. Gas balance and nonce health are two separate checks, and this script covers the balance side. For token balances there is the ERC-20 balance scan.
Next steps
- Wallet balance change alerts in n8n for a no-code version that alerts on any change instead of a minimum.
- Scanning many ERC-20 balances from TypeScript for the token-balance side of the same wallets.
- Multicall3 batching for what happens under the hood when one request reads many addresses.
- Monitors if you would rather have evmquery run the balance check on a schedule and send an email or webhook.


