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
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
//! Env-derived RPC URLs and HTTP-loaded spline parameters per market.

use std::env;
use std::str::FromStr;
use std::sync::{OnceLock, RwLock};

use phoenix_eternal_types::program_ids;
use phoenix_rise::types::MarketStatus;
use phoenix_rise::ExchangeMarketConfig;
use solana_keypair::Keypair;
use solana_pubkey::Pubkey as PhoenixPubkey;
use tracing::warn;

#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum Language {
    #[default]
    English,
    Chinese,
    Russian,
    Spanish,
}

impl Language {
    pub fn label(self) -> &'static str {
        match self {
            Self::English => "English",
            Self::Chinese => "中文",
            Self::Russian => "Русский",
            Self::Spanish => "Español",
        }
    }

    pub fn code(self) -> &'static str {
        match self {
            Self::English => "en",
            Self::Chinese => "cn",
            Self::Russian => "ru",
            Self::Spanish => "es",
        }
    }

    pub fn from_code(s: &str) -> Self {
        match s {
            "cn" | "zh" | "zh-CN" | "zh_CN" => Self::Chinese,
            "ru" | "ru-RU" | "ru_RU" => Self::Russian,
            "es" | "es-ES" | "es_ES" | "es-419" | "es_419" => Self::Spanish,
            _ => Self::English,
        }
    }

    pub fn toggle(self) -> Self {
        match self {
            Self::English => Self::Chinese,
            Self::Chinese => Self::Russian,
            Self::Russian => Self::Spanish,
            Self::Spanish => Self::English,
        }
    }
}

/// Built-in default for `SetComputeUnitPrice` (microlamports per CU). Used
/// when neither the user config nor the env var overrides it.
pub const DEFAULT_COMPUTE_UNIT_PRICE_MICRO_LAMPORTS: u64 = 111;

/// Built-in default for `SetComputeUnitLimit` per trader position touched by
/// a tx. Used when neither the user config nor the env var overrides it.
pub const DEFAULT_COMPUTE_UNIT_LIMIT_PER_POSITION: u32 = 200_000;

/// User-facing settings persisted to `~/.config/phoenix-cinder/config.json`.
/// Empty `rpc_url` = not overridden; fall back to env/default.
#[derive(Debug, Clone)]
pub struct UserConfig {
    pub rpc_url: String,
    pub language: Language,
    /// Whether to subscribe to and display CLOB L2 order data. Defaults to
    /// `true`. When `false`, no websocket is opened for the CLOB feed and
    /// the order book shows only spline rows.
    pub show_clob: bool,
    /// When `true` (default), every signed transaction is also fanned out to
    /// the public mainnet-beta RPC for delivery reliability — even if the
    /// primary RPC is a private/paid endpoint. The primary RPC remains
    /// authoritative for confirmation. Turn off if you want submissions to
    /// stay solely on your configured RPC.
    pub fanout_public_rpc: bool,
    /// Override for `SetComputeUnitPrice` (microlamports per CU). `None` =
    /// fall back to env / built-in default.
    pub compute_unit_price_micro_lamports: Option<u64>,
    /// Override for `SetComputeUnitLimit` per trader position touched by a tx
    /// (the multiplier applied to position counts in CU-scaled flows). `None`
    /// = fall back to env / built-in default.
    pub compute_unit_limit_per_position: Option<u32>,
    /// Last keypair file path successfully loaded via the wallet modal. Used
    /// to pre-populate the modal on next open. Empty = never connected from
    /// a file (fall back to the discovery candidates).
    pub wallet_path: String,
    /// When `true`, place-order submissions skip the `[Y/N]` confirmation
    /// prompt and execute as soon as Enter is pressed in the trade panel.
    /// Defaults to `false` (Y prompt required) for safety.
    pub skip_order_confirmation: bool,
    /// When `true`, every transaction is sent with `skip_preflight: true`,
    /// telling the RPC to broadcast without a local simulation pass. Faster on
    /// congested or slow RPCs, but loses the early "sim says this will fail"
    /// signal — bad transactions still land and burn fees. Defaults to
    /// `false`.
    pub skip_preflight: bool,
}

impl Default for UserConfig {
    fn default() -> Self {
        Self {
            rpc_url: String::new(),
            language: Language::default(),
            show_clob: true,
            fanout_public_rpc: default_fanout_public_rpc_from_env(),
            compute_unit_price_micro_lamports: None,
            compute_unit_limit_per_position: None,
            wallet_path: String::new(),
            skip_order_confirmation: default_skip_order_confirmation_from_env(),
            skip_preflight: default_skip_preflight_from_env(),
        }
    }
}

/// Reads `CINDER_COMPUTE_UNIT_PRICE` (microlamports per CU). Returns `None`
/// when the env var is unset, empty, or fails to parse as `u64`.
fn compute_unit_price_from_env() -> Option<u64> {
    env::var("CINDER_COMPUTE_UNIT_PRICE")
        .ok()
        .and_then(|s| s.trim().parse::<u64>().ok())
}

/// Reads `CINDER_COMPUTE_UNIT_LIMIT` (CUs per position). Returns `None` when
/// the env var is unset, empty, or fails to parse as `u32`.
fn compute_unit_limit_from_env() -> Option<u32> {
    env::var("CINDER_COMPUTE_UNIT_LIMIT")
        .ok()
        .and_then(|s| s.trim().parse::<u32>().ok())
}

/// Resolved `SetComputeUnitPrice` value:
/// user override → env override → auto-derived (p90 of recent network fees) →
/// built-in default. The auto value is `None` until the background refresh
/// task in `tui::tx::priority_fees` produces its first sample.
pub fn current_compute_unit_price_micro_lamports() -> u64 {
    current_user_config()
        .compute_unit_price_micro_lamports
        .or_else(compute_unit_price_from_env)
        .or_else(super::tx::current_auto_priority_fee)
        .unwrap_or(DEFAULT_COMPUTE_UNIT_PRICE_MICRO_LAMPORTS)
}

/// Resolved `SetComputeUnitLimit` per-position multiplier: user override →
/// env → built-in default.
pub fn current_compute_unit_limit_per_position() -> u32 {
    current_user_config()
        .compute_unit_limit_per_position
        .or_else(compute_unit_limit_from_env)
        .unwrap_or(DEFAULT_COMPUTE_UNIT_LIMIT_PER_POSITION)
}

/// Reads the env-var override for the public-RPC fan-out toggle.
/// `CINDER_FANOUT_PUBLIC_RPC=0|false|off|no` disables; anything else (or unset)
/// keeps the default `true`. The env var is only consulted as the seed for a
/// fresh `UserConfig::default()`; once the user toggles it in the config modal,
/// the persisted value wins.
fn default_fanout_public_rpc_from_env() -> bool {
    match env::var("CINDER_FANOUT_PUBLIC_RPC") {
        Ok(v) => !matches!(
            v.trim().to_ascii_lowercase().as_str(),
            "0" | "false" | "off" | "no"
        ),
        Err(_) => true,
    }
}

/// Reads the env-var override for the place-order confirmation bypass.
/// `CINDER_SKIP_ORDER_CONFIRMATION=1|true|on|yes` enables the bypass; anything
/// else (or unset) keeps the default `false` (Y prompt required). The env var
/// is only consulted as the seed for a fresh `UserConfig::default()`; once the
/// user toggles it in the config modal, the persisted value wins.
fn default_skip_order_confirmation_from_env() -> bool {
    match env::var("CINDER_SKIP_ORDER_CONFIRMATION") {
        Ok(v) => matches!(
            v.trim().to_ascii_lowercase().as_str(),
            "1" | "true" | "on" | "yes"
        ),
        Err(_) => false,
    }
}

/// Reads the env-var override for the RPC `skip_preflight` toggle.
/// `CINDER_SKIP_PREFLIGHT=1|true|on|yes` enables; anything else (or unset)
/// keeps the default `false`. The env var is only consulted as the seed for a
/// fresh `UserConfig::default()`; once the user toggles it in the config
/// modal, the persisted value wins.
fn default_skip_preflight_from_env() -> bool {
    match env::var("CINDER_SKIP_PREFLIGHT") {
        Ok(v) => matches!(
            v.trim().to_ascii_lowercase().as_str(),
            "1" | "true" | "on" | "yes"
        ),
        Err(_) => false,
    }
}

/// Candidate wallet paths in priority order: `phoenix.json` next to the
/// binary, then `PHX_WALLET_PATH` / `KEYPAIR_PATH`, then the standard
/// Solana CLI location. The first path that exists and decodes is used.
fn wallet_path_candidates() -> Vec<String> {
    let home = env::var("USERPROFILE").unwrap_or_else(|_| env::var("HOME").unwrap_or_default());
    let env_path = env::var("PHX_WALLET_PATH").or_else(|_| env::var("KEYPAIR_PATH"));
    let solana_default = std::path::PathBuf::from(home)
        .join(".config")
        .join("solana")
        .join("id.json")
        .to_string_lossy()
        .into_owned();
    [
        Some("phoenix.json".to_string()),
        env_path.ok(),
        Some(solana_default),
    ]
    .into_iter()
    .flatten()
    .collect()
}

/// Best-guess wallet path to seed the load-wallet modal: prefer the path the
/// user previously connected with (persisted in the user config), else the
/// first discovery candidate that already exists on disk; otherwise fall back
/// to the standard `~/.config/solana/id.json` location so the user can edit
/// from a sensible default.
pub fn default_wallet_path() -> String {
    let saved = current_user_config().wallet_path;
    if !saved.trim().is_empty() {
        return saved;
    }
    let candidates = wallet_path_candidates();
    for p in &candidates {
        if std::path::Path::new(p).exists() {
            return p.clone();
        }
    }
    candidates
        .into_iter()
        .last()
        .unwrap_or_else(|| "id.json".to_string())
}

/// True when `s` should be read as a filesystem path rather than parsed as
/// inline base58. Solana keypair base58 never contains `/` or `\`; without this,
/// a missing file (e.g. unset Docker volume) falls through to base58 decoding
/// and can panic on `/`.
fn looks_like_filesystem_path(s: &str) -> bool {
    if s.starts_with('/') {
        return true;
    }
    if s.starts_with("./") || s.starts_with("../") {
        return true;
    }
    if s.contains('/') {
        return true;
    }
    if s.contains('\\') {
        return true;
    }
    let mut chars = s.chars();
    let Some(letter) = chars.next() else {
        return false;
    };
    let Some(':') = chars.next() else {
        return false;
    };
    if !letter.is_ascii_alphabetic() {
        return false;
    }
    matches!(chars.next(), Some('/' | '\\'))
}

/// Resolves the "Load wallet" field: JSON `[…]` bytes, path to a keypair file
/// on disk, or a base58 secret when the string does not look like a path.
pub fn resolve_wallet_modal_input(input: &str) -> Result<Keypair, String> {
    let trimmed = input.trim();
    if trimmed.is_empty() {
        return Err("input is empty".to_string());
    }
    if trimmed.starts_with('[') {
        return parse_keypair_text(trimmed);
    }
    let path = std::path::Path::new(trimmed);
    if path.is_file() || looks_like_filesystem_path(trimmed) {
        return load_keypair_from_path(trimmed);
    }
    parse_keypair_text(trimmed)
}

/// Parses a `Keypair` from raw text — accepts either a JSON byte array
/// (`[1,2,3,…]`, the Solana CLI format) or a base58-encoded 64-byte
/// keypair string. The error string is suitable for the TUI modal.
pub fn parse_keypair_text(text: &str) -> Result<Keypair, String> {
    let trimmed = text.trim();
    if trimmed.is_empty() {
        return Err("input is empty".to_string());
    }
    if trimmed.starts_with('[') {
        let bytes: Vec<u8> =
            serde_json::from_str(trimmed).map_err(|_| "invalid JSON byte array".to_string())?;
        if bytes.len() < 32 {
            return Err(format!(
                "keypair too short ({} bytes; need 32)",
                bytes.len()
            ));
        }
        let mut secret_key = [0u8; 32];
        secret_key.copy_from_slice(&bytes[..32]);
        return Ok(Keypair::new_from_array(secret_key));
    }
    match std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
        Keypair::from_base58_string(trimmed)
    })) {
        Ok(kp) => Ok(kp),
        Err(_) => Err("invalid base58 keypair string".to_string()),
    }
}

/// Reads a Solana keypair from `path`. Accepts files containing either a
/// JSON byte array (Solana CLI format) or a base58-encoded keypair string.
pub fn load_keypair_from_path(path: &str) -> Result<Keypair, String> {
    let trimmed = path.trim();
    if trimmed.is_empty() {
        return Err("wallet path is empty".to_string());
    }
    let content = std::fs::read_to_string(trimmed).map_err(|e| match e.kind() {
        std::io::ErrorKind::NotFound => format!("file not found: {trimmed}"),
        std::io::ErrorKind::PermissionDenied => format!("permission denied: {trimmed}"),
        _ => format!("read error: {e}"),
    })?;
    parse_keypair_text(&content)
}

fn home_dir() -> String {
    env::var("USERPROFILE").unwrap_or_else(|_| env::var("HOME").unwrap_or_default())
}

fn config_dir_path() -> String {
    format!("{}/.config/phoenix-cinder", home_dir())
}

pub fn user_config_path() -> String {
    format!("{}/config.json", config_dir_path())
}

fn load_user_config_from_disk() -> UserConfig {
    let Ok(content) = std::fs::read_to_string(user_config_path()) else {
        return UserConfig::default();
    };
    let Ok(v) = serde_json::from_str::<serde_json::Value>(&content) else {
        return UserConfig::default();
    };
    UserConfig {
        rpc_url: v
            .get("rpc_url")
            .and_then(|x| x.as_str())
            .unwrap_or("")
            .to_string(),
        language: Language::from_code(v.get("language").and_then(|x| x.as_str()).unwrap_or("en")),
        show_clob: v.get("show_clob").and_then(|x| x.as_bool()).unwrap_or(true),
        fanout_public_rpc: v
            .get("fanout_public_rpc")
            .and_then(|x| x.as_bool())
            .unwrap_or_else(default_fanout_public_rpc_from_env),
        compute_unit_price_micro_lamports: v
            .get("compute_unit_price_micro_lamports")
            .and_then(|x| x.as_u64()),
        compute_unit_limit_per_position: v
            .get("compute_unit_limit_per_position")
            .and_then(|x| x.as_u64())
            .and_then(|n| u32::try_from(n).ok()),
        wallet_path: v
            .get("wallet_path")
            .and_then(|x| x.as_str())
            .unwrap_or("")
            .to_string(),
        skip_order_confirmation: v
            .get("skip_order_confirmation")
            .and_then(|x| x.as_bool())
            .unwrap_or_else(default_skip_order_confirmation_from_env),
        skip_preflight: v
            .get("skip_preflight")
            .and_then(|x| x.as_bool())
            .unwrap_or_else(default_skip_preflight_from_env),
    }
}

fn user_config_cache() -> &'static RwLock<UserConfig> {
    static CACHE: OnceLock<RwLock<UserConfig>> = OnceLock::new();
    CACHE.get_or_init(|| RwLock::new(load_user_config_from_disk()))
}

/// Current user config (from in-memory cache; first call reads from disk).
pub fn current_user_config() -> UserConfig {
    user_config_cache()
        .read()
        .map(|g| g.clone())
        .unwrap_or_default()
}

/// Persist `cfg` to disk and update the in-memory cache so subsequent
/// `rpc_http_url_from_env` / `current_user_config` calls return the new values.
///
/// Note: clients/streams established with a prior RPC URL keep that URL —
/// changes fully take effect after a restart.
pub fn save_user_config(cfg: &UserConfig) -> std::io::Result<()> {
    std::fs::create_dir_all(config_dir_path())?;
    let value = serde_json::json!({
        "rpc_url": cfg.rpc_url,
        "language": cfg.language.code(),
        "show_clob": cfg.show_clob,
        "fanout_public_rpc": cfg.fanout_public_rpc,
        "compute_unit_price_micro_lamports": cfg.compute_unit_price_micro_lamports,
        "compute_unit_limit_per_position": cfg.compute_unit_limit_per_position,
        "wallet_path": cfg.wallet_path,
        "skip_order_confirmation": cfg.skip_order_confirmation,
        "skip_preflight": cfg.skip_preflight,
    });
    let content = serde_json::to_string_pretty(&value).map_err(std::io::Error::other)?;
    std::fs::write(user_config_path(), content)?;
    if let Ok(mut w) = user_config_cache().write() {
        *w = cfg.clone();
    }
    Ok(())
}

/// Holds configuration and derivation parameters for a specific market's spline
/// chart.
#[derive(Debug, Clone)]
pub struct SplineConfig {
    pub tick_size: u64,
    pub base_lot_decimals: i8,
    pub spline_collection: String,
    pub market_pubkey: String,
    pub symbol: String,
    pub max_leverage: f64,
    pub isolated_only: bool,
    /// Global asset index used as the key in on-chain per-market tables
    /// (e.g. ActiveTraderBuffer position ids). Needed to map an arbitrary
    /// on-chain position back to its market symbol.
    pub asset_id: u32,
    /// Number of decimal places to display for prices, derived from tick_size.
    pub price_decimals: usize,
    /// Number of decimal places for base-asset quantities, derived from
    /// base_lot_decimals.
    pub size_decimals: usize,
}

/// Reads the main HTTP RPC URL, preferring the user config file, then `RPC_URL`
/// / `SOLANA_RPC_URL` env vars, falling back to Mainnet Beta.
pub fn rpc_http_url_from_env() -> String {
    let cfg = current_user_config();
    if !cfg.rpc_url.trim().is_empty() {
        return cfg.rpc_url;
    }
    env::var("RPC_URL")
        .or_else(|_| env::var("SOLANA_RPC_URL"))
        .unwrap_or_else(|_| {
            warn!(
                "Using default mainnet-beta RPC_URL (https://api.mainnet-beta.solana.com) because \
                 neither RPC_URL nor SOLANA_RPC_URL is set."
            );
            "https://api.mainnet-beta.solana.com".to_string()
        })
}

/// Reads the main WebSocket JSON-RPC URL from the environment, or derives it
/// from the HTTP URL.
pub fn ws_url_from_env() -> String {
    env::var("RPC_WS_URL")
        .or_else(|_| env::var("SOLANA_WS_URL"))
        .unwrap_or_else(|_| http_rpc_url_to_ws(&rpc_http_url_from_env()))
}

/// Converts an HTTP(S) Solana RPC endpoint string to its equivalent WSS
/// endpoint.
pub fn http_rpc_url_to_ws(http: &str) -> String {
    let http = http.trim();
    if http.starts_with("wss://") || http.starts_with("ws://") {
        return http.to_string();
    }
    if let Some(rest) = http.strip_prefix("https://") {
        return format!("wss://{rest}");
    }
    if let Some(rest) = http.strip_prefix("http://") {
        if rest == "127.0.0.1:8899" {
            return "ws://127.0.0.1:8900".to_string();
        }
        if rest == "localhost:8899" {
            return "ws://localhost:8900".to_string();
        }
        return format!("ws://{rest}");
    }
    format!("wss://{http}")
}

/// Derive the number of decimal places to display for prices from tick_size and
/// base_lot_decimals.
pub fn compute_price_decimals(tick_size: u64, base_lots_decimals: i8) -> usize {
    let min_price_step = tick_size as f64 * 10_f64.powi(base_lots_decimals as i32) / 10_f64.powi(6); // QUOTE_LOT_DECIMALS = 6
    if min_price_step > 0.0 {
        let raw = (-min_price_step.log10()).ceil().max(0.0);
        // Clamp to a displayable range; pathological tick sizes (e.g. tick_size=1,
        // bld=18) would otherwise produce astronomically large values or
        // NaN-cast-to-usize.
        (raw as usize).min(18)
    } else {
        2
    }
}

/// Build a SplineConfig from an already-fetched ExchangeMarketConfig (no
/// network call).
pub fn build_spline_config(
    market: &ExchangeMarketConfig,
) -> Result<SplineConfig, Box<dyn std::error::Error>> {
    if !matches!(
        market.market_status,
        MarketStatus::Active | MarketStatus::PostOnly
    ) {
        warn!(
            market_status = ?market.market_status,
            symbol = %market.symbol,
            "market status is not Active/PostOnly; continuing with spline indexing"
        );
    }

    let market_pk = PhoenixPubkey::from_str(&market.market_pubkey)?;
    let api_spline_pk = PhoenixPubkey::from_str(&market.spline_pubkey)?;
    let (derived_spline_pk, _) = program_ids::get_spline_collection_address_default(&market_pk);
    if api_spline_pk != derived_spline_pk {
        warn!(
            api = %api_spline_pk,
            derived = %derived_spline_pk,
            symbol = %market.symbol,
            "spline pubkey mismatch; using derived address"
        );
    }

    let price_decimals = compute_price_decimals(market.tick_size, market.base_lots_decimals);
    let max_leverage = market
        .leverage_tiers
        .first()
        .map(|tier| tier.max_leverage)
        .unwrap_or(1.0);

    // base_lot_decimals encodes the exponent in base_lots_to_units:
    //   units = lots / 10^bld
    // A positive bld means fractional units (e.g. bld=2 → 0.01 minimum).
    // A negative bld means lots are > 1 unit each, so 0 decimals suffice.
    let size_decimals = market.base_lots_decimals.max(0) as usize;

    Ok(SplineConfig {
        tick_size: market.tick_size,
        base_lot_decimals: market.base_lots_decimals,
        spline_collection: derived_spline_pk.to_string(),
        market_pubkey: market.market_pubkey.clone(),
        symbol: market.symbol.clone(),
        max_leverage,
        isolated_only: market.isolated_only,
        asset_id: market.asset_id,
        price_decimals,
        size_decimals,
    })
}

#[cfg(test)]
mod tests {
    use solana_signer::Signer;

    use super::*;

    #[test]
    fn resolve_wallet_modal_input_treats_unix_paths_as_paths_not_base58() {
        let err = resolve_wallet_modal_input("/home/nonroot/.config/solana/id.json").unwrap_err();
        assert!(
            err.contains("not found") || err.contains("file not found"),
            "unexpected err: {err}"
        );
    }

    #[test]
    fn resolve_wallet_modal_input_reads_json_keypair_file() {
        let dir = std::env::temp_dir();
        let path = dir.join("cinder-test-keypair.json");
        let kp = Keypair::new();
        let bytes = kp.to_bytes();
        let json: Vec<u8> = bytes.to_vec();
        std::fs::write(&path, serde_json::to_string(&json).unwrap()).unwrap();
        let loaded = resolve_wallet_modal_input(path.to_str().unwrap()).unwrap();
        assert_eq!(loaded.pubkey(), kp.pubkey());
        let _ = std::fs::remove_file(&path);
    }

    #[test]
    fn language_round_trips_through_code() {
        for lang in [
            Language::English,
            Language::Chinese,
            Language::Russian,
            Language::Spanish,
        ] {
            assert_eq!(Language::from_code(lang.code()), lang);
        }
    }

    #[test]
    fn language_from_code_accepts_zh_aliases() {
        assert_eq!(Language::from_code("zh"), Language::Chinese);
        assert_eq!(Language::from_code("zh-CN"), Language::Chinese);
        assert_eq!(Language::from_code("zh_CN"), Language::Chinese);
    }

    #[test]
    fn language_from_code_accepts_ru_aliases() {
        assert_eq!(Language::from_code("ru"), Language::Russian);
        assert_eq!(Language::from_code("ru-RU"), Language::Russian);
        assert_eq!(Language::from_code("ru_RU"), Language::Russian);
    }

    #[test]
    fn language_from_code_accepts_es_aliases() {
        assert_eq!(Language::from_code("es"), Language::Spanish);
        assert_eq!(Language::from_code("es-ES"), Language::Spanish);
        assert_eq!(Language::from_code("es_ES"), Language::Spanish);
        assert_eq!(Language::from_code("es-419"), Language::Spanish);
        assert_eq!(Language::from_code("es_419"), Language::Spanish);
    }

    #[test]
    fn language_from_code_falls_back_to_english() {
        assert_eq!(Language::from_code("fr"), Language::English);
        assert_eq!(Language::from_code(""), Language::English);
    }

    #[test]
    fn language_toggle_cycles() {
        assert_eq!(Language::English.toggle(), Language::Chinese);
        assert_eq!(Language::Chinese.toggle(), Language::Russian);
        assert_eq!(Language::Russian.toggle(), Language::Spanish);
        assert_eq!(Language::Spanish.toggle(), Language::English);
        assert_eq!(
            Language::English.toggle().toggle().toggle().toggle(),
            Language::English
        );
    }

    #[test]
    fn http_to_ws_promotes_https_scheme() {
        assert_eq!(
            http_rpc_url_to_ws("https://api.mainnet-beta.solana.com"),
            "wss://api.mainnet-beta.solana.com"
        );
    }

    #[test]
    fn http_to_ws_demotes_http_scheme() {
        assert_eq!(
            http_rpc_url_to_ws("http://example.com:8899"),
            "ws://example.com:8899"
        );
    }

    #[test]
    fn http_to_ws_remaps_localhost_default_ports() {
        assert_eq!(
            http_rpc_url_to_ws("http://127.0.0.1:8899"),
            "ws://127.0.0.1:8900"
        );
        assert_eq!(
            http_rpc_url_to_ws("http://localhost:8899"),
            "ws://localhost:8900"
        );
    }

    #[test]
    fn http_to_ws_defaults_schemeless_input_to_wss() {
        assert_eq!(
            http_rpc_url_to_ws("api.example.com"),
            "wss://api.example.com"
        );
    }

    #[test]
    fn compute_price_decimals_clamps_pathological_inputs() {
        // Astronomically tiny min step would otherwise produce an unbounded value.
        assert!(compute_price_decimals(1, 18) <= 18);
    }

    #[test]
    fn compute_price_decimals_returns_zero_when_step_is_invalid() {
        assert_eq!(compute_price_decimals(0, 0), 2);
    }

    #[test]
    fn compute_price_decimals_matches_known_step_sizes() {
        // tick_size=1, base_lot_decimals=0 → step = 1e-6 → 6 decimals.
        assert_eq!(compute_price_decimals(1, 0), 6);
        // tick_size=100, base_lot_decimals=0 → step = 1e-4 → 4 decimals.
        assert_eq!(compute_price_decimals(100, 0), 4);
    }
}