cosmic-cinder 0.1.13

Rust terminal UI for Phoenix perpetuals on Solana
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
# 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).