diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..a0bffa9 --- /dev/null +++ b/.env.example @@ -0,0 +1,27 @@ +# Copy to .env (gitignored) and fill in. `bb-bot` auto-loads ./.env at startup, +# so `cp .env.example .env` then `cargo run -- run ...` just works. Real +# environment variables already set take precedence. Use `--env-file ` to +# load a different file. Never commit secrets into config/*.toml. +# +# Paste each key exactly as the venue's UI gives it to you: +# Bullet → base58 +# Hyperliquid → hex (0x…) + +# --- Bullet (base58) --- +# Delegate signer secret from the Bullet delegate UI (base58; hex also works). +# A delegate can only trade (no deposit/withdraw) and is revocable. The bot +# resolves a delegate to its master account automatically for reads. +BB_BULLET_PRIVATE_KEY= +# Alternatively, point at a file containing the key string (e.g. from +# `bb-bot keygen`). Takes precedence over BB_BULLET_PRIVATE_KEY. +# BB_BULLET_KEY_FILE=/path/to/id.key + +# --- Hyperliquid (hex) --- +# API-wallet key (secp256k1 hex) from https://app.hyperliquid.xyz/API. +BB_HYPERLIQUID_PRIVATE_KEY= +# Your MAIN account address (0x… from the HL UI). Required when the key above is +# an API wallet — positions/balances/fills live on the main account, not the API +# wallet. Leave unset if the key is your main wallet itself. +BB_HYPERLIQUID_ACCOUNT_ADDRESS= +# Alternatively, a file containing the hex key. Takes precedence over the key string. +# BB_HYPERLIQUID_KEY_FILE=/path/to/hl.key diff --git a/.gitignore b/.gitignore index f242a71..aa56f85 100644 --- a/.gitignore +++ b/.gitignore @@ -7,3 +7,6 @@ # Local/experimental configs — put personal configs here config/local/ + +# Superpowers brainstorming/specs (local working docs) +docs/superpowers/ diff --git a/AGENTS.md b/AGENTS.md index a509943..8bcaf82 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -35,7 +35,7 @@ Generate a keypair (first time): ```sh cargo run --bin bb-bot -- keygen --network testnet -# → writes ~/.config/bullet/id.json (0600), prints address + faucet curl +# → writes ~/.config/bullet/id.key (base58 secret, 0600), prints address + faucet curl ``` Fund and onboard the account (first time). The faucet credits the on-chain @@ -49,18 +49,18 @@ order placement fail with `user_variants not found`: cargo run --bin bb-bot -- deposit --network testnet --asset USDC --amount 5000 ``` -Run a bot (default: reads `~/.config/bullet/id.json`): +Run a bot (default: reads `~/.config/bullet/id.key`): ```sh cargo run --bin bb-bot -- run --config config/simple-mm-example.toml ``` -Or point at an explicit keystore / use hex for CI: +Or point at an explicit key file / pass a key string for CI: ```sh -export BB_BULLET_KEY_FILE="/path/to/keystore.json" # preferred -# OR -export BB_BULLET_PRIVATE_KEY_HEX="0x..." # fallback +export BB_BULLET_KEY_FILE="/path/to/id.key" # preferred +# OR (base58 from Phantom/delegation export, or hex) +export BB_BULLET_PRIVATE_KEY="" # fallback ``` ## Architecture — the harness, feeds, and actors @@ -294,6 +294,13 @@ the full walkthrough including reconnect patterns and the `Trade` / `InfoClient::with_reconnect` handles reconnection. Symbol mapping: Bullet `"BTC-USD"` ↔ HL `"BTC"`. `ActiveAssetCtx` provides real funding rates; `AllMids` remains a mark-price fallback when no funding field is present. +**API/agent wallets**: set `account_address` (env `BB_HYPERLIQUID_ACCOUNT_ADDRESS`) +to the master account. The agent key signs (orders are attributed to the master +on-chain, `vault_address: None` per the SDK's `approve_agent` pattern); reads +(`user_state` / `open_orders` / `user_fills`) and the `UserFills` / `OrderUpdates` +subscriptions use `account_address`. Unset → reads default to the signer's own +address (main-wallet-key case). HL has no on-chain delegate lookup, so unlike +Bullet the master must be given explicitly. ## Config Format @@ -303,15 +310,22 @@ TOML. Top-level sections: `[engine]`, `[exchanges.]`, `[strategy]`, - `[engine]` — `tick_interval_ms`, `status_port` (optional), or `status_bind = "host:port"` for explicit bind. `symbol` lives inside each `[strategy.]` section so multi-symbol setups are explicit. -- Exchange configs: `type = ""` + adapter-specific fields. Bullet - resolves key material in this order (explicit config wins; env fills a - field the config omits, so an ambient env var can't silently switch - wallets): `key_file` (in config) → env `BB_BULLET_KEY_FILE` → - `private_key_hex` (in config) → env `BB_BULLET_PRIVATE_KEY_HEX`. File-based - keystore is preferred — see `bb-bot keygen`. Hyperliquid keys via - `BB_HYPERLIQUID_PRIVATE_KEY_HEX`. (Standalone `deposit`/`flatten`/`observe` - take no config, so there env is the source: `BB_BULLET_KEY_FILE` → env hex - → default keystore.) +- Exchange configs: `type = ""` + adapter-specific fields. Keys use each + venue's native format: **base58 for Bullet**, **hex for Hyperliquid** — paste + what the UI gives you. Both adapters resolve key material identically through + `bb_core::keys::resolve_key_string`, in this order (explicit config wins; env + fills a field the config omits, so an ambient env var can't silently switch + wallets): `key_file` (config) → env `BB__KEY_FILE` → `private_key` + (config) → env `BB__PRIVATE_KEY`. A `key_file` is a file containing the + key string (as written by `bb-bot keygen`), not a JSON keystore. If a Bullet + signer is a **delegate** key, the adapter resolves it to its master account + (via the `delegateOf` endpoint) for all reads and the user-orders + subscription; signing uses the delegate key directly. For Hyperliquid API/agent + wallets, set `BB_HYPERLIQUID_ACCOUNT_ADDRESS` (or `account_address` in config) + to the master account. `bb-bot` auto-loads `./.env` at startup (override with + `--env-file `); already-set environment variables take precedence. + (Standalone `deposit`/`flatten`/`observe` take no config, so there env is the + source: `BB_BULLET_KEY_FILE` → `BB_BULLET_PRIVATE_KEY` → default key file.) - Strategy configs: `type = ""` with sub-table `[strategy.]`. ## Code Style diff --git a/Cargo.lock b/Cargo.lock index 4179c3c..72bd876 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -340,6 +340,8 @@ dependencies = [ "bb-strategy-simple-mm", "bullet-rust-sdk", "clap", + "dotenvy", + "reqwest 0.13.2", "rust_decimal", "secrecy", "serde", @@ -385,7 +387,10 @@ version = "0.1.0" dependencies = [ "async-trait", "bb-core", + "bs58", "bullet-rust-sdk", + "getrandom 0.2.17", + "reqwest 0.13.2", "rust_decimal", "secrecy", "serde", @@ -1330,6 +1335,12 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "dotenvy" +version = "0.15.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b" + [[package]] name = "dunce" version = "1.0.5" @@ -3587,9 +3598,9 @@ dependencies = [ [[package]] name = "quinn-proto" -version = "0.11.14" +version = "0.11.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "434b42fec591c96ef50e21e886936e66d3cc3f737104fdb9b737c40ffb94c098" +checksum = "4fcb935c5bec503c2f0e306bdd3e58bb9029dcb14fa8d9ac76e3a5256ac0763e" dependencies = [ "aws-lc-rs", "bytes", diff --git a/Cargo.toml b/Cargo.toml index c0fc77d..cf741e7 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -25,11 +25,15 @@ toml = "0.8" rust_decimal = { version = "1", features = ["serde-with-str"] } thiserror = "2" secrecy = { version = "0.8", features = ["serde"] } +bs58 = "0.5" +getrandom = "0.2" +reqwest = { version = "0.13", features = ["json"] } tracing = "0.1" tracing-subscriber = { version = "0.3", features = ["env-filter"] } async-trait = "0.1" clap = { version = "4", features = ["derive"] } axum = "0.8" +dotenvy = "0.15" # Internal crates bb-core = { path = "crates/bb-core" } diff --git a/HACKING.md b/HACKING.md index 1c5dea7..aa72920 100644 --- a/HACKING.md +++ b/HACKING.md @@ -279,8 +279,8 @@ status_port = 3030 type = "bullet" network = "testnet" # Key material — do NOT put private keys in this file. -# Option 1 (preferred): key_file = "/path/to/id.json" (run `bb-bot keygen`) -# Option 2 (CI/ephemeral): export BB_BULLET_PRIVATE_KEY_HEX="0x..." +# Option 1 (preferred): key_file = "/path/to/id.key" (run `bb-bot keygen`) +# Option 2 (CI/ephemeral): export BB_BULLET_PRIVATE_KEY="" [strategy] type = "dip-buyer" @@ -297,7 +297,7 @@ max_position = "0.01" Run: ```sh -export BB_BULLET_PRIVATE_KEY_HEX="0x..." +export BB_BULLET_PRIVATE_KEY="" cargo run --bin bb-bot -- run --config config/dip-buyer-example.toml ``` diff --git a/README.md b/README.md index 70ce3e6..0c3cc60 100644 --- a/README.md +++ b/README.md @@ -28,49 +28,85 @@ events are structurally impossible. For an annotated component diagram, event-flow walkthrough, adapter layout rules, and the broker contract, see [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md). -## Quick start +## Quick start — testing (testnet, ~5 minutes) + +The fastest path: a throwaway testnet key funded from the faucet. **No wallet, +no web UI, no real funds.** This is the recommended way to try the bot. ```sh -# Build cargo build +cargo run --bin bb-bot -- validate --config config/simple-mm-example.toml # no keys needed -# Run tests -cargo nextest run +# 1. Generate a testnet burner key → writes ~/.config/bullet/id.key (0600), +# prints your address and the exact faucet command. +cargo run --bin bb-bot -- keygen --network testnet -# Validate a starter config (no keys needed) -cargo run --bin bb-bot -- validate --config config/simple-mm-example.toml +# 2. Fund it from the faucet. +cargo run --bin bb-bot -- faucet --network testnet +# The faucet is rate-limited and Cloudflare-protected; if this 403s, use the +# web faucet at https://app.testnet.bullet.xyz and fund your printed address. -# Generate a Bullet testnet key, fund it with the printed faucet curl, -# deposit into the perp margin account, then run the starter market maker. -cargo run --bin bb-bot -- keygen --network testnet -# ...run the faucet curl printed above, then deposit into the margin account: +# 3. Move funds into the perp margin account. This also initializes the trading +# account — without it, order placement fails with `user_variants not found`. cargo run --bin bb-bot -- deposit --network testnet --asset USDC --amount 5000 + +# 4. Run the starter market maker (reads ~/.config/bullet/id.key by default). cargo run --bin bb-bot -- run --config config/simple-mm-example.toml ``` -Recommended first path: - -1. `keygen` — create a testnet key. -2. Fund it with the faucet command printed by `keygen`. The faucet credits your - on-chain wallet, not your trading account. The faucet is **testnet only** — - on mainnet you fund the wallet with real bridged/deposited assets instead. -3. `deposit` — move funds from the on-chain wallet into the perp margin account - (e.g. `deposit --network testnet --asset USDC --amount 5000`). The asset must - match a name in Bullet's exchangeInfo (e.g. `USDC`) and the amount is in that - asset's units. This also initializes the trading account; without it, order - placement fails with `user_variants not found`. -4. `observe` — collect Bullet/Binance spread data without trading. -5. `validate` — preflight the config. -6. `run` — start tiny, watch logs plus `GET /status`. -7. `flatten` — cancel and close manually if you need to clean up. +Other commands: `observe` (collect Bullet/Binance spread data, no trading), +`flatten` (cancel orders + market-close positions), `validate` (preflight a config). + +## Production (mainnet, real funds) + +**Do not run mainnet with a `keygen` burner** — that puts a key controlling real +funds inside the bot. Instead use a **delegate** (Bullet) / **API wallet** +(Hyperliquid): a separate key scoped to trading only (cannot deposit or +withdraw), revocable from the webapp at any time, so the bot never holds a key +that can drain your wallet. + +1. Sign in at [app.bullet.xyz](https://app.bullet.xyz) with your wallet (e.g. + Phantom) — this creates the embedded wallet that is your Bullet trading account. +2. Deposit collateral through the webapp. +3. Create a delegate (see the + [delegate setup guide](https://docs.bullet.xyz/bulletx-exchange/how-to-guide/delegate-account-setup)), + then put its **base58** key in `.env` as `BB_BULLET_PRIVATE_KEY` (or save it + to a file and point `BB_BULLET_KEY_FILE` at it). +4. For Hyperliquid, create an API wallet at + [app.hyperliquid.xyz/API](https://app.hyperliquid.xyz/API). Set + `BB_HYPERLIQUID_PRIVATE_KEY` to the **API-wallet key** (hex), and + `BB_HYPERLIQUID_ACCOUNT_ADDRESS` to your **main account address** (the `0x…` + shown in the HL UI). The API wallet signs; positions/balances/fills are read + from the main account. +5. Set `network = "mainnet"` in the config's `[exchanges.*]` sections. + +> **What is a delegate / API wallet?** A separate keypair authorized to trade on +> behalf of your account. It can place and cancel orders but **cannot deposit or +> withdraw**, and you can revoke it from the webapp at any time — so you trade +> without exposing your main wallet's private key. On both venues the bot signs +> with this key but reads account state from the **main account** — Bullet +> resolves the master automatically via `delegateOf`; on Hyperliquid you supply +> it via `BB_HYPERLIQUID_ACCOUNT_ADDRESS`. + +Put these in `.env` — `bb-bot` auto-loads `./.env` at startup, so +`cp .env.example .env`, fill it in, and run. (Use `--env-file ` to load a +different file; real environment variables already set take precedence.) ## Key management -Private keys are passed via environment variables or keystore files, not copied -into example configs. Two options: - -- **Bullet key file (recommended):** generate once with `cargo run --bin bb-bot -- keygen`, then set `BB_BULLET_KEY_FILE` or add `key_file = "/path/to/id.json"` under `[exchanges.bullet]`. -- **Hex key:** set `BB_BULLET_PRIVATE_KEY_HEX` / `BB_HYPERLIQUID_PRIVATE_KEY_HEX`, e.g. via a `.env` file (already gitignored). +Keys use each venue's native format — **paste exactly what the UI gives you**: +**base58 for Bullet** (Phantom / delegation export), **hex for Hyperliquid** (the +HL API page). Never put them in `config/*.toml`. + +- **Key string in `.env` (typical):** set `BB_BULLET_PRIVATE_KEY` (base58) and + `BB_HYPERLIQUID_PRIVATE_KEY` (hex); auto-loaded from `.env`. When the + Hyperliquid key is an API wallet, also set `BB_HYPERLIQUID_ACCOUNT_ADDRESS` to + your main account address. +- **Key file (keeps the secret off the environment):** for Bullet, generate one + with `cargo run --bin bb-bot -- keygen`, then set `BB_BULLET_KEY_FILE` (or + `key_file` in `[exchanges.bullet]`). Both venues accept `key_file` / + `BB__KEY_FILE` — a file containing the key string; it takes precedence + over the inline key. ## Strategies diff --git a/config/avellaneda-stoikov-example.toml b/config/avellaneda-stoikov-example.toml index 7b6f3f5..6d5d4f0 100644 --- a/config/avellaneda-stoikov-example.toml +++ b/config/avellaneda-stoikov-example.toml @@ -9,8 +9,8 @@ status_port = 3034 # must be unique per running bot — collides if two bots sh type = "bullet" network = "testnet" # Key material — do NOT put private keys in this file. -# Option 1 (preferred): key_file = "/path/to/id.json" (run `bb-bot keygen`) -# Option 2 (CI/ephemeral): export BB_BULLET_PRIVATE_KEY_HEX="0x..." +# Option 1 (preferred): key_file = "/path/to/id.key" (run `bb-bot keygen`) +# Option 2 (CI/ephemeral): export BB_BULLET_PRIVATE_KEY="..." (base58 or hex) [strategy] type = "avellaneda-stoikov" diff --git a/config/funding-arb-example.toml b/config/funding-arb-example.toml index e89d665..9d369a5 100644 --- a/config/funding-arb-example.toml +++ b/config/funding-arb-example.toml @@ -7,16 +7,19 @@ status_port = 3031 [exchanges.bullet] type = "bullet" -network = "testnet" +network = "testnet" # match where your delegate was created: testnet (app.testnet.bullet.xyz) or mainnet (app.bullet.xyz) # Key material — do NOT put private keys in this file. -# Option 1 (preferred): key_file = "/path/to/id.json" (run `bb-bot keygen`) -# Option 2 (CI/ephemeral): export BB_BULLET_PRIVATE_KEY_HEX="0x..." +# Option 1 (preferred): key_file = "/path/to/id.key" (run `bb-bot keygen`) +# Option 2: export BB_BULLET_PRIVATE_KEY="..." (base58 from the delegate UI, or hex) [exchanges.hyperliquid] type = "hyperliquid" -network = "testnet" +network = "testnet" # match where your API wallet was created: testnet or mainnet (app.hyperliquid.xyz) # Key material — do NOT put private keys in this file. -# export BB_HYPERLIQUID_PRIVATE_KEY_HEX="0x..." +# export BB_HYPERLIQUID_PRIVATE_KEY="0x..." # API-wallet key (signs orders) +# export BB_HYPERLIQUID_ACCOUNT_ADDRESS="0x..." # your MAIN account (reads +# positions/balances/fills). Required for an API wallet; omit only if the +# key above is your main wallet's own key. [strategy] type = "funding-arb" diff --git a/config/grid-example.toml b/config/grid-example.toml index 21bd8f1..a89af49 100644 --- a/config/grid-example.toml +++ b/config/grid-example.toml @@ -23,8 +23,8 @@ status_port = 3033 # must be unique per running bot — collides if two bots sh type = "bullet" network = "testnet" # Key material — do NOT put private keys in this file. -# Option 1 (preferred): key_file = "/path/to/id.json" (run `bb-bot keygen`) -# Option 2 (CI/ephemeral): export BB_BULLET_PRIVATE_KEY_HEX="0x..." +# Option 1 (preferred): key_file = "/path/to/id.key" (run `bb-bot keygen`) +# Option 2 (CI/ephemeral): export BB_BULLET_PRIVATE_KEY="..." (base58 or hex) [strategy] type = "grid" diff --git a/config/reference-arb-example.toml b/config/reference-arb-example.toml index 65029cb..afa9c25 100644 --- a/config/reference-arb-example.toml +++ b/config/reference-arb-example.toml @@ -16,7 +16,7 @@ status_port = 3032 [exchanges.bullet] type = "bullet" network = "testnet" -# Set via: export BB_BULLET_PRIVATE_KEY_HEX="0x..." or BB_BULLET_KEY_FILE=/path/to/key.json +# Set via: export BB_BULLET_PRIVATE_KEY="..." (base58 or hex) or BB_BULLET_KEY_FILE=/path/to/key.json [strategy] type = "reference-arb" diff --git a/config/simple-mm-example.toml b/config/simple-mm-example.toml index e53e618..a21cb98 100644 --- a/config/simple-mm-example.toml +++ b/config/simple-mm-example.toml @@ -16,8 +16,8 @@ status_port = 3030 # must be unique per running bot — collides if two bots sh type = "bullet" network = "testnet" # Key material — do NOT put private keys in this file. -# Option 1 (preferred): key_file = "/path/to/id.json" (run `bb-bot keygen`) -# Option 2 (CI/ephemeral): export BB_BULLET_PRIVATE_KEY_HEX="0x..." +# Option 1 (preferred): key_file = "/path/to/id.key" (run `bb-bot keygen`) +# Option 2 (CI/ephemeral): export BB_BULLET_PRIVATE_KEY="..." (base58 or hex) [strategy] type = "simple-mm" diff --git a/crates/bb-bot/Cargo.toml b/crates/bb-bot/Cargo.toml index dcc828e..0288b7d 100644 --- a/crates/bb-bot/Cargo.toml +++ b/crates/bb-bot/Cargo.toml @@ -30,5 +30,7 @@ toml = { workspace = true } tracing = { workspace = true } tracing-subscriber = { workspace = true } clap = { workspace = true } +dotenvy = { workspace = true } +reqwest = { workspace = true } secrecy = { workspace = true } async-trait = { workspace = true } diff --git a/crates/bb-bot/src/main.rs b/crates/bb-bot/src/main.rs index b6ae560..fcd5aa4 100644 --- a/crates/bb-bot/src/main.rs +++ b/crates/bb-bot/src/main.rs @@ -38,6 +38,12 @@ use observer::ObserverActor; #[derive(Parser)] #[command(name = "bb-bot", about = "Bullet Bots trading system")] struct Cli { + /// Load environment variables from this file before running. Defaults to + /// `./.env` if present. Keys/account addresses are read from the + /// environment, so this is how a `.env` gets picked up. + #[arg(long, global = true)] + env_file: Option, + #[command(subcommand)] command: Command, } @@ -54,15 +60,21 @@ enum Command { #[arg(short, long)] config: String, }, - /// Generate a burner Ed25519 keypair for Bullet and write it to a - /// Solana-compatible JSON keystore file. + /// Generate a burner Ed25519 keypair for Bullet and write its base58 secret + /// to a 0600 key file. Keygen { #[arg(long, default_value = "testnet")] network: String, - /// Where to write the keystore. Defaults to `$HOME/.config/bullet/id.json`. + /// Where to write the key file. Defaults to `$HOME/.config/bullet/id.key`. #[arg(long)] out: Option, }, + /// Fund the wallet from the testnet faucet (testnet only). Resolves the + /// address from the same key material as `deposit`. + Faucet { + #[arg(long, default_value = "testnet")] + network: String, + }, /// Deposit funds from on-chain balance into the perp margin account. Deposit { #[arg(long, default_value = "testnet")] @@ -153,21 +165,38 @@ fn load_config(path: &str) -> Result> { // leaves empty. For a trading bot this avoids an ambient // BB_BULLET_KEY_FILE silently overriding the wallet set in config. if table.get("key_file").and_then(toml::Value::as_str).is_none_or(str::is_empty) - && let Ok(path) = std::env::var("BB_BULLET_KEY_FILE") + && let Some(path) = std::env::var("BB_BULLET_KEY_FILE").ok().filter(|v| !v.is_empty()) { table.insert("key_file".to_string(), toml::Value::String(path)); } - if table.get("private_key_hex").and_then(toml::Value::as_str).is_none_or(str::is_empty) - && let Ok(key) = std::env::var("BB_BULLET_PRIVATE_KEY_HEX") + if table.get("private_key").and_then(toml::Value::as_str).is_none_or(str::is_empty) + && let Some(key) = bullet_key_from_env() { - table.insert("private_key_hex".to_string(), toml::Value::String(key)); + table.insert("private_key".to_string(), toml::Value::String(key)); } } if let Some(hl) = config.exchanges.get_mut("hyperliquid") - && let Ok(key) = std::env::var("BB_HYPERLIQUID_PRIVATE_KEY_HEX") && let Some(table) = hl.config.as_table_mut() { - table.insert("private_key_hex".to_string(), toml::Value::String(key)); + // Config wins; env only fills a field left unset or empty. + if table.get("key_file").and_then(toml::Value::as_str).is_none_or(str::is_empty) + && let Some(path) = + std::env::var("BB_HYPERLIQUID_KEY_FILE").ok().filter(|v| !v.is_empty()) + { + table.insert("key_file".to_string(), toml::Value::String(path)); + } + if table.get("private_key").and_then(toml::Value::as_str).is_none_or(str::is_empty) + && let Some(key) = + std::env::var("BB_HYPERLIQUID_PRIVATE_KEY").ok().filter(|v| !v.is_empty()) + { + table.insert("private_key".to_string(), toml::Value::String(key)); + } + if table.get("account_address").and_then(toml::Value::as_str).is_none_or(str::is_empty) + && let Some(addr) = + std::env::var("BB_HYPERLIQUID_ACCOUNT_ADDRESS").ok().filter(|v| !v.is_empty()) + { + table.insert("account_address".to_string(), toml::Value::String(addr)); + } } Ok(config) @@ -460,7 +489,7 @@ fn keygen(network: &str, out: Option) -> Result<(), Box` to write elsewhere, or remove it first.", path.display() ) @@ -470,14 +499,13 @@ fn keygen(network: &str, out: Option) -> Result<(), Box) -> Result<(), Box) -> Result<(), Box PathBuf { match std::env::var_os("HOME") { - Some(home) => PathBuf::from(home).join(".config/bullet/id.json"), - None => PathBuf::from("./bullet-id.json"), + Some(home) => PathBuf::from(home).join(".config/bullet/id.key"), + None => PathBuf::from("./bullet-id.key"), } } -/// Set the keystore file to owner-read/write only (0600). On non-Unix -/// platforms this is a no-op — Windows ACLs are left to the user's home -/// directory permissions. +/// Set the key file to owner-read/write only (0600). On non-Unix platforms +/// this is a no-op — Windows ACLs are left to the user's home directory +/// permissions. #[cfg(unix)] fn set_keystore_permissions(path: &std::path::Path) -> std::io::Result<()> { use std::os::unix::fs::PermissionsExt; @@ -523,26 +557,31 @@ fn set_keystore_permissions(_path: &std::path::Path) -> std::io::Result<()> { /// Resolve a Keypair for standalone (non-harness) commands like `deposit`, /// in the same preference order as `BulletConfig`: `BB_BULLET_KEY_FILE` wins, -/// then `BB_BULLET_PRIVATE_KEY_HEX`, then the default path, else error. +/// then `BB_BULLET_PRIVATE_KEY`, then the default path, else error. fn load_deposit_keypair() -> Result> { - if let Ok(path) = std::env::var("BB_BULLET_KEY_FILE") { - return Keypair::read_from_file(&path) - .map_err(|e| format!("Failed to load keystore {path}: {e}").into()); + if let Some(path) = std::env::var("BB_BULLET_KEY_FILE").ok().filter(|v| !v.is_empty()) { + return bb_exchange_bullet::key::keypair_from_key_file(std::path::Path::new(&path)) + .map_err(Into::into); } - if let Ok(hex) = std::env::var("BB_BULLET_PRIVATE_KEY_HEX") { - return Keypair::from_hex(&hex).map_err(Into::into); + if let Some(secret) = bullet_key_from_env() { + return bb_exchange_bullet::key::keypair_from_secret(&secret).map_err(Into::into); } let default = default_key_path(); if default.exists() { - return Keypair::read_from_file(&default).map_err(|e| { - format!("Failed to load default keystore {}: {e}", default.display()).into() - }); + return bb_exchange_bullet::key::keypair_from_key_file(&default).map_err(Into::into); } - Err("No key material — set BB_BULLET_KEY_FILE, BB_BULLET_PRIVATE_KEY_HEX, \ - or run `bb-bot keygen` to create a default keystore" + Err("No key material — set BB_BULLET_KEY_FILE, BB_BULLET_PRIVATE_KEY, \ + or run `bb-bot keygen` to create a default key file" .into()) } +/// Read the Bullet signer key string from the environment (`BB_BULLET_PRIVATE_KEY`). +/// An empty value is treated as absent, so a blank var doesn't shadow a key file +/// or trigger a spurious "no key material" error. +fn bullet_key_from_env() -> Option { + std::env::var("BB_BULLET_PRIVATE_KEY").ok().filter(|v| !v.is_empty()) +} + /// Parse a network name into a [`Network`], accepting only `"mainnet"` / /// `"testnet"`. Unlike `Network::from`, an unknown value is a hard error /// rather than silently mapping to `Network::Custom` — matching how @@ -560,27 +599,84 @@ fn parse_network(s: &str) -> Result { /// Build a [`BulletConfig`] for standalone commands (`flatten` / `observe`) /// that don't load a TOML config. Resolves key material the same way as /// `connect_bullet` / `load_deposit_keypair`: `BB_BULLET_KEY_FILE` env wins, -/// else the default `~/.config/bullet/id.json` keystore if it exists, with the -/// `BB_BULLET_PRIVATE_KEY_HEX` env as a fallback. This lets a user who ran -/// `bb-bot keygen` (which writes the default keystore) use these commands with -/// no extra env setup. `connect_bullet` enforces that at least one source -/// yields usable key material. +/// else `BB_BULLET_PRIVATE_KEY`, else the default `~/.config/bullet/id.key` +/// file if it exists. This lets a user who ran `bb-bot keygen` (which writes +/// the default key file) use these commands with no extra env setup. +/// `connect_bullet` enforces that at least one source yields usable key material. fn bullet_config_from_env(network: String) -> BulletConfig { use secrecy::SecretString; - let private_key_hex = std::env::var("BB_BULLET_PRIVATE_KEY_HEX").unwrap_or_default(); - let key_file = std::env::var_os("BB_BULLET_KEY_FILE").map(Into::into).or_else(|| { - // Only fall back to the default keystore when no hex key was supplied, - // matching `load_deposit_keypair`'s precedence (env key_file → env hex → - // default keystore) so flatten/observe/deposit pick the same account. - if private_key_hex.is_empty() { - let default = default_key_path(); - default.exists().then_some(default) - } else { - None - } - }); - BulletConfig { network, key_file, private_key_hex: SecretString::new(private_key_hex) } + let private_key = bullet_key_from_env().unwrap_or_default(); + let key_file = + std::env::var("BB_BULLET_KEY_FILE").ok().filter(|v| !v.is_empty()).map(Into::into).or_else( + || { + // Only fall back to the default key file when no key string was supplied, + // matching `load_deposit_keypair`'s precedence (env key_file → env key → + // default key file) so flatten/observe/deposit pick the same account. + if private_key.is_empty() { + let default = default_key_path(); + default.exists().then_some(default) + } else { + None + } + }, + ); + BulletConfig { network, key_file, private_key: SecretString::new(private_key) } +} + +/// Build a [`HyperliquidConfig`] from the environment for standalone commands +/// (`flatten`). Returns `None` when no HL key material is set, so `flatten` +/// skips the HL venue. `account_address` is the master for API-wallet keys. +fn hyperliquid_config_from_env(network: String) -> Option { + use secrecy::SecretString; + + let private_key = std::env::var("BB_HYPERLIQUID_PRIVATE_KEY").ok().filter(|v| !v.is_empty()); + let key_file = + std::env::var("BB_HYPERLIQUID_KEY_FILE").ok().filter(|v| !v.is_empty()).map(Into::into); + if private_key.is_none() && key_file.is_none() { + return None; + } + Some(HyperliquidConfig { + network, + key_file, + private_key: SecretString::new(private_key.unwrap_or_default()), + account_address: std::env::var("BB_HYPERLIQUID_ACCOUNT_ADDRESS") + .ok() + .filter(|v| !v.is_empty()), + }) +} + +/// Fund the wallet from the testnet faucet. Resolves the address from the same +/// key material as `deposit`, then calls the faucet endpoint directly (with a +/// browser User-Agent, which the host requires — a plain `curl` is rejected +/// with "Forbidden"). +async fn faucet(network: String) -> Result<(), Box> { + if network != "testnet" { + return Err("Faucet is only available on testnet".into()); + } + let address = load_deposit_keypair()?.address(); + let url = format!("https://app.testnet.bullet.xyz/api/testnet/faucet?address={address}"); + let resp = reqwest::Client::new().post(&url).header("User-Agent", "Mozilla/5.0").send().await?; + let status = resp.status(); + let body = resp.text().await.unwrap_or_default(); + if !status.is_success() { + // The faucet is Cloudflare-protected and rate-limited; a 403 usually + // means "already funded recently from this IP" or a bot challenge. The + // web faucet is the reliable fallback. + return Err(format!( + "Faucet request failed (HTTP {status}): {body}\n\ + It may be rate-limited (already funded recently) or blocking automated \ + requests. Use the web faucet instead: https://app.testnet.bullet.xyz \ + (fund address {address})." + ) + .into()); + } + println!("Faucet funded {address}"); + println!(" {body}"); + println!( + "Next: cargo run --bin bb-bot -- deposit --network testnet --asset USDC --amount 5000" + ); + Ok(()) } async fn deposit( @@ -614,50 +710,60 @@ async fn deposit( } async fn flatten(network: String, symbol: String) -> Result<(), Box> { + // Flatten every venue the bot trades, so a delta-neutral strategy's legs + // are both closed. Key material resolves via env (.env is auto-loaded). Each + // venue is skipped (not fatal) if its keys aren't configured — an HL-only + // user shouldn't be blocked by a missing Bullet key, and vice versa. + let bullet_cfg = bullet_config_from_env(network.clone()); + match bb_exchange_bullet::connect_bullet(&bullet_cfg, &symbol).await { + Ok((bullet, _feeds)) => flatten_broker(&bullet, &symbol, "bullet").await?, + Err(e) => println!("[bullet] skipping flatten — connect failed: {e}"), + } + + if let Some(hl_cfg) = hyperliquid_config_from_env(network) { + match connect_hyperliquid(&hl_cfg, &symbol).await { + Ok((hl, _feeds)) => flatten_broker(&hl, &symbol, "hyperliquid").await?, + Err(e) => println!("[hyperliquid] skipping flatten — connect failed: {e}"), + } + } + Ok(()) +} + +/// Cancel resting orders and market-close any open position on `symbol` for one +/// broker. Used by `flatten` for each configured venue. +async fn flatten_broker( + broker: &B, + symbol: &str, + venue: &str, +) -> Result<(), Box> { use bb_core::types::{NewOrder, OrderType, Side}; const FLATTEN_SLIPPAGE_BPS: u64 = 100; - // Reuse the adapter's connect path to get a real Broker. We don't need the - // feeds — just a broker handle. Key material resolves via env or the - // default keystore written by `bb-bot keygen`. - let bullet_cfg = bullet_config_from_env(network); - let (broker, _feeds) = bb_exchange_bullet::connect_bullet(&bullet_cfg, &symbol).await?; - - let _ = broker.cancel_all_orders(&symbol).await; + let _ = broker.cancel_all_orders(symbol).await; let positions = broker.get_positions().await?; - let position = positions.iter().find(|p| p.symbol == symbol); - - let Some(pos) = position else { - println!("No position on {symbol}. Nothing to flatten."); + let Some(pos) = positions.iter().find(|p| p.symbol == symbol && !p.size.is_zero()) else { + println!("[{venue}] No position on {symbol}. Nothing to flatten."); return Ok(()); }; - if pos.size.is_zero() { - println!("Position on {symbol} is already flat."); - return Ok(()); - } - // Bullet reports size with a Side indicator; convert to signed and close - // with an opposite-side market order of the same magnitude. + // Close with an opposite-side market order of the same magnitude. let (close_side, qty) = match pos.side { Some(Side::Buy) => (Side::Sell, pos.size), Some(Side::Sell) => (Side::Buy, pos.size), None => { - println!("Position size {} with no side — skipping.", pos.size); + println!("[{venue}] Position size {} with no side — skipping.", pos.size); return Ok(()); } }; - // Bullet's Market order is an IoC limit: needs a bounded worst-case price. - // Fetch a fresh book and set price = opposite_side × (1 ± 1%). The IoC - // ensures the actual fill is at top-of-book or better. - let book = broker.get_orderbook(&symbol, 5).await?; + // Market here is an IoC limit: needs a bounded worst-case price. Use the + // opposing top-of-book ± 1%; the IoC fills at top-of-book or better. + let book = broker.get_orderbook(symbol, 5).await?; let base = match close_side { Side::Buy => book.best_ask().map(|l| l.price), Side::Sell => book.best_bid().map(|l| l.price), } .ok_or("Orderbook has no opposing-side liquidity — cannot flatten")?; - // 1% worst-case price — generous for a manual utility command; - // the IoC ensures we actually fill at or better than top-of-book. let slip = Decimal::from(FLATTEN_SLIPPAGE_BPS) / Decimal::from(10_000); let ioc_price = match close_side { Side::Buy => base * (Decimal::ONE + slip), @@ -665,11 +771,11 @@ async fn flatten(network: String, symbol: String) -> Result<(), Box Result<(), Box Result<(), Box> { let cli = Cli::parse(); + // Load .env into the process environment before anything reads env vars. + // Both paths use `from_path` (exact file, no parent-directory search) so a + // stray parent `.env` with different credentials can't be picked up when run + // from a subdirectory. Real environment variables already set win. + if let Some(path) = &cli.env_file { + dotenvy::from_path(path).map_err(|e| format!("--env-file {}: {e}", path.display()))?; + eprintln!("Loaded env from {}", path.display()); + } else { + let default = std::path::Path::new(".env"); + if default.exists() { + dotenvy::from_path(default).map_err(|e| format!(".env: {e}"))?; + eprintln!("Loaded env from .env"); + } + } match cli.command { Command::Keygen { network, out } => keygen(&network, out), + Command::Faucet { network } => faucet(network).await, Command::Deposit { network, asset, amount } => deposit(network, asset, amount).await, Command::Flatten { network, symbol } => flatten(network, symbol).await, Command::Observe { network, symbol, binance_symbol, binance_market, output } => { diff --git a/crates/bb-core/src/harness/harness.rs b/crates/bb-core/src/harness/harness.rs index 417426d..8d01b6e 100644 --- a/crates/bb-core/src/harness/harness.rs +++ b/crates/bb-core/src/harness/harness.rs @@ -128,7 +128,7 @@ impl Harness { // 4. Wait for a shutdown-inducing event. let signal_fut = async { if self.enable_signal { - let _ = tokio::signal::ctrl_c().await; + wait_for_shutdown_signal().await; } else { std::future::pending::<()>().await; } @@ -150,7 +150,7 @@ impl Harness { break WindDownReason::Signal; } () = &mut signal_fut => { - tracing::info!("Ctrl-C received"); + tracing::info!("shutdown signal received"); break WindDownReason::Signal; } Some(join_res) = feed_set.join_next() => { @@ -182,6 +182,33 @@ impl Harness { } } +/// Wait for an OS shutdown signal, then return so the harness runs a graceful +/// `wind_down` (including any position flatten). Catches Ctrl-C (SIGINT) on all +/// platforms and **SIGTERM** on Unix — the signal `docker stop`, systemd, and +/// Kubernetes send — so a managed shutdown flattens positions, not just an +/// interactive Ctrl-C. +#[cfg(unix)] +async fn wait_for_shutdown_signal() { + use tokio::signal::unix::{SignalKind, signal}; + let mut sigterm = match signal(SignalKind::terminate()) { + Ok(s) => s, + Err(e) => { + tracing::warn!(error = %e, "failed to install SIGTERM handler; Ctrl-C only"); + let _ = tokio::signal::ctrl_c().await; + return; + } + }; + tokio::select! { + _ = tokio::signal::ctrl_c() => tracing::info!("SIGINT (Ctrl-C) received"), + _ = sigterm.recv() => tracing::info!("SIGTERM received"), + } +} + +#[cfg(not(unix))] +async fn wait_for_shutdown_signal() { + let _ = tokio::signal::ctrl_c().await; +} + /// Cancel subscriptions, drain handler tasks, call `wind_down` on every actor. async fn wind_down_all( actor_handles: Vec, diff --git a/crates/bb-core/src/harness/testing.rs b/crates/bb-core/src/harness/testing.rs index 8cc6103..d37afdc 100644 --- a/crates/bb-core/src/harness/testing.rs +++ b/crates/bb-core/src/harness/testing.rs @@ -164,6 +164,8 @@ pub struct MockBroker { /// the queued result is returned as-is, bypassing the default all-success path. cancel_results_queue: Mutex>>, cancel_all_queue: Mutex>>, + /// Positions returned by `get_positions` (default empty). + positions: Mutex>, } /// Backwards-compat alias. @@ -179,9 +181,15 @@ impl MockBroker { cancel_queue: Mutex::new(VecDeque::new()), cancel_results_queue: Mutex::new(VecDeque::new()), cancel_all_queue: Mutex::new(VecDeque::new()), + positions: Mutex::new(Vec::new()), } } + /// Set the positions returned by `get_positions`. + pub async fn set_positions(&self, positions: Vec) { + *self.positions.lock().await = positions; + } + pub fn shared(name: impl Into) -> Arc { Arc::new(Self::new(name)) } @@ -269,7 +277,7 @@ impl Broker for MockBroker { } async fn get_positions(&self) -> Result, BotError> { - Ok(vec![]) + Ok(self.positions.lock().await.clone()) } async fn get_open_orders(&self, _symbol: &str) -> Result, BotError> { diff --git a/crates/bb-core/src/keys.rs b/crates/bb-core/src/keys.rs new file mode 100644 index 0000000..dc2c025 --- /dev/null +++ b/crates/bb-core/src/keys.rs @@ -0,0 +1,73 @@ +//! Shared key-material resolution used by exchange adapters. +//! +//! Every adapter resolves a key the same way: a `key_file` (a file containing +//! the key *string*) takes precedence over an inline `private_key`. Only the +//! final parse into the venue's native key type differs — base58 ed25519 for +//! Bullet, hex secp256k1 for Hyperliquid — so that lives in each adapter. + +use std::path::Path; + +use crate::error::BotError; + +/// Read a key string from a file, trimming surrounding whitespace. +pub fn read_key_file(path: &Path) -> Result { + let contents = std::fs::read_to_string(path).map_err(|e| { + BotError::config(format!("Failed to read key file {}: {e}", path.display())) + })?; + Ok(contents.trim().to_string()) +} + +/// Resolve the key string for an adapter: `key_file` (preferred) → inline +/// `private_key`. Returns `Ok(None)` when neither yields a non-empty value, so +/// the caller can emit a venue-specific "no key material" error. +pub fn resolve_key_string( + key_file: Option<&Path>, + inline: &str, +) -> Result, BotError> { + // An empty key_file path (e.g. `BB_BULLET_KEY_FILE=` in .env) is treated as + // absent so it doesn't shadow the inline key. + if let Some(path) = key_file.filter(|p| !p.as_os_str().is_empty()) { + return read_key_file(path).map(Some); + } + let trimmed = inline.trim(); + Ok((!trimmed.is_empty()).then(|| trimmed.to_string())) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn inline_used_when_no_file_and_trimmed() { + let got = resolve_key_string(None, " abc ").expect("resolve"); + assert_eq!(got.as_deref(), Some("abc")); + } + + #[test] + fn empty_inline_is_none() { + assert_eq!(resolve_key_string(None, " ").expect("blank"), None); + assert_eq!(resolve_key_string(None, "").expect("empty"), None); + } + + #[test] + fn file_takes_precedence_over_inline_and_trims() { + // Unique per process so parallel test runs don't collide. + let path = std::env::temp_dir() + .join(format!("bb_core_keys_precedence_{}.key", std::process::id())); + std::fs::write(&path, " filekey\n").expect("write"); + let got = resolve_key_string(Some(&path), "inlinekey").expect("resolve"); + assert_eq!(got.as_deref(), Some("filekey")); + std::fs::remove_file(&path).ok(); + } + + #[test] + fn missing_file_errors() { + assert!(read_key_file(Path::new("/nonexistent/bb/key")).is_err()); + } + + #[test] + fn empty_key_file_path_falls_back_to_inline() { + let got = resolve_key_string(Some(Path::new("")), "inlinekey").expect("resolve"); + assert_eq!(got.as_deref(), Some("inlinekey")); + } +} diff --git a/crates/bb-core/src/lib.rs b/crates/bb-core/src/lib.rs index 46524f3..971162b 100644 --- a/crates/bb-core/src/lib.rs +++ b/crates/bb-core/src/lib.rs @@ -31,6 +31,7 @@ pub mod events; pub mod harness; pub mod health; pub mod helpers; +pub mod keys; pub mod types; /// Convenience re-exports for the most commonly used framework types. diff --git a/crates/exchanges/bullet/Cargo.toml b/crates/exchanges/bullet/Cargo.toml index 0e0ea75..0693fb1 100644 --- a/crates/exchanges/bullet/Cargo.toml +++ b/crates/exchanges/bullet/Cargo.toml @@ -18,6 +18,9 @@ rust_decimal = { workspace = true } secrecy = { workspace = true } tracing = { workspace = true } async-trait = { workspace = true } +bs58 = { workspace = true } +getrandom = { workspace = true } +reqwest = { workspace = true } [dev-dependencies] toml = { workspace = true } diff --git a/crates/exchanges/bullet/src/broker.rs b/crates/exchanges/bullet/src/broker.rs index 8db3bab..69a3b5b 100644 --- a/crates/exchanges/bullet/src/broker.rs +++ b/crates/exchanges/bullet/src/broker.rs @@ -32,6 +32,7 @@ pub(crate) struct Increments { pub struct BulletBroker { client: Arc, + account_address: String, increments: HashMap, health: Arc, } @@ -39,10 +40,11 @@ pub struct BulletBroker { impl BulletBroker { pub(crate) fn new( client: Arc, + account_address: String, increments: HashMap, health: Arc, ) -> Self { - Self { client, increments, health } + Self { client, account_address, increments, health } } fn market_id(&self, symbol: &str) -> Result { @@ -114,7 +116,12 @@ impl Broker for BulletBroker { } async fn get_balances(&self) -> Result, BotError> { - let resp = self.client.my_balances().await.map_err(|e| BotError::exchange(e, true))?; + let resp = self + .client + .account_balance(&self.account_address) + .await + .map_err(|e| BotError::exchange(e, true))? + .into_inner(); Ok(resp .iter() .map(|b| Balance { @@ -126,7 +133,12 @@ impl Broker for BulletBroker { } async fn get_positions(&self) -> Result, BotError> { - let resp = self.client.my_account().await.map_err(|e| BotError::exchange(e, true))?; + let resp = self + .client + .account_info(&self.account_address) + .await + .map_err(|e| BotError::exchange(e, true))? + .into_inner(); Ok(resp .positions .iter() @@ -146,8 +158,12 @@ impl Broker for BulletBroker { } async fn get_open_orders(&self, symbol: &str) -> Result, BotError> { - let resp = - self.client.my_open_orders(symbol).await.map_err(|e| BotError::exchange(e, true))?; + let resp = self + .client + .query_open_orders(&self.account_address, Some(symbol)) + .await + .map_err(|e| BotError::exchange(e, true))? + .into_inner(); Ok(resp .iter() .map(|o| { diff --git a/crates/exchanges/bullet/src/config.rs b/crates/exchanges/bullet/src/config.rs index 9e65840..62b1761 100644 --- a/crates/exchanges/bullet/src/config.rs +++ b/crates/exchanges/bullet/src/config.rs @@ -9,29 +9,28 @@ use serde::Deserialize; /// and an env var only fills a field the config omits (so an ambient env var /// can't silently switch wallets): /// -/// 1. **`key_file`** (config) — path to a Solana-compatible JSON keystore (as produced by `bb-bot -/// keygen` or `solana-keygen`). Preferred: the key lives on disk with whatever permissions the -/// filesystem enforces, never hits the shell history, and isn't trivially exfiltrated via a -/// process environment dump. -/// 2. **`BB_BULLET_KEY_FILE`** (env) — same keystore-file path, supplied via the environment. -/// 3. **`private_key_hex`** (config) — Ed25519 secret as a hex string. Wrapped in [`SecretString`] -/// so it's redacted in `Debug` output and zeroed on drop. -/// 4. **`BB_BULLET_PRIVATE_KEY_HEX`** (env) — Ed25519 secret as a hex string, for CI / ephemeral -/// contexts. +/// 1. **`key_file`** (config) — path to a file containing the key string (as written by `bb-bot +/// keygen`). Preferred: the key lives on disk with whatever permissions the filesystem enforces, +/// never hits the shell history, and isn't trivially exfiltrated via a process environment dump. +/// 2. **`BB_BULLET_KEY_FILE`** (env) — same key-file path, supplied via the environment. +/// 3. **`private_key`** (config) — Ed25519 secret as a **base58** (Phantom / delegation export) or +/// hex string. Wrapped in [`SecretString`] so it's redacted in `Debug` output and zeroed on +/// drop. +/// 4. **`BB_BULLET_PRIVATE_KEY`** (env) — same secret string, for CI / ephemeral contexts. #[derive(Debug, Clone, Deserialize)] pub struct BulletConfig { /// Network to connect to: "mainnet" or "testnet". pub network: String, - /// Path to a Solana-compatible JSON keystore file. Takes precedence over - /// `private_key_hex` when set. + /// Path to a file containing the key string (base58 or hex). Takes + /// precedence over `private_key` when set. #[serde(default)] pub key_file: Option, - /// Ed25519 private key as hex string (with or without "0x" prefix). - /// Only used if `key_file` is not set. + /// Ed25519 private key as a **base58** (preferred) or hex string. Only used + /// if `key_file` is not set. #[serde(default = "default_secret")] - pub private_key_hex: SecretString, + pub private_key: SecretString, } fn default_secret() -> SecretString { @@ -51,7 +50,7 @@ mod tests { let cfg = BulletConfig { network: "testnet".into(), key_file: None, - private_key_hex: SecretString::new(FAKE_KEY.to_string()), + private_key: SecretString::new(FAKE_KEY.to_string()), }; let dbg = format!("{cfg:?}"); assert!(!dbg.contains(FAKE_KEY), "Debug output must not contain key: {dbg}"); @@ -63,11 +62,11 @@ mod tests { let toml_src = format!( r#" network = "testnet" - private_key_hex = "{FAKE_KEY}" + private_key = "{FAKE_KEY}" "# ); let cfg: BulletConfig = toml::from_str(&toml_src).expect("parse"); - assert_eq!(cfg.private_key_hex.expose_secret(), FAKE_KEY); + assert_eq!(cfg.private_key.expose_secret(), FAKE_KEY); assert!(cfg.key_file.is_none()); } @@ -75,17 +74,17 @@ mod tests { fn deserializes_key_file_path() { let toml_src = r#" network = "testnet" - key_file = "/tmp/my-keypair.json" + key_file = "/tmp/my-key" "#; let cfg: BulletConfig = toml::from_str(toml_src).expect("parse"); - assert_eq!(cfg.key_file.as_deref(), Some(std::path::Path::new("/tmp/my-keypair.json"))); - assert_eq!(cfg.private_key_hex.expose_secret(), ""); + assert_eq!(cfg.key_file.as_deref(), Some(std::path::Path::new("/tmp/my-key"))); + assert_eq!(cfg.private_key.expose_secret(), ""); } #[test] fn missing_key_defaults_to_empty() { let cfg: BulletConfig = toml::from_str(r#"network = "testnet""#).expect("parse"); - assert_eq!(cfg.private_key_hex.expose_secret(), ""); + assert_eq!(cfg.private_key.expose_secret(), ""); assert!(cfg.key_file.is_none()); } } diff --git a/crates/exchanges/bullet/src/connection.rs b/crates/exchanges/bullet/src/connection.rs index bf8add7..f432730 100644 --- a/crates/exchanges/bullet/src/connection.rs +++ b/crates/exchanges/bullet/src/connection.rs @@ -22,8 +22,7 @@ use bb_core::harness::MpscFeed; use bb_core::health::ConnectionHealth; use bullet_rust_sdk::ws::models::ServerMessage; use bullet_rust_sdk::{ - Client, Keypair, ManagedWebsocket, Network, OrderbookDepth, Topic, UserActionDiscriminants, - WsEvent, + Client, ManagedWebsocket, Network, OrderbookDepth, Topic, UserActionDiscriminants, WsEvent, }; use tokio::sync::mpsc; @@ -83,21 +82,18 @@ pub async fn connect( config: &BulletConfig, symbol: &str, ) -> Result<(BulletBroker, BulletFeeds), BotError> { - let keypair = if let Some(path) = config.key_file.as_deref() { - Keypair::read_from_file(path).map_err(|e| { - BotError::config(format!("Failed to load keystore {}: {e}", path.display())) - })? - } else { - let hex = secrecy::ExposeSecret::expose_secret(&config.private_key_hex); - if hex.is_empty() { - return Err(BotError::config( - "Bullet: no key material — set [exchanges.bullet].key_file, \ - BB_BULLET_KEY_FILE, private_key_hex, or BB_BULLET_PRIVATE_KEY_HEX" - .to_string(), - )); - } - Keypair::from_hex(hex).map_err(|e| BotError::config(format!("Invalid private key: {e}")))? - }; + let secret = bb_core::keys::resolve_key_string( + config.key_file.as_deref(), + secrecy::ExposeSecret::expose_secret(&config.private_key), + )? + .ok_or_else(|| { + BotError::config( + "Bullet: no key material — set [exchanges.bullet].key_file, \ + BB_BULLET_KEY_FILE, private_key, or BB_BULLET_PRIVATE_KEY" + .to_string(), + ) + })?; + let keypair = crate::key::keypair_from_secret(&secret)?; let network = match config.network.as_str() { "mainnet" => Network::Mainnet, "testnet" => Network::Testnet, @@ -122,19 +118,20 @@ pub async fn connect( .map_err(|e| BotError::exchange(e, true))?; let address = client.address().map_err(|e| BotError::exchange(e, false))?; + let account_address = crate::delegate::resolve_account_address(client.url(), &address).await?; let increments = load_increments(&client).await?; let client = Arc::new(client); // Set up WS + subscriptions. let ws = client.connect_ws_managed().call().await.map_err(|e| BotError::exchange(e, true))?; // Bullet user-order stream: address-prefixed topic (no listenKey flow). - let user_topic = Topic::user_orders(address.clone()).to_string(); + let user_topic = Topic::user_orders(account_address.clone()).to_string(); ws.subscribe( [ Topic::depth(symbol, OrderbookDepth::D20), Topic::book_ticker(symbol), Topic::mark_price(symbol), - Topic::user_orders(address.clone()), + Topic::user_orders(account_address.clone()), ], None, ) @@ -142,7 +139,8 @@ pub async fn connect( tracing::info!( symbol, - address = %address, + signer = %address, + account = %account_address, user_topic, symbols_known = client.symbols().len(), "Bullet: connected + subscribed" @@ -165,7 +163,7 @@ pub async fn connect( // stays alive for the lifetime of the task. tokio::spawn(muxer_loop(ws, trade_tx, book_tx, life_tx, mark_tx, Arc::clone(&health))); - let broker = BulletBroker::new(Arc::clone(&client), increments, health); + let broker = BulletBroker::new(Arc::clone(&client), account_address, increments, health); let feeds = BulletFeeds { trade: MpscFeed::new(trade_rx), book: MpscFeed::bounded(book_rx), diff --git a/crates/exchanges/bullet/src/delegate.rs b/crates/exchanges/bullet/src/delegate.rs new file mode 100644 index 0000000..3ea6fdc --- /dev/null +++ b/crates/exchanges/bullet/src/delegate.rs @@ -0,0 +1,130 @@ +//! Resolve a signer's account address via the Bullet `delegateOf` endpoint. +//! +//! A delegate (API) key has no account of its own — all balances, positions, +//! and orders live on the master ("parent") account. Reads and the user-orders +//! subscription must target that master address. A non-delegate key is its own +//! account, which the endpoint reports as `404`. + +use bb_core::error::BotError; +use serde::Deserialize; + +#[derive(Deserialize)] +struct DelegateOf { + parent: String, +} + +/// Resolve the account address to use for reads/subscriptions. +/// +/// `base_url` is the REST base (e.g. `Client::url()`), `signer` the signer's +/// own base58 address. Returns the master `parent` for a delegate key, or +/// `signer` unchanged if it is not a delegate. +/// One `delegateOf` attempt: HTTP GET + map the response to an account address. +async fn resolve_account_once(base_url: &str, signer: &str) -> Result { + let url = format!("{}/api/v1/delegateOf", base_url.trim_end_matches('/')); + let resp = reqwest::Client::new() + .get(&url) + .query(&[("address", signer)]) + .send() + .await + .map_err(|e| BotError::exchange(format!("delegateOf request failed: {e}"), true))?; + let status = resp.status().as_u16(); + let body = resp + .text() + .await + .map_err(|e| BotError::exchange(format!("delegateOf body read failed: {e}"), true))?; + account_address_from(signer, status, &body) +} + +pub async fn resolve_account_address(base_url: &str, signer: &str) -> Result { + // Retry transient failures (network / 5xx / 429) so a brief API blip at + // startup doesn't block a delegate key; non-retryable errors fail fast. + let mut attempt = 0; + let account = loop { + attempt += 1; + match resolve_account_once(base_url, signer).await { + Ok(a) => break a, + Err(e) if e.is_retryable() && attempt < 3 => { + tracing::warn!(attempt, error = %e, "delegateOf transient failure; retrying"); + tokio::time::sleep(std::time::Duration::from_millis(500)).await; + } + Err(e) => return Err(e), + } + }; + if account == signer { + // Not a registered delegate on this network. Valid for a main-wallet + // key, but it's also what a delegate key looks like when the bot is + // pointed at the wrong network — surface a hint so the otherwise opaque + // "account not found" failures downstream are easier to diagnose. + tracing::info!( + signer, + base_url, + "Bullet: signer is not a registered delegate here; using it as its own account. \ + If you meant to use a delegate key, check the network matches where it was \ + registered." + ); + } else { + tracing::info!(signer, master = %account, "Bullet: resolved delegate to master account"); + } + Ok(account) +} + +/// Pure mapping from an HTTP response to an account address. +/// +/// "Not a delegate" (i.e. the key is its own account) is reported by the API in +/// two ways: the published API spec documents `404`, but the live mainnet/testnet +/// servers return `400` with an `"is not a delegate"` message. Both mean self. +/// Note `400` is also used for a malformed address (`"invalid address"`), which +/// is a genuine error — so we key off the message, not the bare status. +fn account_address_from(signer: &str, status: u16, body: &str) -> Result { + if status == 200 { + let parsed: DelegateOf = serde_json::from_str(body).map_err(|e| { + BotError::exchange(format!("delegateOf response parse error: {e}"), false) + })?; + return Ok(parsed.parent); + } + if status == 404 || (status == 400 && body.contains("is not a delegate")) { + return Ok(signer.to_string()); + } + // 5xx and 429 are transient (server/rate-limit); other statuses are not. + let retryable = status >= 500 || status == 429; + Err(BotError::exchange(format!("delegateOf returned HTTP {status}: {body}"), retryable)) +} + +#[cfg(test)] +mod tests { + use super::account_address_from; + + #[test] + fn delegate_resolves_to_parent() { + let body = r#"{"parent":"MASTER_ADDR","name":"bot","flags":1,"expiresAt":null}"#; + let got = account_address_from("DELEGATE_ADDR", 200, body).expect("200"); + assert_eq!(got, "MASTER_ADDR"); + } + + #[test] + fn not_a_delegate_resolves_to_self() { + // Spec says 404; live mainnet/testnet return 400 "is not a delegate". + let got = account_address_from("SELF_ADDR", 404, "{}").expect("404"); + assert_eq!(got, "SELF_ADDR"); + let body = r#"{"status":400,"message":"Bad request: SELF_ADDR is not a delegate"}"#; + let got = account_address_from("SELF_ADDR", 400, body).expect("400 not-a-delegate"); + assert_eq!(got, "SELF_ADDR"); + } + + #[test] + fn server_error_is_retryable() { + let err = account_address_from("X", 500, "boom").expect_err("500"); + assert!(err.is_retryable(), "5xx should be retryable"); + let err = account_address_from("X", 429, "slow down").expect_err("429"); + assert!(err.is_retryable(), "429 should be retryable"); + } + + #[test] + fn malformed_address_400_is_error() { + // A 400 that is NOT "not a delegate" (e.g. invalid address) is a real + // error, not a resolve-to-self. + let body = r#"{"status":400,"message":"Bad request: invalid address: xyz"}"#; + let err = account_address_from("X", 400, body).expect_err("400 invalid"); + assert!(!err.is_retryable(), "4xx (non-429) should not be retryable"); + } +} diff --git a/crates/exchanges/bullet/src/key.rs b/crates/exchanges/bullet/src/key.rs new file mode 100644 index 0000000..e1b89e3 --- /dev/null +++ b/crates/exchanges/bullet/src/key.rs @@ -0,0 +1,90 @@ +//! Parse an Ed25519 signer secret from hex or base58 into a `Keypair`. + +use std::path::Path; + +use bb_core::error::BotError; +use bullet_rust_sdk::Keypair; + +/// Generate a new random signer, returning its **base58-encoded 32-byte secret** +/// and its address. Base58 is the canonical Bullet key format (matches Phantom / +/// delegation exports). +pub fn generate_base58() -> Result<(String, String), BotError> { + let mut seed = [0u8; 32]; + getrandom::getrandom(&mut seed).map_err(|e| BotError::config(format!("RNG failure: {e}")))?; + let address = Keypair::from_bytes(seed).address(); + Ok((bs58::encode(seed).into_string(), address)) +} + +/// Read a signer key from a file whose contents are a base58 or hex key string +/// (as written by `bb-bot keygen`). Whitespace is trimmed. +pub fn keypair_from_key_file(path: &Path) -> Result { + keypair_from_secret(&bb_core::keys::read_key_file(path)?) +} + +/// Parse a Bullet signer secret into a [`Keypair`]. +/// +/// Accepted formats: +/// - **Hex**: 64 hex chars, optionally `0x`-prefixed (Bullet/Solana-CLI hex). +/// - **Base58**: decodes to 32 bytes (raw seed) or 64 bytes (Phantom / Solana full keypair, where +/// the first 32 bytes are the secret seed). +pub fn keypair_from_secret(secret: &str) -> Result { + let s = secret.trim(); + let hex_body = s.strip_prefix("0x").unwrap_or(s); + if hex_body.len() == 64 && hex_body.bytes().all(|b| b.is_ascii_hexdigit()) { + return Keypair::from_hex(hex_body) + .map_err(|e| BotError::config(format!("Invalid hex private key: {e}"))); + } + + let bytes = bs58::decode(s).into_vec().map_err(|e| { + BotError::config(format!("Key is neither 64-char hex nor valid base58: {e}")) + })?; + + // Phantom/Solana export is 64 bytes (seed ++ pubkey); a raw seed is 32. + let seed: [u8; 32] = bytes + .get(..32) + .filter(|_| matches!(bytes.len(), 32 | 64)) + .and_then(|s| <[u8; 32]>::try_from(s).ok()) + .ok_or_else(|| { + BotError::config(format!( + "base58 secret decoded to {} bytes; expected 32 or 64", + bytes.len() + )) + })?; + Ok(Keypair::from_bytes(seed)) +} + +#[cfg(test)] +mod tests { + use bullet_rust_sdk::Keypair; + + use super::keypair_from_secret; + + /// A fixed 32-byte seed expressed as hex, base58-32, and base58-64 must all + /// resolve to the same address. + #[test] + fn hex_and_base58_resolve_to_same_address() { + let seed = [7u8; 32]; + let want = Keypair::from_bytes(seed).address(); + let pubkey = Keypair::from_bytes(seed).public_key(); // 32 bytes + + let hex = "07".repeat(32); + assert_eq!(keypair_from_secret(&hex).expect("hex").address(), want); + assert_eq!(keypair_from_secret(&format!("0x{hex}")).expect("0x hex").address(), want); + + let b58_32 = bs58::encode(seed).into_string(); + assert_eq!(keypair_from_secret(&b58_32).expect("b58-32").address(), want); + + let mut full = seed.to_vec(); + full.extend_from_slice(&pubkey); + let b58_64 = bs58::encode(full).into_string(); + assert_eq!(keypair_from_secret(&b58_64).expect("b58-64").address(), want); + } + + #[test] + fn rejects_invalid_input() { + // '!' and '0' are not in the base58 alphabet, and it isn't 64-char hex. + assert!(keypair_from_secret("not-a-key!!!").is_err()); + // Valid base58 but wrong length (1 byte). + assert!(keypair_from_secret("2").is_err()); + } +} diff --git a/crates/exchanges/bullet/src/lib.rs b/crates/exchanges/bullet/src/lib.rs index 0a35502..2ba08c2 100644 --- a/crates/exchanges/bullet/src/lib.rs +++ b/crates/exchanges/bullet/src/lib.rs @@ -2,6 +2,8 @@ pub mod broker; pub mod config; pub mod connection; pub mod convert; +pub mod delegate; +pub mod key; pub use broker::BulletBroker; pub use config::BulletConfig; diff --git a/crates/exchanges/hyperliquid/src/broker.rs b/crates/exchanges/hyperliquid/src/broker.rs index 7051612..276ecf6 100644 --- a/crates/exchanges/hyperliquid/src/broker.rs +++ b/crates/exchanges/hyperliquid/src/broker.rs @@ -54,15 +54,28 @@ pub(crate) fn register_client_id(map: &ClientIdMap, client_id: &str) -> Uuid { cloid } +/// Normalize a cloid string to the canonical `Uuid` form used as the map key. +/// HL echoes cloids on fills/order-updates as `0x` + 32 hex (no hyphens), while +/// we key the map by `Uuid::to_string()` (hyphenated). Without normalizing, a +/// fill's cloid wouldn't match the stored key, so the strategy would discard +/// its own fills as "external" and never update inventory. +fn normalize_cloid(cloid: &str) -> String { + let hex = cloid.strip_prefix("0x").or_else(|| cloid.strip_prefix("0X")).unwrap_or(cloid); + Uuid::parse_str(hex).map_or_else(|_| cloid.to_string(), |u| u.to_string()) +} + pub(crate) fn original_client_id(map: &ClientIdMap, cloid: &str) -> String { let guard = map.read().unwrap_or_else(std::sync::PoisonError::into_inner); - guard.get(cloid).cloned().unwrap_or_else(|| cloid.to_string()) + guard.get(&normalize_cloid(cloid)).cloned().unwrap_or_else(|| cloid.to_string()) } pub struct HyperliquidBroker { exchange: ExchangeClient, info: InfoClient, address: H160, + /// Unified account: collateral lives in the spot balance, so `get_balances` + /// reads `user_token_balances` rather than the perp clearinghouse. + unified: bool, health: Arc, client_ids: ClientIdMap, } @@ -72,10 +85,11 @@ impl HyperliquidBroker { exchange: ExchangeClient, info: InfoClient, address: H160, + unified: bool, health: Arc, client_ids: ClientIdMap, ) -> Self { - Self { exchange, info, address, health, client_ids } + Self { exchange, info, address, unified, health, client_ids } } } @@ -100,6 +114,16 @@ impl Broker for HyperliquidBroker { } async fn get_balances(&self) -> Result, BotError> { + // Unified account: collateral is in the spot balance, not the perp + // clearinghouse (whose accountValue is only per-position margin). + if self.unified { + let spot = self + .info + .user_token_balances(self.address) + .await + .map_err(|e| BotError::exchange(e, true))?; + return Ok(convert::spot_state_to_balances(&spot)); + } let state = self.info.user_state(self.address).await.map_err(|e| BotError::exchange(e, true))?; Ok(convert::user_state_to_balances(&state)) @@ -155,8 +179,11 @@ impl Broker for HyperliquidBroker { OrderType::PostOnly => "Alo", OrderType::Market => "Ioc", }; - let price_f64 = o.price.to_f64().ok_or_else(|| { - BotError::strategy(format!("HL: cannot convert price {} to f64", o.price)) + // HL rejects over-precise prices ("Order has invalid price"); snap + // to its 5-significant-figure rule before submitting. + let price = convert::hl_round_price(o.price); + let price_f64 = price.to_f64().ok_or_else(|| { + BotError::strategy(format!("HL: cannot convert price {price} to f64")) })?; let sz_f64 = o.quantity.to_f64().ok_or_else(|| { BotError::strategy(format!("HL: cannot convert quantity {} to f64", o.quantity)) @@ -305,11 +332,9 @@ impl Broker for HyperliquidBroker { OrderType::PostOnly => "Alo", OrderType::Market => "Ioc", }; - let price_f64 = amend.new_order.price.to_f64().ok_or_else(|| { - BotError::strategy(format!( - "HL amend: cannot convert price {} to f64", - amend.new_order.price - )) + let price = convert::hl_round_price(amend.new_order.price); + let price_f64 = price.to_f64().ok_or_else(|| { + BotError::strategy(format!("HL amend: cannot convert price {price} to f64")) })?; let sz_f64 = amend.new_order.quantity.to_f64().ok_or_else(|| { BotError::strategy(format!( @@ -496,7 +521,14 @@ mod tests { let ids = new_client_id_map(); let cloid = register_client_id(&ids, "42"); + // Hyphenated Uuid form (how we store the key). assert_eq!(original_client_id(&ids, &cloid.to_string()), "42"); + // HL wire form on fills: `0x` + 32 hex, no hyphens — must also resolve, + // else the strategy discards its own HL fills. + let hl_wire = format!("0x{}", cloid.simple()); + assert_eq!(original_client_id(&ids, &hl_wire), "42"); + // Bare 32-hex (no prefix) resolves too. + assert_eq!(original_client_id(&ids, &cloid.simple().to_string()), "42"); } #[test] diff --git a/crates/exchanges/hyperliquid/src/config.rs b/crates/exchanges/hyperliquid/src/config.rs index fadc9c1..b1da233 100644 --- a/crates/exchanges/hyperliquid/src/config.rs +++ b/crates/exchanges/hyperliquid/src/config.rs @@ -1,21 +1,40 @@ +use std::path::PathBuf; + use secrecy::SecretString; use serde::Deserialize; /// Configuration for the Hyperliquid exchange adapter. /// -/// `private_key_hex` is wrapped in [`SecretString`] so that `Debug` formatting +/// `private_key` is wrapped in [`SecretString`] so that `Debug` formatting /// emits `"[REDACTED alloc::string::String]"` instead of the raw key, and the /// hex string is zeroed on drop. To read the value, call -/// `config.private_key_hex.expose_secret()`. +/// `config.private_key.expose_secret()`. #[derive(Debug, Clone, Deserialize)] pub struct HyperliquidConfig { /// Network to connect to: "mainnet" or "testnet". pub network: String, + /// Path to a file containing the key string (hex). Takes precedence over + /// `private_key` when set. Env: `BB_HYPERLIQUID_KEY_FILE`. + #[serde(default)] + pub key_file: Option, + /// Ethereum private key as hex string (secp256k1, with or without "0x" prefix). /// Can be overridden via environment variable. #[serde(default = "default_secret")] - pub private_key_hex: SecretString, + pub private_key: SecretString, + + /// Master/main account address (`0x`-prefixed H160 hex) to read positions, + /// balances, and fills from, and to subscribe to. + /// + /// Set this when `private_key` is an **API / agent wallet** key: the + /// agent signs orders (the exchange attributes them to the master account + /// on-chain), but all account state lives on the master account, not the + /// agent address. Leave unset when the key *is* the main wallet — reads + /// then default to the wallet's own address. Env: + /// `BB_HYPERLIQUID_ACCOUNT_ADDRESS`. + #[serde(default)] + pub account_address: Option, } fn default_secret() -> SecretString { @@ -34,7 +53,9 @@ mod tests { fn debug_redacts_private_key() { let cfg = HyperliquidConfig { network: "testnet".into(), - private_key_hex: SecretString::new(FAKE_KEY.to_string()), + key_file: None, + private_key: SecretString::new(FAKE_KEY.to_string()), + account_address: None, }; let dbg = format!("{cfg:?}"); assert!(!dbg.contains(FAKE_KEY), "Debug output must not contain key: {dbg}"); @@ -46,16 +67,30 @@ mod tests { let toml_src = format!( r#" network = "testnet" - private_key_hex = "{FAKE_KEY}" + private_key = "{FAKE_KEY}" "# ); let cfg: HyperliquidConfig = toml::from_str(&toml_src).expect("parse"); - assert_eq!(cfg.private_key_hex.expose_secret(), FAKE_KEY); + assert_eq!(cfg.private_key.expose_secret(), FAKE_KEY); } #[test] fn missing_key_defaults_to_empty() { let cfg: HyperliquidConfig = toml::from_str(r#"network = "testnet""#).expect("parse"); - assert_eq!(cfg.private_key_hex.expose_secret(), ""); + assert_eq!(cfg.private_key.expose_secret(), ""); + assert!(cfg.account_address.is_none()); + } + + #[test] + fn deserializes_account_address() { + let toml_src = r#" + network = "testnet" + account_address = "0x1111111111111111111111111111111111111111" + "#; + let cfg: HyperliquidConfig = toml::from_str(toml_src).expect("parse"); + assert_eq!( + cfg.account_address.as_deref(), + Some("0x1111111111111111111111111111111111111111") + ); } } diff --git a/crates/exchanges/hyperliquid/src/connection.rs b/crates/exchanges/hyperliquid/src/connection.rs index d147b2b..12fc387 100644 --- a/crates/exchanges/hyperliquid/src/connection.rs +++ b/crates/exchanges/hyperliquid/src/connection.rs @@ -21,6 +21,7 @@ use bb_core::events::{BookUpdate, MarkPriceUpdate, OrderLifecycle, Trade}; use bb_core::harness::MpscFeed; use bb_core::health::ConnectionHealth; use ethers::signers::{LocalWallet, Signer}; +use ethers::types::H160; use hyperliquid_rust_sdk::{BaseUrl, ExchangeClient, InfoClient, Message, Subscription}; use tokio::sync::mpsc; @@ -52,11 +53,42 @@ pub async fn connect( config: &HyperliquidConfig, symbol: &str, ) -> Result<(HyperliquidBroker, HyperliquidFeeds), BotError> { - let raw_key = secrecy::ExposeSecret::expose_secret(&config.private_key_hex); + let raw_key = bb_core::keys::resolve_key_string( + config.key_file.as_deref(), + secrecy::ExposeSecret::expose_secret(&config.private_key), + )? + .ok_or_else(|| { + BotError::config( + "Hyperliquid: no key material — set [exchanges.hyperliquid].key_file, \ + BB_HYPERLIQUID_KEY_FILE, private_key, or BB_HYPERLIQUID_PRIVATE_KEY" + .to_string(), + ) + })?; let key_hex = raw_key.strip_prefix("0x").unwrap_or(raw_key.as_str()); let wallet: LocalWallet = key_hex.parse().map_err(|e| BotError::config(format!("Invalid HL private key: {e}")))?; - let address = wallet.address(); + let signer_address = wallet.address(); + // Reads/subscriptions target the master account; for an API/agent wallet + // that's `account_address`, otherwise the signer's own address. Signing + // always uses `wallet`. + let address = resolve_account_address(config.account_address.as_deref(), signer_address)?; + if address == signer_address { + // No master configured. Fine for a main-wallet key, but it's also what + // an API/agent wallet looks like with account_address forgotten — and + // then positions/balances/fills come back empty. Surface a hint. + tracing::info!( + signer = %format!("{signer_address:?}"), + "Hyperliquid: no account_address set; reading account state from the signer's own \ + address. If this key is an API/agent wallet, set BB_HYPERLIQUID_ACCOUNT_ADDRESS to \ + your main account." + ); + } else { + tracing::info!( + signer = %format!("{signer_address:?}"), + account = %format!("{address:?}"), + "Hyperliquid: signing with API/agent wallet, reading from master account" + ); + } let base_url = match config.network.as_str() { "mainnet" => BaseUrl::Mainnet, "testnet" => BaseUrl::Testnet, @@ -73,6 +105,16 @@ pub async fn connect( let info = InfoClient::new(None, Some(base_url)).await.map_err(|e| BotError::exchange(e, true))?; + // On a unified account, USDC collateral lives in the spot balance, not the + // perp clearinghouse — so the broker must read balances from there. + let unified = detect_unified_account(&info, address).await; + if unified { + tracing::info!( + account = %format!("{address:?}"), + "Hyperliquid: unified account — reading collateral from the spot balance" + ); + } + // Separate InfoClient for WS (needs `with_reconnect` and stays alive in the // muxer task). The REST `info` above is kept on the broker for queries. let mut ws_info = InfoClient::with_reconnect(None, Some(base_url)) @@ -82,19 +124,14 @@ pub async fn connect( let (ws_tx, ws_rx) = mpsc::unbounded_channel::(); let coin = convert::to_hl_coin(symbol); - for (label, sub) in [ - ("L2Book", Subscription::L2Book { coin: coin.clone() }), - ("OrderUpdates", Subscription::OrderUpdates { user: address }), - ("UserFills", Subscription::UserFills { user: address }), - ("AllMids", Subscription::AllMids), - ("ActiveAssetCtx", Subscription::ActiveAssetCtx { coin: coin.clone() }), - ] { - ws_info - .subscribe(sub, ws_tx.clone()) - .await - .map_err(|e| BotError::exchange(format!("HL subscribe {label}: {e}"), false))?; - } - tracing::info!(symbol, coin = %coin, address = %format!("{address:?}"), "Hyperliquid: subscribed"); + subscribe_feeds(&mut ws_info, address, &coin, &ws_tx).await?; + tracing::info!( + symbol, + coin = %coin, + signer = %format!("{signer_address:?}"), + account = %format!("{address:?}"), + "Hyperliquid: subscribed" + ); let (trade_tx, trade_rx) = mpsc::unbounded_channel::(); let (book_tx, book_rx) = mpsc::channel::(BOOK_CHANNEL_CAPACITY); @@ -122,7 +159,8 @@ pub async fn connect( target_coin, )); - let broker = HyperliquidBroker::new(exchange_client, info, address, health, client_ids); + let broker = + HyperliquidBroker::new(exchange_client, info, address, unified, health, client_ids); let feeds = HyperliquidFeeds { trade: MpscFeed::new(trade_rx), book: MpscFeed::bounded(book_rx), @@ -245,3 +283,110 @@ async fn muxer_loop( } tracing::warn!("Hyperliquid: WS muxer ended"); } + +/// Subscribe to the venue's WS feeds (book, user orders/fills, mids, asset ctx) +/// for `coin`/`address`, forwarding messages to `ws_tx`. +async fn subscribe_feeds( + ws_info: &mut InfoClient, + address: H160, + coin: &str, + ws_tx: &mpsc::UnboundedSender, +) -> Result<(), BotError> { + for (label, sub) in [ + ("L2Book", Subscription::L2Book { coin: coin.to_string() }), + ("OrderUpdates", Subscription::OrderUpdates { user: address }), + ("UserFills", Subscription::UserFills { user: address }), + ("AllMids", Subscription::AllMids), + ("ActiveAssetCtx", Subscription::ActiveAssetCtx { coin: coin.to_string() }), + ] { + ws_info + .subscribe(sub, ws_tx.clone()) + .await + .map_err(|e| BotError::exchange(format!("HL subscribe {label}: {e}"), false))?; + } + Ok(()) +} + +/// Query the `userAbstraction` info endpoint to detect a unified account. +/// +/// On a unified account the USDC collateral lives in the spot balance, so the +/// broker reads balances from `user_token_balances` instead of the perp +/// clearinghouse. Retries a few times so a transient startup failure doesn't +/// lock the broker into the wrong balance mode for the whole session; if it +/// still fails it defaults to `false` (perp view) and warns loudly. +async fn detect_unified_account(info: &InfoClient, address: H160) -> bool { + let body = format!(r#"{{"type":"userAbstraction","user":"{address:?}"}}"#); + for attempt in 1..=3u32 { + match info.http_client.post("/info", body.clone()).await { + Ok(resp) => return account_mode_is_unified(&resp), + Err(e) => { + tracing::warn!(attempt, error = %e, "Hyperliquid: userAbstraction probe failed"); + if attempt < 3 { + tokio::time::sleep(std::time::Duration::from_millis(500)).await; + } + } + } + } + tracing::warn!( + "Hyperliquid: could not determine account mode after retries — assuming standard \ + (perp) balances. If this is a unified account, balances will under-report until \ + restart." + ); + false +} + +/// True if the `userAbstraction` response indicates a unified account. +/// The endpoint returns a bare JSON string, e.g. `"unifiedAccount"`. +fn account_mode_is_unified(body: &str) -> bool { + body.contains("unifiedAccount") +} + +/// Address used for reads and subscriptions: the configured master +/// `account_address` when set (API/agent-wallet case), otherwise the signer's +/// own address. Signing always uses the wallet, not this address. +fn resolve_account_address(configured: Option<&str>, signer: H160) -> Result { + match configured.map(str::trim).filter(|s| !s.is_empty()) { + Some(addr) => addr.parse::().map_err(|e| { + BotError::config(format!("Invalid hyperliquid account_address '{addr}': {e}")) + }), + None => Ok(signer), + } +} + +#[cfg(test)] +mod tests { + use ethers::types::H160; + + use super::resolve_account_address; + + #[test] + fn unset_falls_back_to_signer() { + let signer = H160::repeat_byte(0xAB); + assert_eq!(resolve_account_address(None, signer).expect("none"), signer); + // Empty / whitespace is treated as unset. + assert_eq!(resolve_account_address(Some(" "), signer).expect("blank"), signer); + } + + #[test] + fn set_uses_configured_master() { + let signer = H160::repeat_byte(0xAB); + let master = "0x1111111111111111111111111111111111111111"; + let got = resolve_account_address(Some(master), signer).expect("master"); + assert_eq!(got, master.parse::().expect("parse master")); + assert_ne!(got, signer); + } + + #[test] + fn invalid_master_errors() { + let signer = H160::repeat_byte(0xAB); + assert!(resolve_account_address(Some("not-an-address"), signer).is_err()); + } + + #[test] + fn detects_unified_account_from_response() { + use super::account_mode_is_unified; + assert!(account_mode_is_unified("\"unifiedAccount\"")); + assert!(!account_mode_is_unified("\"standardAccount\"")); + assert!(!account_mode_is_unified("null")); + } +} diff --git a/crates/exchanges/hyperliquid/src/convert.rs b/crates/exchanges/hyperliquid/src/convert.rs index f6a2d89..2a92e9c 100644 --- a/crates/exchanges/hyperliquid/src/convert.rs +++ b/crates/exchanges/hyperliquid/src/convert.rs @@ -5,7 +5,7 @@ use bb_core::helpers::parse_decimal_or_warn; use bb_core::types::{Balance, Order, OrderBook, OrderStatus, OrderType, Position, Side}; use hyperliquid_rust_sdk::{ ActiveAssetCtxData, AssetCtx, L2BookData, L2SnapshotResponse, OrderUpdate, TradeInfo, - UserStateResponse, + UserStateResponse, UserTokenBalanceResponse, }; use rust_decimal::Decimal; @@ -18,6 +18,19 @@ pub fn to_bb_symbol(hl_coin: &str) -> String { format!("{hl_coin}-USD") } +/// Round an order price to Hyperliquid's precision rule: at most **5 +/// significant figures** for perps. HL rejects over-precise prices with +/// "Order has invalid price" (e.g. `64710.599`, which is 6 sig figs). Rounding +/// to 5 sig figs yields a valid on-tick price (`64711`). Integer prices are +/// always valid, so large prices pass through unchanged. +/// +/// Note: HL also caps decimals at `MAX_DECIMALS - szDecimals` (6 for perps), +/// which only binds for sub-dollar assets; `round_sf(5)` already keeps decimals +/// within that for any price ≳ $0.0001, covering the assets traded here. +pub fn hl_round_price(price: Decimal) -> Decimal { + price.round_sf(5).unwrap_or(price).normalize() +} + /// Strip "-USD" suffix to get the HL coin name. pub fn to_hl_coin(bb_symbol: &str) -> String { bb_symbol.strip_suffix("-USD").unwrap_or(bb_symbol).to_string() @@ -51,6 +64,23 @@ pub fn user_state_to_balances(resp: &UserStateResponse) -> Vec { }] } +/// Balances from the spot clearinghouse — used for unified accounts, where the +/// USDC collateral lives in the spot balance rather than the perp account. +/// `available = total - hold` (hold = margin locked in open positions). +pub fn spot_state_to_balances(resp: &UserTokenBalanceResponse) -> Vec { + resp.balances + .iter() + .map(|b| { + // Warn (not silently zero) on malformed numerics, matching how + // positions/trades parse elsewhere in this module. + let total = parse_decimal_or_warn(&b.total, "spot total").unwrap_or(Decimal::ZERO); + let hold = parse_decimal_or_warn(&b.hold, "spot hold").unwrap_or(Decimal::ZERO); + Balance { asset: b.coin.clone(), available: total - hold, total } + }) + .filter(|b| !b.total.is_zero()) + .collect() +} + pub fn user_state_to_positions(resp: &UserStateResponse) -> Vec { resp.asset_positions .iter() @@ -199,4 +229,40 @@ mod tests { assert_eq!(parse_dec("not_a_number"), Decimal::ZERO); assert_eq!(parse_dec("123.456"), Decimal::new(123_456, 3)); } + + #[test] + fn hl_round_price_to_5_sig_figs() { + // 6 sig figs → rejected by HL; round to 5. + assert_eq!(hl_round_price(Decimal::new(64_710_599, 3)), Decimal::new(64711, 0)); // 64710.599 → 64711 + // Sub-dollar keeps 5 sig figs of precision. + assert_eq!(hl_round_price(Decimal::new(123_456_789, 8)), Decimal::new(12346, 4)); // 1.23456789 → 1.2346 + // Already ≤5 sig figs is unchanged. + assert_eq!(hl_round_price(Decimal::new(64711, 0)), Decimal::new(64711, 0)); + } + + #[test] + fn spot_balances_use_total_minus_hold_and_drop_zero() { + use hyperliquid_rust_sdk::{UserTokenBalance, UserTokenBalanceResponse}; + let resp = UserTokenBalanceResponse { + balances: vec![ + UserTokenBalance { + coin: "USDC".to_string(), + hold: "2.04".to_string(), + total: "997.59".to_string(), + entry_ntl: "0.0".to_string(), + }, + UserTokenBalance { + coin: "TZERO".to_string(), + hold: "0.0".to_string(), + total: "0.0".to_string(), + entry_ntl: "0.0".to_string(), + }, + ], + }; + let bals = spot_state_to_balances(&resp); + assert_eq!(bals.len(), 1, "zero-total balances dropped"); + assert_eq!(bals[0].asset, "USDC"); + assert_eq!(bals[0].total, Decimal::new(99759, 2)); + assert_eq!(bals[0].available, Decimal::new(99555, 2)); // 997.59 - 2.04 + } } diff --git a/crates/strategies/funding-arb/src/strategy.rs b/crates/strategies/funding-arb/src/strategy.rs index f5d7934..d92df9e 100644 --- a/crates/strategies/funding-arb/src/strategy.rs +++ b/crates/strategies/funding-arb/src/strategy.rs @@ -183,18 +183,88 @@ impl FundingArbActor { } if !short_ok || !long_ok { - // At least one leg didn't land. Cancel any orders that may have - // been accepted on either venue to avoid leaving an unhedged leg. - let _ = cx.broker(&short_ex)?.cancel_all_orders(self.symbol()).await; - let _ = cx.broker(&long_ex)?.cancel_all_orders(self.symbol()).await; - tracing::warn!( - "Entry incomplete — cancelling all orders on both venues, returning to Flat" - ); - self.state.go_flat(); + // At least one leg didn't land. Cancel resting orders AND close any + // leg that actually filled — an aggressive (IoC) leg reporting + // success has already executed, so cancelling alone would orphan it. + tracing::warn!("Entry incomplete — cancelling orders and flattening any filled leg"); + let short_flat = self.flatten_filled_leg(cx, &short_ex).await?; + let long_flat = self.flatten_filled_leg(cx, &long_ex).await?; + if short_flat && long_flat { + self.state.go_flat(); + } else { + // Don't pretend to be flat while a leg may still be open — that + // would let the strategy re-enter on top of an open position. + // Request shutdown so the operator sees it and can close manually. + tracing::error!( + "Incomplete-entry cleanup unconfirmed — a leg may still be open. \ + MANUAL INTERVENTION REQUIRED; requesting shutdown." + ); + cx.request_shutdown(); + } } Ok(()) } + /// Close the position `ex` holds for our symbol, reduce-only at market. + /// Reads the **live exchange position** (not internal inventory, which lags + /// the async fill event), so it catches a leg that filled while the other + /// leg of an entry failed. Cancels resting orders first. + /// + /// Closes at most `order_size` so a larger pre-existing position on a shared + /// wallet (not created by us) isn't disturbed. Returns `true` only when the + /// leg is confirmed flat (no position, or the live size ≤ `order_size` and + /// the capped close was accepted). Returns `false` — so the caller halts + /// instead of going Flat — when cleanup couldn't be confirmed: a read/close + /// failure, OR the live size **exceeds** `order_size` (the cap leaves a + /// remainder we won't blindly close, and the bot shouldn't trade on top of + /// an unexpected position). + async fn flatten_filled_leg(&mut self, cx: &ActorContext, ex: &str) -> Result { + let broker = cx.broker(ex)?; + let _ = broker.cancel_all_orders(self.symbol()).await; + let positions = match broker.get_positions().await { + Ok(p) => p, + Err(e) => { + tracing::warn!(exchange = %ex, error = %e, "Incomplete-entry cleanup: get_positions failed"); + return Ok(false); + } + }; + let Some(pos) = positions.iter().find(|p| p.symbol == self.symbol() && !p.size.is_zero()) + else { + return Ok(true); + }; + let close_side = if matches!(pos.side, Some(Side::Buy)) { Side::Sell } else { Side::Buy }; + // Cap to what we tried to enter, so we don't close an external position. + // If the live size exceeds that, the cap leaves a remainder → not flat. + let fully_closes = pos.size <= self.config.order_size; + let qty = pos.size.min(self.config.order_size); + let mut order = self.make_order(ex, close_side, qty, true); + order.order_type = OrderType::Market; + match broker.place_orders(&[order]).await { + Ok(results) if results.first().is_some_and(|r| r.success) => { + if fully_closes { + Ok(true) + } else { + tracing::error!( + exchange = %ex, live_size = %pos.size, closed = %qty, + "Incomplete-entry: position exceeds order_size; closed our size but a \ + remainder remains (possibly external) — not treating leg as flat" + ); + Ok(false) + } + } + Ok(results) => { + let err = + results.first().and_then(|r| r.error.as_deref()).unwrap_or("order rejected"); + tracing::error!(exchange = %ex, error = %err, "Incomplete-entry: flatten order rejected"); + Ok(false) + } + Err(e) => { + tracing::error!(exchange = %ex, error = %e, "Incomplete-entry: flatten order failed"); + Ok(false) + } + } + } + async fn exit(&mut self, cx: &ActorContext) -> Result<(), BotError> { tracing::info!(spread = %self.state.abs_rate_spread(), "Exiting arb position"); self.state.transition(ArbPhase::Exiting); @@ -224,16 +294,37 @@ impl FundingArbActor { for ex in &exchanges { let broker = cx.broker(ex)?; let _ = broker.cancel_all_orders(self.symbol()).await; - let pos = self.net_position(ex); - if pos.is_zero() { + // Size from the LIVE exchange position, not internal inventory: + // inventory can lag a fill that landed within the shutdown window, + // which would otherwise be skipped here, leaving exposure on exit. + // (Consistent with the reconnect reconcile, which also reads live + // positions.) Capped to order_size so a larger pre-existing position + // on a shared wallet isn't disturbed. + let positions = match broker.get_positions().await { + Ok(p) => p, + Err(e) => { + tracing::error!(exchange = %ex, error = %e, "Emergency flatten: get_positions failed — MANUAL INTERVENTION REQUIRED"); + all_ok = false; + continue; + } + }; + let Some(pos) = + positions.iter().find(|p| p.symbol == self.symbol() && !p.size.is_zero()) + else { continue; - } - let (close_side, qty) = - if pos.is_sign_positive() { (Side::Sell, pos) } else { (Side::Buy, -pos) }; + }; + let close_side = + if matches!(pos.side, Some(Side::Buy)) { Side::Sell } else { Side::Buy }; + let qty = pos.size.min(self.config.order_size); let mut order = self.make_order(ex, close_side, qty, true); order.order_type = OrderType::Market; // force IoC regardless of config match broker.place_orders(&[order]).await { - Ok(results) if results.first().is_some_and(|r| r.success) => {} + Ok(results) if results.first().is_some_and(|r| r.success) => { + if pos.size > self.config.order_size { + tracing::error!(exchange = %ex, live_size = %pos.size, closed = %qty, "Emergency flatten: remainder beyond order_size left — MANUAL INTERVENTION REQUIRED"); + all_ok = false; + } + } Ok(results) => { let err = results .first() @@ -710,6 +801,66 @@ mod tests { assert!(hl_cancels >= 1, "cancel_all_orders should be called on hl after failure"); } + /// Incomplete entry where one leg fills and the other is rejected: the + /// filled leg must be flattened, and the close must be capped to + /// `order_size` so a larger pre-existing position (shared wallet) isn't + /// disturbed. + #[tokio::test(flavor = "current_thread")] + async fn incomplete_entry_closes_filled_leg_capped_to_order_size() { + let bullet = MockBroker::shared("bullet"); + let hl = MockBroker::shared("hl"); + + // Short leg (bullet) fills; long leg (hl) is rejected. + bullet.queue_place_response(Ok(())).await; // entry + bullet.queue_place_response(Ok(())).await; // cleanup close + hl.queue_place_response(Err(BotError::exchange("venue error", false))).await; + + // Bullet holds a SHORT of 5 — larger than order_size (1). The cleanup + // must close only order_size, not the full 5. + bullet + .set_positions(vec![Position { + symbol: "BTC-PERP".into(), + side: Some(Side::Sell), + size: d("5"), + entry_price: d("100"), + unrealized_pnl: d("0"), + }]) + .await; + + // rate_a (bullet) 0.002 > rate_b (hl) 0.0005 → short bullet, long hl. + let marks = ScriptedFeed::new(vec![ + mark("bullet", "100", Some("0.002")), + mark("hl", "100", Some("0.0005")), + ]); + + let actor = FundingArbActor::with_client_ids(test_config(), ClientIdIssuer::new()); + let harness = HarnessBuilder::new() + .wire_broker("bullet", bullet.clone() as Arc) + .wire_broker("hl", hl.clone() as Arc) + .wire_feed_named("marks", marks) + .wire_actor(ActorSpec::new("funding-arb", actor).sub::().sub::()) + .build() + .unwrap(); + let reason = harness.run().await.unwrap(); + + // Entry, then the cleanup close — plus, since the leg wasn't confirmed + // flat, the shutdown path's emergency_flatten runs a second capped close. + // Every close is capped to order_size and reduce-only. + assert!(bullet.placed_count().await >= 2, "entry + at least one cleanup close"); + let close = bullet.last_placed_orders().await; + assert_eq!(close.len(), 1, "one close order"); + assert_eq!(close[0].side, Side::Buy, "close is opposite the Sell fill"); + assert_eq!(close[0].quantity, d("1"), "capped to order_size, not the full 5"); + assert!(close[0].reduce_only, "close is reduce-only"); + // Live size (5) exceeds order_size (1), so the capped close leaves a + // remainder → the leg is NOT confirmed flat → the actor requests + // shutdown rather than going Flat (which would allow re-entry on top). + assert!( + matches!(reason, WindDownReason::Signal), + "unconfirmed cleanup should request shutdown, not go flat" + ); + } + // ------------------------------------------------------------------------- // Full `Flat → Entering → Active → Exiting → Flat` cycle driven by /// `ScriptedFeed`s and `MockBroker`. diff --git a/docs/CONTRIBUTING-EXCHANGES.md b/docs/CONTRIBUTING-EXCHANGES.md index 4564d8c..2c79881 100644 --- a/docs/CONTRIBUTING-EXCHANGES.md +++ b/docs/CONTRIBUTING-EXCHANGES.md @@ -79,11 +79,18 @@ Adjust for venues that use a different suffix convention. ## Auth / key material -- Never put private keys in configs. Read from env vars or a keystore file. +- Never put private keys in configs. Read from env vars or a key file. - Wrap raw key strings in `secrecy::SecretString` in your config struct. -- Use `secrecy::ExposeSecret::expose_secret(&config.private_key_hex)` only +- Use `secrecy::ExposeSecret::expose_secret(&config.private_key)` only at the point of actual key use, not earlier. +Bullet API docs are machine-readable: see + for an index, the raw OpenAPI spec at +, and the +[delegate accounts](https://tradingapi.bullet.xyz/docs/delegate-accounts.md) +guide (delegate keys 404 on account reads — resolve the master via +`/api/v1/delegateOf` first). + ## Reconnect patterns Two supported patterns — pick the one your SDK offers: