use std::env;
use std::str::FromStr;
use std::sync::{OnceLock, RwLock};
use phoenix_eternal_types::program_ids;
use phoenix_rise::types::MarketStatus;
use phoenix_rise::ExchangeMarketConfig;
use solana_keypair::Keypair;
use solana_pubkey::Pubkey as PhoenixPubkey;
use tracing::warn;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum Language {
#[default]
English,
Chinese,
Russian,
Spanish,
}
impl Language {
pub fn label(self) -> &'static str {
match self {
Self::English => "English",
Self::Chinese => "中文",
Self::Russian => "Русский",
Self::Spanish => "Español",
}
}
pub fn code(self) -> &'static str {
match self {
Self::English => "en",
Self::Chinese => "cn",
Self::Russian => "ru",
Self::Spanish => "es",
}
}
pub fn from_code(s: &str) -> Self {
match s {
"cn" | "zh" | "zh-CN" | "zh_CN" => Self::Chinese,
"ru" | "ru-RU" | "ru_RU" => Self::Russian,
"es" | "es-ES" | "es_ES" | "es-419" | "es_419" => Self::Spanish,
_ => Self::English,
}
}
pub fn toggle(self) -> Self {
match self {
Self::English => Self::Chinese,
Self::Chinese => Self::Russian,
Self::Russian => Self::Spanish,
Self::Spanish => Self::English,
}
}
}
pub const DEFAULT_COMPUTE_UNIT_PRICE_MICRO_LAMPORTS: u64 = 111;
pub const DEFAULT_COMPUTE_UNIT_LIMIT_PER_POSITION: u32 = 200_000;
#[derive(Debug, Clone)]
pub struct UserConfig {
pub rpc_url: String,
pub language: Language,
pub show_clob: bool,
pub fanout_public_rpc: bool,
pub compute_unit_price_micro_lamports: Option<u64>,
pub compute_unit_limit_per_position: Option<u32>,
pub wallet_path: String,
pub skip_order_confirmation: bool,
pub skip_preflight: bool,
}
impl Default for UserConfig {
fn default() -> Self {
Self {
rpc_url: String::new(),
language: Language::default(),
show_clob: true,
fanout_public_rpc: default_fanout_public_rpc_from_env(),
compute_unit_price_micro_lamports: None,
compute_unit_limit_per_position: None,
wallet_path: String::new(),
skip_order_confirmation: default_skip_order_confirmation_from_env(),
skip_preflight: default_skip_preflight_from_env(),
}
}
}
fn compute_unit_price_from_env() -> Option<u64> {
env::var("CINDER_COMPUTE_UNIT_PRICE")
.ok()
.and_then(|s| s.trim().parse::<u64>().ok())
}
fn compute_unit_limit_from_env() -> Option<u32> {
env::var("CINDER_COMPUTE_UNIT_LIMIT")
.ok()
.and_then(|s| s.trim().parse::<u32>().ok())
}
pub fn current_compute_unit_price_micro_lamports() -> u64 {
current_user_config()
.compute_unit_price_micro_lamports
.or_else(compute_unit_price_from_env)
.or_else(super::tx::current_auto_priority_fee)
.unwrap_or(DEFAULT_COMPUTE_UNIT_PRICE_MICRO_LAMPORTS)
}
pub fn current_compute_unit_limit_per_position() -> u32 {
current_user_config()
.compute_unit_limit_per_position
.or_else(compute_unit_limit_from_env)
.unwrap_or(DEFAULT_COMPUTE_UNIT_LIMIT_PER_POSITION)
}
fn default_fanout_public_rpc_from_env() -> bool {
match env::var("CINDER_FANOUT_PUBLIC_RPC") {
Ok(v) => !matches!(
v.trim().to_ascii_lowercase().as_str(),
"0" | "false" | "off" | "no"
),
Err(_) => true,
}
}
fn default_skip_order_confirmation_from_env() -> bool {
match env::var("CINDER_SKIP_ORDER_CONFIRMATION") {
Ok(v) => matches!(
v.trim().to_ascii_lowercase().as_str(),
"1" | "true" | "on" | "yes"
),
Err(_) => false,
}
}
fn default_skip_preflight_from_env() -> bool {
match env::var("CINDER_SKIP_PREFLIGHT") {
Ok(v) => matches!(
v.trim().to_ascii_lowercase().as_str(),
"1" | "true" | "on" | "yes"
),
Err(_) => false,
}
}
fn wallet_path_candidates() -> Vec<String> {
let home = env::var("USERPROFILE").unwrap_or_else(|_| env::var("HOME").unwrap_or_default());
let env_path = env::var("PHX_WALLET_PATH").or_else(|_| env::var("KEYPAIR_PATH"));
let solana_default = std::path::PathBuf::from(home)
.join(".config")
.join("solana")
.join("id.json")
.to_string_lossy()
.into_owned();
[
Some("phoenix.json".to_string()),
env_path.ok(),
Some(solana_default),
]
.into_iter()
.flatten()
.collect()
}
pub fn default_wallet_path() -> String {
let saved = current_user_config().wallet_path;
if !saved.trim().is_empty() {
return saved;
}
let candidates = wallet_path_candidates();
for p in &candidates {
if std::path::Path::new(p).exists() {
return p.clone();
}
}
candidates
.into_iter()
.last()
.unwrap_or_else(|| "id.json".to_string())
}
fn looks_like_filesystem_path(s: &str) -> bool {
if s.starts_with('/') {
return true;
}
if s.starts_with("./") || s.starts_with("../") {
return true;
}
if s.contains('/') {
return true;
}
if s.contains('\\') {
return true;
}
let mut chars = s.chars();
let Some(letter) = chars.next() else {
return false;
};
let Some(':') = chars.next() else {
return false;
};
if !letter.is_ascii_alphabetic() {
return false;
}
matches!(chars.next(), Some('/' | '\\'))
}
pub fn resolve_wallet_modal_input(input: &str) -> Result<Keypair, String> {
let trimmed = input.trim();
if trimmed.is_empty() {
return Err("input is empty".to_string());
}
if trimmed.starts_with('[') {
return parse_keypair_text(trimmed);
}
let path = std::path::Path::new(trimmed);
if path.is_file() || looks_like_filesystem_path(trimmed) {
return load_keypair_from_path(trimmed);
}
parse_keypair_text(trimmed)
}
pub fn parse_keypair_text(text: &str) -> Result<Keypair, String> {
let trimmed = text.trim();
if trimmed.is_empty() {
return Err("input is empty".to_string());
}
if trimmed.starts_with('[') {
let bytes: Vec<u8> =
serde_json::from_str(trimmed).map_err(|_| "invalid JSON byte array".to_string())?;
if bytes.len() < 32 {
return Err(format!(
"keypair too short ({} bytes; need 32)",
bytes.len()
));
}
let mut secret_key = [0u8; 32];
secret_key.copy_from_slice(&bytes[..32]);
return Ok(Keypair::new_from_array(secret_key));
}
match std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
Keypair::from_base58_string(trimmed)
})) {
Ok(kp) => Ok(kp),
Err(_) => Err("invalid base58 keypair string".to_string()),
}
}
pub fn load_keypair_from_path(path: &str) -> Result<Keypair, String> {
let trimmed = path.trim();
if trimmed.is_empty() {
return Err("wallet path is empty".to_string());
}
let content = std::fs::read_to_string(trimmed).map_err(|e| match e.kind() {
std::io::ErrorKind::NotFound => format!("file not found: {trimmed}"),
std::io::ErrorKind::PermissionDenied => format!("permission denied: {trimmed}"),
_ => format!("read error: {e}"),
})?;
parse_keypair_text(&content)
}
fn home_dir() -> String {
env::var("USERPROFILE").unwrap_or_else(|_| env::var("HOME").unwrap_or_default())
}
fn config_dir_path() -> String {
format!("{}/.config/phoenix-cinder", home_dir())
}
pub fn user_config_path() -> String {
format!("{}/config.json", config_dir_path())
}
fn load_user_config_from_disk() -> UserConfig {
let Ok(content) = std::fs::read_to_string(user_config_path()) else {
return UserConfig::default();
};
let Ok(v) = serde_json::from_str::<serde_json::Value>(&content) else {
return UserConfig::default();
};
UserConfig {
rpc_url: v
.get("rpc_url")
.and_then(|x| x.as_str())
.unwrap_or("")
.to_string(),
language: Language::from_code(v.get("language").and_then(|x| x.as_str()).unwrap_or("en")),
show_clob: v.get("show_clob").and_then(|x| x.as_bool()).unwrap_or(true),
fanout_public_rpc: v
.get("fanout_public_rpc")
.and_then(|x| x.as_bool())
.unwrap_or_else(default_fanout_public_rpc_from_env),
compute_unit_price_micro_lamports: v
.get("compute_unit_price_micro_lamports")
.and_then(|x| x.as_u64()),
compute_unit_limit_per_position: v
.get("compute_unit_limit_per_position")
.and_then(|x| x.as_u64())
.and_then(|n| u32::try_from(n).ok()),
wallet_path: v
.get("wallet_path")
.and_then(|x| x.as_str())
.unwrap_or("")
.to_string(),
skip_order_confirmation: v
.get("skip_order_confirmation")
.and_then(|x| x.as_bool())
.unwrap_or_else(default_skip_order_confirmation_from_env),
skip_preflight: v
.get("skip_preflight")
.and_then(|x| x.as_bool())
.unwrap_or_else(default_skip_preflight_from_env),
}
}
fn user_config_cache() -> &'static RwLock<UserConfig> {
static CACHE: OnceLock<RwLock<UserConfig>> = OnceLock::new();
CACHE.get_or_init(|| RwLock::new(load_user_config_from_disk()))
}
pub fn current_user_config() -> UserConfig {
user_config_cache()
.read()
.map(|g| g.clone())
.unwrap_or_default()
}
pub fn save_user_config(cfg: &UserConfig) -> std::io::Result<()> {
std::fs::create_dir_all(config_dir_path())?;
let value = serde_json::json!({
"rpc_url": cfg.rpc_url,
"language": cfg.language.code(),
"show_clob": cfg.show_clob,
"fanout_public_rpc": cfg.fanout_public_rpc,
"compute_unit_price_micro_lamports": cfg.compute_unit_price_micro_lamports,
"compute_unit_limit_per_position": cfg.compute_unit_limit_per_position,
"wallet_path": cfg.wallet_path,
"skip_order_confirmation": cfg.skip_order_confirmation,
"skip_preflight": cfg.skip_preflight,
});
let content = serde_json::to_string_pretty(&value).map_err(std::io::Error::other)?;
std::fs::write(user_config_path(), content)?;
if let Ok(mut w) = user_config_cache().write() {
*w = cfg.clone();
}
Ok(())
}
#[derive(Debug, Clone)]
pub struct SplineConfig {
pub tick_size: u64,
pub base_lot_decimals: i8,
pub spline_collection: String,
pub market_pubkey: String,
pub symbol: String,
pub max_leverage: f64,
pub isolated_only: bool,
pub asset_id: u32,
pub price_decimals: usize,
pub size_decimals: usize,
}
pub fn rpc_http_url_from_env() -> String {
let cfg = current_user_config();
if !cfg.rpc_url.trim().is_empty() {
return cfg.rpc_url;
}
env::var("RPC_URL")
.or_else(|_| env::var("SOLANA_RPC_URL"))
.unwrap_or_else(|_| {
warn!(
"Using default mainnet-beta RPC_URL (https://api.mainnet-beta.solana.com) because \
neither RPC_URL nor SOLANA_RPC_URL is set."
);
"https://api.mainnet-beta.solana.com".to_string()
})
}
pub fn ws_url_from_env() -> String {
env::var("RPC_WS_URL")
.or_else(|_| env::var("SOLANA_WS_URL"))
.unwrap_or_else(|_| http_rpc_url_to_ws(&rpc_http_url_from_env()))
}
pub fn http_rpc_url_to_ws(http: &str) -> String {
let http = http.trim();
if http.starts_with("wss://") || http.starts_with("ws://") {
return http.to_string();
}
if let Some(rest) = http.strip_prefix("https://") {
return format!("wss://{rest}");
}
if let Some(rest) = http.strip_prefix("http://") {
if rest == "127.0.0.1:8899" {
return "ws://127.0.0.1:8900".to_string();
}
if rest == "localhost:8899" {
return "ws://localhost:8900".to_string();
}
return format!("ws://{rest}");
}
format!("wss://{http}")
}
pub fn compute_price_decimals(tick_size: u64, base_lots_decimals: i8) -> usize {
let min_price_step = tick_size as f64 * 10_f64.powi(base_lots_decimals as i32) / 10_f64.powi(6); if min_price_step > 0.0 {
let raw = (-min_price_step.log10()).ceil().max(0.0);
(raw as usize).min(18)
} else {
2
}
}
pub fn build_spline_config(
market: &ExchangeMarketConfig,
) -> Result<SplineConfig, Box<dyn std::error::Error>> {
if !matches!(
market.market_status,
MarketStatus::Active | MarketStatus::PostOnly
) {
warn!(
market_status = ?market.market_status,
symbol = %market.symbol,
"market status is not Active/PostOnly; continuing with spline indexing"
);
}
let market_pk = PhoenixPubkey::from_str(&market.market_pubkey)?;
let api_spline_pk = PhoenixPubkey::from_str(&market.spline_pubkey)?;
let (derived_spline_pk, _) = program_ids::get_spline_collection_address_default(&market_pk);
if api_spline_pk != derived_spline_pk {
warn!(
api = %api_spline_pk,
derived = %derived_spline_pk,
symbol = %market.symbol,
"spline pubkey mismatch; using derived address"
);
}
let price_decimals = compute_price_decimals(market.tick_size, market.base_lots_decimals);
let max_leverage = market
.leverage_tiers
.first()
.map(|tier| tier.max_leverage)
.unwrap_or(1.0);
let size_decimals = market.base_lots_decimals.max(0) as usize;
Ok(SplineConfig {
tick_size: market.tick_size,
base_lot_decimals: market.base_lots_decimals,
spline_collection: derived_spline_pk.to_string(),
market_pubkey: market.market_pubkey.clone(),
symbol: market.symbol.clone(),
max_leverage,
isolated_only: market.isolated_only,
asset_id: market.asset_id,
price_decimals,
size_decimals,
})
}
#[cfg(test)]
mod tests {
use solana_signer::Signer;
use super::*;
#[test]
fn resolve_wallet_modal_input_treats_unix_paths_as_paths_not_base58() {
let err = resolve_wallet_modal_input("/home/nonroot/.config/solana/id.json").unwrap_err();
assert!(
err.contains("not found") || err.contains("file not found"),
"unexpected err: {err}"
);
}
#[test]
fn resolve_wallet_modal_input_reads_json_keypair_file() {
let dir = std::env::temp_dir();
let path = dir.join("cinder-test-keypair.json");
let kp = Keypair::new();
let bytes = kp.to_bytes();
let json: Vec<u8> = bytes.to_vec();
std::fs::write(&path, serde_json::to_string(&json).unwrap()).unwrap();
let loaded = resolve_wallet_modal_input(path.to_str().unwrap()).unwrap();
assert_eq!(loaded.pubkey(), kp.pubkey());
let _ = std::fs::remove_file(&path);
}
#[test]
fn language_round_trips_through_code() {
for lang in [
Language::English,
Language::Chinese,
Language::Russian,
Language::Spanish,
] {
assert_eq!(Language::from_code(lang.code()), lang);
}
}
#[test]
fn language_from_code_accepts_zh_aliases() {
assert_eq!(Language::from_code("zh"), Language::Chinese);
assert_eq!(Language::from_code("zh-CN"), Language::Chinese);
assert_eq!(Language::from_code("zh_CN"), Language::Chinese);
}
#[test]
fn language_from_code_accepts_ru_aliases() {
assert_eq!(Language::from_code("ru"), Language::Russian);
assert_eq!(Language::from_code("ru-RU"), Language::Russian);
assert_eq!(Language::from_code("ru_RU"), Language::Russian);
}
#[test]
fn language_from_code_accepts_es_aliases() {
assert_eq!(Language::from_code("es"), Language::Spanish);
assert_eq!(Language::from_code("es-ES"), Language::Spanish);
assert_eq!(Language::from_code("es_ES"), Language::Spanish);
assert_eq!(Language::from_code("es-419"), Language::Spanish);
assert_eq!(Language::from_code("es_419"), Language::Spanish);
}
#[test]
fn language_from_code_falls_back_to_english() {
assert_eq!(Language::from_code("fr"), Language::English);
assert_eq!(Language::from_code(""), Language::English);
}
#[test]
fn language_toggle_cycles() {
assert_eq!(Language::English.toggle(), Language::Chinese);
assert_eq!(Language::Chinese.toggle(), Language::Russian);
assert_eq!(Language::Russian.toggle(), Language::Spanish);
assert_eq!(Language::Spanish.toggle(), Language::English);
assert_eq!(
Language::English.toggle().toggle().toggle().toggle(),
Language::English
);
}
#[test]
fn http_to_ws_promotes_https_scheme() {
assert_eq!(
http_rpc_url_to_ws("https://api.mainnet-beta.solana.com"),
"wss://api.mainnet-beta.solana.com"
);
}
#[test]
fn http_to_ws_demotes_http_scheme() {
assert_eq!(
http_rpc_url_to_ws("http://example.com:8899"),
"ws://example.com:8899"
);
}
#[test]
fn http_to_ws_remaps_localhost_default_ports() {
assert_eq!(
http_rpc_url_to_ws("http://127.0.0.1:8899"),
"ws://127.0.0.1:8900"
);
assert_eq!(
http_rpc_url_to_ws("http://localhost:8899"),
"ws://localhost:8900"
);
}
#[test]
fn http_to_ws_defaults_schemeless_input_to_wss() {
assert_eq!(
http_rpc_url_to_ws("api.example.com"),
"wss://api.example.com"
);
}
#[test]
fn compute_price_decimals_clamps_pathological_inputs() {
assert!(compute_price_decimals(1, 18) <= 18);
}
#[test]
fn compute_price_decimals_returns_zero_when_step_is_invalid() {
assert_eq!(compute_price_decimals(0, 0), 2);
}
#[test]
fn compute_price_decimals_matches_known_step_sizes() {
assert_eq!(compute_price_decimals(1, 0), 6);
assert_eq!(compute_price_decimals(100, 0), 4);
}
}