// file: ks-config/src/settings.rs // version: 21 //! Resolved runtime configuration models retained during the `0.5.1` source-document split. use ts_rs::TS; // rust-rules: derive-import const CONFIG_JSON_SCHEMA: &str = include_str!("../../config/schemas/resolved.app.config.schema.json"); /// Resolved runtime configuration containing every composed named profile. #[derive(Clone, Debug, serde::Deserialize, Eq, PartialEq, serde::Serialize, TS)] #[ts(export, export_to = "../frontend/ts/bindings/ks_config/settings/AppConfig.ts")] pub struct AppConfig { /// Active profile name. pub active_profile: std::string::String, /// Named profiles reconstructed from the active binary composition and shared documents. pub profiles: std::vec::Vec, } /// Resolved runtime profile selected by the root active profile name. #[derive(Clone, Debug, serde::Deserialize, Eq, PartialEq, serde::Serialize, TS)] #[ts( export, export_to = "../frontend/ts/bindings/ks_config/settings/ProfileConfig.ts" )] pub struct ProfileConfig { /// Profile code. pub name: std::string::String, /// Application metadata. pub app: AppSectionConfig, /// Database configuration. pub database: DatabaseConfig, /// Local data directories. pub data: DataConfig, /// Solana endpoint and listener configuration. pub solana: SolanaConfig, /// Wallet configuration. pub wallet: WalletConfig, /// Execution safety configuration. pub execution: ExecutionConfig, /// Demo application configuration. pub demo: DemoConfig, } /// Application metadata for a profile. #[derive(Clone, Debug, serde::Deserialize, Eq, PartialEq, serde::Serialize, TS)] #[ts( export, export_to = "../frontend/ts/bindings/ks_config/settings/AppSectionConfig.ts" )] pub struct AppSectionConfig { /// Application name. pub name: std::string::String, /// Environment name. pub environment: std::string::String, /// Default auto reconnect flag used when a transport does not override it. pub auto_reconnect_default: bool, } /// Database backend configuration. #[derive(Clone, Debug, serde::Deserialize, Eq, PartialEq, serde::Serialize, TS)] #[ts( export, export_to = "../frontend/ts/bindings/ks_config/settings/DatabaseConfig.ts" )] pub struct DatabaseConfig { /// Enables database storage. pub enabled: bool, /// Selected backend code. pub backend: std::string::String, /// PostgreSQL configuration. pub postgres: PostgresConfig, /// SQLite configuration for tests or imports. pub sqlite: SqliteConfig, } /// PostgreSQL backend configuration. #[derive(Clone, Debug, serde::Deserialize, Eq, PartialEq, serde::Serialize, TS)] #[ts( export, export_to = "../frontend/ts/bindings/ks_config/settings/PostgresConfig.ts" )] pub struct PostgresConfig { /// PostgreSQL URL or local placeholder. pub url: std::string::String, /// Maximum connection count. pub max_connections: u32, /// Connection timeout in milliseconds. #[ts(type = "number")] pub connect_timeout_ms: u64, /// Enables schema initialization at startup. pub auto_initialize_schema: bool, } /// SQLite backend configuration. #[derive(Clone, Debug, serde::Deserialize, Eq, PartialEq, serde::Serialize, TS)] #[ts(export, export_to = "../frontend/ts/bindings/ks_config/settings/SqliteConfig.ts")] pub struct SqliteConfig { /// SQLite database path. pub path: std::string::String, /// Creates the file when missing. pub create_if_missing: bool, /// Busy timeout in milliseconds. #[ts(type = "number")] pub busy_timeout_ms: u64, /// Maximum connection count. pub max_connections: u32, /// Enables schema initialization at startup. pub auto_initialize_schema: bool, /// Enables WAL mode. pub use_wal: bool, } /// Local data directory configuration. #[derive(Clone, Debug, serde::Deserialize, Eq, PartialEq, serde::Serialize, TS)] #[ts(export, export_to = "../frontend/ts/bindings/ks_config/settings/DataConfig.ts")] pub struct DataConfig { /// Wallet directory path. pub wallets_directory: std::string::String, /// Logs directory path. pub logs_directory: std::string::String, } /// Solana endpoints and listener configuration. #[derive(Clone, Debug, serde::Deserialize, Eq, PartialEq, serde::Serialize, TS)] #[ts(export, export_to = "../frontend/ts/bindings/ks_config/settings/SolanaConfig.ts")] pub struct SolanaConfig { /// HTTP JSON-RPC endpoints. pub http_endpoints: std::vec::Vec, /// Standard Solana WebSocket endpoints. pub ws_endpoints: std::vec::Vec, /// Listener declarations. pub listeners: ListenerConfig, } /// HTTP JSON-RPC endpoint configuration. #[derive(Clone, Debug, serde::Deserialize, Eq, PartialEq, serde::Serialize, TS)] #[ts( export, export_to = "../frontend/ts/bindings/ks_config/settings/HttpEndpointConfig.ts" )] pub struct HttpEndpointConfig { /// Endpoint name. pub name: std::string::String, /// Enables this endpoint. pub enabled: bool, /// Provider code. pub provider: std::string::String, /// Cluster code. pub cluster: std::string::String, /// Full endpoint URL. pub url: std::string::String, /// Connection timeout in milliseconds. #[ts(type = "number")] pub connect_timeout_ms: u64, /// Request timeout in milliseconds. #[ts(type = "number")] pub request_timeout_ms: u64, /// Maximum idle connections per host. pub max_idle_connections_per_host: u32, /// Role-specific limits for this URL. pub roles: std::vec::Vec, } /// Standard Solana WebSocket endpoint configuration. #[derive(Clone, Debug, serde::Deserialize, Eq, PartialEq, serde::Serialize, TS)] #[ts( export, export_to = "../frontend/ts/bindings/ks_config/settings/WsEndpointConfig.ts" )] pub struct WsEndpointConfig { /// Endpoint name. pub name: std::string::String, /// Enables this endpoint. pub enabled: bool, /// Provider code. pub provider: std::string::String, /// Cluster code. pub cluster: std::string::String, /// Full endpoint URL. pub url: std::string::String, /// Connection timeout in milliseconds. #[ts(type = "number")] pub connect_timeout_ms: u64, /// Request timeout in milliseconds. #[ts(type = "number")] pub request_timeout_ms: u64, /// Unsubscribe timeout in milliseconds. #[ts(type = "number")] pub unsubscribe_timeout_ms: u64, /// Writer channel capacity. pub write_channel_capacity: u32, /// Event channel capacity. pub event_channel_capacity: u32, /// Enables automatic reconnect for this endpoint. pub auto_reconnect: bool, /// Role-specific limits for this URL. pub roles: std::vec::Vec, } /// Role-specific endpoint limits. #[derive(Clone, Debug, serde::Deserialize, Eq, PartialEq, serde::Serialize, TS)] #[ts( export, export_to = "../frontend/ts/bindings/ks_config/settings/EndpointRoleConfig.ts" )] pub struct EndpointRoleConfig { /// Role code used by endpoint pools. pub role: std::string::String, /// Enables this role on the endpoint. pub enabled: bool, /// Request or subscription kinds handled by this role. pub request_kinds: std::vec::Vec, /// Role priority where lower values are preferred. pub priority: u32, /// Requests per second allowed for this role on this URL. pub requests_per_second: u32, /// Burst capacity allowed for this role on this URL. pub burst_capacity: u32, /// Maximum concurrent requests allowed for this role on this URL. pub max_concurrent_requests: u32, /// Maximum subscriptions allowed for this role on this URL. pub max_subscriptions: u32, /// Pause after a rate limit response in milliseconds. #[ts(type = "number")] pub pause_after_rate_limit_ms: u64, } /// Listener configuration used by standard WebSocket subscriptions. #[derive(Clone, Debug, serde::Deserialize, Eq, PartialEq, serde::Serialize, TS)] #[ts( export, export_to = "../frontend/ts/bindings/ks_config/settings/ListenerConfig.ts" )] pub struct ListenerConfig { /// Enables listener creation. pub enabled: bool, /// Default commitment used by listener subscriptions. pub default_commitment: std::string::String, /// Log listeners filtered by program mentions. pub log_listeners: std::vec::Vec, /// Program account listeners. pub program_listeners: std::vec::Vec, /// Account listeners. pub account_listeners: std::vec::Vec, } /// Log listener filtered by a program identifier mention. #[derive(Clone, Debug, serde::Deserialize, Eq, PartialEq, serde::Serialize, TS)] #[ts( export, export_to = "../frontend/ts/bindings/ks_config/settings/LogListenerConfig.ts" )] pub struct LogListenerConfig { /// Listener name. pub name: std::string::String, /// Enables this listener. pub enabled: bool, /// Required endpoint role. pub endpoint_role: std::string::String, /// Program identifier used as logsSubscribe mention. pub program_id: std::string::String, /// Human-readable listener purpose. pub purpose: std::string::String, } /// Program account listener. #[derive(Clone, Debug, serde::Deserialize, Eq, PartialEq, serde::Serialize, TS)] #[ts( export, export_to = "../frontend/ts/bindings/ks_config/settings/ProgramListenerConfig.ts" )] pub struct ProgramListenerConfig { /// Listener name. pub name: std::string::String, /// Enables this listener. pub enabled: bool, /// Required endpoint role. pub endpoint_role: std::string::String, /// Program identifier used by programSubscribe. pub program_id: std::string::String, /// Human-readable listener purpose. pub purpose: std::string::String, } /// Account listener. #[derive(Clone, Debug, serde::Deserialize, Eq, PartialEq, serde::Serialize, TS)] #[ts( export, export_to = "../frontend/ts/bindings/ks_config/settings/AccountListenerConfig.ts" )] pub struct AccountListenerConfig { /// Listener name. pub name: std::string::String, /// Enables this listener. pub enabled: bool, /// Required endpoint role. pub endpoint_role: std::string::String, /// Account public key used by accountSubscribe. pub account_pubkey: std::string::String, /// Human-readable listener purpose. pub purpose: std::string::String, } /// Wallet configuration. #[derive(Clone, Debug, serde::Deserialize, Eq, PartialEq, serde::Serialize, TS)] #[ts(export, export_to = "../frontend/ts/bindings/ks_config/settings/WalletConfig.ts")] pub struct WalletConfig { /// Wallet directory path. pub wallet_dir: std::string::String, /// Selected cluster name. pub cluster: std::string::String, /// Enables the managed temporary wallet for this profile. pub temporary_wallet_enabled: bool, /// Alias used for the managed temporary wallet. pub temporary_wallet_alias: std::string::String, /// Persists the managed temporary wallet between process restarts. pub temporary_wallet_persist: bool, /// Enables local validator transaction sending. pub localnet_send_enabled: bool, /// Enables devnet transaction sending. pub devnet_send_enabled: bool, /// Enables testnet transaction sending. pub testnet_send_enabled: bool, /// Enables mainnet transaction sending. pub mainnet_send_enabled: bool, } /// Execution safety configuration. #[derive(Clone, Debug, serde::Deserialize, Eq, PartialEq, serde::Serialize, TS)] #[ts( export, export_to = "../frontend/ts/bindings/ks_config/settings/ExecutionConfig.ts" )] pub struct ExecutionConfig { /// Enables dry-run for newly prepared execution policies. pub dry_run_default: bool, /// Requires simulation before send. pub require_simulation: bool, /// Requires explicit operator confirmation before send. pub require_operator_confirmation: bool, /// Maximum spend in lamports for local validator tests. #[ts(type = "number")] pub localnet_max_spend_lamports: u64, /// Maximum spend in lamports for devnet tests. #[ts(type = "number")] pub devnet_max_spend_lamports: u64, /// Maximum spend in lamports for testnet tests. #[ts(type = "number")] pub testnet_max_spend_lamports: u64, /// Maximum spend in lamports for mainnet operations. #[ts(type = "number")] pub mainnet_max_spend_lamports: u64, /// Maximum estimated transaction fee in lamports. #[ts(type = "number")] pub max_fee_lamports: u64, /// Maximum compute-unit price in micro-lamports. #[ts(type = "number")] pub max_compute_unit_price_micro_lamports: u64, /// Maximum accepted age for a recent blockhash in slots. #[ts(type = "number")] pub recent_blockhash_max_age_slots: u64, /// Maximum node retransmission retries requested by `sendTransaction`. pub send_max_retries: u32, /// Delay between transaction confirmation polls. #[ts(type = "number")] pub confirmation_poll_interval_ms: u64, /// Maximum number of transaction confirmation polls. pub confirmation_max_attempts: u32, /// Maximum devnet faucet airdrop allowed for a temporary wallet. #[ts(type = "number")] pub devnet_airdrop_max_lamports: u64, } /// Demo application configuration. #[derive(Clone, Debug, serde::Deserialize, Eq, PartialEq, serde::Serialize, TS)] #[ts(export, export_to = "../frontend/ts/bindings/ks_config/settings/DemoConfig.ts")] pub struct DemoConfig { /// Enables live demo pages. pub live_demo_enabled: bool, /// Enables trading demo pages. pub trading_demo_enabled: bool, } /// Returns the embedded JSON Schema text used for resolved runtime contract validation. pub fn config_json_schema_text() -> &'static str { return CONFIG_JSON_SCHEMA; } /// Parses the embedded resolved runtime JSON Schema into a JSON value. pub fn config_json_schema_value() -> ks_core::Result { let schema_result = serde_json::from_str::(CONFIG_JSON_SCHEMA); return match schema_result { std::result::Result::Ok(schema) => std::result::Result::Ok(schema), std::result::Result::Err(error) => std::result::Result::Err(ks_core::Error::new( "config_schema_parse_failed", error.to_string(), )), }; } /// Validates a raw resolved runtime JSON string against the embedded compatibility schema. pub fn validate_config_json_schema(raw_json: &str) -> ks_core::Result<()> { let schema = match config_json_schema_value() { std::result::Result::Ok(schema) => schema, std::result::Result::Err(error) => return std::result::Result::Err(error), }; let instance = match serde_json::from_str::(raw_json) { std::result::Result::Ok(instance) => instance, std::result::Result::Err(error) => { return std::result::Result::Err(ks_core::Error::new( "config_json_parse_failed", error.to_string(), )); }, }; let validator = match jsonschema::validator_for(&schema) { std::result::Result::Ok(validator) => validator, std::result::Result::Err(error) => { return std::result::Result::Err(ks_core::Error::new( "config_schema_compile_failed", error.to_string(), )); }, }; let validation_result = validator.validate(&instance); return match validation_result { std::result::Result::Ok(()) => std::result::Result::Ok(()), std::result::Result::Err(error) => std::result::Result::Err(ks_core::Error::new( "config_json_schema_validation_failed", error.to_string(), )), }; } /// Parses the resolved runtime application contract from a JSON string and validates it. pub fn parse_config_json(raw_json: &str) -> ks_core::Result { match validate_config_json_schema(raw_json) { std::result::Result::Ok(()) => (), std::result::Result::Err(error) => return std::result::Result::Err(error), } let config = match serde_json::from_str::(raw_json) { std::result::Result::Ok(config) => config, std::result::Result::Err(error) => { return std::result::Result::Err(ks_core::Error::new( "config_json_decode_failed", error.to_string(), )); }, }; return match validate_config(&config) { std::result::Result::Ok(()) => std::result::Result::Ok(config), std::result::Result::Err(error) => std::result::Result::Err(error), }; } /// Reads and parses a resolved runtime application snapshot from a filesystem path. pub fn read_config_json_file(path: &std::path::Path) -> ks_core::Result { let raw_json = match std::fs::read_to_string(path) { std::result::Result::Ok(content) => content, std::result::Result::Err(error) => { return std::result::Result::Err(ks_core::Error::new( "config_file_read_failed", error.to_string(), )); }, }; return parse_config_json(&raw_json); } /// Loads the workspace environment, resolves placeholders and parses one resolved runtime snapshot. pub fn read_config_json_file_with_environment( path: &std::path::Path, workspace_root: &std::path::Path, ) -> ks_core::Result { match crate::load_workspace_environment(workspace_root) { std::result::Result::Ok(_) => (), std::result::Result::Err(error) => return std::result::Result::Err(error), } let raw_json = match std::fs::read_to_string(path) { std::result::Result::Ok(content) => content, std::result::Result::Err(error) => { return std::result::Result::Err(ks_core::Error::new( "config_file_read_failed", error.to_string(), )); }, }; let resolved = crate::resolve_environment_placeholders(&raw_json); return parse_config_json(&resolved); } /// Serializes a configuration value to compact JSON. pub fn serialize_config_json(config: &AppConfig) -> ks_core::Result { match validate_config(config) { std::result::Result::Ok(()) => (), std::result::Result::Err(error) => return std::result::Result::Err(error), } return match serde_json::to_string(config) { std::result::Result::Ok(serialized) => std::result::Result::Ok(serialized), std::result::Result::Err(error) => std::result::Result::Err(ks_core::Error::new( "config_json_serialize_failed", error.to_string(), )), }; } /// Serializes a configuration value to pretty JSON. pub fn serialize_config_json_pretty(config: &AppConfig) -> ks_core::Result { match validate_config(config) { std::result::Result::Ok(()) => (), std::result::Result::Err(error) => return std::result::Result::Err(error), } return match serde_json::to_string_pretty(config) { std::result::Result::Ok(serialized) => std::result::Result::Ok(serialized), std::result::Result::Err(error) => std::result::Result::Err(ks_core::Error::new( "config_json_pretty_serialize_failed", error.to_string(), )), }; } /// Returns the active profile declared by the root configuration. pub fn active_profile(config: &AppConfig) -> ks_core::Result<&ProfileConfig> { for profile in &config.profiles { if profile.name == config.active_profile { return std::result::Result::Ok(profile); } } return std::result::Result::Err(ks_core::Error::new( "active_profile_not_found", config.active_profile.clone(), )); } /// Validates a typed configuration after JSON Schema validation and deserialization. pub fn validate_config(config: &AppConfig) -> ks_core::Result<()> { match validate_root_config(config) { std::result::Result::Ok(()) => (), std::result::Result::Err(error) => return std::result::Result::Err(error), } for profile in &config.profiles { match validate_profile(profile) { std::result::Result::Ok(()) => (), std::result::Result::Err(error) => return std::result::Result::Err(error), } } return match active_profile(config) { std::result::Result::Ok(_) => std::result::Result::Ok(()), std::result::Result::Err(error) => std::result::Result::Err(error), }; } fn validate_root_config(config: &AppConfig) -> ks_core::Result<()> { match require_non_empty(&config.active_profile, "active_profile") { std::result::Result::Ok(()) => (), std::result::Result::Err(error) => return std::result::Result::Err(error), } if config.profiles.is_empty() { return std::result::Result::Err(ks_core::Error::new( "config_profiles_empty", "at least one profile is required", )); } let mut names = std::collections::BTreeSet::::new(); let mut active_count = 0_u32; for profile in &config.profiles { match require_non_empty(&profile.name, "profile.name") { std::result::Result::Ok(()) => (), std::result::Result::Err(error) => return std::result::Result::Err(error), } if !names.insert(profile.name.clone()) { return std::result::Result::Err(ks_core::Error::new( "profile_name_duplicate", profile.name.clone(), )); } if profile.name == config.active_profile { active_count += 1; } } if active_count != 1 { return std::result::Result::Err(ks_core::Error::new( "active_profile_count_invalid", format!("active profile '{}' must match exactly one profile", config.active_profile), )); } return std::result::Result::Ok(()); } fn validate_profile(profile: &ProfileConfig) -> ks_core::Result<()> { match validate_app_section(&profile.app) { std::result::Result::Ok(()) => (), std::result::Result::Err(error) => return std::result::Result::Err(error), } match validate_database(&profile.database) { std::result::Result::Ok(()) => (), std::result::Result::Err(error) => return std::result::Result::Err(error), } match validate_data(&profile.data) { std::result::Result::Ok(()) => (), std::result::Result::Err(error) => return std::result::Result::Err(error), } match validate_solana(&profile.solana) { std::result::Result::Ok(()) => (), std::result::Result::Err(error) => return std::result::Result::Err(error), } match validate_wallet(&profile.wallet) { std::result::Result::Ok(()) => (), std::result::Result::Err(error) => return std::result::Result::Err(error), } match validate_execution(&profile.execution) { std::result::Result::Ok(()) => (), std::result::Result::Err(error) => return std::result::Result::Err(error), } return validate_wallet_execution_pair(&profile.wallet, &profile.execution); } fn validate_app_section(config: &AppSectionConfig) -> ks_core::Result<()> { match require_non_empty(&config.name, "app.name") { std::result::Result::Ok(()) => (), std::result::Result::Err(error) => return std::result::Result::Err(error), } return require_non_empty(&config.environment, "app.environment"); } fn validate_database(config: &DatabaseConfig) -> ks_core::Result<()> { if config.backend != "postgres" && config.backend != "sqlite" { return std::result::Result::Err(ks_core::Error::new( "database_backend_invalid", config.backend.clone(), )); } match require_non_empty(&config.postgres.url, "database.postgres.url") { std::result::Result::Ok(()) => (), std::result::Result::Err(error) => return std::result::Result::Err(error), } if config.postgres.max_connections == 0 || config.sqlite.max_connections == 0 { return std::result::Result::Err(ks_core::Error::new( "database_max_connections_zero", "database connection limits must be greater than zero", )); } if config.postgres.connect_timeout_ms == 0 || config.sqlite.busy_timeout_ms == 0 { return std::result::Result::Err(ks_core::Error::new( "database_timeout_zero", "database timeouts must be greater than zero", )); } return require_non_empty(&config.sqlite.path, "database.sqlite.path"); } fn validate_data(config: &DataConfig) -> ks_core::Result<()> { match require_non_empty(&config.wallets_directory, "data.wallets_directory") { std::result::Result::Ok(()) => (), std::result::Result::Err(error) => return std::result::Result::Err(error), } return require_non_empty(&config.logs_directory, "data.logs_directory"); } fn validate_solana(config: &SolanaConfig) -> ks_core::Result<()> { if config.http_endpoints.is_empty() { return std::result::Result::Err(ks_core::Error::new( "http_endpoints_empty", "at least one HTTP JSON-RPC endpoint is required", )); } if config.ws_endpoints.is_empty() { return std::result::Result::Err(ks_core::Error::new( "ws_endpoints_empty", "at least one standard Solana WebSocket endpoint is required", )); } let mut http_names = std::collections::BTreeSet::::new(); for endpoint in &config.http_endpoints { if !http_names.insert(endpoint.name.clone()) { return std::result::Result::Err(ks_core::Error::new( "http_endpoint_duplicate", endpoint.name.clone(), )); } match validate_http_endpoint(endpoint) { std::result::Result::Ok(()) => (), std::result::Result::Err(error) => return std::result::Result::Err(error), } } let mut ws_names = std::collections::BTreeSet::::new(); for endpoint in &config.ws_endpoints { if !ws_names.insert(endpoint.name.clone()) { return std::result::Result::Err(ks_core::Error::new( "ws_endpoint_duplicate", endpoint.name.clone(), )); } match validate_ws_endpoint(endpoint) { std::result::Result::Ok(()) => (), std::result::Result::Err(error) => return std::result::Result::Err(error), } } return validate_listeners(&config.listeners); } pub(crate) fn validate_http_endpoint(config: &HttpEndpointConfig) -> ks_core::Result<()> { match validate_endpoint_common(&config.name, &config.provider, &config.cluster, &config.roles) { std::result::Result::Ok(()) => (), std::result::Result::Err(error) => return std::result::Result::Err(error), } if !config.url.starts_with("http://") && !config.url.starts_with("https://") { return std::result::Result::Err(ks_core::Error::new( "http_endpoint_url_invalid", config.url.clone(), )); } if config.connect_timeout_ms == 0 || config.request_timeout_ms == 0 || config.max_idle_connections_per_host == 0 { return std::result::Result::Err(ks_core::Error::new( "http_endpoint_limit_zero", config.name.clone(), )); } for role in &config.roles { if role.max_subscriptions != 0 { return std::result::Result::Err(ks_core::Error::new( "http_endpoint_subscriptions_invalid", role.role.clone(), )); } } return std::result::Result::Ok(()); } pub(crate) fn validate_ws_endpoint(config: &WsEndpointConfig) -> ks_core::Result<()> { match validate_endpoint_common(&config.name, &config.provider, &config.cluster, &config.roles) { std::result::Result::Ok(()) => (), std::result::Result::Err(error) => return std::result::Result::Err(error), } if !config.url.starts_with("ws://") && !config.url.starts_with("wss://") { return std::result::Result::Err(ks_core::Error::new( "ws_endpoint_url_invalid", config.url.clone(), )); } if config.connect_timeout_ms == 0 || config.request_timeout_ms == 0 || config.unsubscribe_timeout_ms == 0 || config.write_channel_capacity == 0 || config.event_channel_capacity == 0 { return std::result::Result::Err(ks_core::Error::new( "ws_endpoint_limit_zero", config.name.clone(), )); } return std::result::Result::Ok(()); } fn validate_endpoint_common( name: &str, provider: &str, cluster: &str, roles: &[EndpointRoleConfig], ) -> ks_core::Result<()> { match require_non_empty(name, "endpoint.name") { std::result::Result::Ok(()) => (), std::result::Result::Err(error) => return std::result::Result::Err(error), } match require_non_empty(provider, "endpoint.provider") { std::result::Result::Ok(()) => (), std::result::Result::Err(error) => return std::result::Result::Err(error), } match require_non_empty(cluster, "endpoint.cluster") { std::result::Result::Ok(()) => (), std::result::Result::Err(error) => return std::result::Result::Err(error), } if roles.is_empty() { return std::result::Result::Err(ks_core::Error::new( "endpoint_roles_empty", name.to_string(), )); } let mut role_names = std::collections::BTreeSet::::new(); for role in roles { if !role_names.insert(role.role.clone()) { return std::result::Result::Err(ks_core::Error::new( "endpoint_role_duplicate", role.role.clone(), )); } match validate_endpoint_role(role) { std::result::Result::Ok(()) => (), std::result::Result::Err(error) => return std::result::Result::Err(error), } } return std::result::Result::Ok(()); } fn validate_endpoint_role(config: &EndpointRoleConfig) -> ks_core::Result<()> { match require_non_empty(&config.role, "endpoint.roles.role") { std::result::Result::Ok(()) => (), std::result::Result::Err(error) => return std::result::Result::Err(error), } if config.request_kinds.is_empty() { return std::result::Result::Err(ks_core::Error::new( "endpoint_role_request_kinds_empty", config.role.clone(), )); } for request_kind in &config.request_kinds { match require_non_empty(request_kind, "endpoint.roles.request_kinds") { std::result::Result::Ok(()) => (), std::result::Result::Err(error) => return std::result::Result::Err(error), } } if config.requests_per_second == 0 || config.burst_capacity == 0 || config.max_concurrent_requests == 0 || config.pause_after_rate_limit_ms == 0 { return std::result::Result::Err(ks_core::Error::new( "endpoint_role_limit_zero", config.role.clone(), )); } return std::result::Result::Ok(()); } pub(crate) fn validate_listeners(config: &ListenerConfig) -> ks_core::Result<()> { match require_non_empty(&config.default_commitment, "solana.listeners.default_commitment") { std::result::Result::Ok(()) => (), std::result::Result::Err(error) => return std::result::Result::Err(error), } for listener in &config.log_listeners { match validate_log_listener(listener) { std::result::Result::Ok(()) => (), std::result::Result::Err(error) => return std::result::Result::Err(error), } } for listener in &config.program_listeners { match validate_program_listener(listener) { std::result::Result::Ok(()) => (), std::result::Result::Err(error) => return std::result::Result::Err(error), } } for listener in &config.account_listeners { match validate_account_listener(listener) { std::result::Result::Ok(()) => (), std::result::Result::Err(error) => return std::result::Result::Err(error), } } return std::result::Result::Ok(()); } fn validate_log_listener(config: &LogListenerConfig) -> ks_core::Result<()> { match require_non_empty(&config.name, "log_listener.name") { std::result::Result::Ok(()) => (), std::result::Result::Err(error) => return std::result::Result::Err(error), } match require_non_empty(&config.endpoint_role, "log_listener.endpoint_role") { std::result::Result::Ok(()) => (), std::result::Result::Err(error) => return std::result::Result::Err(error), } return require_non_empty(&config.program_id, "log_listener.program_id"); } fn validate_program_listener(config: &ProgramListenerConfig) -> ks_core::Result<()> { match require_non_empty(&config.name, "program_listener.name") { std::result::Result::Ok(()) => (), std::result::Result::Err(error) => return std::result::Result::Err(error), } match require_non_empty(&config.endpoint_role, "program_listener.endpoint_role") { std::result::Result::Ok(()) => (), std::result::Result::Err(error) => return std::result::Result::Err(error), } return require_non_empty(&config.program_id, "program_listener.program_id"); } fn validate_account_listener(config: &AccountListenerConfig) -> ks_core::Result<()> { match require_non_empty(&config.name, "account_listener.name") { std::result::Result::Ok(()) => (), std::result::Result::Err(error) => return std::result::Result::Err(error), } match require_non_empty(&config.endpoint_role, "account_listener.endpoint_role") { std::result::Result::Ok(()) => (), std::result::Result::Err(error) => return std::result::Result::Err(error), } return require_non_empty(&config.account_pubkey, "account_listener.account_pubkey"); } fn validate_wallet(config: &WalletConfig) -> ks_core::Result<()> { match require_non_empty(&config.wallet_dir, "wallet.wallet_dir") { std::result::Result::Ok(()) => (), std::result::Result::Err(error) => return std::result::Result::Err(error), } match require_non_empty(&config.cluster, "wallet.cluster") { std::result::Result::Ok(()) => (), std::result::Result::Err(error) => return std::result::Result::Err(error), } match validate_wallet_alias(&config.temporary_wallet_alias) { std::result::Result::Ok(()) => (), std::result::Result::Err(error) => return std::result::Result::Err(error), } if config.temporary_wallet_persist && !config.temporary_wallet_enabled { return std::result::Result::Err(ks_core::Error::new( "wallet_temporary_persist_without_enablement", "temporary wallet persistence requires temporary_wallet_enabled", )); } if config.temporary_wallet_enabled && config.cluster == "mainnet-beta" { return std::result::Result::Err(ks_core::Error::new( "wallet_temporary_mainnet_forbidden", "the managed temporary wallet is forbidden on mainnet-beta profiles", )); } return std::result::Result::Ok(()); } fn validate_wallet_alias(value: &str) -> ks_core::Result<()> { if value.is_empty() || value.len() > 64 { return std::result::Result::Err(ks_core::Error::new( "wallet_alias_length_invalid", "temporary wallet alias length must be between 1 and 64 bytes", )); } if !value.bytes().enumerate().all(|(index, byte)| { return byte.is_ascii_alphanumeric() || (index > 0 && (byte == b'_' || byte == b'-')); }) { return std::result::Result::Err(ks_core::Error::new( "wallet_alias_invalid", "temporary wallet alias contains invalid characters", )); } return std::result::Result::Ok(()); } fn validate_execution(config: &ExecutionConfig) -> ks_core::Result<()> { let configured_spend = config .localnet_max_spend_lamports .saturating_add(config.devnet_max_spend_lamports) .saturating_add(config.testnet_max_spend_lamports) .saturating_add(config.mainnet_max_spend_lamports); if configured_spend > 0 && !config.require_simulation { return std::result::Result::Err(ks_core::Error::new( "execution_spend_without_simulation", "any configured spend requires simulation", )); } if config.mainnet_max_spend_lamports > 0 && !config.require_operator_confirmation { return std::result::Result::Err(ks_core::Error::new( "execution_mainnet_without_confirmation", "mainnet spend requires operator confirmation", )); } if config.max_fee_lamports == 0 { return std::result::Result::Err(ks_core::Error::new( "execution_fee_limit_zero", "max_fee_lamports must be greater than zero", )); } if config.recent_blockhash_max_age_slots == 0 { return std::result::Result::Err(ks_core::Error::new( "execution_blockhash_age_zero", "recent_blockhash_max_age_slots must be greater than zero", )); } if config.confirmation_poll_interval_ms == 0 || config.confirmation_poll_interval_ms > 60_000 { return std::result::Result::Err(ks_core::Error::new( "execution_confirmation_poll_interval_invalid", "confirmation_poll_interval_ms must be between 1 and 60000", )); } if config.confirmation_max_attempts == 0 || config.confirmation_max_attempts > 10_000 { return std::result::Result::Err(ks_core::Error::new( "execution_confirmation_attempts_invalid", "confirmation_max_attempts must be between 1 and 10000", )); } return std::result::Result::Ok(()); } fn validate_wallet_execution_pair( wallet: &WalletConfig, execution: &ExecutionConfig, ) -> ks_core::Result<()> { for (enabled, limit, cluster) in [ (wallet.localnet_send_enabled, execution.localnet_max_spend_lamports, "localnet"), (wallet.devnet_send_enabled, execution.devnet_max_spend_lamports, "devnet"), (wallet.testnet_send_enabled, execution.testnet_max_spend_lamports, "testnet"), ( wallet.mainnet_send_enabled, execution.mainnet_max_spend_lamports, "mainnet-beta", ), ] { if enabled && limit == 0 { return std::result::Result::Err(ks_core::Error::new( "execution_enabled_cluster_without_spend_limit", format!("{cluster} sending requires a positive spend limit"), )); } } if wallet.mainnet_send_enabled && !execution.require_operator_confirmation { return std::result::Result::Err(ks_core::Error::new( "execution_mainnet_send_without_confirmation", "mainnet sending requires operator confirmation", )); } if wallet.cluster != "devnet" && execution.devnet_airdrop_max_lamports > 0 { return std::result::Result::Err(ks_core::Error::new( "execution_devnet_airdrop_on_non_devnet_profile", "devnet_airdrop_max_lamports must be zero outside devnet profiles", )); } return std::result::Result::Ok(()); } pub(crate) fn require_non_empty(value: &str, field_name: &str) -> ks_core::Result<()> { if value.trim().is_empty() { return std::result::Result::Err(ks_core::Error::new( "config_field_empty", field_name.to_string(), )); } return std::result::Result::Ok(()); } #[cfg(test)] mod tests { use ts_rs::TS; // rust-rules: derive-import #[test] fn exported_json_configuration_numbers_do_not_use_bigint() { let config = ts_rs::Config::default(); let declarations = [ ::decl(&config), ::decl(&config), ::decl(&config), ::decl(&config), ::decl(&config), ::decl(&config), ]; for declaration in declarations { assert!(!declaration.contains("bigint")); } } const DEFAULT_CONFIG: &str = include_str!("../../test-fixtures/config/resolved.app.config.json"); const EXAMPLE_CONFIG: &str = include_str!("../../test-fixtures/config/example.resolved.app.config.json"); fn parse_default_value() -> serde_json::Value { let result = serde_json::from_str::(DEFAULT_CONFIG); match result { std::result::Result::Ok(value) => return value, std::result::Result::Err(error) => { panic!("default app config must be valid JSON: {error}") }, } } fn value_to_json(value: &serde_json::Value) -> std::string::String { let result = serde_json::to_string(value); match result { std::result::Result::Ok(raw_json) => return raw_json, std::result::Result::Err(error) => panic!("test value must serialize: {error}"), } } #[test] fn schema_text_is_valid_json() { let result = super::config_json_schema_value(); assert!(result.is_ok()); } #[test] fn default_and_example_app_configs_validate_against_schema() { assert!(super::validate_config_json_schema(DEFAULT_CONFIG).is_ok()); assert!(super::validate_config_json_schema(EXAMPLE_CONFIG).is_ok()); } #[test] fn default_app_config_parses_and_resolves_active_profile() { let config_result = super::parse_config_json(DEFAULT_CONFIG); assert!(config_result.is_ok()); let config = match config_result { std::result::Result::Ok(config) => config, std::result::Result::Err(error) => panic!("default app config must parse: {error}"), }; let active_result = super::active_profile(&config); assert!(active_result.is_ok()); //let active = match active_result { std::result::Result::Ok(active) => active, std::result::Result::Err(error) => panic!("active profile must resolve: {error}"), }; //assert_eq!(active.name, "local_devnet"); } #[test] fn default_app_config_serializes_and_roundtrips() { let config_result = super::parse_config_json(DEFAULT_CONFIG); assert!(config_result.is_ok()); let config = match config_result { std::result::Result::Ok(config) => config, std::result::Result::Err(error) => panic!("default app config must parse: {error}"), }; let serialized_result = super::serialize_config_json_pretty(&config); assert!(serialized_result.is_ok()); let serialized = match serialized_result { std::result::Result::Ok(serialized) => serialized, std::result::Result::Err(error) => panic!("default app config must serialize: {error}"), }; let reparsed_result = super::parse_config_json(&serialized); assert!(reparsed_result.is_ok()); let reparsed = match reparsed_result { std::result::Result::Ok(reparsed) => reparsed, std::result::Result::Err(error) => panic!("serialized config must parse: {error}"), }; assert_eq!(config, reparsed); } #[test] fn parser_rejects_missing_active_profile() { let mut value = parse_default_value(); value["active_profile"] = serde_json::Value::String("missing_profile".to_string()); let raw_json = value_to_json(&value); let result = super::parse_config_json(&raw_json); assert!(result.is_err()); } #[test] fn default_app_config_separates_runtime_postgres_profiles() { let value = parse_default_value(); assert_eq!( value["profiles"][0]["database"]["postgres"]["url"], serde_json::Value::String("${KS_SECRET_POSTGRES_DEVNET_URL}".to_string()) ); assert_eq!( value["profiles"][1]["database"]["postgres"]["url"], serde_json::Value::String("${KS_SECRET_POSTGRES_MAINNET_URL}".to_string()) ); assert_eq!( value["profiles"][2]["database"]["postgres"]["url"], serde_json::Value::String("${KS_SECRET_POSTGRES_MAINNET_URL}".to_string()) ); } #[test] fn schema_rejects_profile_enabled_field() { let mut value = parse_default_value(); value["profiles"][0]["enabled"] = serde_json::Value::Bool(true); let raw_json = value_to_json(&value); let result = super::validate_config_json_schema(&raw_json); assert!(result.is_err()); } #[test] fn parser_rejects_duplicate_profile_names() { let mut value = parse_default_value(); value["profiles"][1]["name"] = serde_json::Value::String("local_devnet".to_string()); let raw_json = value_to_json(&value); let result = super::parse_config_json(&raw_json); assert!(result.is_err()); } #[test] fn schema_rejects_unknown_endpoint_secret_field() { let mut value = parse_default_value(); value["profiles"][0]["solana"]["http_endpoints"][0]["secret_env_field"] = serde_json::Value::String("KS_SECRET_HELIUS_API_KEY".to_string()); let raw_json = value_to_json(&value); let result = super::validate_config_json_schema(&raw_json); assert!(result.is_err()); } #[test] fn schema_rejects_runtime_idls_directory_field() { let mut value = parse_default_value(); value["profiles"][0]["data"]["idls_directory"] = serde_json::Value::String("idls".to_string()); let raw_json = value_to_json(&value); let result = super::validate_config_json_schema(&raw_json); assert!(result.is_err()); } #[test] fn schema_rejects_unsupported_transport_surface() { let mut value = parse_default_value(); value["profiles"][0]["solana"]["advanced_streams"] = serde_json::json!([]); let raw_json = value_to_json(&value); let result = super::validate_config_json_schema(&raw_json); assert!(result.is_err()); } #[test] fn parser_rejects_invalid_http_endpoint_url() { let mut value = parse_default_value(); value["profiles"][0]["solana"]["http_endpoints"][0]["url"] = serde_json::Value::String("wss://api.devnet.solana.com".to_string()); let raw_json = value_to_json(&value); let result = super::parse_config_json(&raw_json); assert!(result.is_err()); } #[test] fn parser_rejects_invalid_ws_endpoint_url() { let mut value = parse_default_value(); value["profiles"][0]["solana"]["ws_endpoints"][0]["url"] = serde_json::Value::String("https://api.devnet.solana.com".to_string()); let raw_json = value_to_json(&value); let result = super::parse_config_json(&raw_json); assert!(result.is_err()); } #[test] fn parser_rejects_invalid_temporary_wallet_alias() { let mut value = parse_default_value(); value["profiles"][0]["wallet"]["temporary_wallet_alias"] = serde_json::Value::String("../wallet".to_string()); let raw_json = value_to_json(&value); let result = super::parse_config_json(&raw_json); assert!(result.is_err()); } #[test] fn parser_rejects_persistent_disabled_temporary_wallet() { let mut value = parse_default_value(); value["profiles"][0]["wallet"]["temporary_wallet_enabled"] = serde_json::Value::Bool(false); let raw_json = value_to_json(&value); let result = super::parse_config_json(&raw_json); assert!(result.is_err()); } #[test] fn parser_rejects_enabled_cluster_without_spend_limit() { let mut value = parse_default_value(); value["profiles"][0]["execution"]["devnet_max_spend_lamports"] = serde_json::Value::Number(serde_json::Number::from(0)); let raw_json = value_to_json(&value); let result = super::parse_config_json(&raw_json); assert!(result.is_err()); } #[test] fn parser_rejects_zero_fee_or_blockhash_limits() { for field in [ "max_fee_lamports", "recent_blockhash_max_age_slots", "confirmation_poll_interval_ms", "confirmation_max_attempts", ] { let mut value = parse_default_value(); value["profiles"][0]["execution"][field] = serde_json::Value::Number(serde_json::Number::from(0)); let raw_json = value_to_json(&value); let result = super::parse_config_json(&raw_json); assert!(result.is_err()); } } #[test] fn parser_rejects_http_role_with_subscriptions() { let mut value = parse_default_value(); value["profiles"][0]["solana"]["http_endpoints"][0]["roles"][0]["max_subscriptions"] = serde_json::Value::Number(serde_json::Number::from(1)); let raw_json = value_to_json(&value); let result = super::parse_config_json(&raw_json); assert!(result.is_err()); } }