Changelog¶
Project + documentation history. Newest first. Add a one-line bullet for every code or doc change worth surfacing.
Format: YYYY-MM-DD — <area> — <one-line summary>
Areas: feat, fix, refactor, test, docs, chore, security.
2026-08-23 — live-testing findings: identity leaks, dashboard DoS, dry-run signer¶
Found by running the bot against a real config rather than reading it — every item below was reproduced in a live dry run or a standalone repro before being fixed, and each now has a regression test that was verified to fail against the old code.
security— thepositionlog serializer was a no-op on strings.maskPositionreturned any non-object verbatim, andtriggerEnginelogs{ position: 'chain:dex:identifier' }— so the one line written on every single trigger published the full pair/pool address, while the object form on the adjacent "Execution failed" line masked correctly. Same key, same run, two behaviours. Now masks each colon-separated segment that is an EVM address or base58 pubkey, leaving chain/dex/label readable.scripts/securityScan.jsenforces it.security— address-bearing log keys outside the serializer set.pair,pool,poolAddress,token,token0,token1,tokenId,spender,router,contract,owner,operator,recipient,configured,derivedall reached log sinks in full — exactly the "novel key" gap the invariants warn about. A live dry run leaked the pool address on 5 of 6 representative lines; it is now 0. Added serializers for all of them and taught the scanner the full list.security— a single malformed URL killed the bot.decodeURIComponentwas called bare on the request path and on cookie values;GET /%ZZthrewURIErrorout of the request handler, reached the process as anuncaughtException, andindex.jsanswers that by shutting down. On the default loopback bind there is no token, so any local process — or any web page the operator had open, viafetch('http://127.0.0.1:3001/%ZZ', {mode:'no-cors'}), which needs no CORS permission to be sent — could stop all position monitoring. Now decodes defensively (400) and the whole handler is wrapped so no future throw can take the bot down. The token gate did block it, and still does.fix— a dry run was not keyless and could not run without a key. Signers were preloaded only in live mode, but the trigger handler calledsignerFor()unconditionally, so a dry run lazily built a real signing-capable Wallet the moment anything fired — the log printed "Using raw private key" after the trigger, contradicting the "DRY_RUN stays keyless" comment. With no key configured it threw a transient error, so the position stayed armed and re-fired forever instead of simulating. A dry run is now handed an ethersVoidSigner(every read works;sendTransactionthrows, so a dry run is structurally incapable of broadcasting),DRY_RUN_ADDRESSmakes it genuinely keyless, and a missing signer fails permanently. Extracted tosrc/security/signerFactory.jsso the split is testable.fix— the retry storm bypassed the tx rate limiter.markFailed()only cleared the firing guard, so the next chain event re-fired immediately; observed live as 5 fires in 30s againstMAX_TX_PER_MINUTE=3, because the failure happened insignerFor()beforeexecuteRemoval()— where the limiter lives — was ever reached. With alerts on that is two messages per iteration, unbounded. AddedTRIGGER_RETRY_COOLDOWN_SEC(default 60s), enforced in_canFire.fix—approvalManagerignored the gas cap and waited forever. It built fees but discardedrequiredFeePerGaswithout callinggasAboveCap, then awaited a baretx.wait(). During a gas spike it broadcast an approve the bot refused to pay for one line later, then blocked indefinitely — holding the firing guard the V2 executor deliberately bounds. Now cap-checked before broadcast and bounded byTX_DEADLINE_SEC; an above-cap revoke is skipped as non-fatal.fix— the WS reconnect retry was dead. Thehandledlatch (there to stop close+error double-firing) was set before the retry timer and never cleared, so thescheduleReconnect()in the catch returned immediately. The bot logged "retrying in 3s" and then sat blind forever. Narrow trigger — it needs a synchronous construction failure, since each new provider gets a fresh closure via its own close handler — but the log line was a lie.security—npm run audit:evm-onlyreported a clean bill of health by hiding the proxy.--omit=optionaldroppedsocks-proxy-agent, which pulledip-addresswith three high-severity SSRF advisories — the proxy thatREQUIRE_PROXY=truemakes mandatory. Both proxy agents moved todependencies(they are not optional to the privacy model) andip-addresspinned to^10.5.0via overrides. Remaining advisories are confined to the optional Solana/Ledger stack and now reported honestly.security— the dashboard API served whole position objects. The signer address was masked on the way in, but/api/statereturnedidentifier,token0,token1andpoolAddressverbatim — the same operator↔pool trail the logger masks. Now reduced to chain/dex/label plus a masked identifier (the only fields the UI renders), withDASHBOARD_SHOW_FULL_ADDRESSas the opt-in. Keys are masked per-segment so per-position sparklines stay distinct.fix—uncaughtExceptionexited 0, telling every supervisor the bot stopped on purpose. Now exits 1.chore— branch protection is now enforced client-side. GitHub rulesets and branch protection return 403 on this repo (private, free plan), somaincannot be protected server-side. Two guards instead: aPreToolUse(Bash)hook (.claude/hooks/protect-main-branch.js) that denies commit/push/merge onmain, any refspec targetingmain/master, and--all/--mirrorpushes; and a versioned.githooks/pre-push(wired viacore.hooksPath) that catches every push from this clone, including ones Claude never runs. Both honourALLOW_MAIN_PUSH=1as a deliberate human override.test.ymlis now unfiltered and runssecurity:scantoo, so a PR touching onlytests/no longer skips the scanner.test— 270 → 323 tests. New suites:identityLeaks(real pino instance, real call-site shapes, asserts no identifier survives),signerFactory(dry-run/live split),wsReconnect(reconnect + retry), plus dashboard-crash, trigger-cooldown and approval gas-cap coverage. Every new suite was verified to fail against the pre-fix code. Added focused runners:npm run test:security,test:safety,test:resilience, andnpm run verify(lint + scan + tests + audit) as the single pre-push gate.
2026-08-20 — security & privacy audit remediation¶
Full audit of the repo weighted toward live-operation privacy. Findings and fixes:
security— CRITICAL: the fail-closed proxy guard failed open.getProxyAgent()set its memo flag before building the agent, so the first caller got the exception and every later caller gotnull— a silent direct connection. The first caller isresolvePositions, whose per-positioncatchswallows it and logs a benign "Could not resolve position from chain". Net effect: withSOCKS_PROXYset but the agent package missing (both agents areoptionalDependencies, dropped by the repo's own--omit=optionalinstall), the bot ran fully un-proxied — real IP to the RPC provider, Telegram, Discord and CoinGecko — with no error surfaced. Failures are now cached and re-thrown on every call, andassertProxyReady()is probed fromsecurityPreflightoutside any catch, so a broken proxy aborts the boot. Regression test verified against the old logic.security— CRITICAL: the full operator wallet address was logged at every live boot.keystore.jslogged{ address }at INFO;addresswas not inSENSITIVE_KEYSand had no serializer, andmaskAddresswas applied only to the dashboard store. Live mode preloads signers at boot, so every production start wrote the on-chain identity to stdout → journald/docker json-file. Dry runs never hit it, which is why it went unnoticed. Added identity serializers (address,wallet,signer,from,to) and apositionserializer that strips theidentifier/token0/token1/poolAddresstrail. Same fix coverseventMonitor's emergency-wallet line.security— CRITICAL: safety gates keyed onNODE_ENV, not on whether the bot can sign. FlippingDRY_RUN=false— the natural way to go live — left the raw-key refusal and the file-permission check disarmed. Introducedconfig.IS_LIVE(!DRY_RUN || NODE_ENV==='production'); rawEVM_PRIVATE_KEY/SOLANA_PRIVATE_KEYare now refused, and world-readable secret files are critical, whenever the bot could sign.security— the dashboard loadedmarkedanddompurifyfrom jsDelivr. The bot's egress is proxied; the operator's browser is not — every dashboard open sent the real IP and a timestamp to a third-party CDN, correlatable against on-chain activity. Both libraries are now vendored undersrc/dashboard/public/vendor/and the CSP names no remote origin at all.security— axios1.16.0→1.19.0(dependency and override; the stale pin was blocking the fix). Cleared a high-severity advisory group includingGHSA-gcfj-64vw-6mp9(inherited proxy after interceptor config cloning) andGHSA-f4gw-2p7v-4548(NO_PROXY bypass) — proxy-integrity bugs are privacy bugs here, since IP hiding assumes axios honors the injected agent. EVM-only audit back to 0.security—REQUIRE_PROXY(default on): running live with no proxy is now a critical preflight failure, not a warning.socks5://is flagged as a DNS leak (hostnames still resolve locally);socks5h://is the documented form. A configured Solana position now warns that its SDK-owned account-subscription WebSocket escapes the proxy.security— dashboard:DASHBOARD_TOKENgate for non-loopback binds (constant-time compare;?token=is exchanged for an HttpOnly/SameSite=Strict cookie and stripped from the URL so it never lands in history or proxy logs), and an off-loopback bind with no token is now a critical failure. Fixed a static-path escape —startsWith(PUBLIC_DIR)also accepted a sibling directory sharing the prefix; now compares againstPUBLIC_DIR + path.sep. Routes match on pathname rather than the raw URL.security—DASHBOARD_PERSISTnow defaults off: the plaintext trade history atdata/events.jsonl(labels, tx hashes, PnL, timestamps) survives restarts and is a trace in its own right. When enabled it is written0600in a0700directory.security— added astackserializer: stack traces embed their own message verbatim, so an ethers/wsfailure carrying an API-keyed RPC URL printed it in full at fatal level. Config-validation errors are now scrubbed too — they throw before the logger exists, so nothing else redacts them.feat— optional private broadcast relay (ETH_/BASE_/BSC_BROADCAST_RPC, e.g. Flashbots Protect). Reads stay on the normal RPC; only the signed removal goes to the relay, so the tx never enters the public mempool and is not correlated with the monitoring subscription that precedes it by milliseconds. Confirmation polls the read RPC — a private tx is invisible until mined and visible everywhere after. Empty by default = unchanged behaviour.src/chains/broadcastSigner.js.fix—alerts/telegram.jsandalerts/discord.jscalledgetHttpClient()at module scope, which built the shared proxy agent at import time — beforesecurityPreflightran. A misconfigured proxy therefore crashed at load with a rawTypeErrorstack instead of being reported as a clean critical. Found by end-to-end testing the new preflight, not by the unit suite. Both now resolve the client per call (aspnl.jsalready did), and the scanner enforces it.chore—docker-compose.ymlhardened: non-root,read_only,cap_drop: ALL,no-new-privileges, tmpfs/tmp, log retention cut from 50 MB to 4 MB (container logs are an on-disk trace), DNS pinning documented.feat—security-auditskill +npm run security:scan.scripts/securityScan.jsproves the decidable invariants — egress chokepoint, proxy fail-closed, logger redaction contract (including "every new secret-shaped env var must be inSENSITIVE_KEYS"), privacy defaults, dashboard self-containment, executor interlocks. It caught a real miss during its own bring-up (DASHBOARD_TOKENwas unredacted) and was verified against four deliberately reintroduced regressions..github/workflows/security.ymlruns it plus the EVM-only audit and a tracked-secret scan on every PR.test— newtests/loggerRedaction.test.js(8),tests/broadcastSigner.test.js(5); extended proxy fail-closed, preflight, dashboard auth/traversal and config live-mode suites. Suite: 270 passing, lint clean, EVM-only audit 0.chore— local hygiene:.env644→600,data/0700, dry-run trace logs deleted. The two raw keys that sat in a world-readable.envshould be considered compromised and rotated.
2026-08-20 — on-chain position enrichment¶
feat— newsrc/positions/resolver.jsruns at boot (after the security preflight, before any monitor subscribes) and fills in what the chain already knows for every EVM position: a V3 entry'spoolAddress(derived viapositions(tokenId)→factory.getPool(token0, token1, fee), with the factory read from the position manager rather than hardcoded) andtoken0/token1for both V2 and V3. Closes two hand-entry footguns: a V3 entry missingpoolAddresssilently ran with no price feed, and_assertNotBlacklistedis only as good as the hand-enteredtoken0/token1, so a typo quietly disabled blacklist enforcement for that position.feat— resolution is best-effort and never fatal: an unresolvable position passes through untouched and the bot starts normally (executors still do their own pre-flight validation). Derived values are authoritative — a configured field that disagrees with the contract is overridden and logged as a conflict, so a stalePOSITIONS_JSONannounces itself at boot.feat— boot-time warnings for two conditions that previously only surfaced at fire time: a V3 position that is already empty (removal would fail permanently), and a resolved pair holding a blacklisted token.fix— resolver — the per-position-manager factory cache did check-then-set across anawait, so concurrently-resolved positions all missed the cache and each fired their ownfactory()call. Caches the in-flight promise instead, and evicts it on rejection so one RPC blip can't poison every later position in the batch. Caught by its own regression test.chore—abis/UniswapV3Factory.json(getPoolonly) +UNISWAP_V3_FACTORY_ABIexport.test—tests/positionResolver.test.js, 16 tests. Suite: 215 passing.fix— CI — the docs workflow has failed on every run since it was added:actions/setup-pythonwas toldcache: pipwith norequirements.txtorpyproject.tomlin the repo, so the job errored beforemkdocs buildever ran. Addeddocs/requirements.txt(also pins what the site builds against) and pointed the cache at it.
2026-07-08 — full-egress IP privacy¶
security— all network egress now shares one proxy agent (src/utils/proxyAgent.js): ethers RPC over HTTP (FetchRequest agent) and WebSocket (ws factory + agent), the Solana connection (httpAgent), and axios (alerts/price). Previously only axios was proxied, so the RPC connection — the bot's most continuous trace — leaked the real IP even with a proxy set. Verified live: withHTTPS_PROXYset, the RPC host is tunnelled through the proxy, not connected directly. One switch (SOCKS_PROXY/HTTPS_PROXY) covers everything; fail-closed if the agent dep is missing.security—PRICE_LOOKUP_ENABLEDnow defaults false (privacy-first): no CoinGecko holdings leak out of the box. It only affects cosmetic USD PnL in alerts, never triggers/automation.security— addedhttps-proxy-agentoptional dep (alongsidesocks-proxy-agent) for the HTTP-proxy agent path.test—proxyAgentsuite + updatedhttpClientsuite (shared-agent model). Suite: 199 passing.docs— safety.md §8a: RPC now proxied, CoinGecko off by default, "minimal setup for full IP privacy" (Tor in 3 steps).
2026-07-08 — security & privacy hardening¶
security— logs — no secret or key-bearing URL can reach the logs: newsrc/utils/redact.js(maskUrl/maskAddress/scrubSecrets) + a two-layer pino config (expanded key list incl. all*_RPC_*, + value-scrubbing serializers). Fixed a direct RPC-URL-with-API-key leak insolana.js. Verified live: 0 secret occurrences in logs/state.security— identity — wallet address is masked (0x1234…5678) before it ever enters the dashboard store//api/state/SSE, unlessDASHBOARD_SHOW_FULL_ADDRESS=true.security— dashboard — CSP (connect-src 'self'blocks exfiltration),X-Frame-Options: DENY,nosniff,no-referrer,no-storeon every response; configurableDASHBOARD_BIND(default 127.0.0.1) with a loud warning on non-loopback binds.security— startup preflight (src/security/preflight.js): refuses to start in production when.env/keystore are group/world-readable (warns in dev); warns on identity/egress exposure (no proxy, exposed dashboard, CoinGecko holdings leak, raw key).security— egress privacy — all outbound HTTP (Telegram/Discord/CoinGecko) routed through a shared client honoringSOCKS_PROXY(Tor, socks5h) /HTTPS_PROXY; fails closed if the proxy is configured but unavailable.PRICE_LOOKUP_ENABLED=falsedisables CoinGecko holdings leakage.DASHBOARD_PERSIST=falsedisables the plaintext trade log.security— deps — overrode vulnerablews(ethers transitive DoS) → 8.21.0 andform-data(CRLF) → 4.0.6. EVM-only install now audits 0 vulnerabilities; remaining advisories are all upstream in the optional Solana/Raydium SDK tree.test— addedredact,preflight,dashboardSecurity,httpClientsuites (34 new tests). Suite: 195 passing.-
docs— safety.md — new §8a Privacy & identity + rewritten §8 Logging. -
fix— triggerEngine/index.js — positions are retired after a permanent executor failure (markPermanentlyFailed): no more endless re-fire/alert loop on errors that can never succeed (e.g. "No LP tokens to remove"). Dashboard showsfailed-permanent; alert says the position will not re-fire. Regression test added. feat— alerts — Telegram/Discord messages rebuilt: per-channel rendering (Telegram HTML with escaping — labels with underscores no longer break parsing; Discord markdown), human trigger reasons (single-swap vs cumulative move, volume ratio, position age, emergency tx), received amounts with symbols, PNL, explorer links, dry-run labeling, and retry-vs-retired guidance on errors.feat— alerts/telegram —/statusnow actually replies with a live summary (mode, uptime, chain WS health, tx budget, every position with status/price/last-seen) instead of only logging. Built from the same store snapshot the dashboard uses.feat— dashboard — UI rebuilt for at-a-glance visibility: DRY-RUN/LIVE badge, stat tiles (positions, chain WS health, tx budget vs caps, slippage/gas caps), armed-trigger chips, per-position cards with live price, last-swap/session deltas, drop-from-peak meter showing proximity to the price-drop trigger, price sparkline with hover tooltip (240-point rolling buffer added to the store), and a clearer event feed distinguishing retrying vs retired errors. Coverage/docs panels collapsed by default. Operational view is now dependency-free (works offline); CDN libs only for the docs viewer with plain-text fallback.
2026-07-07¶
fix— config.js —poolAddresswas missing fromPositionSchema, so zod silently stripped it and the V3 price monitor was disabled for every V3 position even when configured exactly as documented. Found during live mainnet verification of the audit fixes; regression test added.fix— raydium.js — missingawaitonloadSolanaSigner(); the SDK was receiving a Promise asowner, breaking every live Solana removal.fix— evm.js + monitors — WS reconnect now emitsws-down/ws-reconnectedviaproviderEvents; PriceMonitor and EventMonitor re-establish their subscriptions on reconnect (previously the bot went silently blind after the first disconnect). Chain WS status surfaces on the dashboard.feat— triggerEngine.js — price drop/rise now also fire on cumulative moves (drop from trailing peak, rise from first observation), catching gradual dumps a per-swap delta never sees.feat— triggerEngine.js — max-age evaluated by a 30s timer sweep, so it fires even for pools with zero swap activity;engine.stop()added to shutdown.fix— gas.js — base fee read from the latest block (previous derivation double-counted ethers' getFeeData convention); the gas-cap guard is now live: executors abort pre-broadcast when base+priority exceedsMAX_GAS_GWEI(previously the fee was silently clamped and the guard could never trip, risking indefinitely-stuck transactions).tx.waitbounded byTX_DEADLINE_SEC.fix— priceMonitor.js — volume-spike baseline is now average volume per window-sized span (was per-swap mean), eliminating false fires on steady volume.feat— BSC/PancakeSwap wired end to end:bscchain inCHAINS/schema/position enum,BSC_RPC_*env vars, bscscan explorer URL, CoinGecko platform mapping, dex-first positionManager lookup.fix— executors — emergency removals bypass the rate limiter (dead-man's switch no longer strands positions beyondMAX_TX_PER_MINUTE); EVM signers wrapped inethers.NonceManagerfor concurrent removals; live mode preloads signers at boot.fix— executors/index.js — permanent errors (blacklist, not-owner, empty position, post-broadcast failures) abort the retry loop instead of re-running the whole removal (a retry after broadcast could misreport a confirmed removal as failed).fix— telegram.js — command backlog queued while the bot was down is discarded at startup (a stale/removeno longer executes on boot).fix— keystore.js — fails fast whenKEYSTORE_PASSWORDis unset and there is no TTY (previously hung forever on a readline prompt in Docker).fix— priceMonitor.js —priceUnitsnow decimals-adjusted (human token1-per-token0 ratio); removed a wastedpositions()RPC from the V3 monitor.fix— alerts — dry-run success alerts no longer rendertx/undefined; dry runs are labeled.chore— eslint 9 flat config added (npm run lintwas broken); CI workflow.github/workflows/test.yml(lint + tests); Dockerfile usesnpm ciwith the lockfile.test—tests/poc.findings.test.js: 6 proof-of-concept tests pinned the audit defects, then were flipped to regression tests (10 tests) when the fixes landed. Monitor resubscription + new gas/volume semantics covered in existing suites. Suite now 158 tests.docs— testing.md, CLAUDE.md — synced with new trigger semantics, gas-cap contract, WS resubscription invariant, BSC support.
2026-05-13¶
docs— Initial/docsdirectory created (README, getting-started, usage, architecture, testing, safety, extending, CHANGELOG).test— Keystore test suite now passes 9/9. Switched toencryptKeystoreJsonSyncfor correct ethers v6 API.feat— UniswapV2 honeypot pre-flight viaremoveLiquidity.staticCallsimulation (gap 1).feat— Raydium price decoder viaAmmRpcDatapre-computed prices (gap 2).feat— Raydium CPMM/CLMM dispatch + AMM v4 liquidity removal (gap 3).test— Coverage now at 100 tests, ~87% branch. Suites: config, rateLimit, triggerEngine, priceMonitor, eventMonitor, executorDispatch, executorV2, executorV3, executorRaydium, approvalManager, keystore, gas, slippage, pnl.fix— Telegram HTTP 400 on alert send. Caused by malformedTELEGRAM_CHAT_ID(leading colon). Stripped — delivery confirmed.
Template for future entries¶
## YYYY-MM-DD
- `feat` — <module> — short description.
- `fix` — <module> — short description.
- `docs` — <page touched> — short description.
Open-ended TODOs and known gaps live in docs/testing.md §3 and docs/architecture.md §7, not here. This file is for what happened, not what's pending.