Skip to content

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.

  • securitythe position log serializer was a no-op on strings. maskPosition returned any non-object verbatim, and triggerEngine logs { 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.js enforces it.
  • securityaddress-bearing log keys outside the serializer set. pair, pool, poolAddress, token, token0, token1, tokenId, spender, router, contract, owner, operator, recipient, configured, derived all 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.
  • securitya single malformed URL killed the bot. decodeURIComponent was called bare on the request path and on cookie values; GET /%ZZ threw URIError out of the request handler, reached the process as an uncaughtException, and index.js answers 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, via fetch('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.
  • fixa dry run was not keyless and could not run without a key. Signers were preloaded only in live mode, but the trigger handler called signerFor() 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 ethers VoidSigner (every read works; sendTransaction throws, so a dry run is structurally incapable of broadcasting), DRY_RUN_ADDRESS makes it genuinely keyless, and a missing signer fails permanently. Extracted to src/security/signerFactory.js so the split is testable.
  • fixthe 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 against MAX_TX_PER_MINUTE=3, because the failure happened in signerFor() before executeRemoval() — where the limiter lives — was ever reached. With alerts on that is two messages per iteration, unbounded. Added TRIGGER_RETRY_COOLDOWN_SEC (default 60s), enforced in _canFire.
  • fixapprovalManager ignored the gas cap and waited forever. It built fees but discarded requiredFeePerGas without calling gasAboveCap, then awaited a bare tx.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 by TX_DEADLINE_SEC; an above-cap revoke is skipped as non-fatal.
  • fixthe WS reconnect retry was dead. The handled latch (there to stop close+error double-firing) was set before the retry timer and never cleared, so the scheduleReconnect() 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.
  • securitynpm run audit:evm-only reported a clean bill of health by hiding the proxy. --omit=optional dropped socks-proxy-agent, which pulled ip-address with three high-severity SSRF advisories — the proxy that REQUIRE_PROXY=true makes mandatory. Both proxy agents moved to dependencies (they are not optional to the privacy model) and ip-address pinned to ^10.5.0 via overrides. Remaining advisories are confined to the optional Solana/Ledger stack and now reported honestly.
  • securitythe dashboard API served whole position objects. The signer address was masked on the way in, but /api/state returned identifier, token0, token1 and poolAddress verbatim — the same operator↔pool trail the logger masks. Now reduced to chain/dex/label plus a masked identifier (the only fields the UI renders), with DASHBOARD_SHOW_FULL_ADDRESS as the opt-in. Keys are masked per-segment so per-position sparklines stay distinct.
  • fixuncaughtException exited 0, telling every supervisor the bot stopped on purpose. Now exits 1.
  • chorebranch protection is now enforced client-side. GitHub rulesets and branch protection return 403 on this repo (private, free plan), so main cannot be protected server-side. Two guards instead: a PreToolUse(Bash) hook (.claude/hooks/protect-main-branch.js) that denies commit/push/merge on main, any refspec targeting main/master, and --all/--mirror pushes; and a versioned .githooks/pre-push (wired via core.hooksPath) that catches every push from this clone, including ones Claude never runs. Both honour ALLOW_MAIN_PUSH=1 as a deliberate human override. test.yml is now unfiltered and runs security:scan too, so a PR touching only tests/ 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, and npm 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:

  • securityCRITICAL: 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 got null — a silent direct connection. The first caller is resolvePositions, whose per-position catch swallows it and logs a benign "Could not resolve position from chain". Net effect: with SOCKS_PROXY set but the agent package missing (both agents are optionalDependencies, dropped by the repo's own --omit=optional install), 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, and assertProxyReady() is probed from securityPreflight outside any catch, so a broken proxy aborts the boot. Regression test verified against the old logic.
  • securityCRITICAL: the full operator wallet address was logged at every live boot. keystore.js logged { address } at INFO; address was not in SENSITIVE_KEYS and had no serializer, and maskAddress was 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 a position serializer that strips the identifier/token0/token1/poolAddress trail. Same fix covers eventMonitor's emergency-wallet line.
  • securityCRITICAL: safety gates keyed on NODE_ENV, not on whether the bot can sign. Flipping DRY_RUN=false — the natural way to go live — left the raw-key refusal and the file-permission check disarmed. Introduced config.IS_LIVE (!DRY_RUN || NODE_ENV==='production'); raw EVM_PRIVATE_KEY/SOLANA_PRIVATE_KEY are now refused, and world-readable secret files are critical, whenever the bot could sign.
  • security — the dashboard loaded marked and dompurify from 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 under src/dashboard/public/vendor/ and the CSP names no remote origin at all.
  • security — axios 1.16.01.19.0 (dependency and override; the stale pin was blocking the fix). Cleared a high-severity advisory group including GHSA-gcfj-64vw-6mp9 (inherited proxy after interceptor config cloning) and GHSA-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.
  • securityREQUIRE_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_TOKEN gate 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 against PUBLIC_DIR + path.sep. Routes match on pathname rather than the raw URL.
  • securityDASHBOARD_PERSIST now defaults off: the plaintext trade history at data/events.jsonl (labels, tx hashes, PnL, timestamps) survives restarts and is a trace in its own right. When enabled it is written 0600 in a 0700 directory.
  • security — added a stack serializer: stack traces embed their own message verbatim, so an ethers/ws failure 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.
  • fixalerts/telegram.js and alerts/discord.js called getHttpClient() at module scope, which built the shared proxy agent at import time — before securityPreflight ran. A misconfigured proxy therefore crashed at load with a raw TypeError stack 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 (as pnl.js already did), and the scanner enforces it.
  • choredocker-compose.yml hardened: 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.
  • featsecurity-audit skill + npm run security:scan. scripts/securityScan.js proves the decidable invariants — egress chokepoint, proxy fail-closed, logger redaction contract (including "every new secret-shaped env var must be in SENSITIVE_KEYS"), privacy defaults, dashboard self-containment, executor interlocks. It caught a real miss during its own bring-up (DASHBOARD_TOKEN was unredacted) and was verified against four deliberately reintroduced regressions. .github/workflows/security.yml runs it plus the EVM-only audit and a tracked-secret scan on every PR.
  • test — new tests/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: .env 644600, data/ 0700, dry-run trace logs deleted. The two raw keys that sat in a world-readable .env should be considered compromised and rotated.

2026-08-20 — on-chain position enrichment

  • feat — new src/positions/resolver.js runs 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's poolAddress (derived via positions(tokenId)factory.getPool(token0, token1, fee), with the factory read from the position manager rather than hardcoded) and token0/token1 for both V2 and V3. Closes two hand-entry footguns: a V3 entry missing poolAddress silently ran with no price feed, and _assertNotBlacklisted is only as good as the hand-entered token0/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 stale POSITIONS_JSON announces 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 an await, so concurrently-resolved positions all missed the cache and each fired their own factory() 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.
  • choreabis/UniswapV3Factory.json (getPool only) + UNISWAP_V3_FACTORY_ABI export.
  • testtests/positionResolver.test.js, 16 tests. Suite: 215 passing.
  • fix — CI — the docs workflow has failed on every run since it was added: actions/setup-python was told cache: pip with no requirements.txt or pyproject.toml in the repo, so the job errored before mkdocs build ever ran. Added docs/requirements.txt (also pins what the site builds against) and pointed the cache at it.

2026-07-08 — full-egress IP privacy

  • securityall 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: with HTTPS_PROXY set, 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.
  • securityPRICE_LOOKUP_ENABLED now defaults false (privacy-first): no CoinGecko holdings leak out of the box. It only affects cosmetic USD PnL in alerts, never triggers/automation.
  • security — added https-proxy-agent optional dep (alongside socks-proxy-agent) for the HTTP-proxy agent path.
  • testproxyAgent suite + updated httpClient suite (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: new src/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 in solana.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, unless DASHBOARD_SHOW_FULL_ADDRESS=true.
  • security — dashboard — CSP (connect-src 'self' blocks exfiltration), X-Frame-Options: DENY, nosniff, no-referrer, no-store on every response; configurable DASHBOARD_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 honoring SOCKS_PROXY (Tor, socks5h) / HTTPS_PROXY; fails closed if the proxy is configured but unavailable. PRICE_LOOKUP_ENABLED=false disables CoinGecko holdings leakage. DASHBOARD_PERSIST=false disables the plaintext trade log.
  • security — deps — overrode vulnerable ws (ethers transitive DoS) → 8.21.0 and form-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 — added redact, preflight, dashboardSecurity, httpClient suites (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 shows failed-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 — /status now 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 — poolAddress was missing from PositionSchema, 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 — missing await on loadSolanaSigner(); the SDK was receiving a Promise as owner, breaking every live Solana removal.
  • fix — evm.js + monitors — WS reconnect now emits ws-down/ws-reconnected via providerEvents; 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 exceeds MAX_GAS_GWEI (previously the fee was silently clamped and the guard could never trip, risking indefinitely-stuck transactions). tx.wait bounded by TX_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: bsc chain in CHAINS/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 beyond MAX_TX_PER_MINUTE); EVM signers wrapped in ethers.NonceManager for 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 /remove no longer executes on boot).
  • fix — keystore.js — fails fast when KEYSTORE_PASSWORD is unset and there is no TTY (previously hung forever on a readline prompt in Docker).
  • fix — priceMonitor.js — priceUnits now decimals-adjusted (human token1-per-token0 ratio); removed a wasted positions() RPC from the V3 monitor.
  • fix — alerts — dry-run success alerts no longer render tx/undefined; dry runs are labeled.
  • chore — eslint 9 flat config added (npm run lint was broken); CI workflow .github/workflows/test.yml (lint + tests); Dockerfile uses npm ci with the lockfile.
  • testtests/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 /docs directory created (README, getting-started, usage, architecture, testing, safety, extending, CHANGELOG).
  • test — Keystore test suite now passes 9/9. Switched to encryptKeystoreJsonSync for correct ethers v6 API.
  • feat — UniswapV2 honeypot pre-flight via removeLiquidity.staticCall simulation (gap 1).
  • feat — Raydium price decoder via AmmRpcData pre-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 malformed TELEGRAM_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.