Architecture¶
System design, module responsibilities, data flow, and the invariants you must not break.
Last updated: 2026-05-13
1. High-level data flow¶
flowchart LR
subgraph Onchain[On-chain]
UniV2[Uniswap V2 Pairs]
UniV3[Uniswap V3 Pools + NFTs]
Ray[Raydium AMM/CPMM/CLMM]
EmgWallet[(Emergency Wallet)]
end
subgraph Bot[TradingBot Process]
direction TB
Monitors[Monitors<br/>priceMonitor + eventMonitor]
TE[Trigger Engine]
Exec[Executor Dispatch]
Alerter[Alerter]
Monitors -- normalized events --> TE
TE -- 'fire(position)' --> Exec
Exec -- removeLiquidity tx --> SignerLayer
Exec -- success/failure --> TE
TE -- 'alert(...)' --> Alerter
Exec -- 'alert(...)' --> Alerter
end
subgraph SignerLayer[Signer Stack]
Ledger[Ledger Device]
Keystore[Encrypted Keystore]
RawKey[Raw Key dev only]
end
UniV2 -. swap events .-> Monitors
UniV3 -. swap events .-> Monitors
Ray -. account updates .-> Monitors
EmgWallet -. tx inclusion .-> Monitors
SignerLayer -- signed tx --> Broadcast[(RPC Provider)]
Broadcast --> UniV2
Broadcast --> UniV3
Broadcast --> Ray
Alerter --> Telegram[(Telegram)]
Alerter --> Discord[(Discord)]
Telegram -- '/remove, /status' --> TE
Principles:
- Push, not poll. Every input is a WebSocket subscription. No setInterval price loops.
- Single-writer concurrency. One Node process per signer. The reentrancy guard is in-memory; running two instances against the same wallet causes nonce collisions and double-spends.
- Pre-flight everything. Gas cap check, slippage calc, blacklist check, NFT-ownership check, and (for V2) a honeypot staticCall simulation all run before the broadcast.
2. Module map¶
flowchart TD
Index[src/index.js<br/>entry & glue] --> Cfg[src/config.js<br/>zod env loader]
Index --> Log[src/logger.js<br/>pino + redaction]
Index --> Chains[src/chains/]
Index --> Monitors[src/monitors/]
Index --> TE[src/triggers/triggerEngine.js]
Index --> ExecIdx[src/executors/index.js<br/>dispatch + retry]
Index --> Alerts[src/alerts/]
Index --> Sec[src/security/]
Chains --> Evm[chains/evm.js<br/>makeWsProvider + chain map]
Chains --> Sol[chains/solana.js<br/>connection]
Monitors --> PriceMon[priceMonitor.js<br/>V2/V3/Ray price feeds]
Monitors --> EvtMon[eventMonitor.js<br/>custom logs + emergency wallet]
ExecIdx --> ExecV2[executors/uniswapV2.js]
ExecIdx --> ExecV3[executors/uniswapV3.js]
ExecIdx --> ExecRay[executors/raydium.js]
ExecIdx --> Appr[executors/approvalManager.js]
Sec --> Keystore[security/keystore.js<br/>signer priority chain]
Sec --> RateLim[security/rateLimit.js<br/>token bucket]
Alerts --> Tg[alerts/telegram.js]
Alerts --> Dc[alerts/discord.js]
Alerts --> AlertIdx[alerts/index.js<br/>fan-out + explorer URLs]
Utils[src/utils/] --> Gas[utils/gas.js<br/>EIP-1559 + cap]
Utils --> Slip[utils/slippage.js<br/>bigint applySlippage]
Utils --> Pnl[utils/pnl.js<br/>USD value calc]
ExecV2 --> Gas
ExecV3 --> Gas
ExecV2 --> Slip
ExecV3 --> Slip
ExecRay --> Slip
ExecV2 --> Appr
ExecIdx --> RateLim
| Layer | Files | Responsibility |
|---|---|---|
| Entry | src/index.js |
Wire everything; subscribe monitors to engine; subscribe engine to executors |
| Config | src/config.js |
Single source of truth for env; zod validation; refuses raw key in prod |
| Chains | src/chains/{evm,solana}.js |
RPC providers; WebSocket reconnect logic |
| Monitors | src/monitors/* |
Subscribe and emit normalized events |
| Triggers | src/triggers/triggerEngine.js |
Evaluate trigger predicates; firing reentrancy guard; completed terminal set |
| Executors | src/executors/* |
Per-DEX removal flow; rate-limit + retry wrapper in index.js |
| Alerts | src/alerts/* |
Telegram + Discord fan-out; Telegram command listener |
| Security | src/security/* |
Keystore loader; rate limiter |
| Utils | src/utils/* |
Gas builder, slippage math, PnL |
| ABIs | abis/* |
Minimal human-readable ABIs + ADDRESSES map (chain → dex → role) |
3. Lifecycle of one removal¶
sequenceDiagram
autonumber
participant Pool as DEX Pool
participant Mon as priceMonitor
participant TE as TriggerEngine
participant Exec as Executor Dispatch
participant Impl as V2/V3/Ray Impl
participant Rate as RateLimiter
participant Signer
participant RPC
participant Alert
Pool->>Mon: Swap event (or account update)
Mon->>Mon: decode price (sqrtPriceX96 / reserves / AmmRpcData)
Mon->>TE: priceUpdate(position, price, deltaPct)
TE->>TE: evaluate triggers
alt trigger matches AND not already firing AND not completed
TE->>TE: firing.add(position)
TE->>Exec: fire(position, signer)
Exec->>Rate: acquire('remove')
Rate-->>Exec: ok
Exec->>Impl: executeXxxRemoval({position, signer})
Impl->>Impl: blacklist check
Impl->>Impl: gas cap check
Impl->>Impl: slippage calc (staticCall for V3)
Impl->>Signer: sign tx
Signer->>RPC: broadcast
RPC-->>Impl: receipt
Impl->>Alert: success
Impl-->>Exec: { tx, amounts }
Exec->>TE: markCompleted(position)
TE->>TE: completed.add(position) ; firing.delete(position)
else trigger no match
TE-->>Mon: noop
end
Failure path: if Impl throws, pRetry retries up to 2× with exponential backoff. Final failure → Exec calls TE.markFailed(position) which clears firing but does not add to completed — so the next trigger event will retry. This is deliberate: a single failure should not permanently disable a position.
4. Trigger engine state machine¶
stateDiagram-v2
[*] --> Idle: position registered
Idle --> Firing: trigger matches
Firing --> Idle: markFailed (retryable failure)
Firing --> Completed: markCompleted (tx confirmed)
Completed --> [*]
Idle --> Idle: trigger no match / already completed / already firing
note right of Firing
Reentrancy guard:
all further triggers for this
position are dropped while
in this state
end note
note right of Completed
Terminal. Position never
fires again in this process.
end note
Trigger types evaluated:
| Trigger | Source | Predicate |
|---|---|---|
priceDropPct |
priceMonitor | (entry - current) / entry * 100 >= threshold |
priceRisePct |
priceMonitor | (current - entry) / entry * 100 >= threshold |
maxAge |
internal timer | now - entryTimestamp >= TRIGGER_MAX_AGE_SEC |
volumeSpike |
priceMonitor rolling buffer (cap 500 entries per position) | recentVol > rollingAvg * TRIGGER_VOLUME_SPIKE_MULT |
customEvent |
eventMonitor log filter | topic0 matches TRIGGER_CUSTOM_EVENT_TOPIC |
emergency |
eventMonitor | block.prefetchedTransactions[*].from == EMERGENCY_WALLET (confirmed inclusion only — not mempool) |
manual |
Telegram /remove |
Bypasses all other conditions |
5. Executor specifics¶
Uniswap V2 (and PancakeSwap V2)¶
flowchart LR
Start --> BL[blacklist check]
BL --> StaticSim[router.removeLiquidity.staticCall<br/>honeypot pre-flight]
StaticSim --> Allow[approvalManager.ensureAllowance LP token]
Allow --> GasCheck[gasAboveCap?]
GasCheck -- yes --> Abort
GasCheck -- no --> Tx[router.removeLiquidity]
Tx --> Decode[parse Transfer logs<br/>compute received amounts]
Decode --> Revoke{AUTO_REVOKE_<br/>APPROVALS?}
Revoke -- yes --> Set0[allowance ← 0]
Revoke -- no --> Done
Set0 --> Done
Uniswap V3 (and PancakeSwap V3)¶
flowchart LR
Start --> BL[blacklist check]
BL --> Own[pm.ownerOf tokenId == signer]
Own --> StaticSim[decreaseLiquidity.staticCall<br/>derive amount0/1Min]
StaticSim --> GasCheck[gasAboveCap?]
GasCheck -- yes --> Abort
GasCheck -- no --> MC[multicall<br/>decreaseLiquidity + collect MAX,MAX + burn]
MC --> Decode[decode Transfer logs]
Decode --> Done
Atomic single-tx removal. NFT is burned at the end so the position cannot be re-used.
Raydium¶
Dispatch by position.type:
- amm-v4 → liquidity.removeLiquidity({ ... slippage: bps/10000 })
- cpmm → CPMM withdraw call
- clmm → CLMM decrease + collect
Solana lacks an EIP-1559 gas market; the gas cap check is skipped. Slippage is enforced via the SDK's slippage param. The SDK is lazy-loaded so EVM-only deployments don't pay the Solana dep cost at startup.
6. Cross-cutting invariants¶
These have caused real bugs in the past. Do not violate them.
| # | Invariant | Where enforced | Why |
|---|---|---|---|
| 1 | src/config.js is the only place that reads process.env |
src/config.js zod schema |
Centralized validation; impossible to forget a coercion |
| 2 | Every executor entry-point calls txLimiter.acquire('remove') |
src/executors/index.js (dispatch wrapper) |
Rate limit can't be bypassed by adding a new executor |
| 3 | gasAboveCap() checked before broadcast on every EVM tx |
per executor | Hard cap on gas — refuses to broadcast in fee spikes |
| 4 | _assertNotBlacklisted([token0, token1]) per executor |
per executor | Blacklist is per-call, not global registration |
| 5 | V3: pm.ownerOf(tokenId) verified before any tx |
executors/uniswapV3.js |
Prevents wasted gas if NFT was transferred out |
| 6 | V3 position config must include poolAddress |
monitors/priceMonitor.js |
Price feed listens on pool, not NFT; missing → warn + skip |
| 7 | Logger redaction list updated when adding sensitive env vars | src/logger.js |
Never log raw keys / tokens |
| 8 | WS reconnect preserved in makeWsProvider |
src/chains/evm.js |
ethers v6 does not reconnect natively |
| 9 | Single process per signer | operational | In-memory reentrancy guard; nonce collisions otherwise |
| 10 | Telegram TELEGRAM_CHAT_ID is pure numeric, no leading colon |
env config | HTTP 400 on send; see CHANGELOG 2026-05-13 |
7. Known limitations¶
- Raydium price-delta decoder is implemented but uses
AmmRpcDatapre-computed prices only; manualPoolInfoLayoutdecoding is not wired. Sufficient for AMM v4; CLMM tick math is delegated to SDK. - MEV exposure on Ethereum mainnet is real. Use a private mempool RPC (Flashbots Protect) for any non-trivial position size. See Safety → MEV.
- Emergency wallet detection has ~12s latency — it checks confirmed block inclusion, not mempool. This is a deliberate trade-off against false positives.
- No persistent state. Process restart clears the
firingandcompletedsets. A position already removed on-chain will not re-fire (executors check on-chain state), but a position mid-removal at restart is a hazard — prefer graceful shutdown.
8. File-level pointers¶
Quick map for navigation:
| Concern | File:Line ref |
|---|---|
| Entry & glue | 📁 src/index.js |
| Env schema | 📁 src/config.js (zod object) |
| Trigger evaluation | 📁 src/triggers/triggerEngine.js |
| V2 executor | 📁 src/executors/uniswapV2.js |
| V3 executor | 📁 src/executors/uniswapV3.js |
| Raydium executor + dispatch | 📁 src/executors/raydium.js |
| Rate limiter | 📁 src/security/rateLimit.js |
| Keystore priority chain | 📁 src/security/keystore.js |
| Gas cap + EIP-1559 | 📁 src/utils/gas.js |
| Slippage (bigint) | 📁 src/utils/slippage.js |
| WS reconnect | 📁 src/chains/evm.js (makeWsProvider) |
| Addresses + ABIs | 📁 abis/index.js |