1093 lines
42 KiB
Rust
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());
|
|
}
|
|
}
|