Skip to content

Security: Gariyuuu/hyperliquid-bot

Security

SECURITY.md

SECURITY.md

This repository is real-money-capable. Everything in this document should be read with that fact in mind — the usual "worst case: some data leaks" framing for a typical web app does not apply here; the worst case for a bug or credential leak in this specific repo is direct financial loss. See CLAUDE.md's top banner for the evidence establishing this.

API key / credential handling

  • What's sensitive: HL_SECRET_KEY (the agent wallet's private key — full signing authority over that wallet's trading, though not withdrawal, per the agent-wallet model) and, to a lesser degree, HL_ACCOUNT_ADDRESS (a public address, not secret by itself, but reveals which account is being traded).
  • Where it lives: Only in .env (gitignored via .gitignore's .env line; no .env file exists in this repo as audited — only the placeholder .env.example, confirmed to contain no real key). Loaded into os.environ by config.py's custom load_dotenv(), read once into Config.secret_key, and passed to eth_account.Account.from_key() only when dry_run=False.
  • In-memory only, plaintext, for the process lifetime. No OS keychain integration, no encryption at rest, no secret-manager integration. The entire security boundary is: (1) .env never gets committed to git, (2) normal OS file permissions on the local machine. This is a reasonable model for a single-user local script, but it does mean anyone with read access to the machine's filesystem (or to a backup/sync of it, e.g. Time Machine, iCloud Drive if ~/Projects were ever synced there, a misconfigured dotfile sync tool, etc.) has the key.
  • Never logged. No code path prints, logs, or otherwise surfaces HL_SECRET_KEY's value — confirmed via repo-wide reading of every print() call in bot.py/hl_client.py. The one place a live order's response is printed ("LIVE order -> {result}" in market_order()) echoes whatever the SDK's Exchange.market_open() returns — this was not independently verified to never include sensitive data (out of scope for a static audit; the SDK is a third-party dependency), but order-response payloads from exchange APIs are not typically credential data.
  • Mitigation already in place, verified via README.md + code: the documented setup flow uses a Hyperliquid agent wallet rather than the user's main wallet key — an agent wallet can trade but cannot withdraw, so a leaked key's worst case is "attacker can place/close trades on your behalf" (still real financial risk via bad trades / intentional loss-inducing orders) rather than "attacker drains your entire balance." This bound only holds if the user actually follows the agent-wallet instructions — nothing in the code enforces that HL_SECRET_KEY is specifically an agent key rather than a main wallet key; that's entirely on the user.

Real-money risk exposure (the central concern of this document)

  • The live-order code path is fully functional, not a stub — see CLAUDE.md/API_REFERENCE.md. Once DRY_RUN=false, USE_TESTNET=false, and ALLOW_MAINNET=yes are all set, every subsequent loop iteration can place real market orders sized up to MAX_POSITION_USD and continue trading until DAILY_MAX_LOSS_USD is lost (kill switch) or the process is stopped.
  • The default state is safe. With no .env present (this repo's actual state), or with .env copied unedited from .env.example, the bot cannot place any real order — it's dry-run + testnet by default, and mainnet additionally requires the explicit ALLOW_MAINNET=yes flag on top of USE_TESTNET=false. This three-switch design is a genuinely good defense-in-depth pattern against accidental real-money exposure from a stale or partially-edited .env.
  • run-crazy.sh is a real-money risk surface if .env is misconfigured. It does not override DRY_RUN/USE_TESTNET — only strategy/risk parameter values (MAX_POSITION_USD=20000, LEVERAGE=20, DAILY_MAX_LOSS_USD=995). Its own comment says "FAKE MONEY ONLY," but nothing in the script enforces that if .env happens to already be configured for live mainnet trading. Recommendation: have this script explicitly export DRY_RUN=true itself so it can never be anything but a simulation, regardless of .env's state. See TASKS.md TASK-004 — this is a real trading-behavior change and needs explicit user confirmation before being made.
  • No per-trade or intraday drawdown limit beyond the session-total kill switch. A single bad order, up to MAX_POSITION_USD in size, could in principle lose a meaningful fraction of DAILY_MAX_LOSS_USD in one move before the kill switch's next check (check_kill_switch() is only evaluated once per loop iteration, at the top of the loop — a large, fast adverse price move within one POLL_SECONDS window is not protected against until the next iteration reads equity again).
  • No upper bound on LEVERAGE. config.py only enforces `LEVERAGE

    = 1; a .env(orrun-crazy.sh`-style override) can set arbitrarily high leverage.

Risk-limit enforcement (see also risk.py, FEATURES.md #2)

  • Enforced in-process, every loop iteration, before any order is sent: RiskManager.clamp_order_notional() (position cap) and RiskManager.allows_order() (minimum order size). Enforced every loop iteration, before the signal is even computed: RiskManager.check_kill_switch() (session loss cap).
  • This enforcement is convention-based, not structurally guaranteed. Nothing prevents a future code change from calling client.market_order()/client.close_position() directly, bypassing risk.py entirely. There is no access-control layer, no assertion, no test that would catch this today (see ARCHITECTURE.md's architectural risk #2, TASKS.md TASK-003 for the recommended test coverage that would at least partially guard against a regression here).
  • The kill switch halts the process; it does not prevent it from being restarted immediately with the same .env (and thus the same effective daily loss cap re-baselined against whatever equity exists at the new start). There is no persisted "already lost the daily max today, don't restart" state — a user (or an automated supervisor, if one were ever added) restarting the bot immediately after a kill-switch trip would reset the loss-tracking baseline (RiskManager.__init__ sets start_equity fresh each process start) and could, in principle, lose the same dollar amount again.

What happens on network failure mid-trade

This is the most important unresolved risk in the codebase. See ARCHITECTURE.md's "Major architectural risks" #1 for the full technical detail. Summary:

  • bot.py's main loop only catches RiskHalt and KeyboardInterrupt.
  • If client.market_order() or client.close_position() raises an exception for any reason (network timeout, connection drop, malformed API response, an SDK-internal exception) after the order may have already been transmitted to (and potentially filled by) the exchange, the exception is uncaught and crashes the process with a raw traceback.
  • The bot has no way to know, at the moment of the crash, whether the order succeeded, partially filled, or never reached the exchange at all.
  • There is no automatic retry, no automatic reconciliation-on-restart check, no alert. A human must notice the process died, manually check the actual account state on Hyperliquid, and decide what to do.
  • Mitigating factor: because live-mode position/equity reads (position_size(), account_value()) always query the real exchange fresh (Info.user_state()), a restarted bot process will correctly pick up whatever the true post-crash position actually is — the bot does not maintain a stale local copy of position state that could drift from reality. The risk is specifically the gap between the crash and a human/restart noticing it, not permanent state corruption.
  • Recommendation (not implemented, flagged for TASKS.md): wrap the order-placement calls in bot.py's loop with a try/except that at minimum logs the failure clearly and attempts to re-read/report the actual current position before exiting, rather than a bare traceback. This is a behavior change to code adjacent to order execution and needs explicit user confirmation before being implemented, per CLAUDE.md.

Dependency concerns

  • hyperliquid-python-sdk is unpinned in requirements.txt. A version bump on reinstall could change the behavior or signature of Exchange.market_open()/market_close()/update_leverage() — the exact functions responsible for real order placement — without any explicit review. See TASKS.md TASK-001.
  • No dependency vulnerability scanning (no pip-audit, no Dependabot config, no safety check) configured anywhere in the repo.
  • eth_account (used for key handling) is a transitive dependency of the SDK, not directly pinned either — same concern as above, one level removed.

Secrets scan performed during this audit

A repo-wide search for hardcoded credentials, API keys, and plaintext-secret patterns was performed across all .py files. No hardcoded secrets, credentials, or real key values were found anywhere in the codebase. No .env file exists in this repo to leak from. The only place any credential-shaped variable appears is as an empty placeholder in .env.example (HL_SECRET_KEY= with no value, and a comment explaining what belongs there) — this was read and confirmed to contain no real value, and no value from it (there is none) was reproduced anywhere in this documentation.

Recommended fixes (prioritized)

  1. High: Add explicit error handling around market_order()/ close_position() calls in bot.py's loop so a network failure mid-order produces a clear, actionable message and an attempt to verify true account state, rather than an uncaught crash. (Needs explicit user confirmation — touches order-execution-adjacent code.)
  2. Medium-High: Decide and fix run-crazy.sh's relationship to .env's safety switches (force DRY_RUN=true in the script itself). (Needs explicit user confirmation — real trading-behavior change.)
  3. Medium: Pin hyperliquid-python-sdk in requirements.txt to the currently-installed, presumably-tested version.
  4. Medium: Add unit tests for risk.py specifically (highest-value test target given real-money stakes).
  5. Low-Medium: Consider a persistent, append-only trade/action log (not just stdout) for post-hoc auditability.
  6. Low: Consider an upper bound check on LEVERAGE in config.py's validation, consistent with the existing >= 1 check.

None of the above were implemented during this audit — this is a documentation-only pass per the task's explicit instructions.

There aren't any published security advisories