Skip to main content

cinder/tui/
config.rs

1//! Env-derived RPC URLs and HTTP-loaded spline parameters per market.
2
3use std::env;
4use std::str::FromStr;
5use std::sync::{OnceLock, RwLock};
6
7use phoenix_eternal_types::program_ids;
8use phoenix_rise::types::MarketStatus;
9use phoenix_rise::ExchangeMarketConfig;
10use solana_keypair::Keypair;
11use solana_pubkey::Pubkey as PhoenixPubkey;
12use tracing::warn;
13
14#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
15pub enum Language {
16    #[default]
17    English,
18    Chinese,
19    Russian,
20    Spanish,
21}
22
23impl Language {
24    pub fn label(self) -> &'static str {
25        match self {
26            Self::English => "English",
27            Self::Chinese => "中文",
28            Self::Russian => "Русский",
29            Self::Spanish => "Español",
30        }
31    }
32
33    pub fn code(self) -> &'static str {
34        match self {
35            Self::English => "en",
36            Self::Chinese => "cn",
37            Self::Russian => "ru",
38            Self::Spanish => "es",
39        }
40    }
41
42    pub fn from_code(s: &str) -> Self {
43        match s {
44            "cn" | "zh" | "zh-CN" | "zh_CN" => Self::Chinese,
45            "ru" | "ru-RU" | "ru_RU" => Self::Russian,
46            "es" | "es-ES" | "es_ES" | "es-419" | "es_419" => Self::Spanish,
47            _ => Self::English,
48        }
49    }
50
51    pub fn toggle(self) -> Self {
52        match self {
53            Self::English => Self::Chinese,
54            Self::Chinese => Self::Russian,
55            Self::Russian => Self::Spanish,
56            Self::Spanish => Self::English,
57        }
58    }
59}
60
61/// Built-in default for `SetComputeUnitPrice` (microlamports per CU). Used
62/// when neither the user config nor the env var overrides it.
63pub const DEFAULT_COMPUTE_UNIT_PRICE_MICRO_LAMPORTS: u64 = 111;
64
65/// Built-in default for `SetComputeUnitLimit` per trader position touched by
66/// a tx. Used when neither the user config nor the env var overrides it.
67pub const DEFAULT_COMPUTE_UNIT_LIMIT_PER_POSITION: u32 = 200_000;
68
69/// User-facing settings persisted to `~/.config/phoenix-cinder/config.json`.
70/// Empty `rpc_url` = not overridden; fall back to env/default.
71#[derive(Debug, Clone)]
72pub struct UserConfig {
73    pub rpc_url: String,
74    pub language: Language,
75    /// Whether to subscribe to and display CLOB L2 order data. Defaults to
76    /// `true`. When `false`, no websocket is opened for the CLOB feed and
77    /// the order book shows only spline rows.
78    pub show_clob: bool,
79    /// When `true` (default), every signed transaction is also fanned out to
80    /// the public mainnet-beta RPC for delivery reliability — even if the
81    /// primary RPC is a private/paid endpoint. The primary RPC remains
82    /// authoritative for confirmation. Turn off if you want submissions to
83    /// stay solely on your configured RPC.
84    pub fanout_public_rpc: bool,
85    /// Override for `SetComputeUnitPrice` (microlamports per CU). `None` =
86    /// fall back to env / built-in default.
87    pub compute_unit_price_micro_lamports: Option<u64>,
88    /// Override for `SetComputeUnitLimit` per trader position touched by a tx
89    /// (the multiplier applied to position counts in CU-scaled flows). `None`
90    /// = fall back to env / built-in default.
91    pub compute_unit_limit_per_position: Option<u32>,
92    /// Last keypair file path successfully loaded via the wallet modal. Used
93    /// to pre-populate the modal on next open. Empty = never connected from
94    /// a file (fall back to the discovery candidates).
95    pub wallet_path: String,
96    /// When `true`, place-order submissions skip the `[Y/N]` confirmation
97    /// prompt and execute as soon as Enter is pressed in the trade panel.
98    /// Defaults to `false` (Y prompt required) for safety.
99    pub skip_order_confirmation: bool,
100    /// When `true`, every transaction is sent with `skip_preflight: true`,
101    /// telling the RPC to broadcast without a local simulation pass. Faster on
102    /// congested or slow RPCs, but loses the early "sim says this will fail"
103    /// signal — bad transactions still land and burn fees. Defaults to
104    /// `false`.
105    pub skip_preflight: bool,
106}
107
108impl Default for UserConfig {
109    fn default() -> Self {
110        Self {
111            rpc_url: String::new(),
112            language: Language::default(),
113            show_clob: true,
114            fanout_public_rpc: default_fanout_public_rpc_from_env(),
115            compute_unit_price_micro_lamports: None,
116            compute_unit_limit_per_position: None,
117            wallet_path: String::new(),
118            skip_order_confirmation: default_skip_order_confirmation_from_env(),
119            skip_preflight: default_skip_preflight_from_env(),
120        }
121    }
122}
123
124/// Reads `CINDER_COMPUTE_UNIT_PRICE` (microlamports per CU). Returns `None`
125/// when the env var is unset, empty, or fails to parse as `u64`.
126fn compute_unit_price_from_env() -> Option<u64> {
127    env::var("CINDER_COMPUTE_UNIT_PRICE")
128        .ok()
129        .and_then(|s| s.trim().parse::<u64>().ok())
130}
131
132/// Reads `CINDER_COMPUTE_UNIT_LIMIT` (CUs per position). Returns `None` when
133/// the env var is unset, empty, or fails to parse as `u32`.
134fn compute_unit_limit_from_env() -> Option<u32> {
135    env::var("CINDER_COMPUTE_UNIT_LIMIT")
136        .ok()
137        .and_then(|s| s.trim().parse::<u32>().ok())
138}
139
140/// Resolved `SetComputeUnitPrice` value:
141/// user override → env override → auto-derived (p90 of recent network fees) →
142/// built-in default. The auto value is `None` until the background refresh
143/// task in `tui::tx::priority_fees` produces its first sample.
144pub fn current_compute_unit_price_micro_lamports() -> u64 {
145    current_user_config()
146        .compute_unit_price_micro_lamports
147        .or_else(compute_unit_price_from_env)
148        .or_else(super::tx::current_auto_priority_fee)
149        .unwrap_or(DEFAULT_COMPUTE_UNIT_PRICE_MICRO_LAMPORTS)
150}
151
152/// Resolved `SetComputeUnitLimit` per-position multiplier: user override →
153/// env → built-in default.
154pub fn current_compute_unit_limit_per_position() -> u32 {
155    current_user_config()
156        .compute_unit_limit_per_position
157        .or_else(compute_unit_limit_from_env)
158        .unwrap_or(DEFAULT_COMPUTE_UNIT_LIMIT_PER_POSITION)
159}
160
161/// Reads the env-var override for the public-RPC fan-out toggle.
162/// `CINDER_FANOUT_PUBLIC_RPC=0|false|off|no` disables; anything else (or unset)
163/// keeps the default `true`. The env var is only consulted as the seed for a
164/// fresh `UserConfig::default()`; once the user toggles it in the config modal,
165/// the persisted value wins.
166fn default_fanout_public_rpc_from_env() -> bool {
167    match env::var("CINDER_FANOUT_PUBLIC_RPC") {
168        Ok(v) => !matches!(
169            v.trim().to_ascii_lowercase().as_str(),
170            "0" | "false" | "off" | "no"
171        ),
172        Err(_) => true,
173    }
174}
175
176/// Reads the env-var override for the place-order confirmation bypass.
177/// `CINDER_SKIP_ORDER_CONFIRMATION=1|true|on|yes` enables the bypass; anything
178/// else (or unset) keeps the default `false` (Y prompt required). The env var
179/// is only consulted as the seed for a fresh `UserConfig::default()`; once the
180/// user toggles it in the config modal, the persisted value wins.
181fn default_skip_order_confirmation_from_env() -> bool {
182    match env::var("CINDER_SKIP_ORDER_CONFIRMATION") {
183        Ok(v) => matches!(
184            v.trim().to_ascii_lowercase().as_str(),
185            "1" | "true" | "on" | "yes"
186        ),
187        Err(_) => false,
188    }
189}
190
191/// Reads the env-var override for the RPC `skip_preflight` toggle.
192/// `CINDER_SKIP_PREFLIGHT=1|true|on|yes` enables; anything else (or unset)
193/// keeps the default `false`. The env var is only consulted as the seed for a
194/// fresh `UserConfig::default()`; once the user toggles it in the config
195/// modal, the persisted value wins.
196fn default_skip_preflight_from_env() -> bool {
197    match env::var("CINDER_SKIP_PREFLIGHT") {
198        Ok(v) => matches!(
199            v.trim().to_ascii_lowercase().as_str(),
200            "1" | "true" | "on" | "yes"
201        ),
202        Err(_) => false,
203    }
204}
205
206/// Candidate wallet paths in priority order: `phoenix.json` next to the
207/// binary, then `PHX_WALLET_PATH` / `KEYPAIR_PATH`, then the standard
208/// Solana CLI location. The first path that exists and decodes is used.
209fn wallet_path_candidates() -> Vec<String> {
210    let home = env::var("USERPROFILE").unwrap_or_else(|_| env::var("HOME").unwrap_or_default());
211    let env_path = env::var("PHX_WALLET_PATH").or_else(|_| env::var("KEYPAIR_PATH"));
212    let solana_default = std::path::PathBuf::from(home)
213        .join(".config")
214        .join("solana")
215        .join("id.json")
216        .to_string_lossy()
217        .into_owned();
218    [
219        Some("phoenix.json".to_string()),
220        env_path.ok(),
221        Some(solana_default),
222    ]
223    .into_iter()
224    .flatten()
225    .collect()
226}
227
228/// Best-guess wallet path to seed the load-wallet modal: prefer the path the
229/// user previously connected with (persisted in the user config), else the
230/// first discovery candidate that already exists on disk; otherwise fall back
231/// to the standard `~/.config/solana/id.json` location so the user can edit
232/// from a sensible default.
233pub fn default_wallet_path() -> String {
234    let saved = current_user_config().wallet_path;
235    if !saved.trim().is_empty() {
236        return saved;
237    }
238    let candidates = wallet_path_candidates();
239    for p in &candidates {
240        if std::path::Path::new(p).exists() {
241            return p.clone();
242        }
243    }
244    candidates
245        .into_iter()
246        .last()
247        .unwrap_or_else(|| "id.json".to_string())
248}
249
250/// True when `s` should be read as a filesystem path rather than parsed as
251/// inline base58. Solana keypair base58 never contains `/` or `\`; without this,
252/// a missing file (e.g. unset Docker volume) falls through to base58 decoding
253/// and can panic on `/`.
254fn looks_like_filesystem_path(s: &str) -> bool {
255    if s.starts_with('/') {
256        return true;
257    }
258    if s.starts_with("./") || s.starts_with("../") {
259        return true;
260    }
261    if s.contains('/') {
262        return true;
263    }
264    if s.contains('\\') {
265        return true;
266    }
267    let mut chars = s.chars();
268    let Some(letter) = chars.next() else {
269        return false;
270    };
271    let Some(':') = chars.next() else {
272        return false;
273    };
274    if !letter.is_ascii_alphabetic() {
275        return false;
276    }
277    matches!(chars.next(), Some('/' | '\\'))
278}
279
280/// Resolves the "Load wallet" field: JSON `[…]` bytes, path to a keypair file
281/// on disk, or a base58 secret when the string does not look like a path.
282pub fn resolve_wallet_modal_input(input: &str) -> Result<Keypair, String> {
283    let trimmed = input.trim();
284    if trimmed.is_empty() {
285        return Err("input is empty".to_string());
286    }
287    if trimmed.starts_with('[') {
288        return parse_keypair_text(trimmed);
289    }
290    let path = std::path::Path::new(trimmed);
291    if path.is_file() || looks_like_filesystem_path(trimmed) {
292        return load_keypair_from_path(trimmed);
293    }
294    parse_keypair_text(trimmed)
295}
296
297/// Parses a `Keypair` from raw text — accepts either a JSON byte array
298/// (`[1,2,3,…]`, the Solana CLI format) or a base58-encoded 64-byte
299/// keypair string. The error string is suitable for the TUI modal.
300pub fn parse_keypair_text(text: &str) -> Result<Keypair, String> {
301    let trimmed = text.trim();
302    if trimmed.is_empty() {
303        return Err("input is empty".to_string());
304    }
305    if trimmed.starts_with('[') {
306        let bytes: Vec<u8> =
307            serde_json::from_str(trimmed).map_err(|_| "invalid JSON byte array".to_string())?;
308        if bytes.len() < 32 {
309            return Err(format!(
310                "keypair too short ({} bytes; need 32)",
311                bytes.len()
312            ));
313        }
314        let mut secret_key = [0u8; 32];
315        secret_key.copy_from_slice(&bytes[..32]);
316        return Ok(Keypair::new_from_array(secret_key));
317    }
318    match std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
319        Keypair::from_base58_string(trimmed)
320    })) {
321        Ok(kp) => Ok(kp),
322        Err(_) => Err("invalid base58 keypair string".to_string()),
323    }
324}
325
326/// Reads a Solana keypair from `path`. Accepts files containing either a
327/// JSON byte array (Solana CLI format) or a base58-encoded keypair string.
328pub fn load_keypair_from_path(path: &str) -> Result<Keypair, String> {
329    let trimmed = path.trim();
330    if trimmed.is_empty() {
331        return Err("wallet path is empty".to_string());
332    }
333    let content = std::fs::read_to_string(trimmed).map_err(|e| match e.kind() {
334        std::io::ErrorKind::NotFound => format!("file not found: {trimmed}"),
335        std::io::ErrorKind::PermissionDenied => format!("permission denied: {trimmed}"),
336        _ => format!("read error: {e}"),
337    })?;
338    parse_keypair_text(&content)
339}
340
341fn home_dir() -> String {
342    env::var("USERPROFILE").unwrap_or_else(|_| env::var("HOME").unwrap_or_default())
343}
344
345fn config_dir_path() -> String {
346    format!("{}/.config/phoenix-cinder", home_dir())
347}
348
349pub fn user_config_path() -> String {
350    format!("{}/config.json", config_dir_path())
351}
352
353fn load_user_config_from_disk() -> UserConfig {
354    let Ok(content) = std::fs::read_to_string(user_config_path()) else {
355        return UserConfig::default();
356    };
357    let Ok(v) = serde_json::from_str::<serde_json::Value>(&content) else {
358        return UserConfig::default();
359    };
360    UserConfig {
361        rpc_url: v
362            .get("rpc_url")
363            .and_then(|x| x.as_str())
364            .unwrap_or("")
365            .to_string(),
366        language: Language::from_code(v.get("language").and_then(|x| x.as_str()).unwrap_or("en")),
367        show_clob: v.get("show_clob").and_then(|x| x.as_bool()).unwrap_or(true),
368        fanout_public_rpc: v
369            .get("fanout_public_rpc")
370            .and_then(|x| x.as_bool())
371            .unwrap_or_else(default_fanout_public_rpc_from_env),
372        compute_unit_price_micro_lamports: v
373            .get("compute_unit_price_micro_lamports")
374            .and_then(|x| x.as_u64()),
375        compute_unit_limit_per_position: v
376            .get("compute_unit_limit_per_position")
377            .and_then(|x| x.as_u64())
378            .and_then(|n| u32::try_from(n).ok()),
379        wallet_path: v
380            .get("wallet_path")
381            .and_then(|x| x.as_str())
382            .unwrap_or("")
383            .to_string(),
384        skip_order_confirmation: v
385            .get("skip_order_confirmation")
386            .and_then(|x| x.as_bool())
387            .unwrap_or_else(default_skip_order_confirmation_from_env),
388        skip_preflight: v
389            .get("skip_preflight")
390            .and_then(|x| x.as_bool())
391            .unwrap_or_else(default_skip_preflight_from_env),
392    }
393}
394
395fn user_config_cache() -> &'static RwLock<UserConfig> {
396    static CACHE: OnceLock<RwLock<UserConfig>> = OnceLock::new();
397    CACHE.get_or_init(|| RwLock::new(load_user_config_from_disk()))
398}
399
400/// Current user config (from in-memory cache; first call reads from disk).
401pub fn current_user_config() -> UserConfig {
402    user_config_cache()
403        .read()
404        .map(|g| g.clone())
405        .unwrap_or_default()
406}
407
408/// Persist `cfg` to disk and update the in-memory cache so subsequent
409/// `rpc_http_url_from_env` / `current_user_config` calls return the new values.
410///
411/// Note: clients/streams established with a prior RPC URL keep that URL —
412/// changes fully take effect after a restart.
413pub fn save_user_config(cfg: &UserConfig) -> std::io::Result<()> {
414    std::fs::create_dir_all(config_dir_path())?;
415    let value = serde_json::json!({
416        "rpc_url": cfg.rpc_url,
417        "language": cfg.language.code(),
418        "show_clob": cfg.show_clob,
419        "fanout_public_rpc": cfg.fanout_public_rpc,
420        "compute_unit_price_micro_lamports": cfg.compute_unit_price_micro_lamports,
421        "compute_unit_limit_per_position": cfg.compute_unit_limit_per_position,
422        "wallet_path": cfg.wallet_path,
423        "skip_order_confirmation": cfg.skip_order_confirmation,
424        "skip_preflight": cfg.skip_preflight,
425    });
426    let content = serde_json::to_string_pretty(&value).map_err(std::io::Error::other)?;
427    std::fs::write(user_config_path(), content)?;
428    if let Ok(mut w) = user_config_cache().write() {
429        *w = cfg.clone();
430    }
431    Ok(())
432}
433
434/// Holds configuration and derivation parameters for a specific market's spline
435/// chart.
436#[derive(Debug, Clone)]
437pub struct SplineConfig {
438    pub tick_size: u64,
439    pub base_lot_decimals: i8,
440    pub spline_collection: String,
441    pub market_pubkey: String,
442    pub symbol: String,
443    pub max_leverage: f64,
444    pub isolated_only: bool,
445    /// Global asset index used as the key in on-chain per-market tables
446    /// (e.g. ActiveTraderBuffer position ids). Needed to map an arbitrary
447    /// on-chain position back to its market symbol.
448    pub asset_id: u32,
449    /// Number of decimal places to display for prices, derived from tick_size.
450    pub price_decimals: usize,
451    /// Number of decimal places for base-asset quantities, derived from
452    /// base_lot_decimals.
453    pub size_decimals: usize,
454}
455
456/// Reads the main HTTP RPC URL, preferring the user config file, then `RPC_URL`
457/// / `SOLANA_RPC_URL` env vars, falling back to Mainnet Beta.
458pub fn rpc_http_url_from_env() -> String {
459    let cfg = current_user_config();
460    if !cfg.rpc_url.trim().is_empty() {
461        return cfg.rpc_url;
462    }
463    env::var("RPC_URL")
464        .or_else(|_| env::var("SOLANA_RPC_URL"))
465        .unwrap_or_else(|_| {
466            warn!(
467                "Using default mainnet-beta RPC_URL (https://api.mainnet-beta.solana.com) because \
468                 neither RPC_URL nor SOLANA_RPC_URL is set."
469            );
470            "https://api.mainnet-beta.solana.com".to_string()
471        })
472}
473
474/// Reads the main WebSocket JSON-RPC URL from the environment, or derives it
475/// from the HTTP URL.
476pub fn ws_url_from_env() -> String {
477    env::var("RPC_WS_URL")
478        .or_else(|_| env::var("SOLANA_WS_URL"))
479        .unwrap_or_else(|_| http_rpc_url_to_ws(&rpc_http_url_from_env()))
480}
481
482/// Converts an HTTP(S) Solana RPC endpoint string to its equivalent WSS
483/// endpoint.
484pub fn http_rpc_url_to_ws(http: &str) -> String {
485    let http = http.trim();
486    if http.starts_with("wss://") || http.starts_with("ws://") {
487        return http.to_string();
488    }
489    if let Some(rest) = http.strip_prefix("https://") {
490        return format!("wss://{rest}");
491    }
492    if let Some(rest) = http.strip_prefix("http://") {
493        if rest == "127.0.0.1:8899" {
494            return "ws://127.0.0.1:8900".to_string();
495        }
496        if rest == "localhost:8899" {
497            return "ws://localhost:8900".to_string();
498        }
499        return format!("ws://{rest}");
500    }
501    format!("wss://{http}")
502}
503
504/// Derive the number of decimal places to display for prices from tick_size and
505/// base_lot_decimals.
506pub fn compute_price_decimals(tick_size: u64, base_lots_decimals: i8) -> usize {
507    let min_price_step = tick_size as f64 * 10_f64.powi(base_lots_decimals as i32) / 10_f64.powi(6); // QUOTE_LOT_DECIMALS = 6
508    if min_price_step > 0.0 {
509        let raw = (-min_price_step.log10()).ceil().max(0.0);
510        // Clamp to a displayable range; pathological tick sizes (e.g. tick_size=1,
511        // bld=18) would otherwise produce astronomically large values or
512        // NaN-cast-to-usize.
513        (raw as usize).min(18)
514    } else {
515        2
516    }
517}
518
519/// Build a SplineConfig from an already-fetched ExchangeMarketConfig (no
520/// network call).
521pub fn build_spline_config(
522    market: &ExchangeMarketConfig,
523) -> Result<SplineConfig, Box<dyn std::error::Error>> {
524    if !matches!(
525        market.market_status,
526        MarketStatus::Active | MarketStatus::PostOnly
527    ) {
528        warn!(
529            market_status = ?market.market_status,
530            symbol = %market.symbol,
531            "market status is not Active/PostOnly; continuing with spline indexing"
532        );
533    }
534
535    let market_pk = PhoenixPubkey::from_str(&market.market_pubkey)?;
536    let api_spline_pk = PhoenixPubkey::from_str(&market.spline_pubkey)?;
537    let (derived_spline_pk, _) = program_ids::get_spline_collection_address_default(&market_pk);
538    if api_spline_pk != derived_spline_pk {
539        warn!(
540            api = %api_spline_pk,
541            derived = %derived_spline_pk,
542            symbol = %market.symbol,
543            "spline pubkey mismatch; using derived address"
544        );
545    }
546
547    let price_decimals = compute_price_decimals(market.tick_size, market.base_lots_decimals);
548    let max_leverage = market
549        .leverage_tiers
550        .first()
551        .map(|tier| tier.max_leverage)
552        .unwrap_or(1.0);
553
554    // base_lot_decimals encodes the exponent in base_lots_to_units:
555    //   units = lots / 10^bld
556    // A positive bld means fractional units (e.g. bld=2 → 0.01 minimum).
557    // A negative bld means lots are > 1 unit each, so 0 decimals suffice.
558    let size_decimals = market.base_lots_decimals.max(0) as usize;
559
560    Ok(SplineConfig {
561        tick_size: market.tick_size,
562        base_lot_decimals: market.base_lots_decimals,
563        spline_collection: derived_spline_pk.to_string(),
564        market_pubkey: market.market_pubkey.clone(),
565        symbol: market.symbol.clone(),
566        max_leverage,
567        isolated_only: market.isolated_only,
568        asset_id: market.asset_id,
569        price_decimals,
570        size_decimals,
571    })
572}
573
574#[cfg(test)]
575mod tests {
576    use solana_signer::Signer;
577
578    use super::*;
579
580    #[test]
581    fn resolve_wallet_modal_input_treats_unix_paths_as_paths_not_base58() {
582        let err = resolve_wallet_modal_input("/home/nonroot/.config/solana/id.json").unwrap_err();
583        assert!(
584            err.contains("not found") || err.contains("file not found"),
585            "unexpected err: {err}"
586        );
587    }
588
589    #[test]
590    fn resolve_wallet_modal_input_reads_json_keypair_file() {
591        let dir = std::env::temp_dir();
592        let path = dir.join("cinder-test-keypair.json");
593        let kp = Keypair::new();
594        let bytes = kp.to_bytes();
595        let json: Vec<u8> = bytes.to_vec();
596        std::fs::write(&path, serde_json::to_string(&json).unwrap()).unwrap();
597        let loaded = resolve_wallet_modal_input(path.to_str().unwrap()).unwrap();
598        assert_eq!(loaded.pubkey(), kp.pubkey());
599        let _ = std::fs::remove_file(&path);
600    }
601
602    #[test]
603    fn language_round_trips_through_code() {
604        for lang in [
605            Language::English,
606            Language::Chinese,
607            Language::Russian,
608            Language::Spanish,
609        ] {
610            assert_eq!(Language::from_code(lang.code()), lang);
611        }
612    }
613
614    #[test]
615    fn language_from_code_accepts_zh_aliases() {
616        assert_eq!(Language::from_code("zh"), Language::Chinese);
617        assert_eq!(Language::from_code("zh-CN"), Language::Chinese);
618        assert_eq!(Language::from_code("zh_CN"), Language::Chinese);
619    }
620
621    #[test]
622    fn language_from_code_accepts_ru_aliases() {
623        assert_eq!(Language::from_code("ru"), Language::Russian);
624        assert_eq!(Language::from_code("ru-RU"), Language::Russian);
625        assert_eq!(Language::from_code("ru_RU"), Language::Russian);
626    }
627
628    #[test]
629    fn language_from_code_accepts_es_aliases() {
630        assert_eq!(Language::from_code("es"), Language::Spanish);
631        assert_eq!(Language::from_code("es-ES"), Language::Spanish);
632        assert_eq!(Language::from_code("es_ES"), Language::Spanish);
633        assert_eq!(Language::from_code("es-419"), Language::Spanish);
634        assert_eq!(Language::from_code("es_419"), Language::Spanish);
635    }
636
637    #[test]
638    fn language_from_code_falls_back_to_english() {
639        assert_eq!(Language::from_code("fr"), Language::English);
640        assert_eq!(Language::from_code(""), Language::English);
641    }
642
643    #[test]
644    fn language_toggle_cycles() {
645        assert_eq!(Language::English.toggle(), Language::Chinese);
646        assert_eq!(Language::Chinese.toggle(), Language::Russian);
647        assert_eq!(Language::Russian.toggle(), Language::Spanish);
648        assert_eq!(Language::Spanish.toggle(), Language::English);
649        assert_eq!(
650            Language::English.toggle().toggle().toggle().toggle(),
651            Language::English
652        );
653    }
654
655    #[test]
656    fn http_to_ws_promotes_https_scheme() {
657        assert_eq!(
658            http_rpc_url_to_ws("https://api.mainnet-beta.solana.com"),
659            "wss://api.mainnet-beta.solana.com"
660        );
661    }
662
663    #[test]
664    fn http_to_ws_demotes_http_scheme() {
665        assert_eq!(
666            http_rpc_url_to_ws("http://example.com:8899"),
667            "ws://example.com:8899"
668        );
669    }
670
671    #[test]
672    fn http_to_ws_remaps_localhost_default_ports() {
673        assert_eq!(
674            http_rpc_url_to_ws("http://127.0.0.1:8899"),
675            "ws://127.0.0.1:8900"
676        );
677        assert_eq!(
678            http_rpc_url_to_ws("http://localhost:8899"),
679            "ws://localhost:8900"
680        );
681    }
682
683    #[test]
684    fn http_to_ws_defaults_schemeless_input_to_wss() {
685        assert_eq!(
686            http_rpc_url_to_ws("api.example.com"),
687            "wss://api.example.com"
688        );
689    }
690
691    #[test]
692    fn compute_price_decimals_clamps_pathological_inputs() {
693        // Astronomically tiny min step would otherwise produce an unbounded value.
694        assert!(compute_price_decimals(1, 18) <= 18);
695    }
696
697    #[test]
698    fn compute_price_decimals_returns_zero_when_step_is_invalid() {
699        assert_eq!(compute_price_decimals(0, 0), 2);
700    }
701
702    #[test]
703    fn compute_price_decimals_matches_known_step_sizes() {
704        // tick_size=1, base_lot_decimals=0 → step = 1e-6 → 6 decimals.
705        assert_eq!(compute_price_decimals(1, 0), 6);
706        // tick_size=100, base_lot_decimals=0 → step = 1e-4 → 4 decimals.
707        assert_eq!(compute_price_decimals(100, 0), 4);
708    }
709}