1use 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
17pub 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
29pub 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
50pub 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
67pub 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
143pub const DEFAULT_COMPUTE_UNIT_PRICE_MICRO_LAMPORTS: u64 = 111;
146
147pub const DEFAULT_COMPUTE_UNIT_LIMIT_PER_POSITION: u32 = 275_000;
150
151#[derive(Debug, Clone)]
154pub struct UserConfig {
155 pub rpc_url: String,
156 pub language: Language,
157 pub show_clob: bool,
161 pub fanout_public_rpc: bool,
167 pub compute_unit_price_micro_lamports: Option<u64>,
170 pub compute_unit_limit_per_position: Option<u32>,
174 pub wallet_path: String,
178 pub skip_order_confirmation: bool,
182 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
206fn 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
214fn 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
222pub 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
234pub 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
243fn 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
258fn 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
273fn 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
288fn 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
310pub 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
332fn 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
362pub 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
379pub 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
408pub 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
482pub fn current_user_config() -> UserConfig {
484 user_config_cache()
485 .read()
486 .map(|g| g.clone())
487 .unwrap_or_default()
488}
489
490pub 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#[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 pub asset_id: u32,
535 pub price_decimals: usize,
537 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
558pub 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
570pub 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
578pub 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
600pub 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); if min_price_step > 0.0 {
605 let raw = (-min_price_step.log10()).ceil().max(0.0);
606 (raw as usize).min(18)
610 } else {
611 2
612 }
613}
614
615pub 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 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 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 assert_eq!(compute_price_decimals(1, 0), 6);
813 assert_eq!(compute_price_decimals(100, 0), 4);
815 }
816}