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