# Cinder — Agent Reference
Rust terminal trading client for the Phoenix perpetuals exchange on Solana. Renders a ratatui TUI with live price charts, a coalesced spline+CLOB order book, market selector, position/order management, top-positions leaderboard, and a live liquidation feed. Built on top of the `phoenix-rise` SDK and a vendored `phoenix-eternal-types` crate that knows the on-chain account layouts.
This file is the technical map. Use it before reading code so you know which crate / module / channel owns the thing you're about to change.
---
## Quick orientation
| Question | Answer |
| --- | --- |
| Binary entry | `src/main.rs` → `cinder::run()` (`src/lib.rs`, `src/app.rs`) |
| Library crate name | `cinder` (single binary `cinder`, `publish = false`) |
| Workspace members | `.` and `crates/phoenix-eternal-types` |
| Rust edition / MSRV | `2021`, `rust-version = "1.94"` |
| Async runtime | `tokio` (multi-thread, `macros`, `time`, `sync`, `signal`) |
| TUI stack | `ratatui` 0.30 + `crossterm` 0.29 (event-stream feature) |
| TLS / WSS | `rustls` 0.23 with the `ring` provider — required by `solana-pubsub-client` ≥ 2.3 (no aws-lc-rs) |
| Solana wire stack | Pinned `=2.3.13` for `solana-rpc-client`, `solana-pubsub-client`, `solana-rpc-client-types`, `solana-account-decoder-client-types`, `solana-transaction-status-client-types` so HTTP RPC and WSS resolve to one `solana-commitment-config` graph |
| Phoenix SDK | `phoenix-rise = 0.1.2` (HTTP client, WS client, account types, IX builders, metadata) |
| Vendored on-chain types | `phoenix-eternal-types` (Trader / Spline / GTI / inner-instruction event decoder) |
| Persisted user config | `~/.config/phoenix-cinder/config.json` — `rpc_url`, `language` (`en`/`cn`), `show_clob` |
| Config defaults RPC fallback | `https://api.mainnet-beta.solana.com` (with a warn log) |
| Wallet path resolution | `phoenix.json` (cwd) → `PHX_WALLET_PATH` / `KEYPAIR_PATH` → `~/.config/solana/id.json` (first that exists) |
| Pre-commit hooks | `lefthook.yml` runs `cargo fmt --all --check` and the strict clippy command |
| Mise tasks | `mise run fmt | clippy | test | release-check` (release-check chains all three) |
---
## Directory map
```
.
├── Cargo.toml # workspace root + cinder bin manifest
├── Cargo.lock
├── Dockerfile # multi-stage musl build (statically linked)
├── docker-compose.yml # service `cinder`
├── lefthook.yml # fmt + clippy pre-commit
├── mise.toml # tool versions and aliased tasks
├── rustfmt.toml # max_width=100, edition=2021, field-init/try shorthand
├── crates/
│ └── phoenix-eternal-types/ # vendored zero-copy account / event types
│ └── Cargo.toml # no_std-friendly; optional cli/geyser/serde features
└── src/
├── main.rs # dotenvy + tracing init, calls cinder::run()
├── lib.rs # re-exports `app::run`; `spline` alias for older callers
├── app.rs # Phoenix HTTP markets + WSS stats subscribe + 60s poll loop
└── tui/
├── mod.rs # crate-internal module root, public surface for `app.rs`
├── config.rs # UserConfig, SplineConfig, RPC/WS env, keypair load helpers
├── constants.rs # SOL_SYMBOL, MAX_PRICE_HISTORY, TOP_N, ORDER_SIZE_PRESETS, palette
├── format.rs # fmt_price/size/balance, compact notation, pubkey_trader_prefix
├── i18n/ # Strings struct + EN/CN tables (no allocation per render)
│ ├── mod.rs # `strings()` reads UserConfig.language at call time
│ ├── en.rs
│ └── zh.rs
├── math.rs # ticks_to_price, base_lots_to_units, lot conversions, pct_change_24h
├── splash.rs # opt-in startup splash task (paints over alt-screen during load)
├── terminal.rs # crossterm/ratatui setup, panic-hook teardown, restore helpers
├── data/ # on-chain decoders + caches (read path)
│ ├── mod.rs
│ ├── spline_book.rs # SplineCollection → ParsedSplineData; L2 book parser
│ ├── trader_index.rs # GTI sokoban tree + `node_addr → authority` cache
│ └── position_leaderboard.rs # ActiveTraderBuffer scan for largest positions
├── trading/ # Trading domain types (no async, no I/O)
│ ├── mod.rs
│ ├── balance.rs # fetch_phoenix_balance_and_position
│ ├── input_mode.rs # InputMode enum (Normal / EditingSize / Confirming(...) / ...)
│ ├── order_info.rs # OrderInfo (per-row trader-orders snapshot)
│ ├── order_kind.rs # OrderKind: Market | Limit { price } | StopMarket { trigger }
│ ├── pending_action.rs # PendingAction: PlaceOrder / Close / Cancel / Deposit / Withdraw
│ ├── position_info.rs # PositionInfo, FullPositionInfo (per-symbol)
│ ├── side.rs # TradingSide::{Long,Short} + toggle()
│ └── top_position_entry.rs
├── runtime/ # event loop, input routing, async tasks (write path)
│ ├── mod.rs # crate-internal sub-module root + tuning consts
│ ├── channels.rs # Channels/Receivers structs, KeyAction, TxCtxMsg
│ ├── connection.rs # initial WS config, RPC reconnect, market-switch helpers
│ ├── event_loop.rs # `spawn_spline_poller` — the central tokio::select! hub
│ ├── keyboard.rs # InputMode-dispatch from KeyEvent
│ ├── redraw.rs # full-frame redraw helpers (chart bounds + ratatui draw)
│ ├── submit.rs # PendingAction → tx submit dispatch
│ ├── update_handlers.rs # per-channel state-mutation handlers
│ ├── wallet.rs # connect/disconnect bookkeeping
│ ├── input/ # one file per InputMode
│ │ ├── mod.rs
│ │ ├── amounts.rs # numeric editing (size, price, deposit, withdraw)
│ │ ├── clipboard.rs # arboard wrapper (copy txid / pubkey)
│ │ ├── forms.rs # config / RPC URL / wallet-path edit modes
│ │ ├── market.rs # market-selector navigation
│ │ ├── normal.rs # main hotkeys (q/m/c/o/p/Tab/t/e/s/+ -/w/Enter/x/d/D/L/T/F)
│ │ ├── settings.rs # config-modal field cycling + save
│ │ └── views.rs # positions/orders/top/liquidations/ledger key handlers
│ └── tasks/ # one spawnable task per stream / workload
│ ├── mod.rs
│ ├── balances.rs # spawn_balance_fetch (HTTP balance + position pull)
│ ├── connect_flow.rs # connect_wallet (keypair → tx_context bootstrap)
│ ├── l2_book.rs # spawn_phoenix_l2_book_rpc (Solana accountSubscribe on market)
│ ├── liquidations.rs # spawn_liquidation_feed_task (logsSubscribe → events)
│ ├── orders.rs # trader-orders WS subscription
│ ├── position_leaderboard.rs# spawn_top_positions_refresh (5s tick)
│ ├── tx_context.rs # one-shot TxContext::new + warm-up blockhash poller
│ └── wallet_stream.rs # USDC ATA + native SOL balance WSS subscriptions
├── ui/ # ratatui widgets (no async, no I/O)
│ ├── mod.rs # render_frame entrypoint; layout constants
│ ├── chart.rs # price chart (canvas) with trade markers + order chart markers
│ ├── status.rs # status tray + funds panel
│ ├── orderbook/ # MergedBook → table cells
│ │ ├── mod.rs
│ │ └── table.rs
│ ├── trade_panel/
│ │ ├── mod.rs
│ │ ├── actions.rs # bottom action row (`[m] markets`, etc.)
│ │ ├── layout.rs
│ │ ├── order_entry.rs
│ │ └── position.rs
│ └── modals/ # one file per modal
│ ├── mod.rs
│ ├── chrome.rs # rounded-border, title + footer hint helper
│ ├── config.rs # RPC URL / language / show_clob
│ ├── ledger.rs # last 50 actions
│ ├── liquidation_feed.rs # `[F]` modal
│ ├── market_selector.rs # `[m]` modal
│ ├── orders.rs # `[o]` modal
│ ├── position_leaderboard.rs# `[T]` modal (capital T)
│ ├── positions.rs # `[p]` modal
│ ├── quit.rs # `[q]` confirm
│ └── wallet_path.rs # `[w]` load-wallet modal
├── state/ # mutable runtime state containers
│ ├── mod.rs # re-exports + `make_status_timestamp`
│ ├── tui.rs # TuiState (the top-level state owned by event_loop)
│ ├── tui_tests.rs # path = "tui_tests.rs" test module for tui.rs
│ ├── book.rs # MergedBook, BookRow, RowSource, ClobLevel, L2BookStreamMsg
│ ├── liquidation_feed_view.rs
│ ├── markers.rs # TradeMarker, OrderChartMarker, LedgerEntry
│ ├── market.rs # MarketInfo, MarketSelector, MarketListUpdate, MarketStatUpdate
│ ├── orders_view.rs
│ ├── position_leaderboard_view.rs
│ ├── positions_view.rs
│ ├── trade_panel.rs # TradingState (in-flight inputs, wallet, status, ledger)
│ └── updates.rs # TxStatusMsg, BalanceUpdate, LiquidationEntry payload types
└── tx/ # transaction building + submission (write path)
├── mod.rs
├── compute_budget.rs # CU price/limit instruction builder
├── confirmation.rs # signatureSubscribe over the shared TxContext.sig_pubsub
├── context.rs # TxContext (RPCs, metadata, blockhash pool, sig pubsub)
├── error.rs # log-scraping for Phoenix-program errors → user strings
├── funds.rs # USDC deposit / withdraw
├── limit_order.rs
├── market_order.rs
├── stop_market_order.rs
├── cancel.rs # cancel-orders / cancel-stop-loss batch builder
└── positions.rs # close_all_positions (cross-market batch)
```
---
## Build / test commands
```bash
cargo build # debug
cargo build --release # size-tuned release (LTO=thin, opt=s, strip)
cargo build --profile release-size # smallest-binary profile (LTO=fat, opt=z)
cargo test # all tests
cargo test --workspace --locked # CI-equivalent
cargo test --lib format # tests under tui::format
cargo test balance_formatting # single test by name
cargo test -- --nocapture --test-threads=1 # see println; serial
cargo clippy --workspace --locked --all-targets -- -D warnings # CI clippy gate
cargo fmt --all --check # CI fmt gate
mise run release-check # fmt + clippy + test
docker compose build && docker compose run --rm cinder # containerised
```
The `[profile.release]` settings (`codegen-units=1`, `lto=thin`, `opt-level="s"`, `panic=abort`, `strip=true`) are size-focused. Don't change them as part of unrelated work.
---
## Environment variables
| Variable | Required | Notes |
| --- | --- | --- |
| `RPC_URL` / `SOLANA_RPC_URL` | Runtime | Solana JSON-RPC HTTP endpoint. User config `rpc_url` overrides both. |
| `RPC_WS_URL` / `SOLANA_WS_URL` | Optional | Derived from the HTTP URL when unset (`http_rpc_url_to_ws`); localhost `:8899` → `:8900`. |
| `PHX_WALLET_PATH` / `KEYPAIR_PATH` | Optional | Used as wallet candidate after `phoenix.json` and before `~/.config/solana/id.json`. |
| `RUST_LOG` | Optional | `tracing-subscriber` filter, e.g. `phoenix_sdk=warn,info`. |
| `USERPROFILE` / `HOME` | Optional | Used to expand `~` for config and wallet paths (Windows uses `USERPROFILE`). |
`tui::config::current_user_config()` is cached behind a `OnceLock<RwLock<UserConfig>>`; first access reads `~/.config/phoenix-cinder/config.json`, later writes through `save_user_config` go to disk **and** update the cache. Existing RPC clients keep their URL — only newly-built ones see the change.
---
## High-level architecture
```
┌──────────────────────────────────────────────┐
│ app::run │
│ ─────────────────────────────────────────── │
│ 1. setup_terminal + spawn_splash │
│ 2. PhoenixHttpClient::new_from_env() │
│ PhoenixClient::new_from_env() (WSS) │
│ 3. http.get_markets() → tradable filter │
│ 4. subscribe_market_stats(per symbol) │
│ → forwarder tasks → stat_rx (mpsc 128) │
│ 5. build MarketInfo + SplineConfig per sym │
│ 6. spawn_spline_poller (TUI runtime) │
│ 7. 60s poll loop discovers new markets, │
│ sends MarketListUpdate via market_tx │
│ 8. tokio::select on ctrl_c | tui_task │
│ 9. cleanup_terminal │
└──────────────────────────────────────────────┘
│
▼
┌───────────────────────────────────────────────────────────────────┐
│ tui::runtime::event_loop │
│ ───────────────────────────────────────────────────────────────── │
│ Owned by one tokio task (`spawn_spline_poller`): │
│ • TuiState + SplineConfig (current symbol) │
│ • watch::channel<SplineConfig> for L2 task │
│ • mpsc<L2BookStreamMsg> from L2 task │
│ • (Channels, Receivers) for tx status / balances / │
│ wallet WSS / tx_ctx / orders / top positions / liquidations │
│ │
│ Outer loop reconnects spline pubsub on WSS_RETRY_*. │
│ Inner `'sub` loop runs a tokio::select! biased on │
│ clock → keyboard → spline-stream → all other channels. │
│ │
│ Background tasks owned via JoinHandles inside the loop: │
│ • spawn_phoenix_l2_book_rpc (only when show_clob) │
│ • spawn_gti_loader (always) │
│ • spawn_liquidation_feed_task (always; survives reconnect)│
│ • spawn_balance_fetch (1.1 s tick, in-flight gate)│
│ • spawn_top_positions_refresh (5 s tick, in-flight gate) │
│ • spawn_wallet_*_subscribe (USDC ATA, native SOL) │
│ • spawn_trader_orders (Phoenix WS trader stream) │
│ • spawn_blockhash_refresh (warm pool inside TxContext)│
│ • spawn_tx_context (one-shot bootstrap) │
└───────────────────────────────────────────────────────────────────┘
│
▼
┌──────────────────────────────────────────────┐
│ tui::ui::render_frame │
│ ─────────────────────────────────────────── │
│ Pure: TuiState + SplineConfig → Frame paint │
│ Layout: orderbook | chart / trade panel | │
│ funds / status tray / modals │
└──────────────────────────────────────────────┘
```
### Concurrency model
- **One owning task** runs `spawn_spline_poller` and holds the entire `TuiState`. Mutable state is never shared via `Arc<Mutex<…>>`; it lives on the event loop and is mutated synchronously inside `tokio::select!` arms.
- **Background tasks** never touch `TuiState` directly. They send typed payloads through `tokio::sync::mpsc::Unbounded*` channels (or one bounded channel for stats). The event loop drains those receivers and applies updates via `update_handlers::*`.
- **Static work** (decoders, IX builders, formatters, math) is sync and free of `tokio` types.
- **`watch::channel<SplineConfig>`** drives the L2 book task without an extra mpsc — the task `borrow().clone()`s on every reconnect so a market switch propagates by replacing the watched value.
- **Backpressure**: the only bounded channel is `MarketStatUpdate` (cap 128). On full it drops via `try_send`. Every other channel is unbounded; producers are externally rate-limited (`L2_POLL_INTERVAL = 500 ms`, balances 1.1 s, top positions 5 s).
- **Reconnect backoff**: `WSS_RETRY_INIT = 2 s` doubling to `WSS_RETRY_CAP = 30 s`. The spline pubsub uses a flat 5 s sleep instead.
### Channel topology (`tui::runtime::channels`)
| Channel | Direction | Payload | Producer | Consumer |
| --- | --- | --- | --- | --- |
| `tx_status` | unbounded | `TxStatusMsg` | every `tx::*` submit flow | `update_handlers::handle_tx_status_update` |
| `balance_tx` | unbounded | `BalanceUpdate` | `tasks::balances` | `handle_balance_update` |
| `wallet_usdc_tx` | unbounded | `f64` | `tasks::wallet_stream` (USDC ATA WSS) | `handle_wallet_usdc_update` |
| `wallet_sol_tx` | unbounded | `f64` | `tasks::wallet_stream` (native SOL WSS) | `handle_wallet_sol_update` |
| `tx_ctx_tx` | unbounded | `(authority, symbol, Arc<TxContext>)` | `tasks::tx_context` (one-shot per connect) | `handle_tx_context_update` |
| `orders_tx` | unbounded | `Vec<OrderInfo>` | `tasks::orders` (Phoenix WS trader sub) | `handle_orders_update` |
| `top_positions_tx` | unbounded | `Vec<TopPositionEntry>` | `tasks::position_leaderboard` (5 s) | `handle_position_leaderboard_update` |
| `liquidation_tx` | unbounded | `LiquidationEntry` | `tasks::liquidations` (always-on) | `handle_liquidation_update` |
| `market_tx` (in `app.rs`) | bounded 16 | `MarketListUpdate` | 60 s market poll | `handle_market_list_update` |
| `stat_tx` (in `app.rs`) | bounded 128 | `MarketStatUpdate` | per-symbol Phoenix WS stats | `handle_stat_update` |
| `l2_book_tx` (in event loop) | unbounded | `L2BookStreamMsg` | `tasks::l2_book` | `connection::handle_l2_book_msg` |
`KeyAction` (the return type of every key handler) tells the event loop what to do next: `Nothing` / `Redraw` / `BreakInner` (e.g. market switch — re-subscribe spline) / `BreakOuter` (quit) / `ReconnectRpc` (rebuild every WSS) / `ToggleClob` (start or abort the L2 task).
### Redraw policy
- **Force redraw**: keypress, clock tick (every 1 s aligned to UTC second).
- **Coalesced redraw**: stream + stats updates respect `FEED_REDRAW_MIN_INTERVAL = 150 ms` (`last_feed_paint`). State always updates; only the actual `terminal.draw` is throttled.
- Bump the constant up if CPU is high; down for snappier visuals.
---
## On-chain data model
The crate sees Phoenix Eternal in three flavours, each with a distinct decoder:
1. **Spline collection account** (per market) — the per-market spline-bid/ask account. Decoded by `phoenix_rise::types::accounts::SplineCollection::try_from_account_bytes` in [src/tui/data/spline_book.rs](../src/tui/data/spline_book.rs). The decoder iterates `active_splines()` and emits `(trader_pda, price_start, price_end, density, filled, total_size)` rows. We wrap the call in `catch_unwind` to isolate panicky bytemuck mismatches.
2. **Phoenix CLOB market account** (the `Orderbook`) — decoded in `data::spline_book::parse_l2_book_from_market_account`. Yields `L2Level { trader_id: u32, price: f64, qty: f64 }`. `trader_id` is a sokoban pointer into the `GlobalTraderIndex`, **not** a wallet pubkey.
3. **GlobalTraderIndex (GTI) + Trader accounts** — `tui::data::trader_index` builds two maps in one refresh pass:
- `node_addr (u32) → wallet authority pubkey` — for CLOB rows.
- `trader_pda → wallet authority pubkey` — for spline rows (their `trader` is the PDA, not a node pointer).
The cache fetches the GTI arena 0 raw bytes (`UiAccountEncoding::Base64`), reads `Superblock.num_arenas` at offset `GTI_HEADER_SIZE + 4 = 52`, then walks the sokoban tree (`GlobalTraderIndexTree`). It batches `getMultipleAccounts` (`RPC_BATCH_SIZE = 100`) for each trader PDA and reads the `authority` field from `DynamicTraderHeader` at offset `56..88`. Misses trigger a refresh via the `gti_refresh` `Notify`, throttled to once per `REFRESH_MIN_INTERVAL = 10 s`. Resolved rows are dropped (not flashed as placeholders) until the cache catches up.
4. **ActiveTraderBuffer** — `data::position_leaderboard::fetch_top_positions` scans the per-market ATB to build the `[T]` modal's leaderboard. Indexed by `SplineConfig::asset_id`.
5. **Liquidation events** — `tasks::liquidations` `logsSubscribe`s on Phoenix's sole liquidator (currently `BP7sV1VFnbPMPyJX1tZNbXHbZkyLNFEaBWJhyMvkbxKz`), then for each tx `getTransaction`s and walks inner instructions through `phoenix_eternal_types::events::parse_events_from_inner_instructions_with_context`. `MarketEvent::Liquidation`s are converted to display units via the cached `SplineConfig` table. The task survives reconnects so the buffer is warm on first modal open. `SIGNATURE_DEDUP_CAP = 256`, `MAX_CONCURRENT_GET_TX = 8`, `GET_TX_TIMEOUT = 8 s`.
### Display math
`tui::math` is the canonical place for tick / lot conversions:
- `ticks_to_price(ticks, tick_size, base_lot_decimals)` = `ticks * tick_size * 10^bld / 10^QUOTE_LOT_DECIMALS` where `QUOTE_LOT_DECIMALS = 6`.
- `base_lots_to_units(lots, bld)` = `lots / 10^bld` (negative `bld` ⇒ each lot is many units).
- `ui_size_to_num_base_lots(size, bld)` returns `Result<u64, LotConversionError>` with explicit handling of NaN / non-positive / over-cap (`MAX_UI_ORDER_SIZE_UNITS = 1e9`) / below one lot / `> u64::MAX` after scaling.
- `phoenix_decimal_to_num_base_lots(value, value_decimals, bld)` converts an HTTP-API `Decimal` exactly using checked integer arithmetic. Used during close-position to round-trip the on-chain raw lots when available (`PositionInfo::position_size_raw`).
- `pct_change_24h(mark, prev)` returns 0 when `prev == 0` (avoids div-by-zero for new markets).
- `compute_price_decimals(tick_size, bld)` is shared by `app.rs` and `config.rs`. Pathological inputs (`tick_size=1, bld=18`) are clamped to 18 decimals max; `tick_size=0` falls back to 2.
`SplineConfig` carries `tick_size`, `base_lot_decimals`, `spline_collection`, `market_pubkey`, `symbol`, `asset_id`, `price_decimals`, `size_decimals`. The spline pubkey from the HTTP API is verified against `program_ids::get_spline_collection_address_default(&market_pk)` and the derived address wins on mismatch (with a warn log).
---
## Trading domain
```
TradingState (state/trade_panel.rs)
├── side: TradingSide ── Tab toggles
├── size_index, custom_size ── + / - / Up / Down or [s]+digits
├── order_kind: OrderKind ── [t] cycles Market → Limit → StopMarket
├── input_mode: InputMode ── selects which input/* handler runs
├── input_buffer / deposit_buffer / withdraw_buffer / wallet_path_buffer
├── keypair: Option<Arc<Keypair>> ── set on connect, cleared on disconnect
├── tx_context: Option<Arc<TxContext>> ── set when one-shot tx_ctx_tx fires
├── usdc_balance / phoenix_balance / sol_balance
├── position: Option<PositionInfo> ── active-symbol position only (PositionsView holds the rest)
├── status_timestamp / status_title / status_detail ── status tray rendering
├── ledger: VecDeque<LedgerEntry> ── newest-first, cap 50
└── config: UserConfig ── editable copy; saved via save_user_config
```
`TuiState` (in `state/tui.rs`) owns the rest: `price_history` (`MAX_PRICE_HISTORY = 150`), `merged_book`, `market_selector`, `positions_view`, `orders_view`, `top_positions_view`, `liquidation_feed_view`, plus chart-cache fields (`chart_data_cache`, `price_bounds_cache`, `chart_min`, `chart_max`).
- `push_price` keeps a running `chart_min`/`chart_max` so per-tick chart bound updates are O(1); a full rescan happens only when the popped sample was an extremum.
- `rebuild_merged_book` re-sorts both sides and computes `spread`; for `SOL` it floors at `MIN_SOL_SPREAD_USD = 0.01` so a near-zero spread does not flash.
- `begin_market_switch` / `complete_market_switch` keep stale chart/book visible until the first new-market WSS payload arrives, then flush. Chart markers are scrolled by `push_price` (subtract 1 from x each pop); trade markers are pruned when `x < 0`, **order chart markers are not** (the order is still live; the chart widget clips x out of range).
### Order kinds and confirmation
`OrderKind` is `Market | Limit { price } | StopMarket { trigger }` (one file per concept under `tui/trading/`). Submission paths:
| Action | Builder | File |
| --- | --- | --- |
| Market | `submit_market_order` | `tx/market_order.rs` |
| Limit | `submit_limit_order` | `tx/limit_order.rs` |
| Stop-market | `submit_stop_market_order` | `tx/stop_market_order.rs` |
| Cancel orders (batch incl. stops) | `submit_cancel_orders` | `tx/cancel.rs` |
| Close one / all positions | `submit_close_all_positions` | `tx/positions.rs` |
| Deposit / withdraw USDC | `submit_funds_transfer` | `tx/funds.rs` |
Stop-market direction mapping (matches Phoenix on-chain semantics):
```
TradingSide::Long → Direction::LessThan (long stops below)
TradingSide::Short → Direction::GreaterThan (short stops above)
```
Cancellation must mirror the same mapping or the IX won't match the resting order; see `submit::stop_direction_for`.
### Pending action flow
```
Normal mode ── KeyCode::Enter ──▶ InputMode::Confirming(PendingAction::PlaceOrder { ... })
└─ status tray shows the "Confirm … (Y/N)" prompt
Confirming ── 'y' / 'Y' ───────▶ submit::execute_confirmed_action
└─ Dispatches to tx::submit_* with TxContext, keypair, and
fresh `TxStatusMsg` updates streamed back through tx_status
── 'n' / 'N' / Esc ──▶ Reverts to InputMode::Normal with submit::cancel_message
```
Status messages are localized through `tui::i18n::strings()`. New status strings must be added to **both** `en.rs` and `zh.rs`.
### TxContext (per wallet × symbol)
[src/tui/tx/context.rs](../src/tui/tx/context.rs) holds the per-session state needed to send transactions:
- Primary `RpcClient` at the configured URL with `CommitmentConfig::processed()`.
- `secondary_send_rpc`: an extra `Arc<RpcClient>` pointed at `https://api.mainnet-beta.solana.com` purely for `send_transaction` fan-out, **only** when the primary isn't already that URL (so we don't double-send to the same host). Confirmation always listens on the primary.
- `phoenix_rise::PhoenixMetadata` (cached `getExchange` result) and `MarketAddrs` (orderbook, spline, perp_asset_map, GTI vec, ATB vec) for the active market.
- `authority_v2` and `trader_pda_v2` (derived via `TraderKey::derive_pda`); `trader_registered: AtomicBool` is set true once an account exists at the PDA.
- `blockhash_pool: Mutex<VecDeque<[u8; 32]>>` — capped at 30 entries, refreshed by a background task. `pop_blockhash` consumes from the back (newest = most validity); when empty, falls back to a 5 s-bounded HTTP fetch (`BLOCKHASH_FETCH_TIMEOUT`). Each blockhash is consumed exactly once.
- `sig_pubsub: Mutex<Option<Arc<PubsubClient>>>` — a single shared pubsub client used by every order's `signatureSubscribe`, so we don't open one WSS per tx.
`TxContext::new` is awaited inside `tasks::tx_context::spawn_tx_context`; the resulting `Arc<TxContext>` is shipped via `tx_ctx_tx` so the event loop can attach it to `TradingState.tx_context` only if the `(authority, symbol)` still matches (late completions for replaced wallets / old markets are dropped — see `update_handlers::handle_tx_context_update`).
---
## Rendering pipeline
Layout (`tui::ui::render_frame`):
```
┌──────────────────────────────────────────────────────────────────────┐
│ Top vertical block (orderbook_height rows) │
│ ┌─────────────────────── 65% ──────────────────┬──── 35% ─────────┐ │
│ │ Order book (bids+asks, header+separator+...) │ Price chart │ │
│ └──────────────────────────────────────────────┴───────────────────┘ │
├──────────────────────────────────────────────────────────────────────┤
│ Trading panel (height 6) │ Funds panel (height 6) │
├──────────────────────────────────────────────────────────────────────┤
│ Status tray (height 4, 2 body lines) │
├──────────────────────────────────────────────────────────────────────┤
│ Min(0) — unused; modals overlay the whole frame area │
└──────────────────────────────────────────────────────────────────────┘
```
Modals (mutually exclusive via `InputMode`): MarketSelector, Positions, TopPositions, Liquidations, Orders, Ledger, Config / EditingRpcUrl, EditingWalletPath, ConfirmQuit. The "switching to …" modal can overlay any input mode while `state.switching_to` is `Some`.
`user_trader_prefix` is the user wallet's first 4 base58 chars. The book table uses this (not price) to decide which CLOB rows get the `>` arrow — multiple traders can share a tick.
`MODAL_BORDER` and `MODAL_HIGHLIGHT_BG` are deliberate single sources of truth for modal chrome; reuse them in new modals.
---
## Hotkey reference (Normal mode)
| Key | Action | File |
| --- | --- | --- |
| `q` | Open Quit confirm | `runtime/input/normal.rs` |
| `Ctrl+C` | Hard exit (`KeyAction::BreakOuter`) | `runtime/input/normal.rs` |
| `m` | Open market selector | `runtime/input/market.rs` |
| `c` | Open config modal | `runtime/input/settings.rs` |
| `o` | Open orders modal | `runtime/input/views.rs` |
| `p` | Open positions modal | `runtime/input/views.rs` |
| `T` | Open top-positions modal (capital — lowercase `t` is taken) | `runtime/input/views.rs` |
| `F` | Open liquidation feed modal | `runtime/input/views.rs` |
| `L` | Open ledger modal | `runtime/input/views.rs` |
| `Tab` | Toggle Long / Short | `runtime/input/normal.rs` |
| `t` | Cycle Market → Limit → StopMarket (seeds price from mark on entry) | `runtime/input/normal.rs` |
| `e` | Edit limit/stop price (seeds Limit from mark if currently Market) | `runtime/input/amounts.rs` |
| `s` | Edit size | `runtime/input/amounts.rs` |
| `+` / `=` / `↑` | Step up size preset (clears `custom_size`) | `runtime/input/normal.rs` |
| `-` / `↓` | Step down size preset | `runtime/input/normal.rs` |
| `w` | Load wallet (or disconnect when loaded) | `runtime/input/normal.rs` + `runtime/wallet.rs` |
| `Enter` | Confirm an order | `runtime/input/normal.rs` |
| `x` | Close active-market position | `runtime/input/normal.rs` |
| `d` / `D` | Deposit / Withdraw USDC | `runtime/input/normal.rs` |
`ORDER_SIZE_PRESETS` in `tui::constants` holds 29 entries from `0.0001` to `100_000.0`; `DEFAULT_SIZE_INDEX = 12` (= `0.1`).
---
## Style and conventions
The crate mirrors the [phx](https://github.com/skynetcap/phx) SDK. Stick to these:
- **rustfmt**: stable settings only (`max_width = 100`, `edition = "2021"`, `use_field_init_shorthand = true`, `use_try_shorthand = true`). Local `cargo fmt --all --check` must match CI.
- **Module headers**: every file opens with a `//!` doc header describing its role. Keep these one or two short lines.
- **One concept per file**: enums, structs, free functions live next to their tests in focused modules under `tui/trading/`, `tui/state/`, `tui/tx/`, etc. Don't pile types into a shared `types.rs`.
- **Cargo manifest**: table-aligned `key = value` formatting in `[package]` and similar blocks.
- **Tests**: `#[cfg(test)] mod tests { … }` lives at the bottom of the same file (or `#[path = "x_tests.rs"]` for state/tui-style splits). Use `tokio::test` only when the function is genuinely async.
- **`tracing`**: prefer `warn!` for recoverable errors. Logs go through `tracing-subscriber` with `RUST_LOG`; never `println!` in runtime code.
- **i18n**: any new user-visible string must be a field on `Strings` and have entries in both `en.rs` and `zh.rs`. Format placeholders ("Switching to {}…") are concatenated at the call site, not via `format!` strings inside the table.
- **No global mutable state** other than the existing `OnceLock`s in `config.rs`. Pass things through `&mut TuiState` or `Channels`.
- **No new `Arc<Mutex<…>>` over UI data** — channels go through `tokio::sync::mpsc`, and the event loop is the single owner.
---
## Common pitfalls
- **Stat channel is bounded** (`MarketStatUpdate`, cap 128). Producers `try_send` and drop on full. Don't switch to `send().await` — Phoenix recv loops indefinitely and a slow consumer would back up the WS stream.
- **Spline pubkey in HTTP API can mismatch** the derived `program_ids::get_spline_collection_address_default(market_pk)`. Cinder always uses the derived address (with a warn log) — don't change this without coordinating with Phoenix.
- **CLOB rows reference traders by `u32` node pointer**, not by pubkey. Resolving them needs the GTI cache. Spline rows reference traders by **PDA** (different pointer space). Both are mapped to wallet authority through `GtiCache`, but via different fields (`authorities` vs `pda_to_authority`).
- **Stop-market direction mapping is asymmetric** (long ↔ LessThan, short ↔ GreaterThan). Mismatched direction at cancel time silently fails to find the resting order.
- **Wallet path detection** distinguishes paths from base58 keypair strings via `looks_like_filesystem_path`. Slashes, backslashes, leading `./` / `../`, leading `/`, or a Windows drive prefix (`C:\…` / `C:/…`) all force the path branch. Don't accept arbitrary strings as base58 if any path-like character appears.
- **Blockhashes are consumed once** — never put one back into the pool. The pool grows from the back (`push_blockhash`) and pops from the back (newest first) so each tx gets ~150 blocks of validity. An empty pool falls back to a 5 s HTTP fetch.
- **`accountSubscribe` on the spline / market account uses `CommitmentConfig::processed()`** for snappier UI; transaction confirmation uses `processed` for polling and progresses through the standard commitment escalation. Don't mix these up when adding new subscriptions.
- **Reconnect tears down**: a full RPC reconnect (user saved a new RPC URL) aborts the wallet WSS, blockhash refresh, tx-context, liquidation feed, and L2 task before rebuilding. See `connection::handle_full_rpc_reconnect`. Adding a new long-lived task means wiring it into both the spawn site and that teardown helper.
- **Liquidation feed is process-lifetime**. Toggling the modal does not stop the task — that's intentional so the buffer is warm on first open. Don't add a "stop on close" path.
- **Transmute-heavy interop**: `tui/mod.rs` allows `clippy::missing_transmute_annotations`, `clippy::too_many_arguments`, and `clippy::type_complexity`. These are deliberate concessions to the Solana SDK shape — don't try to "fix" them with annotations or refactors unrelated to your change.
---
## Editing this file
Keep this document tight and pointer-rich — it's read by future agents before they touch anything. Update it when you:
- Add or remove a top-level module / file under `src/tui/`.
- Change the channel topology (new producer / consumer, new payload).
- Change a pinned dependency version (especially the `=2.3.13` solana stack).
- Touch a tuning constant (`FEED_REDRAW_MIN_INTERVAL`, `L2_POLL_INTERVAL`, `WSS_RETRY_*`, `MAX_PRICE_HISTORY`, `TOP_N`, `LEDGER_CAPACITY`, `SIGNATURE_DEDUP_CAP`, `MAX_CONCURRENT_GET_TX`, `GET_TX_TIMEOUT`, `BLOCKHASH_FETCH_TIMEOUT`, `REFRESH_MIN_INTERVAL`, `RPC_BATCH_SIZE`).
- Add a new InputMode, KeyAction variant, or modal.
Out of scope for this file: per-task implementation detail (read the source), one-off bug fixes (commit message), and ephemeral release notes (CHANGELOG / git log).