cosmic-cinder 0.1.21

Rust terminal UI for Phoenix perpetuals on Solana
Documentation
//! Per-market transaction context — caches RPC clients, the trader PDA, the
//! market account addresses, a warm blockhash pool, and a shared
//! signatureSubscribe pubsub client used by every submission flow.

use std::collections::VecDeque;
use std::str::FromStr;
use std::sync::{Arc, RwLock};
use std::time::Duration;

use phoenix_eternal_types::program_ids;
use phoenix_rise::api::{PhoenixMetadata, Trader, TraderKey};

/// Shared wallet `Trader` mirror with a hydration flag. Written by the
/// trader-state WS task once it applies its first update; read by isolated-
/// margin order builders so they can construct instructions locally without
/// round-tripping through the Phoenix HTTP API.
///
/// Splitting `hydrated` from the `Trader` lets readers distinguish "wallet
/// truly has no positions" from "WS hasn't delivered the first snapshot yet"
/// — the latter must NOT be used to build orders because the missing
/// subaccount state would cause the local builder to create a brand-new
/// subaccount on top of an existing one.
pub struct TraderMirror {
    pub trader: Trader,
    pub hydrated: bool,
}

impl TraderMirror {
    pub fn empty(authority: solana_pubkey::Pubkey) -> Self {
        Self {
            trader: Trader::new(TraderKey::new(authority)),
            hydrated: false,
        }
    }
}
use solana_commitment_config::CommitmentConfig;
use solana_keypair::Keypair;
use solana_pubsub_client::nonblocking::pubsub_client::PubsubClient;
use solana_signer::Signer;
use tracing::warn;

use super::super::config::{
    DEFAULT_PUBLIC_SOLANA_RPC_URL, current_user_config, is_public_mainnet_rpc,
    rpc_http_url_from_env, ws_url_from_env,
};

/// Derive the canonical spline-collection PDA from the market account.
///
/// The Phoenix HTTP metadata exposes a `spline_pubkey` field, but it can be
/// stale or otherwise inconsistent with the on-chain PDA. The read/display
/// path (`build_spline_config`) already prefers the derived address; signed
/// transaction flows must do the same so account metas line up with what the
/// program expects.
fn derive_spline_collection(
    symbol: &str,
    market_pubkey: &str,
    api_spline_pubkey: &str,
) -> Result<solana_pubkey::Pubkey, solana_pubkey::ParsePubkeyError> {
    let market_pk = solana_pubkey::Pubkey::from_str(market_pubkey)?;
    let (derived, _) = program_ids::get_spline_collection_address_default(&market_pk);
    if let Ok(api_pk) = solana_pubkey::Pubkey::from_str(api_spline_pubkey)
        && api_pk != derived
    {
        warn!(
            api = %api_pk,
            derived = %derived,
            symbol = %symbol,
            "spline pubkey mismatch; using derived address"
        );
    }
    Ok(derived)
}

/// Bound transaction preparation on slow/stalled RPCs. Without this, an empty
/// warm blockhash pool can leave the UI stuck at "Broadcasting ..." forever.
pub(super) const BLOCKHASH_FETCH_TIMEOUT: Duration = Duration::from_secs(5);

pub struct MarketAddrs {
    pub perp_asset_map: solana_pubkey::Pubkey,
    pub orderbook: solana_pubkey::Pubkey,
    pub spline_collection: solana_pubkey::Pubkey,
    pub global_trader_index: Vec<solana_pubkey::Pubkey>,
    pub active_trader_buffer: Vec<solana_pubkey::Pubkey>,
}

pub struct TxContext {
    pub rpc_client: solana_rpc_client::nonblocking::rpc_client::RpcClient,
    /// Optional secondary RPC used purely for `send_transaction` fan-out.
    /// Confirmation still listens exclusively on `rpc_client`. `None` when the
    /// primary already targets the public mainnet-beta endpoint.
    pub secondary_send_rpc: Option<Arc<solana_rpc_client::nonblocking::rpc_client::RpcClient>>,
    /// Phoenix HTTP SDK client. Kept on the context as a connection-pool
    /// holder even though signed-order construction now builds instructions
    /// locally via `phoenix_rise::core::PhoenixTxBuilder`; future flows (e.g. metadata refresh,
    /// account-info helpers) reuse this handle instead of opening a second.
    #[allow(dead_code)]
    pub http_client: Arc<phoenix_rise::api::PhoenixHttpClient>,
    pub metadata: PhoenixMetadata,
    pub authority_v2: solana_pubkey::Pubkey,
    pub trader_pda_v2: solana_pubkey::Pubkey,
    pub market_addrs: MarketAddrs,
    /// Symbol the `market_addrs` were built for. The non-isolated order
    /// builders use `market_addrs.orderbook` etc. directly, so submissions
    /// for a different symbol would target the wrong on-chain accounts.
    /// Compared against the caller-supplied `symbol` at submit time.
    pub active_symbol: String,
    pub blockhash_pool: tokio::sync::Mutex<VecDeque<[u8; 32]>>,
    /// Cached WS URL for signature confirmations.
    pub(super) ws_url: String,
    /// Shared WSS client for signature confirmations — all orders multiplex
    /// through a single WebSocket instead of opening one per transaction.
    pub(super) sig_pubsub: tokio::sync::Mutex<Option<Arc<PubsubClient>>>,
    /// Live mirror of the wallet's `Trader` state — written by the trader-state
    /// WS subscription, read by isolated-margin order builders so they can
    /// construct subaccount-aware instructions locally without round-tripping
    /// through the Phoenix HTTP API. Starts empty and is hydrated by the WS
    /// task on the first `TraderUpdate`. Readers must check
    /// `TraderMirror::hydrated` before using the snapshot.
    pub shared_trader: Arc<RwLock<TraderMirror>>,
}

impl TxContext {
    /// Initializes a new transaction context with RPC hooks and loaded static
    /// margin configurations. Accepts an existing `PhoenixHttpClient` to
    /// avoid opening a redundant SDK connection.
    pub async fn new(
        keypair: &Keypair,
        symbol: &str,
        http: Arc<phoenix_rise::api::PhoenixHttpClient>,
        shared_trader: Arc<RwLock<TraderMirror>>,
    ) -> Result<Self, Box<dyn std::error::Error + Send + Sync>> {
        use solana_rpc_client::nonblocking::rpc_client::RpcClient;

        let rpc_url = rpc_http_url_from_env();
        let fanout_enabled = current_user_config().fanout_public_rpc;
        let secondary_send_rpc = if !fanout_enabled || is_public_mainnet_rpc(&rpc_url) {
            None
        } else {
            Some(Arc::new(RpcClient::new_with_commitment(
                DEFAULT_PUBLIC_SOLANA_RPC_URL.to_string(),
                CommitmentConfig::processed(),
            )))
        };
        let rpc_client = RpcClient::new_with_commitment(rpc_url, CommitmentConfig::processed());

        let exchange = http.exchange().get_exchange().await?;
        let metadata = PhoenixMetadata::new(exchange.into());

        let keys = metadata.keys();
        let market = metadata
            .get_market(symbol)
            .ok_or_else(|| format!("{} market not found in exchange metadata", symbol))?;
        let market_addrs = MarketAddrs {
            perp_asset_map: solana_pubkey::Pubkey::from_str(&keys.perp_asset_map)?,
            orderbook: solana_pubkey::Pubkey::from_str(&market.market_pubkey)?,
            spline_collection: derive_spline_collection(
                &market.symbol,
                &market.market_pubkey,
                &market.spline_pubkey,
            )?,
            global_trader_index: keys
                .global_trader_index
                .iter()
                .map(|s| solana_pubkey::Pubkey::from_str(s))
                .collect::<Result<Vec<_>, _>>()?,
            active_trader_buffer: keys
                .active_trader_buffer
                .iter()
                .map(|s| solana_pubkey::Pubkey::from_str(s))
                .collect::<Result<Vec<_>, _>>()?,
        };

        let authority_v2 = solana_pubkey::Pubkey::from_str(&keypair.pubkey().to_string())?;
        let trader_pda_v2 = TraderKey::derive_pda(&authority_v2, 0, 0);

        Ok(Self {
            rpc_client,
            secondary_send_rpc,
            http_client: http,
            metadata,
            authority_v2,
            trader_pda_v2,
            market_addrs,
            active_symbol: symbol.to_string(),
            blockhash_pool: tokio::sync::Mutex::new(VecDeque::with_capacity(30)),
            ws_url: ws_url_from_env(),
            sig_pubsub: tokio::sync::Mutex::new(None),
            shared_trader,
        })
    }

    /// Snapshot the shared `Trader` state under a short read lock if the WS
    /// task has hydrated it. Returns `None` when the wallet is connected but
    /// the trader-state WS hasn't delivered its first snapshot yet — order
    /// builders must surface a "waiting for trader state" status rather than
    /// build against an empty `Trader`, which would create a new isolated
    /// subaccount on top of any existing one.
    pub fn snapshot_trader(&self) -> Option<Trader> {
        let guard = match self.shared_trader.read() {
            Ok(g) => g,
            Err(poisoned) => poisoned.into_inner(),
        };
        if !guard.hydrated {
            return None;
        }
        Some(guard.trader.clone())
    }

    /// Construct an empty (un-hydrated) trader mirror keyed to the supplied
    /// authority. Used as the initial value for the shared trader before the
    /// WS subscription delivers its first snapshot.
    pub fn empty_trader_mirror(authority: solana_pubkey::Pubkey) -> TraderMirror {
        TraderMirror::empty(authority)
    }

    /// Pushes the latest blockhash from the network into the rotating memory
    /// pool.
    pub async fn push_blockhash(&self) {
        if let Ok(Ok((bh, _))) = tokio::time::timeout(
            BLOCKHASH_FETCH_TIMEOUT,
            self.rpc_client
                .get_latest_blockhash_with_commitment(CommitmentConfig::confirmed()),
        )
        .await
        {
            let mut pool = self.blockhash_pool.lock().await;
            let bytes = bh.to_bytes();
            if pool.back() != Some(&bytes) {
                pool.push_back(bytes);
                if pool.len() > 30 {
                    pool.pop_front();
                }
            }
        }
    }

    /// Removes and returns the newest warm blockhash from the pool.
    /// Each blockhash is consumed so it is never reused across transactions.
    /// Using the newest entry maximises remaining validity (~150 blocks on
    /// Solana). Falls back to an HTTP fetch if the pool is empty.
    pub async fn pop_blockhash(&self) -> Result<solana_hash::Hash, String> {
        let mut pool = self.blockhash_pool.lock().await;
        if let Some(bytes) = pool.pop_back() {
            Ok(solana_hash::Hash::new_from_array(bytes))
        } else {
            drop(pool);
            tokio::time::timeout(
                BLOCKHASH_FETCH_TIMEOUT,
                self.rpc_client
                    .get_latest_blockhash_with_commitment(CommitmentConfig::confirmed()),
            )
            .await
            .map_err(|_| "blockhash fetch timed out after 5s; check RPC health".to_string())?
            .map(|(hash, _)| hash)
            .map_err(|e| format!("{}", e))
        }
    }

    pub fn trader_pda_for_subaccount(&self, subaccount_index: u8) -> solana_pubkey::Pubkey {
        TraderKey::derive_pda(&self.authority_v2, 0, subaccount_index)
    }

    pub fn market_isolated_only(&self, symbol: &str) -> bool {
        self.metadata
            .get_market(symbol)
            .map(|market| market.isolated_only)
            .unwrap_or(false)
    }

    pub fn max_leverage_for_symbol(&self, symbol: &str) -> Option<f64> {
        self.metadata
            .get_market(symbol)?
            .leverage_tiers
            .first()
            .map(|tier| tier.max_leverage)
    }

    pub fn market_addrs_for_symbol(&self, symbol: &str) -> Option<MarketAddrs> {
        let keys = self.metadata.keys();
        let market = self.metadata.get_market(symbol)?;
        Some(MarketAddrs {
            perp_asset_map: solana_pubkey::Pubkey::from_str(&keys.perp_asset_map).ok()?,
            orderbook: solana_pubkey::Pubkey::from_str(&market.market_pubkey).ok()?,
            spline_collection: derive_spline_collection(
                &market.symbol,
                &market.market_pubkey,
                &market.spline_pubkey,
            )
            .ok()?,
            global_trader_index: keys
                .global_trader_index
                .iter()
                .map(|s| solana_pubkey::Pubkey::from_str(s))
                .collect::<Result<Vec<_>, _>>()
                .ok()?,
            active_trader_buffer: keys
                .active_trader_buffer
                .iter()
                .map(|s| solana_pubkey::Pubkey::from_str(s))
                .collect::<Result<Vec<_>, _>>()
                .ok()?,
        })
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn derive_spline_collection_overrides_stale_api_value() {
        // A real Phoenix market pubkey shape; the actual value just needs to
        // parse and produce a deterministic PDA via find_program_address.
        let market_pubkey = "11111111111111111111111111111112";
        let market_pk = solana_pubkey::Pubkey::from_str(market_pubkey).unwrap();
        let (canonical, _) = program_ids::get_spline_collection_address_default(&market_pk);

        // Pretend the HTTP metadata reported a different (stale) pubkey.
        let stale_api = solana_pubkey::Pubkey::new_unique().to_string();
        assert_ne!(stale_api, canonical.to_string());

        let resolved = derive_spline_collection("TEST-PERP", market_pubkey, &stale_api).unwrap();
        assert_eq!(
            resolved, canonical,
            "write path must use the derived spline PDA, not the API-reported pubkey"
        );
    }

    #[test]
    fn derive_spline_collection_returns_canonical_when_api_matches() {
        let market_pubkey = "11111111111111111111111111111112";
        let market_pk = solana_pubkey::Pubkey::from_str(market_pubkey).unwrap();
        let (canonical, _) = program_ids::get_spline_collection_address_default(&market_pk);

        let resolved =
            derive_spline_collection("TEST-PERP", market_pubkey, &canonical.to_string()).unwrap();
        assert_eq!(resolved, canonical);
    }
}