Skip to content

Repository files navigation

Hadron CLI

Set up a Hadron pool, run a crank to keep its price fresh, and monitor it - in TypeScript, on devnet, against the V2 SDK (@hadron-fi/sdk-v2) so it lines up 1:1 with the dashboard.

Quickstart

Prerequisites: Node >= 20 and a funded devnet keypair - solana-keygen new -o wallet.json, then solana airdrop 2 <pubkey> --url devnet.

npm install
npm link                    # one-time: puts `hadron` on your PATH

hadron create    # design + create the pool in the dashboard, paste its address back
hadron crank configure
hadron crank run     # keep its midprice fresh (default feed: a zero-config mock walk)
hadron monitor   # open the pool's dashboard page to watch it live

Don't want the global symlink? Skip npm link and run any command as npm run hadron -- <cmd> (e.g. npm run hadron -- init) straight from the repo.

That's the whole loop. The price feed defaults to a mock random walk, so crank runs with no setup; point it at Binance or your own websocket with hadron crank configure. Everything below is detail - the full command list, feeds, running many pools, and deploying the crank to ECS.

The hadron CLI

The CLI scaffolds the project, runs the crank against your price feed, and opens the dashboard; the dashboard stays the place to design + create + monitor a pool (browser wallet signing). Shared settings live in hadron.config.json; each pool's feed/strategy/deploy lives in its own pools/<address>.json (see Running multiple pools).

hadron init             # write hadron.config.json (shared: network, rpc, wallet)
hadron create           # open the dashboard /create -> paste the new pool address back
hadron crank configure  # set the price feed + wallet + pool (interactive)
hadron crank run        # run the quoting loop (alias: bare `hadron crank`)
hadron monitor          # open the dashboard pool page
hadron pools            # list your pools (* = active) | `use <addr>` to switch default
hadron pull [addr]      # snapshot a pool's on-chain curves to strategy.json (read-only)
hadron build            # generate a Dockerfile + build the crank container image
hadron secrets          # push keypair (+RPC key) to AWS Secrets Manager; record ARNs
hadron deploy [--yes]   # build/push image + terraform apply (ECS Fargate)

(Commands shown as hadron <cmd> assume you ran npm link; otherwise use npm run hadron -- <cmd>.) Every command acts on the active pool; target a different one for a single command with --pool <addr>.

Running multiple pools

One checkout can run many pools. Settings split in two:

  • hadron.config.json - shared across pools: network, rpcUrl, wallet, dashboardUrl, and activePool (which pool commands act on by default).
  • pools/<address>.json - one per pool: its priceSource, strategy, and deploy block.
hadron create                 # appends a new pools/<addr>.json and makes it active
hadron pools                  # list them all (* = active)
hadron use <addr>             # switch the default pool
hadron --pool <addr> crank    # run one command against a specific pool (doesn't change the default)

So hadron create-ing a second pool no longer overwrites the first - each gets its own profile, and the latest becomes active. A deployed pool gets its own ECS service (the deploy name defaults to hadron-crank-<addr>), so pools don't collide.

Price feeds

hadron crank configure writes the feed into the active pool's pools/<addr>.json. Options:

  • mock - random walk around the current midprice (default; no setup).
  • binance - real mid of best bid/ask from <symbol>@bookTicker (e.g. SOLUSDC). Public stream, no API key. Make the symbol match your pool's pair.
  • websocket / polling - your own ws/HTTP endpoint; the generic parser reads {price|p|value|data.value} (edit priceSourceFromConfig for other shapes, or implement the PriceSource interface).

Deploy to ECS

hadron deploy configure   # pick region + VPC/subnets (auto-discovered via aws) -> writes the deploy block
hadron secrets            # push keypair (+ RPC key) to Secrets Manager, record ARNs
hadron deploy             # tfvars -> terraform init -> ECR -> build/push -> terraform apply

deploy configure lists your VPCs/subnets so you don't hunt for IDs (manual entry if aws isn't set up). It writes a per-pool deploy block to the active pool's pools/<addr>.json:

"deploy": { "region": "eu-central-1", "vpcId": "vpc-...", "subnetIds": ["subnet-..."], "assignPublicIp": true, "arch": "arm64" }

arch (the image + Fargate task CPU architecture) defaults to the machine you deploy from - arm64 on Apple Silicon, x86_64 on Intel. deploy builds --platform to match and sets the task arch the same, so they always agree. (Set it explicitly to override, e.g. "x86_64".)

Once it's running, operate it without Terraform:

hadron status        # running / desired counts
hadron logs          # stream the ↑/↓/→ push lines live (--once for a snapshot)
hadron stop          # pause (desired-count 0 - compute billing stops, infra stays)
hadron start         # resume (desired-count 1)

deploy runs terraform apply so you review the plan and confirm (pass --yes to auto-approve in CI). Single-writer is enforced by the module (desired_count=1, stop-then-start). Needs terraform / docker / aws on PATH + AWS credentials. Details: terraform/README.md.

Run in Docker (without ECS)

hadron build writes a Dockerfile + .dockerignore and builds the hadron-crank image. Secrets are never baked in - pass them at runtime:

hadron build
docker run --rm \
  -v "$(pwd)/wallet.json:/app/wallet.json:ro" \
  -v "$(pwd)/hadron.config.json:/app/hadron.config.json:ro" \
  -v "$(pwd)/pools:/app/pools:ro" \
  hadron-crank

(Or skip the file mounts entirely and pass POOL / PRICE_SOURCE / RPC_URL / WALLET_SECRET as env - hadron build prints that form, and it's what hadron deploy uses on ECS.)

The crank only needs pool address + your wallet + a price feed - the curves already live on-chain, so strategy.json is just a design snapshot (pull writes it; create offers to). Config is split into shared globals + a per-pool profile:

// hadron.config.json - shared
{ "network": "devnet", "rpcUrl": "https://api.devnet.solana.com", "wallet": "./wallet.json",
  "dashboardUrl": "https://dashboard.hadron.fi", "activePool": "7Xp..." }

// pools/7Xp....json - per-pool
{ "pool": "7Xp...", "priceSource": { "kind": "mock" }, "strategy": "strategy-7Xp.json" }

Secrets: hadron.config.json and pools/ are git-ignored (they may hold an RPC API key + your pool). To keep a key out of the file entirely, use a ${VAR} placeholder resolved from the environment, e.g. "rpcUrl": "https://devnet.helius-rpc.com/?api-key=${HELIUS_API_KEY}" - set HELIUS_API_KEY in your shell, or in Docker/ECS via the runtime env / Secrets Manager. Never commit a config with a literal key.

Wallet: the crank reads the keypair from the wallet file path or, if set, from a WALLET_SECRET env var. WALLET_SECRET needs no file - use it in containers / ECS (inject via Secrets Manager). The file wallet path is ignored when it's set. Either one may be a solana-keygen JSON array ([12,34,...], i.e. wallet.json), or a base58 secret key - what Phantom / Solflare's "export private key" copies, and what a .txt key is usually in - or base64, or hex. A 32-byte value is taken as the ed25519 seed.

The scripts below (setup-pool / crank) remain the env-driven path for headless / power use.

Headless / scripted use (npm run)

Prefer environment variables to the interactive CLI - for CI, scripting, or power use? The setup-pool and crank scripts run the same lifecycle driven entirely by env vars, including minting fresh test tokens for a throwaway pool (which the dashboard flow doesn't do).

npm install
cp .env.example .env          # then set WALLET to a funded devnet keypair
solana airdrop 2 <pubkey> --url devnet

# Create a pool - by default mints two test tokens, sets the recipe's curves + spread, seeds a
# 50/50 deposit, and goes live. Target a real pair with MINT_X / MINT_Y (deposit via DEPOSIT_X/Y).
WALLET=./wallet.json STRATEGY=sol-usdc-bluechip MIDPRICE=150 npm run setup-pool

# Crank it (attaches to the most recent pool in output/pool-config.json):
WALLET=./wallet.json STRATEGY=sol-usdc-bluechip npm run crank
Cranking 7Xp...  source: mock-walk(base=150)
  ↑ seq=1 mid=150.014 spread=8.0bps  3sJ4k...
  ↓ seq=2 mid=149.992 spread=8.0bps  9aZ1q...

This path keeps its own pool registry in output/pool-config.json, separate from the CLI's pools/. Pick one path and stick with it - the hadron CLI is the recommended one.

Project layout

strategies/            Recipes you edit - *.json quoting designs (round-trip with the dashboard)
pools/                 Per-pool profiles (feed/strategy/deploy), one <address>.json each (git-ignored)
terraform/             ECS Fargate module for `hadron deploy`
src/
  engine/              The quoting core (lift this into your own bot if you like)
    price-source.ts      BYO feed: mock / binance / websocket / polling
    middleware.ts        compose() + steps: smooth -> throttle -> push
    crank.ts             runCrank() - wires a feed through the middleware to the chain
    health.ts            /health server for orchestrators
    math.ts              Q32 <-> UI-price conversion
  strategy/            The recipe layer
    recipe.ts            StrategyConfig type + loader (reads strategies/*.json)
    presets.ts           preset -> on-chain curve points
  chain/               Solana / SDK glue
    harness.ts           SDK client + wallet loading
    instance-store.ts    pool record (output/) + dashboard URLs
  cli/                 The `hadron` tool
    index.ts             command dispatcher
    prompts.ts, sh.ts    readline + shell helpers
    commands/            init | create | crank | pull | pools/use | build | secrets | deploy | ops
  scripts/             Env-driven npm entrypoints (headless/power path)
    setup-pool.ts        `npm run setup-pool`
    crank.ts             `npm run crank`
  config.ts            globals (hadron.config.json) + per-pool profiles (pools/) - load/save

Three files are named crank on purpose, disambiguated by folder: engine/crank.ts is the engine, cli/commands/crank.ts is the hadron crank command, scripts/crank.ts is the npm run crank entrypoint. The latter two are thin wrappers around the first.

The three layers

Concept File What it is
Recipe strategies/*.json The reusable quoting design - base spread + price/risk curves. Vendor- and pair-neutral (no feeds, URLs, or keys), so it round-trips with the dashboard.
Resolver src/strategy/presets.ts Turns a recipe into on-chain curve points (preset or explicit points). Mirrors the dashboard's curve math.
Price source src/engine/price-source.ts Bring your own. A subscribe(onPrice) seam modeled on a websocket. Ships mockWalkSource, websocketSource, pollingSource.
Middleware src/engine/middleware.ts A composable (ctx, next) chain: smooth -> (volatility) -> throttle -> push. Edit the chain in src/engine/crank.ts.

Strategy recipes (strategies/)

{
  "id": "sol-usdc-bluechip",
  "name": "Bluechip - Balanced",
  "baseSpreadBps": 8,
  "priceCurve": { "preset": "gentle" },
  "riskCurve":  { "preset": "risk-conservative" }
}

priceCurve / riskCurve take either a preset id (flat/gentle/steep, risk-none/risk-conservative/risk-aggressive) or explicit points (see longtail-volatile.json). A strategy.json exported from the dashboard's /create page drops straight into this folder.

Bring your own price source

Implement one interface (src/engine/price-source.ts):

interface PriceSource {
  name: string;
  subscribe(onPrice: (price: number) => void): () => void; // returns unsubscribe
}

Then point the crank at it via PRICE_SOURCE=websocket WS_URL=... (edit the parser in src/engine/price-source.ts for your feed) or PRICE_SOURCE=polling HTTP_URL=.... The default mock needs no config.

Tune quoting (src/engine/crank.ts)

const pipeline = compose([
  ewma({ halfLifeMs: 3_000 }),            // smooth the feed
  // volatility({ windowMs: 60_000 }),    // <- uncomment these two for a volatility-adaptive spread
  // volatilityToSpread({ base: baseSpreadBps, k: 1, max: 50, horizonMs: pushIntervalMs }),
  throttle({ minMs: pushIntervalMs }),    // rate ceiling - default 2_000, i.e. push at most once every 2s
  pushSink(authority, submit, { decimalsX, decimalsY, status }),
]);

Push rate. Feeds tick much faster than you want to transact - Binance's bookTicker fires on every top-of-book change, many times a second - so the throttle is what sets your actual on-chain update rate. It defaults to 2000ms (one push every 2s) for every feed; override per run with PUSH_INTERVAL_MS=500 hadron crank run. Faster means fresher quotes and more fees/rate-limit pressure; slower means more staleness for arbitrageurs to pick off (widen the spread to compensate).

volatility measures realized vol normalized to a per-second rate (so it's independent of feed speed); volatilityToSpread widens the base spread by the expected price move over a refresh horizon (vol * sqrt(horizonMs)), which is what an oracle-following MM gets adversely selected on. Set horizonMs near your refresh cadence (throttle + confirmation). exponent defaults to 1 (linear in vol); set exponent: 2 to widen defensively (super-linearly) in turbulent regimes. Inventory skew is handled separately by the on-chain risk curve, not here.

Notes

  • Devnet-focused - the point is for the dashboard to see your pool live on-chain.
  • The pool authority is your WALLET (fee payer + quoting key here). No secret keys are ever written to output/; pool-config.json holds public info only.
  • Recipe (design) vs instance (output/pool-config.json, a specific pool) are kept separate.

About

No description, website, or topics provided.

Resources

Stars

1 star

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages