Safety¶
This bot signs and broadcasts irreversible mainnet transactions. The defaults are conservative but not foolproof. Read this whole page before any change that touches executors, slippage, gas, approvals, or signers.
Last updated: 2026-05-13
1. The risk profile¶
What this bot can do badly if misconfigured:
- Over-slip on removal — accept far less than fair value because MAX_SLIPPAGE_BPS is too high.
- Burn gas in a fee spike — broadcast at any price because MAX_GAS_GWEI is too high.
- Get sandwich-attacked — public-mempool exposure on Ethereum mainnet.
- Approve a malicious LP token — interact with a fake pool. Mitigated by TOKEN_BLACKLIST and V2 honeypot pre-flight, but not eliminated.
- Drain to the wrong address — EMERGENCY_WALLET is treated as trusted; if it's compromised, all positions get drained.
- Get front-run on the /remove Telegram command — anyone with Telegram chat access can trigger a tx.
Treat the wallet running this bot as a hot wallet. Hold only what you're willing to lose to a worst-case bug.
2. Safety knobs (env vars)¶
| Var | Purpose | Recommended starting value | Notes |
|---|---|---|---|
DRY_RUN |
Simulate only | true for any new config |
Set to false only after ≥24h clean dry run |
MAX_SLIPPAGE_BPS |
Max accepted slippage on removal | 100 (1%) |
Tighten for stable pairs (e.g., 30); loosen only for very thin pools |
MAX_GAS_GWEI |
Hard cap; tx aborts above this | 150 mainnet, 5 Base |
Tx aborts before broadcast — does not retry until gas drops |
GAS_PRIORITY_GWEI |
Priority fee | 2 mainnet, 0.001 Base |
Too low → tx stuck pending |
TX_DEADLINE_SEC |
V2 removeLiquidity deadline | 600 |
Tx reverts if not mined within this window — protects against stale prices |
MIN_LIQUIDITY_USD |
Skip dust positions | 50 |
Below this, gas exceeds redeemable value |
TOKEN_BLACKLIST |
Comma-separated lowercase addresses; never interacted with | tokens you know are scams | Per-executor check; mirror in any new executor |
AUTO_REVOKE_APPROVALS |
Set LP allowance → 0 after removal | true |
Cheap insurance against future router exploits |
MAX_TX_PER_MINUTE |
Token-bucket cap | 3 |
Cascading triggers cannot run away |
MAX_TX_PER_HOUR |
Same | 20 |
Same |
EMERGENCY_WALLET |
Dead-man's switch wallet | dedicated cold address | See §3 |
3. Emergency wallet¶
A dead-man's switch. Designate a wallet other than the one running this bot. If you ever spot trouble (suspected key compromise, anomaly on the pool, you're away from a keyboard), send any transaction from the emergency wallet — even a 1-wei self-transfer. The bot, on seeing that tx confirmed in a block, fires removal on every position immediately.
How it works internally¶
eventMonitor.jssubscribes to new blocks and scansblock.prefetchedTransactions[*].from.- If
from.toLowerCase() === EMERGENCY_WALLET.toLowerCase(), it emits anemergencytrigger to the engine. - The trigger engine bypasses all other predicates and dispatches removal for every position.
Trade-offs¶
| Property | Value |
|---|---|
| Latency | ~12 s (one block confirmation) |
| False positives | Low (you control the wallet) |
| Mempool spoofing risk | None — we only check confirmed inclusion |
| MEV race | None — by the time we see the tx, it's already mined |
| Cost to trigger | One trivial tx fee from the emergency wallet |
Operational notes¶
- The emergency wallet must hold a small amount of native gas. Refill periodically.
- Keep its private key on hardware or cold storage.
- Do not reuse this wallet for anything else.
- Test it: in dry-run mode, send a tx from the emergency wallet and confirm the bot logs
emergency trigger firedfor every position.
4. Signers and keys¶
Priority order in 📁 src/security/keystore.js:
- Ledger — preferred for non-trivial position size. Every removal tx requires a physical button press, which kills any silent-drain attack but means you must be near the device.
- Encrypted keystore —
./keystore.jsonproduced bynpm run encrypt-key. Password protects the key at rest. Suitable for VPS / Docker deployments. - Raw private key —
EVM_PRIVATE_KEYin.env. Refused inNODE_ENV=production. Dev-only.
Rules¶
- Never commit
.envorkeystore.json. Both are in.gitignore— keep it that way. - Rotate keys after any suspected exposure (you pasted in chat, plugged USB into untrusted host, etc.).
- Use a dedicated wallet for this bot. Do not use your main wallet.
- Solana has no Ledger / keystore support yet — use a separate hot wallet, minimal SOL.
5. Gas cap behavior¶
utils/gas.js::gasAboveCap() is called inside every EVM executor before the broadcast. If (baseFee + priorityFee) > MAX_GAS_GWEI:
- Tx is not broadcast.
- Logged as a warning, not an error.
- The trigger remains in
firingstate until the executor returns; on return, the engine callsmarkFailed, which clearsfiring. The next trigger event will re-evaluate.
This is intentional — the bot does not retry until you drop the cap or gas drops. During severe congestion, you may need to manually raise MAX_GAS_GWEI and restart.
6. Slippage math¶
utils/slippage.js::applySlippage(amount, bps) is bigint-only. Mixing Number and bigint will throw at runtime. Reads:
bps=0→ no slippage tolerance (often reverts; use only on stable pairs in calm conditions).bps=10000→ 100% slippage; accepts any amount. Never use this in prod.- V3 derives
amount0Min/amount1Minfrom adecreaseLiquidity.staticCallso the slippage is applied to a realistic expectation, not the theoretical max.
7. MEV¶
On Ethereum mainnet, the public mempool is hostile. Any sufficiently large removeLiquidity tx may get sandwiched.
Mitigations the bot already provides:
- Slippage cap (MAX_SLIPPAGE_BPS) — sandwicher cannot extract more than this.
- Honeypot pre-flight (V2) — staticCall simulation aborts if the pool refuses removal.
Mitigations you must add yourself for non-trivial size:
- Use a private mempool RPC — Flashbots Protect, MEV Blocker, or BloXroute. Set ETH_RPC_HTTP to the private endpoint; keep ETH_RPC_WS on a public endpoint for monitoring.
- Split large positions into multiple smaller LP tokens and stagger removal — reduces the per-tx attacker payoff.
- Avoid trigger thresholds that everyone else uses (e.g., round 10%/20%/30% drops) — those are easy to front-run.
L2 / Solana are less hostile but not immune; the same private-RPC pattern helps where available.
8. Logging¶
pino logger in 📁 src/logger.js redacts on two layers:
- Key-name redaction — the
SENSITIVE_KEYSlist (private keys, keystore password, bot token, webhook URL, CoinGecko key, and every*_RPC_*URL, since those carry provider API keys) is blanked wherever it appears, up to 4 levels of nesting. - Value scrubbing serializers —
url/rpc/endpointfields are masked to host-only via📁 src/utils/redact.js::maskUrl;err/error/reasonstrings are run throughscrubSecrets, which masks any embedded 64+‑char hex (private keys), base58 secret blobs, and key-bearing URLs.
Rules¶
- If you add a new sensitive env var, add it to
SENSITIVE_KEYS. Covered by📁 tests/redact.test.jsfor the helpers, but the key list itself relies on code review. - Never log a full RPC URL,
Wallet, orKeypair. Log the masked host ({ rpc }is auto-masked) ormaskAddress(signer.address). - Default log level for production is
info.debug/tracemay emit pool addresses + amounts that, while not secrets, leak strategy.
8a. Privacy & identity¶
The goal: nothing links your operator identity (wallet address, IP) to your activity beyond what is unavoidably public on-chain.
| Vector | Exposure | Mitigation |
|---|---|---|
| RPC provider | Sees your IP + every address/position you query | SOCKS_PROXY=socks5h://127.0.0.1:9050 (Tor) or HTTPS_PROXY — routes all egress including the ethers RPC HTTP + WebSocket and the Solana RPC HTTP. REQUIRE_PROXY=true (default) makes an un-proxied live start a fatal preflight error. Use socks5h, not socks5: the latter resolves DNS locally and leaks every hostname to your resolver (flagged at boot). |
| Solana account-subscription WS | SDK-owned socket, cannot carry the proxy agent | Not covered by SOCKS_PROXY. The preflight warns whenever a Solana position is configured; add OS-level Tor/VPN, or drop the position. |
| Broadcast / mempool | The removal tx is public and permanent, and broadcasting through the read RPC correlates it with the monitoring subscription milliseconds earlier | ETH_/BASE_/BSC_BROADCAST_RPC (e.g. Flashbots Protect) sends only the signed tx to a private relay; reads stay on the normal RPC. Keeps the tx out of the public mempool until mined and decouples it from the read path. |
| CoinGecko (PnL) | Reveals which tokens you hold to a third party | Off by default (PRICE_LOOKUP_ENABLED=false) — only affects cosmetic USD PnL, never triggers. |
| Log stream | An address in a log line is your on-chain identity, and logs outlive the run (journald, docker json-file) | Addresses are masked by serializer (address, wallet, signer, from, to); position objects are reduced to chain/dex/masked-id, dropping the token0/token1/poolAddress trail. LOG_FULL_ADDRESS=true opts out — don't. Use LOG_LEVEL=warn live; debug records token contracts. |
| Dashboard → your browser | Your browser is not behind the proxy — any remote asset it loads hands a third party your real IP and a timestamp for every dashboard open | The page is fully self-contained: docs-viewer libraries are vendored under public/vendor/, and the CSP (default-src 'none', connect-src 'self') permits no remote origin. Enforced by npm run security:scan. |
| Dashboard → network | Serves wallet address + positions | Bound to 127.0.0.1 by default; address masked unless DASHBOARD_SHOW_FULL_ADDRESS=true. A non-loopback bind requires DASHBOARD_TOKEN or the preflight refuses to start. |
data/events.jsonl |
Plaintext record of positions/PnL/tx hashes on disk, surviving restarts | Off by default (DASHBOARD_PERSIST=false). When enabled, written 0600 in a 0700 directory. |
| Secret files at rest | .env/keystore readable by other OS users |
ENFORCE_FILE_PERMS=true (default) refuses to start in live mode; run chmod 600 .env keystore.json. |
| Alerts (Telegram/Discord) | A proxy hides your IP from them, not your account — contents are retained server-side and are not end-to-end encrypted | Inherent. Leave TELEGRAM_BOT_TOKEN/DISCORD_WEBHOOK_URL blank to opt out; a self-hosted sink reached via the proxy is the private alternative. |
| Container logs | Docker retains bot stdout on disk | docker-compose.yml caps retention at 4 MB; set the driver to none for no log trace at all. |
All egress shares one proxy agent (📁 src/utils/proxyAgent.js), so a single
SOCKS_PROXY/HTTPS_PROXY setting covers ethers RPC (HTTP + WS), the Solana
connection, and axios (alerts, price) at once.
Fail-closed, and sticky. A configured-but-unbuildable proxy throws on every
call, not just the first, and securityPreflight probes it at boot outside any
catch. This matters: most call sites treat an RPC error as recoverable, so a
one-shot throw used to be swallowed and every later call silently connected
direct. If you touch proxyAgent.js, keep the failure cached and re-thrown.
"Live" means DRY_RUN=false or NODE_ENV=production (config.IS_LIVE).
Every safety gate keys on that, not on NODE_ENV alone — flipping DRY_RUN is
how you actually go live.
Minimal setup for full IP privacy¶
- Run Tor locally (e.g.
brew install tor && tor, listening on 9050). - In
.env:SOCKS_PROXY=socks5h://127.0.0.1:9050(note the h). - Boot — you should see
All egress (RPC + HTTP) routed through SOCKS proxy — IP hidden. Its absence is the signal something is wrong.
That routes the RPC connection, alerts, and price lookups through Tor. No other change is needed; automation is unaffected.
Auditing a change¶
npm run security:scan proves the decidable invariants (egress chokepoint, proxy
fail-closed, logger redaction contract, privacy defaults, dashboard
self-containment, executor interlocks) and runs on every PR via
.github/workflows/security.yml. For the judgement half — new SDKs that open
their own sockets, guards that can degrade, addresses logged under novel key
names — run the security-audit skill against the diff.
Unavoidable: every removal is a public mainnet transaction from your wallet. Privacy here means not additionally leaking your IP/identity through the bot's own machinery — it cannot make on-chain activity anonymous. For that, fund the wallet privately and use a private mempool (see §7 MEV).
9. Blacklist¶
TOKEN_BLACKLIST is enforced per executor, not globally registered. Every executeXxxRemoval calls _assertNotBlacklisted([token0, token1]) against config.blacklist before submitting.
When adding a new DEX, copy this pattern. If you forget, blacklist entries silently do nothing on that DEX.
What to put on the blacklist¶
- Known scam tokens / rugs you've identified.
- Tokens with fee-on-transfer mechanics that break LP math.
- Tokens that have been used as honeypots against pools you watched.
- Stale tokens whose contracts have been paused or had ownership transferred to suspicious addresses.
10. Pre-deploy safety checklist¶
Run through this every time you change executors, signers, chains, or risk parameters:
- Unit tests green (
npm run test:run) - Lint clean (
npm run lint) - Audit reviewed (
npm run audit) -
.envreviewed line-by-line — no secrets in plain text where avoidable -
DRY_RUN=trueuntil validated -
MAX_SLIPPAGE_BPSset per pool type (tight for stable, looser for volatile, never above ~300 bps without explicit reason) -
MAX_GAS_GWEIset per chain -
EMERGENCY_WALLETset and funded with gas -
TOKEN_BLACKLISTreviewed - Signer = Ledger or encrypted keystore (raw key blocked in production — but verify)
- Logger redaction list still covers any new env vars
- Telegram alerts test-pinged
- ≥24 h continuous dry run, no unhandled errors
- Sensitivity probe completed (forced trigger, verified executor logged correct tx)
- Manual
/removetest completed in dry-run - One person standing by for the first hour live
If you can't tick every box, stay on dry-run.