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.
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 liveDon't want the global symlink? Skip
npm linkand run any command asnpm 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 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>.
One checkout can run many pools. Settings split in two:
hadron.config.json- shared across pools:network,rpcUrl,wallet,dashboardUrl, andactivePool(which pool commands act on by default).pools/<address>.json- one per pool: itspriceSource,strategy, anddeployblock.
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.
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}(editpriceSourceFromConfigfor other shapes, or implement thePriceSourceinterface).
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 applydeploy 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.
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.jsonandpools/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}"- setHELIUS_API_KEYin 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
walletfile path or, if set, from aWALLET_SECRETenv var.WALLET_SECRETneeds no file - use it in containers / ECS (inject via Secrets Manager). The filewalletpath 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.txtkey 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.
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 crankCranking 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'spools/. Pick one path and stick with it - thehadronCLI is the recommended one.
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
crankon purpose, disambiguated by folder:engine/crank.tsis the engine,cli/commands/crank.tsis thehadron crankcommand,scripts/crank.tsis thenpm run crankentrypoint. The latter two are thin wrappers around the first.
| 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. |
{
"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.
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.
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.
- 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 tooutput/;pool-config.jsonholds public info only. - Recipe (design) vs instance (
output/pool-config.json, a specific pool) are kept separate.