Skip to content
Merged
Show file tree
Hide file tree
Changes from 12 commits
Commits
Show all changes
35 commits
Select commit Hold shift + click to select a range
c734de2
docs(spec): base58 signers + delegate support + onboarding design
0xtristan Jun 18, 2026
50f70b4
chore: gitignore docs/superpowers working docs
0xtristan Jun 18, 2026
2b56bb3
feat(bullet): parse signer keys from hex or base58
0xtristan Jun 18, 2026
99e3981
fix(bullet): silence clippy format_collect in key tests; tidy dep order
0xtristan Jun 18, 2026
07af5d4
feat(bullet): accept base58 keys via private_key / BB_BULLET_PRIVATE_KEY
0xtristan Jun 18, 2026
a582adb
docs(bullet): update key-precedence doc comments for private_key/base58
0xtristan Jun 18, 2026
b1ecad7
feat(bullet): resolve delegate signer to master via delegateOf
0xtristan Jun 18, 2026
a70ecb6
fix(bullet): propagate delegateOf body-read errors as BotError
0xtristan Jun 18, 2026
9195a59
feat(bullet): read account data + subscribe via resolved master address
0xtristan Jun 18, 2026
d9d0c10
docs: base58 keys, delegate wallets, from-scratch onboarding
0xtristan Jun 18, 2026
3217556
docs(agents): use canonical BB_BULLET_PRIVATE_KEY in setup example
0xtristan Jun 18, 2026
5fde9b1
docs: use canonical BB_BULLET_PRIVATE_KEY in example configs; fix env…
0xtristan Jun 18, 2026
5524e26
fix(review): robust env key resolution, retryable delegateOf 5xx/429,…
0xtristan Jun 18, 2026
9265192
feat(hyperliquid): support API/agent wallets via account_address
0xtristan Jun 18, 2026
dd6998b
feat(bullet): log a hint when a signer resolves to its own account
0xtristan Jun 19, 2026
1cd97a6
docs(config): document BB_HYPERLIQUID_ACCOUNT_ADDRESS + network-match…
0xtristan Jun 19, 2026
8432e99
docs(config): clarify network matches the delegate's network (testnet…
0xtristan Jun 19, 2026
375c0e1
feat(hyperliquid): log whether reads use the signer or a master account
0xtristan Jun 19, 2026
e5d4f34
fix(bullet): treat delegateOf 400 'is not a delegate' as self
0xtristan Jun 19, 2026
064fbce
feat!: standardize on base58 for Bullet, hex for Hyperliquid
0xtristan Jun 19, 2026
34882df
feat: auto-load .env, unify key resolution, add Hyperliquid key_file
0xtristan Jun 19, 2026
cefd022
feat(hyperliquid): read collateral from spot balance for unified acco…
0xtristan Jun 21, 2026
e7e7e2c
fix(hyperliquid): round order prices to 5 sig figs; flatten orphaned …
0xtristan Jun 22, 2026
b68afa7
feat(cli): flatten closes all configured venues (Bullet + Hyperliquid)
0xtristan Jun 22, 2026
f4630e1
fix(hyperliquid): normalize cloid format so own fills are recognized
0xtristan Jun 22, 2026
34f0878
fix(harness): flatten on SIGTERM, not just Ctrl-C
0xtristan Jun 22, 2026
2dcaeeb
fix(review): address bot findings on flatten + funding-arb cleanup
0xtristan Jun 22, 2026
8695a00
fix(review): treat empty BB_*_KEY_FILE as absent in load_config merges
0xtristan Jun 22, 2026
669c47f
fix(keygen): faucet curl needs a browser User-Agent; restructure READ…
0xtristan Jun 22, 2026
3b077da
feat(cli): add 'bb-bot faucet' command + web-faucet fallback
0xtristan Jun 23, 2026
76154b8
test(funding-arb): cover incomplete-entry cleanup (filled leg closed,…
0xtristan Jun 23, 2026
0c15873
chore(deps): bump quinn-proto 0.11.14 → 0.11.15 (RUSTSEC-2026-0185)
0xtristan Jun 23, 2026
369ff5b
fix(review): address remaining bot findings
0xtristan Jun 23, 2026
74a9f58
fix(funding-arb): don't treat a capped partial cleanup as flat
0xtristan Jun 23, 2026
0f03852
fix(review): delegateOf retries; emergency_flatten sizes from live po…
0xtristan Jun 23, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
# Copy to .env (gitignored) and fill in. Keys are loaded from the environment
# or a keystore file — never commit them into config/*.toml.

# --- Bullet ---
# Delegate (API) signer secret. Accepts base58 (Phantom / delegation export),
# hex (with or without 0x), or use BB_BULLET_KEY_FILE for a Solana JSON keystore.
# A delegate can only trade (no deposit/withdraw) and is revocable from the webapp.
BB_BULLET_PRIVATE_KEY=

# Alternatively, point at a Solana-compatible JSON keystore (takes precedence):
# BB_BULLET_KEY_FILE=/path/to/id.json

# --- Hyperliquid ---
# API wallet key (secp256k1 hex) created at https://app.hyperliquid.xyz/API.
BB_HYPERLIQUID_PRIVATE_KEY_HEX=
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -7,3 +7,6 @@

# Local/experimental configs — put personal configs here
config/local/

# Superpowers brainstorming/specs (local working docs)
docs/superpowers/
20 changes: 12 additions & 8 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,12 +55,12 @@ Run a bot (default: reads `~/.config/bullet/id.json`):
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 keystore / 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
# OR (base58 from Phantom/delegation export, or hex)
export BB_BULLET_PRIVATE_KEY="<base58-or-hex>" # fallback
```

## Architecture — the harness, feeds, and actors
Expand Down Expand Up @@ -307,11 +307,15 @@ TOML. Top-level sections: `[engine]`, `[exchanges.<name>]`, `[strategy]`,
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.)
`private_key` (in config, alias `private_key_hex`) → env
`BB_BULLET_PRIVATE_KEY` (alias `BB_BULLET_PRIVATE_KEY_HEX`). The key string
may be base58 (Phantom / delegation export) or hex. If the 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. 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.)
- Strategy configs: `type = "<name>"` with sub-table `[strategy.<name>]`.

## Code Style
Expand Down
2 changes: 2 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@ toml = "0.8"
rust_decimal = { version = "1", features = ["serde-with-str"] }
thiserror = "2"
secrecy = { version = "0.8", features = ["serde"] }
bs58 = "0.5"
reqwest = { version = "0.13", features = ["json"] }
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
async-trait = "0.1"
Expand Down
32 changes: 31 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -64,13 +64,43 @@ Recommended first path:
6. `run` — start tiny, watch logs plus `GET /status`.
7. `flatten` — cancel and close manually if you need to clean up.

## Trading from your own wallet (recommended)

For real usage, trade with a **delegate key** rather than your main wallet key:

1. Sign in at [app.bullet.xyz](https://app.bullet.xyz) (or
[app.testnet.bullet.xyz](https://app.testnet.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 UI — this initializes the trading
account.
3. Create a delegate (see Bullet's
[delegate setup guide](https://docs.bullet.xyz/bulletx-exchange/how-to-guide/delegate-account-setup)),
then copy the delegate signer private key into `.env` as
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
Outdated
`BB_BULLET_PRIVATE_KEY`. Base58 (Phantom / delegation export), hex, and
Solana JSON keystores (`BB_BULLET_KEY_FILE`) are all accepted.
4. For Hyperliquid, create an API wallet at
[app.hyperliquid.xyz/API](https://app.hyperliquid.xyz/API) and copy its key
into `.env` as `BB_HYPERLIQUID_PRIVATE_KEY_HEX`.

> **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. The bot resolves the
> delegate to its master account automatically; all balances and positions live
> on the master account.

Copy `.env.example` to `.env` (gitignored) to get started.

## 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).
- **Key string:** set `BB_BULLET_PRIVATE_KEY` (base58 or hex) /
`BB_HYPERLIQUID_PRIVATE_KEY_HEX`, e.g. via a `.env` file (already gitignored).
`BB_BULLET_PRIVATE_KEY_HEX` still works as an alias.

## Strategies

Expand Down
2 changes: 1 addition & 1 deletion config/avellaneda-stoikov-example.toml
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ 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 2 (CI/ephemeral): export BB_BULLET_PRIVATE_KEY="..." (base58 or hex)

[strategy]
type = "avellaneda-stoikov"
Expand Down
2 changes: 1 addition & 1 deletion config/funding-arb-example.toml
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ 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 2 (CI/ephemeral): export BB_BULLET_PRIVATE_KEY="..." (base58 or hex)

[exchanges.hyperliquid]
type = "hyperliquid"
Expand Down
2 changes: 1 addition & 1 deletion config/grid-example.toml
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ 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 2 (CI/ephemeral): export BB_BULLET_PRIVATE_KEY="..." (base58 or hex)

[strategy]
type = "grid"
Expand Down
2 changes: 1 addition & 1 deletion config/reference-arb-example.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
2 changes: 1 addition & 1 deletion config/simple-mm-example.toml
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ 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 2 (CI/ephemeral): export BB_BULLET_PRIVATE_KEY="..." (base58 or hex)

[strategy]
type = "simple-mm"
Expand Down
27 changes: 17 additions & 10 deletions crates/bb-bot/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -158,9 +158,11 @@ fn load_config(path: &str) -> Result<AppConfig, Box<dyn std::error::Error>> {
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")
&& table.get("private_key").and_then(toml::Value::as_str).is_none_or(str::is_empty)
&& let Ok(key) = std::env::var("BB_BULLET_PRIVATE_KEY")
.or_else(|_| std::env::var("BB_BULLET_PRIVATE_KEY_HEX"))
{
table.insert("private_key_hex".to_string(), toml::Value::String(key));
table.insert("private_key".to_string(), toml::Value::String(key));
Comment thread
cursor[bot] marked this conversation as resolved.
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
}
}
if let Some(hl) = config.exchanges.get_mut("hyperliquid")
Expand Down Expand Up @@ -523,14 +525,17 @@ 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` (preferred) or `BB_BULLET_PRIVATE_KEY_HEX` (fallback), then the
/// default path, else error.
fn load_deposit_keypair() -> Result<Keypair, Box<dyn std::error::Error>> {
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 Ok(hex) = std::env::var("BB_BULLET_PRIVATE_KEY_HEX") {
return Keypair::from_hex(&hex).map_err(Into::into);
if let Ok(secret) = std::env::var("BB_BULLET_PRIVATE_KEY")
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
Outdated
.or_else(|_| std::env::var("BB_BULLET_PRIVATE_KEY_HEX"))
{
return bb_exchange_bullet::key::keypair_from_secret(&secret).map_err(Into::into);
}
let default = default_key_path();
if default.exists() {
Expand Down Expand Up @@ -560,15 +565,17 @@ fn parse_network(s: &str) -> Result<Network, bb_core::error::BotError> {
/// 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
/// else `BB_BULLET_PRIVATE_KEY` (preferred) or `BB_BULLET_PRIVATE_KEY_HEX` (fallback),
/// else the default `~/.config/bullet/id.json` keystore if it exists.
/// 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.
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 private_key_hex = std::env::var("BB_BULLET_PRIVATE_KEY")
.or_else(|_| 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 →
Expand Down
2 changes: 2 additions & 0 deletions crates/exchanges/bullet/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@ rust_decimal = { workspace = true }
secrecy = { workspace = true }
tracing = { workspace = true }
async-trait = { workspace = true }
bs58 = { workspace = true }
reqwest = { workspace = true }

[dev-dependencies]
toml = { workspace = true }
Expand Down
26 changes: 21 additions & 5 deletions crates/exchanges/bullet/src/broker.rs
Original file line number Diff line number Diff line change
Expand Up @@ -32,17 +32,19 @@ pub(crate) struct Increments {

pub struct BulletBroker {
client: Arc<Client>,
account_address: String,
increments: HashMap<String, Increments>,
health: Arc<ConnectionHealth>,
}

impl BulletBroker {
pub(crate) fn new(
client: Arc<Client>,
account_address: String,
increments: HashMap<String, Increments>,
health: Arc<ConnectionHealth>,
) -> Self {
Self { client, increments, health }
Self { client, account_address, increments, health }
}

fn market_id(&self, symbol: &str) -> Result<MarketId, BotError> {
Expand Down Expand Up @@ -114,7 +116,12 @@ impl Broker for BulletBroker {
}

async fn get_balances(&self) -> Result<Vec<Balance>, 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 {
Expand All @@ -126,7 +133,12 @@ impl Broker for BulletBroker {
}

async fn get_positions(&self) -> Result<Vec<Position>, 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()
Expand All @@ -146,8 +158,12 @@ impl Broker for BulletBroker {
}

async fn get_open_orders(&self, symbol: &str) -> Result<Vec<Order>, 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| {
Expand Down
25 changes: 19 additions & 6 deletions crates/exchanges/bullet/src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -14,10 +14,10 @@ use serde::Deserialize;
/// 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.
/// 3. **`private_key`** (config, alias `private_key_hex`) — Ed25519 secret as a **hex or base58**
/// string. Wrapped in [`SecretString`] so it's redacted in `Debug` output and zeroed on drop.
/// 4. **`BB_BULLET_PRIVATE_KEY`** (env, alias `BB_BULLET_PRIVATE_KEY_HEX`) — Ed25519 secret as a
/// hex or base58 string, for CI / ephemeral contexts.
#[derive(Debug, Clone, Deserialize)]
pub struct BulletConfig {
/// Network to connect to: "mainnet" or "testnet".
Expand All @@ -28,9 +28,10 @@ pub struct BulletConfig {
#[serde(default)]
pub key_file: Option<PathBuf>,

/// Ed25519 private key as hex string (with or without "0x" prefix).
/// Ed25519 private key as a hex **or base58** string (with or without "0x"
/// prefix for hex). Accepts the TOML key `private_key` or `private_key_hex`.
/// Only used if `key_file` is not set.
#[serde(default = "default_secret")]
#[serde(default = "default_secret", alias = "private_key")]
pub private_key_hex: SecretString,
}

Expand Down Expand Up @@ -88,4 +89,16 @@ mod tests {
assert_eq!(cfg.private_key_hex.expose_secret(), "");
assert!(cfg.key_file.is_none());
}

#[test]
fn deserializes_private_key_alias() {
let toml_src = format!(
r#"
network = "testnet"
private_key = "{FAKE_KEY}"
"#
);
let cfg: BulletConfig = toml::from_str(&toml_src).expect("parse");
assert_eq!(cfg.private_key_hex.expose_secret(), FAKE_KEY);
}
}
Loading