1use 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
61pub const DEFAULT_COMPUTE_UNIT_PRICE_MICRO_LAMPORTS: u64 = 111;
64
65pub const DEFAULT_COMPUTE_UNIT_LIMIT_PER_POSITION: u32 = 200_000;
68
69#[derive(Debug, Clone)]
72pub struct UserConfig {
73 pub rpc_url: String,
74 pub language: Language,
75 pub show_clob: bool,
79 pub fanout_public_rpc: bool,
85 pub compute_unit_price_micro_lamports: Option<u64>,
88 pub compute_unit_limit_per_position: Option<u32>,
92 pub wallet_path: String,
96 pub skip_order_confirmation: bool,
100 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
124fn 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
132fn 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
140pub 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
152pub 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
161fn 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
176fn 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
191fn 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
206fn 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
228pub 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
250fn 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
280pub 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
297pub 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
326pub 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
400pub fn current_user_config() -> UserConfig {
402 user_config_cache()
403 .read()
404 .map(|g| g.clone())
405 .unwrap_or_default()
406}
407
408pub 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#[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 pub asset_id: u32,
449 pub price_decimals: usize,
451 pub size_decimals: usize,
454}
455
456pub 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
474pub 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
482pub 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
504pub 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); if min_price_step > 0.0 {
509 let raw = (-min_price_step.log10()).ceil().max(0.0);
510 (raw as usize).min(18)
514 } else {
515 2
516 }
517}
518
519pub 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 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 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 assert_eq!(compute_price_decimals(1, 0), 6);
706 assert_eq!(compute_price_decimals(100, 0), 4);
708 }
709}