Files
khadhroony-bot3/ks-config/src/settings.rs
2026-08-10 23:14:17 +02:00

1093 lines
42 KiB
Rust

// file: ks-config/src/settings.rs
// version: 24
//! Resolved runtime configuration models retained during the `0.5.1` source-document split.
const CONFIG_JSON_SCHEMA: &str =
include_str!("../../config/schemas/resolved.app.config.schema.json");
/// Resolved runtime configuration containing every composed named profile.
#[derive(Clone, serde::Deserialize, Eq, PartialEq)]
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<ProfileConfig>,
}
/// Resolved runtime profile selected by the root active profile name.
#[derive(Clone, serde::Deserialize, Eq, PartialEq)]
pub struct ProfileConfig {
/// Profile code.
pub name: std::string::String,
/// Database configuration.
pub database: DatabaseConfig,
/// Solana endpoint and listener configuration.
pub solana: SolanaConfig,
/// Wallet configuration.
pub wallet: WalletConfig,
/// Execution safety configuration.
pub execution: ExecutionConfig,
}
/// Database backend configuration.
#[derive(Clone, serde::Deserialize, Eq, PartialEq)]
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, serde::Deserialize, Eq, PartialEq)]
pub struct PostgresConfig {
/// PostgreSQL URL or local placeholder.
pub url: std::string::String,
/// Maximum connection count.
pub max_connections: u32,
/// Connection timeout in milliseconds.
pub connect_timeout_ms: u64,
/// Enables schema initialization at startup.
pub auto_initialize_schema: bool,
}
/// SQLite backend configuration.
#[derive(Clone, serde::Deserialize, Eq, PartialEq)]
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.
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,
}
/// Solana endpoints and listener configuration.
#[derive(Clone, serde::Deserialize, Eq, PartialEq)]
pub struct SolanaConfig {
/// HTTP JSON-RPC endpoints.
pub http_endpoints: std::vec::Vec<HttpEndpointConfig>,
/// Standard Solana WebSocket endpoints.
pub ws_endpoints: std::vec::Vec<WsEndpointConfig>,
/// Listener declarations.
pub listeners: ListenerConfig,
}
/// HTTP JSON-RPC endpoint configuration.
#[derive(Clone, serde::Deserialize, Eq, PartialEq)]
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.
pub connect_timeout_ms: u64,
/// Request timeout in milliseconds.
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<EndpointRoleConfig>,
}
/// Standard Solana WebSocket endpoint configuration.
#[derive(Clone, serde::Deserialize, Eq, PartialEq)]
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.
pub connect_timeout_ms: u64,
/// Request timeout in milliseconds.
pub request_timeout_ms: u64,
/// Unsubscribe timeout in milliseconds.
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<EndpointRoleConfig>,
}
/// Role-specific endpoint limits.
#[derive(Clone, serde::Deserialize, Eq, PartialEq)]
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<std::string::String>,
/// 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.
pub pause_after_rate_limit_ms: u64,
}
/// Listener configuration used by standard WebSocket subscriptions.
#[derive(Clone, serde::Deserialize, Eq, PartialEq)]
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<LogListenerConfig>,
/// Program account listeners.
pub program_listeners: std::vec::Vec<ProgramListenerConfig>,
/// Account listeners.
pub account_listeners: std::vec::Vec<AccountListenerConfig>,
}
/// Log listener filtered by a program identifier mention.
#[derive(Clone, serde::Deserialize, Eq, PartialEq)]
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, serde::Deserialize, Eq, PartialEq)]
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, serde::Deserialize, Eq, PartialEq)]
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, serde::Deserialize, Eq, PartialEq)]
pub struct WalletConfig {
/// Wallet directory path.
pub wallet_dir: std::string::String,
/// Selected cluster name.
pub cluster: std::string::String,
/// Optional persistent native wallet alias selected by this profile.
pub wallet_alias: std::option::Option<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,
}
/// Execution safety configuration.
#[derive(Clone, serde::Deserialize, Eq, PartialEq)]
pub struct ExecutionConfig {
/// 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,
/// 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.
pub localnet_max_spend_lamports: u64,
/// Maximum spend in lamports for devnet tests.
pub devnet_max_spend_lamports: u64,
/// Maximum spend in lamports for testnet tests.
pub testnet_max_spend_lamports: u64,
/// Maximum spend in lamports for mainnet operations.
pub mainnet_max_spend_lamports: u64,
/// Maximum estimated transaction fee in lamports.
pub max_fee_lamports: u64,
/// Maximum compute-unit price in micro-lamports.
pub max_compute_unit_price_micro_lamports: u64,
/// Maximum accepted age for a recent blockhash in slots.
pub recent_blockhash_max_age_slots: u64,
/// Maximum node retransmission retries requested by `sendTransaction`.
pub send_max_retries: u32,
/// Delay between transaction confirmation polls.
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.
pub devnet_airdrop_max_lamports: u64,
}
/// 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<serde_json::Value> {
let schema_result = serde_json::from_str::<serde_json::Value>(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::<serde_json::Value>(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(_) => std::result::Result::Err(ks_core::Error::new(
"config_json_schema_validation_failed",
"resolved configuration does not satisfy its schema",
)),
};
}
/// Parses the resolved runtime application contract from a JSON string and validates it.
pub fn parse_config_json(raw_json: &str) -> ks_core::Result<AppConfig> {
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::<AppConfig>(raw_json) {
std::result::Result::Ok(config) => config,
std::result::Result::Err(_) => {
return std::result::Result::Err(ks_core::Error::new(
"config_json_decode_failed",
"resolved configuration could not be decoded",
));
},
};
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<AppConfig> {
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<AppConfig> {
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);
}
/// 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::<std::string::String>::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_database(&profile.database) {
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);
}
pub(crate) 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_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::<std::string::String>::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::<std::string::String>::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.name.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.name.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::<std::string::String>::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");
}
pub(crate) 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),
}
if let std::option::Option::Some(alias) = config.wallet_alias.as_deref() {
match validate_wallet_alias(alias, "wallet.wallet_alias") {
std::result::Result::Ok(()) => (),
std::result::Result::Err(error) => return std::result::Result::Err(error),
}
}
match validate_wallet_alias(&config.temporary_wallet_alias, "wallet.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(());
}
pub(crate) fn validate_wallet_alias(value: &str, field: &str) -> ks_core::Result<()> {
if value.is_empty() || value.len() > 64 {
return std::result::Result::Err(ks_core::Error::new(
"wallet_alias_length_invalid",
format!("{field} 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",
format!("{field} contains invalid characters"),
));
}
return std::result::Result::Ok(());
}
pub(crate) 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 [
(
execution.localnet_send_enabled,
execution.localnet_max_spend_lamports,
"localnet",
),
(execution.devnet_send_enabled, execution.devnet_max_spend_lamports, "devnet"),
(execution.testnet_send_enabled, execution.testnet_max_spend_lamports, "testnet"),
(
execution.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 execution.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 {
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::<serde_json::Value>(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 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_persistent_wallet_alias() {
let mut value = parse_default_value();
value["profiles"][0]["wallet"]["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_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());
}
}