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-31 — the dry run now exercises the pre-flight it used to skip

  • fix — a V2 dry run validated neither the approve nor the honeypot check. executeV2Removal returned at its DRY_RUN branch before ensureAllowance and before the removeLiquidity.staticCall pre-flight, so the two steps most likely to fail on a real removal were the only two that the documented "≥24h dry run, then flip live" gate never touched — the first live removal was the first time either ran. Demonstrated on a Base fork: against a deliberately paused token the old dry run logged a clean V2 removeLiquidity simulated, while the fixed one reports pre-flight simulation reverted ... UniswapV2: TRANSFER_FAILED and retires the position. V3 was never affected: its decreaseLiquidity.staticCall already sat above its DRY_RUN return, because an NFT position needs no allowance.
  • fix — the obstacle was the allowance, and the fix is to grant it only inside the simulation. The pre-flight reverts with ds-math-sub-underflow when the router has no LP allowance — indistinguishable from a real honeypot — and granting it for real is a broadcast, which a dry run must never do. The dry run now runs the same call through eth_call with a state override that fakes the allowance. Confirmed against the real Base V2 WETH/USDC pair: without the override the call reverts, with it the call returns 2.57 WETH + 6363 USDC for a 1% holding.
  • security — the storage layout is verified, never assumed. buildAllowanceOverride reads the slot the standard UniswapV2ERC20 layout claims holds balanceOf (slot 1) and compares it against the balance just read through the ABI. Only on a match does it trust slot 2 for the allowance mapping. On a mismatch — a fork with reordered storage — it returns null and the caller reports preflight: 'unvalidated' with a warning naming why. A false honeypot is the one outcome worse than not checking, because it retires a healthy position permanently.
  • feat — the dry-run result now carries preflight ('passed' / 'unvalidated'), approveSimulated, and the simulated amountA/amountB, so a 24h validation run shows what the removal would actually return rather than only the pro-rata estimate.
  • chore — allowance(address,address) added to the minimal UniswapV2Pair ABI; the pre-flight needs to read it to decide whether an override is required at all.
  • test — tests/executorV2.test.js grows to 17 cases: the pre-flight runs with a faked allowance, uses a plain staticCall when the router is already approved, reports unvalidated rather than inventing a honeypot on a non-standard pair, surfaces a genuine revert as permanent, and still never broadcasts. Its placeholder addresses became real ones — 0xowner is not ABI-encodable, so the layout probe (correctly) refused to build an override and the tests would have asserted nothing.

2026-08-31 — gas tuned for Base, a dead safety knob removed, keystores that start empty

  • fix — MAX_GAS_GWEI=150 was not a brake on Base. Base's base fee is ~0.005 gwei, so the cap sat 75x above current fees and could never trip, while a removal at that cap would have cost 0.034 ETH. GAS_PRIORITY_GWEI is a floor, not a fixed tip — buildGasFees takes the max of it and the chain's own suggestion — so 2 forced a 400x overpay on every transaction. .env now uses the per-chain values docs/safety.md already documented for Base (5 / 0.001). Measured: a full removal (approve + removeLiquidity + revoke, 225k gas) went from 0.00045 ETH to 0.00000135 ETH, and the cap now bounds one at 0.001125 ETH. Both remain a single global pair — gas.js resolves them at module load and executors pass only a provider — so .env.example now states that outright with a per-chain table, since Base values applied globally would abort every Ethereum removal.
  • fix — MIN_LIQUIDITY_USD was a dead knob: validated by the zod schema, then never read anywhere in src/ or scripts/. .env.example advertised "skip dust positions" and README called it a "hard liquidity floor". Nothing enforced it. Removed rather than implemented — a safety setting that silently does nothing is worse than an absent one, because positions get sized against it. README now describes the real gas-to-value consideration.
  • feat — npm run encrypt-key -- --generate: mint a new wallet whose key is created inside the process, encrypted, and written straight out. Never printed, never logged, never on disk in plaintext; only the address is shown. The import path had a hole in its own premise — the keystore exists so a raw key never has to sit around, but you had to produce one to feed in. Also makes a dedicated bot wallet (blast-radius containment, and unlinkable from the operator's other on-chain activity) a one-command step.
  • security — neither mode will overwrite an existing keystore without --force. Under --generate the replacement key is random and the old one exists nowhere else, so an accidental overwrite destroys access to whatever it held, silently and permanently.
  • docs — the keystore stores the wallet's mnemonic encrypted alongside the key, so a paper backup is possible, but only while you still hold the password. getting-started.md spells out the trade rather than leaving "the password is your only backup" as the whole story.
  • test — tests/encryptKey.test.js grows to 13 cases, including that no 32-byte secret ever reaches stdout or stderr in --generate mode, that two runs produce different keys, and that both modes refuse to clobber.

2026-08-31 — live end-to-end run: process lifecycle and command handling

Found by running the bot for real — live Base mainnet in full production posture (NODE_ENV=production arms every gate via IS_LIVE, DRY_RUN=true keeps the signer a VoidSigner), then a local anvil fork of Base where it launched a token, seeded a pool and actually broadcast the removal. The removal engine, the launch step, the price-orientation fix, identity masking and the four boot interlocks all held. What broke sits in the layer that decides whether the engine runs at all.

  • fix — a stale Telegram /remove could execute on boot. skipBacklog() exists because "a stale /remove executing the moment the bot boots is never what the operator intended", but its failure path logged a warning and fell through with offset = 0 — and getUpdates from an unconfirmed offset replays everything Telegram still holds, which is 24 hours' worth. /remove routes to manualFire(), which unwinds every matching position immediately, bypassing every price trigger. Seen live: the probe timed out and a queued /status from an earlier session executed 70s later. The probe now retries with backoff (2s→60s) and the command loop does not start until it succeeds. The only defect of the set that could move money on its own — and one made more likely, not less, by running behind the proxy the project recommends.
  • fix — a busy dashboard port killed the bot at boot. server.listen had no 'error' listener, so EADDRINUSE became an unhandled 'error' event, which Node promotes to uncaughtException, which index.js treats as fatal. The process died before a single monitor subscribed — the documented "the dashboard must never be able to stop the bot" invariant, inverted. The request handler had been wrapped for exactly this reason; listen was not.
  • security — the run lock never checked for an existing holder. acquireRunLock() went straight to writeFileSync without ever calling readRunLock(), so a second bot stamped its pid over the first and the two ran side by side on one signer with two independent NonceManagers. Found the machine already running six orphaned instances accumulated over ten hours, each having overwritten the lock in turn. A live holder is now refused (err.code = 'ELOCKED'), with ALLOW_CONCURRENT_BOT=true as the deliberate override — the bot's equivalent of the launcher's --force-concurrent. A failed write stays non-fatal, as before.
  • security — a crashed instance made the lock report "all clear" while a bot was running. The lock is single-slot and last-writer-wins; the dashboard crash above claimed it at index.js:46 and died before the shutdown handlers were registered, leaving it naming a dead pid. readRunLock() treats a dead pid as absent, so it returned null while the healthy bot kept monitoring — and launchToken.js:168 gates on exactly that value. Demonstrated end to end. index.js now registers process.once('exit', releaseRunLock) immediately after acquiring, and a forcing process deliberately does not claim the lock, so its exit cannot unlock the incumbent.
  • fix — "IP hidden" was logged for a proxy that had never been contacted. assertProxyReady() only proves the agent is constructible; pointed at a dead port the boot still announced that egress was proxied, on the one line an operator reads to confirm their IP is covered. New probeProxyReachable() does a real TCP connect and the confirmed message is logged only on success. Not a gate — egress already fails closed, and a proxy still bootstrapping must not stop the boot.
  • fix — WebSocket health on the dashboard was frozen at boot. lastSeenAt was written only by setChainStatus at subscribe/reconnect, so after 324s and four price updates it still read its boot value: a socket that went quiet without closing showed as healthy forever, and a blind bot looked identical to a healthy one. store.updatePrice now refreshes it on every price event, piggy-backing on the existing emit. Relatedly, ws-reconnected was emitted synchronously after getProvider(), which only constructs the provider — it now fires from onWsOpen(), once the socket is genuinely open.
  • fix — encrypt-key could not be scripted. Fed answers on stdin it printed two prompts, exited 0 and wrote no file — a silent success producing nothing, blocking CI and container bootstrap on a keystore every live run requires. It now reads piped stdin up front, treats a trailing newline as a terminator rather than a blank answer, and fails loudly when stdin ends early. Interactive use is unchanged.
  • fix — the launch banner printed above the transactions it reported. LAUNCH COMPLETE, the pair address and the POSITIONS_JSON line rendered before the addLiquidityETH submitted / Pool seeded lines, so a still-landing transaction read as settled. pino-pretty is a worker thread, so a flush resolved on the main thread does not mean the bytes reached stdout; the launcher now drains and settles before handing over the summary.
  • test — tests/telegramListener.test.js (new, 6 cases) asserts the listener never polls from offset 0 while the probe is failing, resumes from the confirmed offset once it succeeds, and that stop() interrupts the backoff. Verified to fail against the pre-fix code. tests/encryptKey.test.js (new, 6 cases) spawns the real script with piped stdin. Plus new cases in runLock.test.js (live holder refused, dead pid taken over, force does not claim), dashboardSecurity.test.js (survives a taken port — verified failing pre-fix), proxyAgent.test.js (reachability probe), and wsReconnect.test.js (no announce until the socket opens).
  • docs — none of this was reachable from unit tests: every defect is in process lifecycle, port binding or a network failure path that only appears when the thing actually runs. Same lesson as the price-orientation bug one entry below.

2026-08-31 — price triggers were inverted on most Base pairs

Found by running the bot end-to-end against a local Base mainnet fork (anvil, real Uniswap contracts, no real funds) rather than against mocks.

  • fix — a price crash fired the take-profit trigger, and the stop-loss never ran. priceMonitor emitted reserve1/reserve0 (and, for V3, sqrtPriceX96) with nothing normalising which side of the pair is the quote asset. Uniswap sorts a pair by address, so that ratio is the position's real price only when the quote asset is token1. When it sorts first the ratio is the reciprocal and every move is reported with its sign flipped. Demonstrated on the fork: dumping 20,000 of a 250,000-token pool — the token down 14.25% — logged price-rise 16.61% and fired TRIGGER_PRICE_RISE_PCT, while TRIGGER_PRICE_DROP_PCT, armed at the same 10% threshold, never fired at all. priceMonitor._shouldInvert() now picks the orientation from ADDRESSES[chain].weth, overridable per position with a new optional quoteToken field, and both V2 and V3 apply it to every price they emit. After the fix the same dump reports price-drop -14.25% in both orderings, and the two agree to six places — as they must, being one trade in one pool.
  • fix — the exposure is heavily skewed by chain. Only addresses sorting below the wrapped native are unaffected: ~75% on Ethereum, ~73% on BSC, but only ~26% on Base, where WETH is 0x4200…0006. So roughly three quarters of Base WETH pairs had inverted price triggers.
  • fix — the quote asset is chosen by priority, and a stablecoin outranks the wrapped native. The first cut of this fix treated WETH as the quote asset always, which is right for a TOKEN/WETH pool but wrong for WETH/USDC, where the operator means ETH's price in dollars. Caught by extending the fork run to a real Base V3 WETH/USDC pool: ETH fell 3.16% and the bot reported price-rise 3.26% — the same bug, one pair type over. Order is now explicit quoteToken > STABLECOINS[chain] (new map in abis/index.js; Base entries verified on-chain by symbol and decimals) > ADDRESSES[chain].weth. Where both sides or neither look like a quote — a stable-stable pool — it keeps the raw orientation and warns rather than guessing. After the fix the same ETH sell reports price-drop -4.45%, matching the real move.
  • test — the V3 path is now exercised live too, against the real Base WETH/USDC 0.05% pool, not just V2.
  • test — tests/priceOrientation.test.js drives _watchV2 with fake pools and asserts the sign of the emitted deltaPct for both orderings. Verified to fail against the pre-fix code, emitting +16.59% — matching the +16.61% observed live on the fork. Plus six _shouldInvert unit cases in priceMonitor.test.js covering WETH-first/second, case-insensitivity, the quoteToken override, per-chain wrapped-native, and the no-native-quote fallback.
  • docs — no unit test could have caught this: they feed synthetic deltaPct values and never touch real reserves with real address ordering. Noted in docs/testing.md as a reason the manual/fork validation step is not optional.

2026-08-30 — audit of the token launch step: concurrency, approvals, stdout traces

Security/privacy audit of the launch step against the repo's own invariants. Egress, fail-closed proxy handling, identity masking in logs, the private broadcast relay, the executor interlocks and the privacy defaults all held. Three gaps did not.

  • security — "single process per signer" was documentation, not code — and the launcher is the first thing that can violate it. scripts/launchToken.js builds its own signer with its own ethers.NonceManager, so a launch racing a live bot means two processes independently assigning nonces; the loser is silently replaced. Either a removal never lands and the position rides out the drop it was configured to escape, or addLiquidityETH is dropped and leaves a deployed token with a standing approval and no pool. Neither failure is loud. Added src/security/runLock.js: the bot claims data/bot.lock at boot and releases it on shutdown, the launcher refuses while a live pid holds it, --force-concurrent overrides. The lock holds {pid, startedAt} and nothing else — data/ is the traces directory and DASHBOARD_PERSIST defaults off precisely so nothing identifying lands there, so a coarse check that errs toward refusing beats a precise one that writes the wallet to disk. The write is non-fatal: docker-compose.yml runs read_only: true with no ./data volume, and a lock-file problem must never stop position monitoring.
  • security — the launcher approved the router and never revoked. Both V2 executor paths honour AUTO_REVOKE_APPROVALS (default on); the launch path did not. The router pulls only what it uses, so a front-run pair that shifts the ratio leaves a residual allowance standing indefinitely against a wallet still holding ~75% of the token's supply — as does any failure between the approve and a confirmed addLiquidityETH. Now revoked on both paths.
  • security — the plan banner printed the operator's wallet in full. The console.log/pino split is deliberate for values that must be copied verbatim, but Deployer and Token owner are informational and needed no such treatment. The Dockerfile ships scripts/ and compose logs to json-file ("Container logs are an on-disk trace", per its own comment), so docker compose run bot npm run launch-token -- --yes wrote the operator's on-chain identity into a log that outlives the run — the exact trace LOG_FULL_ADDRESS=false exists to prevent. Both are masked now; token/pair/token0/token1 stay full because pasting them is the point.
  • chore — two new scanner rules, each negative-controlled before being kept: launch/no-revoke (a launch path that calls ensureAllowance( must call revokeAllowance() and entrypoint/no-preflight (any scripts/ file that builds a signer via createSignerFactory must call securityPreflight(), or it reaches the network with no proxy probe).
  • docs — two accepted tradeoffs stated rather than fixed: solc carries a remote-compiler-download path (binaries.soliditylang.org) that the egress rule structurally cannot see, though buildToken.js never calls it and a substituted compiler would fail the artifact test; and the full npm audit goes 0 → 33 with the new dev deps while the --omit=dev --omit=optional gate stays 0. The latter is flagged explicitly because this repo's previous audit finding was precisely that --omit=optional hid real vulnerabilities in the proxy stack — "the gate is clean" should never pass again without naming what the gate declines to look at.
  • test — 381 tests (was 367). tests/runLock.test.js added to npm run test:security.

2026-08-30 — token launch: create the position the bot watches

The bot was entirely removal-side: POSITIONS_JSON assumed a pool that already existed, and there was no way to create one. This adds the missing first step as a one-shot operator tool. The bot's runtime is untouched — every LAUNCH_* variable is optional and npm start behaves identically when they are unset.

  • feat — npm run launch-token deploys a fixed-supply ERC-20, seeds a Uniswap-V2-style pool with it (uniswap-v2 on ethereum/base, pancakeswap-v2 on bsc), and prints a ready-to-paste POSITIONS_JSON entry for the resulting LP. Runs the same securityPreflight as src/index.js (proxy probe included) before any network I/O, and requires a typed LAUNCH confirmation in live mode (--yes for unattended runs). Core logic in src/launch/tokenLauncher.js with injected collaborators, following the signerFactory.js shape.
  • feat — contracts/LaunchToken.sol: fixed supply minted once in the constructor with no mint function afterwards, one owner power (pause/unpause), and renounceOwnership() to drop it permanently. No fee-on-transfer, blacklist, max-tx or upgrade hooks. Built on OpenZeppelin 5.x.
  • security — the deployed bytecode is verifiable, not just vendored. abis/LaunchToken.json is generated by npm run build:token and records the solc version, optimizer settings, OZ version, and a hash of the source it compiled. tests/launchTokenArtifact.test.js recompiles and compares the whole file byte-for-byte on every CI run, so a hand-edited blob or a contract edited without a rebuild both fail. solc and @openzeppelin/contracts are devDependencies — absent from the Docker runtime image, which deploys from the committed artifact.
  • security — the launcher carries the executors' interlocks: gasAboveCap before every broadcast, applySlippage on both liquidity legs, blacklist check on the paired token, addLiquidityETH simulated before it is sent, and a balance pre-flight so a shortfall fails before the token exists rather than stranding one with no pool. scripts/securityScan.js enforces all of it (rule 8), matching call sites rather than imports — a check that matches the import line stays green after the call is deleted.
  • security — the rate limiter is acquired once for the whole launch, not per transaction. The sequence is 3–4 broadcasts and MAX_TX_PER_MINUTE defaults to 3, so per-tx acquisition would refuse a mid-sequence transaction and leave a deployed token with no market — the exact stranded state the limiter exists to prevent.
  • security — every failure after the deploy broadcast is permanent, and the error carries the deploy tx hash. A retried launch does not resume; it deploys a second token.
  • security — a dry run predicts the token's CREATE address and the gas budget while reaching neither the rate limiter nor the router. The VoidSigner from signerFactory makes broadcasting structurally impossible, and the launcher branches on DRY_RUN before each send regardless.
  • docs — a paused token cannot have its liquidity removed. Pausing blocks all transfers, removeLiquidity reverts, the V2 executor's pre-flight catches it and raises a permanent error, and the position is retired for the process lifetime. Called out in the contract, .env.example, the README, and the launcher's own post-launch summary.
  • test — 43 new tests (323 → 366). Launch config is validated by buildLaunchPlan() rather than the zod schema on purpose: config.js is imported by src/index.js, so a required launch field would stop the bot booting for everyone who never launches a token. tests/config.test.js asserts that stays true. Amounts are strings, not num() — coercing a large supply through a JS number loses precision.

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 — the 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.
  • security — address-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.
  • security — a 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.
  • 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 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.
  • 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 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.
  • fix — approvalManager 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.
  • fix — the 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.
  • security — npm 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.
  • security — the 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.
  • fix — uncaughtException exited 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), 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:

  • 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 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.
  • security — CRITICAL: 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.
  • security — CRITICAL: 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.0 → 1.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.
  • 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_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.
  • security — DASHBOARD_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.
  • fix — alerts/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.
  • chore — docker-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.
  • feat — security-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 644 → 600, 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.
  • chore — abis/UniswapV3Factory.json (getPool only) + UNISWAP_V3_FACTORY_ABI export.
  • 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-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

  • 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: 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.
  • security — PRICE_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.
  • test — proxyAgent 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.
  • 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 /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.