diff --git a/CHANGELOG.md b/CHANGELOG.md index 75e0f25ffc..5749ac16d2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -30,6 +30,8 @@ All notable changes to this project will be documented in this file. - `geoprobe-target-sender` gains opt-in `--challenged` flag (default off). When set, the sender extracts the nonce from `Reply0.SinceLastRxNs`, writes it into `Probe1.Sec || Frac`, signs Probe 1 only after Reply 0 is parsed, and surfaces `Reply1.Challenged` on every per-pair log line (JSON `"challenged"`, text `Challenged Inbound:`). Default off preserves the existing pre-sign-both / fire-Probe-1-immediately fast path byte-for-byte. Trade-off: challenged mode inflates `Reply1.SinceLastRxNs` by the sender's Probe 1 signing latency ([#3738](https://github.com/malbeclabs/doublezero/pull/3738)) - state-ingest no longer logs a spurious `server exited with error: use of closed network connection` at shutdown; the listener-closed race during graceful shutdown (`net.ErrClosed`) is now treated as a clean stop alongside `http.ErrServerClosed` - CLI + - Introduce `doublezero-cli-core` (`crates/doublezero-cli-core/`), the shared library crate that every `doublezero--cli` will reuse per RFC-20. Ships `CliContext` + `CliContextBuilder` (resolved configuration value carried into every verb), `RequirementCheck` bitflags aligned with the legacy `CHECK_ID_JSON | CHECK_BALANCE | CHECK_FOUNDATION_ALLOWLIST` bit values, the shared validator set (`validate_pubkey`, `validate_pubkey_or_code`, `validate_code`, `validate_parse_bandwidth`, `validate_parse_delay_ms`, `validate_parse_jitter_ms`, `validate_parse_delay_override_ms`), display formatters (`DisplayVec`, `stringify_vec`), a `tracing` + `tracing-subscriber` `init_logging(verbosity)` helper that writes to stderr, and a `testing` module with a `CliContext` builder for verb unit tests. Existing call sites in `smartcontract/cli` continue to compile unchanged: `smartcontract/cli/src/validators.rs` and `formatters.rs` are now thin `pub use` shims over the core crate. + - Add `solana_l1_rpc_url` to `doublezero-config::NetworkConfig`. Per RFC-20 §Environments: `mainnet-beta` resolves to `https://api.mainnet-beta.solana.com`, `testnet` to `https://api.testnet.solana.com`, `devnet` to `https://api.testnet.solana.com` (intentional asymmetry, see RFC), and `local` to `http://localhost:8899`. A new `DZ_SOLANA_RPC_URL` environment variable overrides the resolved value, mirroring the existing `DZ_LEDGER_RPC_URL` / `DZ_LEDGER_WS_RPC_URL` overrides. - Drop the activator-only pollers from `doublezero` (user and multicastgroup activation waits). The `--wait` flag on `user create`, `user create-subscribe`, `user subscribe`, `multicastgroup create`, and `multicastgroup update` now fetches the post-create state once instead of polling; creates are atomic to `Activated` post-RFC-11, so the wait loop was watching a transition that no longer happens ([#3614](https://github.com/malbeclabs/doublezero/issues/3614)) - `doublezero geolocation` `probe ...` and `user ...` mirrors `doublezero-geolocation` versions; new `--geo-program-id` global flag, `config get/set` include Geolocation Program ID; new `-init-geolocation-config` for init of geolocation program - cli: `doublezero geolocation` `probe ...` and `user ...` mirrors `doublezero-geolocation` versions; new `--geo-program-id` global flag, `config get/set` include Geolocation Program ID. diff --git a/Cargo.lock b/Cargo.lock index 7981b6408f..57a003c01f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1546,6 +1546,25 @@ dependencies = [ "tokio", ] +[[package]] +name = "doublezero-cli-core" +version = "0.24.0" +dependencies = [ + "bitflags", + "clap", + "doublezero-config", + "doublezero-program-common", + "eyre", + "serde", + "serde_json", + "serial_test", + "solana-sdk", + "tempfile", + "thiserror 2.0.17", + "tracing", + "tracing-subscriber", +] + [[package]] name = "doublezero-config" version = "0.24.0" @@ -1703,6 +1722,7 @@ dependencies = [ "chrono", "clap", "console", + "doublezero-cli-core", "doublezero-config", "doublezero-geolocation", "doublezero-program-common", diff --git a/Cargo.toml b/Cargo.toml index 7c654deeb1..88d25ef721 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -12,6 +12,7 @@ members = [ "smartcontract/programs/doublezero-geolocation", "smartcontract/programs/common", "e2e/docker/ledger/fork-accounts", + "crates/doublezero-cli-core", "crates/sentinel", ] default-members = [] @@ -103,6 +104,7 @@ tokio = { version = "1", default-features = false, features = [ "rt-multi-thread", "signal", ] } +doublezero-cli-core = { path = "crates/doublezero-cli-core" } doublezero-config = { path = "config" } doublezero-sentinel = { path = "crates/sentinel" } doublezero_cli = { path = "smartcontract/cli" } diff --git a/config/src/constants.rs b/config/src/constants.rs index e141df713a..907ac75fd2 100644 --- a/config/src/constants.rs +++ b/config/src/constants.rs @@ -15,6 +15,7 @@ pub const ENV_MAINNET_BETA_DOUBLEZERO_LEDGER_RPC_URL: &str = "https://doublezero-mainnet-beta.rpcpool.com/db336024-e7a8-46b1-80e5-352dd77060ab"; pub const ENV_MAINNET_BETA_DOUBLEZERO_LEDGER_WS_RPC_URL: &str = "wss://doublezero-mainnet-beta.rpcpool.com/db336024-e7a8-46b1-80e5-352dd77060ab"; +pub const ENV_MAINNET_BETA_SOLANA_L1_RPC_URL: &str = "https://api.mainnet-beta.solana.com"; pub const ENV_MAINNET_BETA_SERVICEABILITY_PUBKEY: Pubkey = Pubkey::from_str_const("ser2VaTMAcYTaauMrTSfSrxBaUDq7BLNs2xfUugTAGv"); pub const ENV_MAINNET_BETA_TELEMETRY_PUBKEY: Pubkey = @@ -29,6 +30,7 @@ pub const ENV_TESTNET_DOUBLEZERO_LEDGER_RPC_URL: &str = "https://doublezerolocalnet.rpcpool.com/8a4fd3f4-0977-449f-88c7-63d4b0f10f16"; pub const ENV_TESTNET_DOUBLEZERO_LEDGER_WS_RPC_URL: &str = "wss://doublezerolocalnet.rpcpool.com/8a4fd3f4-0977-449f-88c7-63d4b0f10f16/whirligig"; +pub const ENV_TESTNET_SOLANA_L1_RPC_URL: &str = "https://api.testnet.solana.com"; pub const ENV_TESTNET_SERVICEABILITY_PUBKEY: Pubkey = Pubkey::from_str_const("DZtnuQ839pSaDMFG5q1ad2V95G82S5EC4RrB3Ndw2Heb"); pub const ENV_TESTNET_TELEMETRY_PUBKEY: Pubkey = @@ -39,10 +41,14 @@ pub const ENV_TESTNET_GEOLOCATION_PUBKEY: Pubkey = Pubkey::from_str_const("3AG2BCA7gAm47Q6xZzPQcUUYvnBjxAvPKnPz919cxHF4"); // Constants related to DoubleZero devnet configuration +// +// Devnet intentionally points at Solana testnet for L1 access, matching the +// existing config-crate mapping documented in RFC-20 (§Environments). pub const ENV_DEVNET_DOUBLEZERO_LEDGER_RPC_URL: &str = "https://doublezerolocalnet.rpcpool.com/8a4fd3f4-0977-449f-88c7-63d4b0f10f16"; pub const ENV_LEDGER_DOUBLEZERO_DEVNET_WS_RPC_URL: &str = "wss://doublezerolocalnet.rpcpool.com/8a4fd3f4-0977-449f-88c7-63d4b0f10f16/whirligig"; +pub const ENV_DEVNET_SOLANA_L1_RPC_URL: &str = "https://api.testnet.solana.com"; pub const ENV_DEVNET_SERVICEABILITY_PUBKEY: Pubkey = Pubkey::from_str_const("GYhQDKuESrasNZGyhMJhGYFtbzNijYhcrN9poSqCQVah"); pub const ENV_DEVNET_TELEMETRY_PUBKEY: Pubkey = @@ -55,6 +61,7 @@ pub const ENV_DEVNET_GEOLOCATION_PUBKEY: Pubkey = // Constants related to DoubleZero localnet configuration pub const ENV_LOCAL_DOUBLEZERO_LEDGER_RPC_URL: &str = "http://localhost:8899"; pub const ENV_LOCAL_DOUBLEZERO_LEDGER_WS_RPC_URL: &str = "ws://localhost:8899"; +pub const ENV_LOCAL_SOLANA_L1_RPC_URL: &str = "http://localhost:8899"; pub const ENV_LOCAL_SERVICEABILITY_PUBKEY: Pubkey = Pubkey::from_str_const("7CTniUa88iJKUHTrCkB4TjAoG6TD7AMivhQeuqN2LPtX"); pub const ENV_LOCAL_TELEMETRY_PUBKEY: Pubkey = diff --git a/config/src/env.rs b/config/src/env.rs index bf0d7ac316..ea98c02d8d 100644 --- a/config/src/env.rs +++ b/config/src/env.rs @@ -62,6 +62,7 @@ impl Environment { Environment::MainnetBeta => NetworkConfig { ledger_public_rpc_url: ENV_MAINNET_BETA_DOUBLEZERO_LEDGER_RPC_URL.to_string(), ledger_public_ws_rpc_url: ENV_MAINNET_BETA_DOUBLEZERO_LEDGER_WS_RPC_URL.to_string(), + solana_l1_rpc_url: ENV_MAINNET_BETA_SOLANA_L1_RPC_URL.to_string(), serviceability_program_id: ENV_MAINNET_BETA_SERVICEABILITY_PUBKEY, telemetry_program_id: ENV_MAINNET_BETA_TELEMETRY_PUBKEY, internet_latency_collector_pk: ENV_MAINNET_BETA_INTERNET_LATENCY_COLLECTOR_PUBKEY, @@ -70,6 +71,7 @@ impl Environment { Environment::Testnet => NetworkConfig { ledger_public_rpc_url: ENV_TESTNET_DOUBLEZERO_LEDGER_RPC_URL.to_string(), ledger_public_ws_rpc_url: ENV_TESTNET_DOUBLEZERO_LEDGER_WS_RPC_URL.to_string(), + solana_l1_rpc_url: ENV_TESTNET_SOLANA_L1_RPC_URL.to_string(), serviceability_program_id: ENV_TESTNET_SERVICEABILITY_PUBKEY, telemetry_program_id: ENV_TESTNET_TELEMETRY_PUBKEY, internet_latency_collector_pk: ENV_TESTNET_INTERNET_LATENCY_COLLECTOR_PUBKEY, @@ -78,6 +80,7 @@ impl Environment { Environment::Devnet => NetworkConfig { ledger_public_rpc_url: ENV_DEVNET_DOUBLEZERO_LEDGER_RPC_URL.to_string(), ledger_public_ws_rpc_url: ENV_LEDGER_DOUBLEZERO_DEVNET_WS_RPC_URL.to_string(), + solana_l1_rpc_url: ENV_DEVNET_SOLANA_L1_RPC_URL.to_string(), serviceability_program_id: ENV_DEVNET_SERVICEABILITY_PUBKEY, telemetry_program_id: ENV_DEVNET_TELEMETRY_PUBKEY, internet_latency_collector_pk: ENV_DEVNET_INTERNET_LATENCY_COLLECTOR_PUBKEY, @@ -86,6 +89,7 @@ impl Environment { Environment::Local => NetworkConfig { ledger_public_rpc_url: ENV_LOCAL_DOUBLEZERO_LEDGER_RPC_URL.to_string(), ledger_public_ws_rpc_url: ENV_LOCAL_DOUBLEZERO_LEDGER_WS_RPC_URL.to_string(), + solana_l1_rpc_url: ENV_LOCAL_SOLANA_L1_RPC_URL.to_string(), serviceability_program_id: ENV_LOCAL_SERVICEABILITY_PUBKEY, telemetry_program_id: ENV_LOCAL_TELEMETRY_PUBKEY, internet_latency_collector_pk: ENV_LOCAL_INTERNET_LATENCY_COLLECTOR_PUBKEY, @@ -99,6 +103,9 @@ impl Environment { if std::env::var("DZ_LEDGER_WS_RPC_URL").is_ok() { config.ledger_public_ws_rpc_url = std::env::var("DZ_LEDGER_WS_RPC_URL").unwrap(); } + if std::env::var("DZ_SOLANA_RPC_URL").is_ok() { + config.solana_l1_rpc_url = std::env::var("DZ_SOLANA_RPC_URL").unwrap(); + } Ok(config) } @@ -108,6 +115,10 @@ impl Environment { pub struct NetworkConfig { pub ledger_public_rpc_url: String, pub ledger_public_ws_rpc_url: String, + /// Solana L1 RPC URL. Distinct from the DZ ledger transport: per RFC-20 + /// (§Backend client patterns), the DZ ledger and Solana L1 are separate + /// backends with separate override flags (`--url` vs `--solana-url`). + pub solana_l1_rpc_url: String, pub serviceability_program_id: Pubkey, pub telemetry_program_id: Pubkey, pub internet_latency_collector_pk: Pubkey, @@ -250,6 +261,38 @@ mod tests { std::env::remove_var("DZ_LEDGER_WS_RPC_URL"); } + #[test] + #[serial] + fn test_network_config_solana_l1_urls() { + assert_eq!( + Environment::MainnetBeta.config().unwrap().solana_l1_rpc_url, + "https://api.mainnet-beta.solana.com", + ); + assert_eq!( + Environment::Testnet.config().unwrap().solana_l1_rpc_url, + "https://api.testnet.solana.com", + ); + // Devnet intentionally points at Solana testnet, matching RFC-20 + // §Environments. + assert_eq!( + Environment::Devnet.config().unwrap().solana_l1_rpc_url, + "https://api.testnet.solana.com", + ); + assert_eq!( + Environment::Local.config().unwrap().solana_l1_rpc_url, + "http://localhost:8899", + ); + } + + #[test] + #[serial] + fn test_network_config_solana_url_env_override() { + std::env::set_var("DZ_SOLANA_RPC_URL", "https://custom-solana.example/"); + let config = Environment::MainnetBeta.config().unwrap(); + assert_eq!(config.solana_l1_rpc_url, "https://custom-solana.example/"); + std::env::remove_var("DZ_SOLANA_RPC_URL"); + } + #[test] #[serial] fn test_environment_match_environment() { diff --git a/crates/doublezero-cli-core/Cargo.toml b/crates/doublezero-cli-core/Cargo.toml new file mode 100644 index 0000000000..d9739e4903 --- /dev/null +++ b/crates/doublezero-cli-core/Cargo.toml @@ -0,0 +1,30 @@ +[package] +name = "doublezero-cli-core" + +version.workspace = true +authors.workspace = true +edition.workspace = true +homepage.workspace = true +license.workspace = true +repository.workspace = true + +[lib] +name = "doublezero_cli_core" + +[dependencies] +bitflags.workspace = true +clap.workspace = true +eyre.workspace = true +serde.workspace = true +serde_json.workspace = true +solana-sdk.workspace = true +thiserror.workspace = true +tracing.workspace = true +tracing-subscriber.workspace = true + +doublezero-config.workspace = true +doublezero-program-common.workspace = true + +[dev-dependencies] +serial_test.workspace = true +tempfile.workspace = true diff --git a/crates/doublezero-cli-core/src/context.rs b/crates/doublezero-cli-core/src/context.rs new file mode 100644 index 0000000000..90e71498a8 --- /dev/null +++ b/crates/doublezero-cli-core/src/context.rs @@ -0,0 +1,337 @@ +//! Resolved, read-only configuration shared between the binary and every +//! module crate. +//! +//! Per RFC-20 (§CliContext): the binary populates `CliContext` once at +//! startup from `--env` plus any explicit flag or environment-variable +//! overrides. Modules treat the value as read-only. The context carries +//! resolved values only — URLs, paths, identifiers, the signer path, and +//! the output-format hint — never live clients. + +use serde::{Deserialize, Serialize}; +use solana_sdk::pubkey::Pubkey; +use std::path::PathBuf; + +use doublezero_config::Environment; + +/// The output-format hint carried by `CliContext`. +/// +/// Verbs continue to own their own `--json` / `--json-compact` flags per RFC +/// §Output ("per-command `--json` keeps coupling to the binary low"). This +/// hint exists so the binary can communicate a default when a verb chooses to +/// honor it. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] +pub enum OutputFormat { + #[default] + Table, + Json, + JsonCompact, +} + +/// Resolved configuration carried from the binary into every module verb. +#[derive(Debug, Clone)] +pub struct CliContext { + /// Selected environment (e.g., `Devnet`). + pub env: Environment, + + /// DZ ledger RPC URL (HTTPS). + pub ledger_rpc_url: String, + /// DZ ledger WebSocket URL. + pub ledger_ws_rpc_url: String, + /// Solana L1 RPC URL. + pub solana_l1_rpc_url: String, + + /// Serviceability program ID. + pub serviceability_program_id: Pubkey, + /// Geolocation program ID. + pub geolocation_program_id: Pubkey, + /// Telemetry program ID. + pub telemetry_program_id: Pubkey, + + /// Path to the signer keypair file, if provided. + /// + /// Modules construct their own `Keypair` from this path lazily; the + /// context never holds keypair material directly (RFC-20 §Security). + pub keypair_path: Option, + + /// Daemon Unix socket path, if provided. + pub daemon_socket_path: Option, + + /// Default output-format hint. + pub output_format: OutputFormat, +} + +/// Builder for `CliContext`. The binary populates a builder from parsed +/// global flags and per-field overrides; the builder applies `--env`-derived +/// defaults from `doublezero-config` and yields a fully resolved context. +#[derive(Debug, Default)] +pub struct CliContextBuilder { + env: Option, + ledger_rpc_url: Option, + ledger_ws_rpc_url: Option, + solana_l1_rpc_url: Option, + serviceability_program_id: Option, + geolocation_program_id: Option, + telemetry_program_id: Option, + keypair_path: Option, + daemon_socket_path: Option, + output_format: OutputFormat, +} + +impl CliContextBuilder { + pub fn new() -> Self { + Self::default() + } + + pub fn with_env(mut self, env: Environment) -> Self { + self.env = Some(env); + self + } + + pub fn with_ledger_rpc_url(mut self, url: impl Into) -> Self { + self.ledger_rpc_url = Some(url.into()); + self + } + + pub fn with_ledger_ws_rpc_url(mut self, url: impl Into) -> Self { + self.ledger_ws_rpc_url = Some(url.into()); + self + } + + pub fn with_solana_l1_rpc_url(mut self, url: impl Into) -> Self { + self.solana_l1_rpc_url = Some(url.into()); + self + } + + pub fn with_serviceability_program_id(mut self, id: Pubkey) -> Self { + self.serviceability_program_id = Some(id); + self + } + + pub fn with_geolocation_program_id(mut self, id: Pubkey) -> Self { + self.geolocation_program_id = Some(id); + self + } + + pub fn with_telemetry_program_id(mut self, id: Pubkey) -> Self { + self.telemetry_program_id = Some(id); + self + } + + pub fn with_keypair_path(mut self, path: PathBuf) -> Self { + self.keypair_path = Some(path); + self + } + + pub fn with_daemon_socket_path(mut self, path: PathBuf) -> Self { + self.daemon_socket_path = Some(path); + self + } + + pub fn with_output_format(mut self, format: OutputFormat) -> Self { + self.output_format = format; + self + } + + /// Resolve all fields and produce a `CliContext`. + /// + /// If `env` is set and a given override is `None`, the corresponding + /// value is sourced from the `doublezero-config` `NetworkConfig` for that + /// environment. If `env` is unset, the caller must supply every URL and + /// program-ID field explicitly. + /// + /// When the caller supplies `ledger_rpc_url` but not `ledger_ws_rpc_url`, + /// the WebSocket URL is derived from the RPC URL by scheme swap + /// (`https → wss`, `http → ws`) so that a custom RPC override is not + /// silently paired with a stale env-default WS URL. + pub fn build(self) -> eyre::Result { + let Some(env) = self.env else { + return self.build_without_env(); + }; + let config = env.config()?; + + let ledger_rpc_url_override = self.ledger_rpc_url.is_some(); + let ledger_rpc_url = self.ledger_rpc_url.unwrap_or(config.ledger_public_rpc_url); + let ledger_ws_rpc_url = match self.ledger_ws_rpc_url { + Some(ws) => ws, + None if ledger_rpc_url_override => derive_ws_from_rpc(&ledger_rpc_url), + None => config.ledger_public_ws_rpc_url, + }; + + Ok(CliContext { + env, + ledger_rpc_url, + ledger_ws_rpc_url, + solana_l1_rpc_url: self.solana_l1_rpc_url.unwrap_or(config.solana_l1_rpc_url), + serviceability_program_id: self + .serviceability_program_id + .unwrap_or(config.serviceability_program_id), + geolocation_program_id: self + .geolocation_program_id + .unwrap_or(config.geolocation_program_id), + telemetry_program_id: self + .telemetry_program_id + .unwrap_or(config.telemetry_program_id), + keypair_path: self.keypair_path, + daemon_socket_path: self.daemon_socket_path, + output_format: self.output_format, + }) + } + + fn build_without_env(self) -> eyre::Result { + let ledger_rpc_url = self + .ledger_rpc_url + .ok_or_else(|| eyre::eyre!("ledger_rpc_url is required when env is unset"))?; + let ledger_ws_rpc_url = self + .ledger_ws_rpc_url + .unwrap_or_else(|| derive_ws_from_rpc(&ledger_rpc_url)); + let solana_l1_rpc_url = self + .solana_l1_rpc_url + .ok_or_else(|| eyre::eyre!("solana_l1_rpc_url is required when env is unset"))?; + let serviceability_program_id = self.serviceability_program_id.ok_or_else(|| { + eyre::eyre!("serviceability_program_id is required when env is unset") + })?; + let geolocation_program_id = self + .geolocation_program_id + .ok_or_else(|| eyre::eyre!("geolocation_program_id is required when env is unset"))?; + let telemetry_program_id = self + .telemetry_program_id + .ok_or_else(|| eyre::eyre!("telemetry_program_id is required when env is unset"))?; + + Ok(CliContext { + env: Environment::default(), + ledger_rpc_url, + ledger_ws_rpc_url, + solana_l1_rpc_url, + serviceability_program_id, + geolocation_program_id, + telemetry_program_id, + keypair_path: self.keypair_path, + daemon_socket_path: self.daemon_socket_path, + output_format: self.output_format, + }) + } +} + +fn derive_ws_from_rpc(rpc: &str) -> String { + if let Some(rest) = rpc.strip_prefix("https://") { + return format!("wss://{rest}"); + } + if let Some(rest) = rpc.strip_prefix("http://") { + return format!("ws://{rest}"); + } + rpc.to_string() +} + +#[cfg(test)] +mod tests { + use super::*; + use serial_test::serial; + + #[test] + #[serial] + fn builder_resolves_from_env_defaults() { + let ctx = CliContextBuilder::new() + .with_env(Environment::MainnetBeta) + .build() + .unwrap(); + assert_eq!(ctx.env, Environment::MainnetBeta); + assert!(ctx.ledger_rpc_url.starts_with("https://")); + assert!(ctx.ledger_ws_rpc_url.starts_with("wss://")); + assert!(!ctx.solana_l1_rpc_url.is_empty()); + assert_eq!(ctx.output_format, OutputFormat::Table); + assert!(ctx.keypair_path.is_none()); + } + + #[test] + #[serial] + fn builder_per_field_overrides_win_over_env() { + let ctx = CliContextBuilder::new() + .with_env(Environment::Devnet) + .with_ledger_rpc_url("https://custom-rpc.example/") + .build() + .unwrap(); + // Per-field overrides win over env. Binaries are free to forbid this + // combination at the CLI layer (see `client/doublezero`); the builder + // itself stays permissive so library callers can mix env-derived + // defaults with targeted overrides. + assert_eq!(ctx.ledger_rpc_url, "https://custom-rpc.example/"); + // The WS URL is derived from the custom RPC, not left at devnet's + // default — otherwise the resolved context would be inconsistent. + assert_eq!(ctx.ledger_ws_rpc_url, "wss://custom-rpc.example/"); + } + + #[test] + #[serial] + fn builder_derives_wss_from_https_rpc_override() { + let ctx = CliContextBuilder::new() + .with_env(Environment::Devnet) + .with_ledger_rpc_url("https://custom-rpc.example/") + .build() + .unwrap(); + assert_eq!(ctx.ledger_rpc_url, "https://custom-rpc.example/"); + assert_eq!(ctx.ledger_ws_rpc_url, "wss://custom-rpc.example/"); + } + + #[test] + #[serial] + fn builder_derives_ws_from_http_rpc_override() { + let ctx = CliContextBuilder::new() + .with_env(Environment::Devnet) + .with_ledger_rpc_url("http://localhost:8899/") + .build() + .unwrap(); + assert_eq!(ctx.ledger_rpc_url, "http://localhost:8899/"); + assert_eq!(ctx.ledger_ws_rpc_url, "ws://localhost:8899/"); + } + + #[test] + #[serial] + fn builder_explicit_ws_wins_over_derivation() { + let ctx = CliContextBuilder::new() + .with_env(Environment::Devnet) + .with_ledger_rpc_url("https://custom-rpc.example/") + .with_ledger_ws_rpc_url("wss://other-ws.example/") + .build() + .unwrap(); + assert_eq!(ctx.ledger_ws_rpc_url, "wss://other-ws.example/"); + } + + #[test] + #[serial] + fn builder_env_only_uses_network_config_ws() { + let env_ctx = CliContextBuilder::new() + .with_env(Environment::Devnet) + .build() + .unwrap(); + let cfg = Environment::Devnet.config().unwrap(); + assert_eq!(env_ctx.ledger_rpc_url, cfg.ledger_public_rpc_url); + assert_eq!(env_ctx.ledger_ws_rpc_url, cfg.ledger_public_ws_rpc_url); + } + + #[test] + #[serial] + fn builder_without_env_requires_all_fields() { + let pk = solana_sdk::pubkey::Pubkey::new_unique(); + let ctx = CliContextBuilder::new() + .with_ledger_rpc_url("https://custom-rpc.example/") + .with_solana_l1_rpc_url("https://custom-l1.example/") + .with_serviceability_program_id(pk) + .with_geolocation_program_id(pk) + .with_telemetry_program_id(pk) + .build() + .unwrap(); + assert_eq!(ctx.ledger_rpc_url, "https://custom-rpc.example/"); + assert_eq!(ctx.ledger_ws_rpc_url, "wss://custom-rpc.example/"); + assert_eq!(ctx.solana_l1_rpc_url, "https://custom-l1.example/"); + } + + #[test] + #[serial] + fn builder_without_env_fails_when_field_missing() { + let err = CliContextBuilder::new() + .with_ledger_rpc_url("https://custom-rpc.example/") + .build() + .unwrap_err(); + assert!(err.to_string().contains("solana_l1_rpc_url is required")); + } +} diff --git a/crates/doublezero-cli-core/src/error.rs b/crates/doublezero-cli-core/src/error.rs new file mode 100644 index 0000000000..ba1ac3446d --- /dev/null +++ b/crates/doublezero-cli-core/src/error.rs @@ -0,0 +1,55 @@ +//! Error and result types for CLI modules. +//! +//! Per RFC-20 (§Error handling and requirements): "All `execute` functions +//! return a fallible result. The binary catches the top-level error and +//! renders a single-line message followed by a chain of causes." +//! +//! `Result` aliases `eyre::Result` so existing modules that already use eyre +//! can adopt this crate without churn. `CliError` is a `thiserror`-based +//! enum for the small set of structured errors the core helpers produce +//! themselves (missing keypair, malformed env-var override, etc.); module +//! errors continue to flow as `eyre::Report`. + +use thiserror::Error; + +/// Result alias used across `doublezero-cli-core` and module verbs. +pub type Result = eyre::Result; + +/// Structured errors produced by `doublezero-cli-core` helpers. +#[derive(Debug, Error)] +pub enum CliError { + #[error("no keypair available: {0}")] + MissingKeypair(String), + + #[error("invalid environment variable {name}: {reason}")] + InvalidEnvVar { name: String, reason: String }, +} + +/// Render a top-level error to stderr as a single-line message followed by +/// the chain of causes, matching RFC-20 §Error handling. +/// +/// Intended to be called once at the binary's top-level error handler. +pub fn render_error(err: &E) +where + E: AsRef, +{ + let err: &dyn std::error::Error = err.as_ref(); + eprintln!("Error: {err}"); + let mut source = err.source(); + while let Some(cause) = source { + eprintln!(" caused by: {cause}"); + source = cause.source(); + } +} + +/// Render an `eyre::Report` using its native chain iteration (preferred when +/// the binary uses eyre, since it keeps wrapped contexts). +pub fn render_eyre(err: &eyre::Report) { + let mut chain = err.chain(); + if let Some(first) = chain.next() { + eprintln!("Error: {first}"); + } + for cause in chain { + eprintln!(" caused by: {cause}"); + } +} diff --git a/crates/doublezero-cli-core/src/formatters.rs b/crates/doublezero-cli-core/src/formatters.rs new file mode 100644 index 0000000000..c3000c342d --- /dev/null +++ b/crates/doublezero-cli-core/src/formatters.rs @@ -0,0 +1,58 @@ +//! Shared display formatters used by CLI verbs to render table output. +//! +//! Per RFC-20 (§Output conventions): "Modules SHOULD use shared display +//! helpers from the CLI core crate for common types (pubkey, bandwidth, +//! latency, IPv4)." This module is the home for those helpers; today it +//! exposes the `DisplayVec` helper and `stringify_vec` shared by several +//! verbs. Type-specific formatters (bandwidth, latency, IPv4, pubkey) move +//! here as they are touched. + +use std::fmt::{self, Display}; + +pub struct DisplayVec<'a, T: Display>(pub &'a Vec); + +impl<'a, T: Display> Display for DisplayVec<'a, T> { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let mut iter = self.0.iter(); + if let Some(first) = iter.next() { + write!(f, "{first}")?; + for item in iter { + write!(f, ",{item}")?; + } + } + Ok(()) + } +} + +impl<'a, T: Display> From<&'a Vec> for DisplayVec<'a, T> { + fn from(vec: &'a Vec) -> Self { + DisplayVec(vec) + } +} + +pub fn stringify_vec(v: &Vec) -> String { + format!("{}", DisplayVec(v)) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn display_vec_joins_with_comma() { + let v = vec![1, 2, 3]; + assert_eq!(stringify_vec(&v), "1,2,3"); + } + + #[test] + fn display_vec_handles_empty() { + let v: Vec = vec![]; + assert_eq!(stringify_vec(&v), ""); + } + + #[test] + fn display_vec_handles_singleton() { + let v = vec!["only"]; + assert_eq!(stringify_vec(&v), "only"); + } +} diff --git a/crates/doublezero-cli-core/src/lib.rs b/crates/doublezero-cli-core/src/lib.rs new file mode 100644 index 0000000000..9dfa0c5047 --- /dev/null +++ b/crates/doublezero-cli-core/src/lib.rs @@ -0,0 +1,21 @@ +//! Shared utilities for DoubleZero CLI modules. +//! +//! Defined by RFC-20 (`rfcs/rfc20-cli-standardization.md`). This crate is the +//! small, dependency-light layer that every `doublezero--cli` module +//! crate reuses: a resolved configuration value (`CliContext`), preflight +//! bitflags, the shared input validators, the shared display formatters, and +//! the diagnostic-logging facade. Module crates own their typed backend +//! clients; this crate has no opinion about transports. + +pub mod context; +pub mod error; +pub mod formatters; +pub mod logging; +pub mod requirements; +pub mod testing; +pub mod validators; + +pub use context::{CliContext, CliContextBuilder, OutputFormat}; +pub use error::{render_error, render_eyre, CliError, Result}; +pub use logging::init_logging; +pub use requirements::RequirementCheck; diff --git a/crates/doublezero-cli-core/src/logging.rs b/crates/doublezero-cli-core/src/logging.rs new file mode 100644 index 0000000000..61a9fdaa17 --- /dev/null +++ b/crates/doublezero-cli-core/src/logging.rs @@ -0,0 +1,45 @@ +//! Diagnostic-logging facade. +//! +//! Per RFC-20 (§Diagnostic logging): diagnostic output goes to standard +//! error through `tracing`. The binary configures the global log level from +//! `--verbose`; modules use the standard log macros (`debug!`, `info!`, +//! `warn!`, `error!`, `trace!`) for anything that explains what a verb is +//! doing internally. JSON output on stdout stays parseable because logs go +//! to stderr. + +use tracing_subscriber::EnvFilter; + +/// Configure the global `tracing` subscriber from a verbosity count. +/// +/// - `0` → `warn` (default for non-verbose runs) +/// - `1` → `debug` (`-v`) +/// - `2` or more → `trace` (`-vv`) +/// +/// If the `RUST_LOG` environment variable is set, it overrides the verbosity +/// argument; this matches what operators expect from standard Rust logging +/// stacks and makes it possible to tune per-module log levels from the +/// environment without changing CLI flags. +/// +/// Logs are written to standard error so command output on standard output +/// remains parseable when combined with `--json`. +/// +/// Safe to call multiple times: subsequent calls are no-ops because the +/// subscriber registration uses `try_init`. +pub fn init_logging(verbosity: u8) { + let filter = if let Ok(env_filter) = EnvFilter::try_from_default_env() { + env_filter + } else { + let level = match verbosity { + 0 => "warn", + 1 => "debug", + _ => "trace", + }; + EnvFilter::new(level) + }; + + let _ = tracing_subscriber::fmt() + .with_env_filter(filter) + .with_writer(std::io::stderr) + .with_target(false) + .try_init(); +} diff --git a/crates/doublezero-cli-core/src/requirements.rs b/crates/doublezero-cli-core/src/requirements.rs new file mode 100644 index 0000000000..fce032fdd1 --- /dev/null +++ b/crates/doublezero-cli-core/src/requirements.rs @@ -0,0 +1,69 @@ +//! Preflight requirement bitflags. +//! +//! Per RFC-20 (§Error handling and requirements): "Preflight checks compose +//! from a shared bitflag type defined in the CLI core crate (keypair +//! available, payer has balance, payer on allowlist, ...)." +//! +//! Bit values are kept aligned with the legacy `u8` constants in +//! `smartcontract/cli/src/requirements.rs` so the two representations are +//! interchangeable during the opportunistic migration described by RFC-20: +//! +//! | Flag | Bit value | Legacy constant | +//! | --------------------- | --------- | -------------------------- | +//! | `KEYPAIR` | `0b0001` | `CHECK_ID_JSON` | +//! | `BALANCE` | `0b0010` | `CHECK_BALANCE` | +//! | `FOUNDATION_ALLOWLIST`| `0b0100` | `CHECK_FOUNDATION_ALLOWLIST` | +//! +//! This crate intentionally does **not** ship a `check_requirements` +//! implementation: the balance and allowlist checks require a typed +//! per-module backend client, which lives in the module crate. Modules +//! consume `RequirementCheck` as the canonical bitflag set and provide +//! their own dispatch. + +use bitflags::bitflags; + +bitflags! { + /// Preflight checks a verb requests at the top of `execute`. + #[derive(Debug, Clone, Copy, PartialEq, Eq)] + pub struct RequirementCheck: u8 { + /// Verify that a keypair source is available (CLI flag, env var, or + /// piped stdin). + const KEYPAIR = 0b0000_0001; + /// Verify that the payer account has a non-zero balance. + const BALANCE = 0b0000_0010; + /// Verify that the payer is on the foundation allowlist. + const FOUNDATION_ALLOWLIST = 0b0000_0100; + } +} + +impl From for RequirementCheck { + fn from(bits: u8) -> Self { + Self::from_bits_truncate(bits) + } +} + +impl From for u8 { + fn from(flags: RequirementCheck) -> Self { + flags.bits() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn bit_values_match_legacy_u8_constants() { + assert_eq!(RequirementCheck::KEYPAIR.bits(), 1); + assert_eq!(RequirementCheck::BALANCE.bits(), 2); + assert_eq!(RequirementCheck::FOUNDATION_ALLOWLIST.bits(), 4); + } + + #[test] + fn round_trips_via_u8() { + let checks = RequirementCheck::KEYPAIR | RequirementCheck::BALANCE; + let bits: u8 = checks.into(); + let back: RequirementCheck = bits.into(); + assert_eq!(back, checks); + } +} diff --git a/crates/doublezero-cli-core/src/testing.rs b/crates/doublezero-cli-core/src/testing.rs new file mode 100644 index 0000000000..b70a76bfe8 --- /dev/null +++ b/crates/doublezero-cli-core/src/testing.rs @@ -0,0 +1,64 @@ +//! Test helpers for module-crate unit tests. +//! +//! Per RFC-20 (§Testing conventions): "The CLI core crate SHOULD ship the +//! test helpers needed to make verb unit tests and per-module integration +//! tests low-cost." This module is the seed of that helper set; it grows as +//! the conforming-verb pattern is rolled out across modules. + +use solana_sdk::pubkey::Pubkey; + +use doublezero_config::Environment; + +use crate::context::{CliContext, CliContextBuilder, OutputFormat}; + +/// Build a `CliContext` suitable for unit tests against a mocked client. +/// +/// The returned context uses the `Local` environment by default. Override +/// any field by chaining additional `with_*` builder methods before calling +/// `.build()` — for example: +/// +/// ```ignore +/// use doublezero_cli_core::testing::cli_context_for_tests; +/// let ctx = cli_context_for_tests().with_env(Environment::Devnet).build()?; +/// ``` +pub fn cli_context_for_tests() -> CliContextBuilder { + CliContextBuilder::new() + .with_env(Environment::Local) + .with_ledger_rpc_url("http://localhost:8899") + .with_ledger_ws_rpc_url("ws://localhost:8900") + .with_solana_l1_rpc_url("http://localhost:8899") + .with_serviceability_program_id(Pubkey::new_unique()) + .with_geolocation_program_id(Pubkey::new_unique()) + .with_telemetry_program_id(Pubkey::new_unique()) + .with_output_format(OutputFormat::Table) +} + +/// Convenience: build a fully resolved `CliContext` with sensible defaults +/// for unit tests. Tests that need to override specific fields should use +/// `cli_context_for_tests()` and chain builder methods. +pub fn cli_context_default_for_tests() -> CliContext { + cli_context_for_tests() + .build() + .expect("default test context must resolve") +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn default_test_context_is_local() { + let ctx = cli_context_default_for_tests(); + assert_eq!(ctx.env, Environment::Local); + assert_eq!(ctx.ledger_rpc_url, "http://localhost:8899"); + } + + #[test] + fn builder_overrides_apply() { + let ctx = cli_context_for_tests() + .with_ledger_rpc_url("http://override.test") + .build() + .unwrap(); + assert_eq!(ctx.ledger_rpc_url, "http://override.test"); + } +} diff --git a/crates/doublezero-cli-core/src/validators.rs b/crates/doublezero-cli-core/src/validators.rs new file mode 100644 index 0000000000..6a9018906b --- /dev/null +++ b/crates/doublezero-cli-core/src/validators.rs @@ -0,0 +1,146 @@ +//! Shared `clap` value-parser validators. +//! +//! Per RFC-20 (§Argument conventions): "Identifiers accept both pubkey and +//! code. Any flag that references an onchain entity MUST accept either a +//! Solana pubkey or the entity's human-readable code via the shared +//! validator. The magic value `\"me\"` resolves to the current payer's pubkey +//! at execution time." Module crates re-export and consume these helpers +//! rather than re-implementing them. + +use doublezero_program_common::{types::parse_utils::bandwidth_parse, validate_account_code}; +use solana_sdk::pubkey::Pubkey; + +pub fn validate_code(val: &str) -> Result { + validate_account_code(val).map_err(String::from) +} + +pub fn validate_pubkey(val: &str) -> Result { + if val.eq("me") { + return Ok(val.to_string()); + } + match val.parse::() { + Ok(_) => Ok(val.to_string()), + Err(_) => Err(String::from("invalid pubkey format")), + } +} + +pub fn validate_pubkey_or_code(val: &str) -> Result { + val.parse::() + .map(|pubkey| pubkey.to_string()) + .or_else(|_| validate_code(val).map_err(|_| "invalid pubkey or code format".to_string())) +} + +pub fn validate_parse_bandwidth(val: &str) -> Result { + bandwidth_parse(val).map_err(|_| String::from("invalid bandwidth format")) +} + +pub fn validate_parse_delay_ms(val: &str) -> Result { + if let Ok(delay) = val.parse::() { + if (0.01..=1000.0).contains(&delay) { + Ok(delay) + } else { + Err(String::from("Delay must be between 0.01 and 1000 ms")) + } + } else { + Err(String::from("invalid delay format")) + } +} + +pub fn validate_parse_jitter_ms(val: &str) -> Result { + if let Ok(jitter) = val.parse::() { + if (0.01..=1000.0).contains(&jitter) { + Ok(jitter) + } else { + Err(String::from("Jitter must be between 0.01 and 1000 ms")) + } + } else { + Err(String::from("invalid jitter format")) + } +} + +pub fn validate_parse_delay_override_ms(val: &str) -> Result { + if let Ok(delay) = val.parse::() { + if (delay == 0.0) || (0.01..=1000.0).contains(&delay) { + Ok(delay) + } else { + Err(String::from( + "Delay override must be 0 (disabled) or between 0.01 and 1000 ms", + )) + } + } else { + Err(String::from("invalid delay override format")) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use solana_sdk::pubkey::Pubkey; + + #[test] + fn test_validate_code() { + assert!(validate_code("abc_123-:XYZ").is_ok()); + assert!(validate_code("abc@123").is_err()); + assert!(validate_code("abc 123-:XYZ").is_err()); + } + + #[test] + fn test_validate_pubkey() { + let pk = Pubkey::new_unique().to_string(); + assert!(validate_pubkey(&pk).is_ok()); + assert!(validate_pubkey("me").is_ok()); + assert!(validate_pubkey("not_a_pubkey").is_err()); + } + + #[test] + fn test_validate_pubkey_or_code() { + let pk = Pubkey::new_unique().to_string(); + assert!(validate_pubkey_or_code(&pk).is_ok()); + assert!(validate_pubkey_or_code("valid_code-123").is_ok()); + assert!(validate_pubkey_or_code("invalid code!").is_err()); + } + + #[test] + fn test_validate_bandwidth() { + assert!(validate_parse_bandwidth("100Mbps").is_ok()); + assert!(validate_parse_bandwidth("1Gbps").is_ok()); + assert!(validate_parse_bandwidth("1.5Gbps").is_ok()); + assert!(validate_parse_bandwidth("500Kbps").is_ok()); + assert!(validate_parse_bandwidth("200bps").is_ok()); + assert!(validate_parse_bandwidth("invalid").is_err()); + assert!(validate_parse_bandwidth("1000").is_err()); + assert!(validate_parse_bandwidth("0").is_err()); + } + + #[test] + fn test_validate_delay_ms() { + assert!(validate_parse_delay_ms("0.01").is_ok()); + assert!(validate_parse_delay_ms("1").is_ok()); + assert!(validate_parse_delay_ms("1000").is_ok()); + assert!(validate_parse_delay_ms("0.009").is_err()); + assert!(validate_parse_delay_ms("1001").is_err()); + assert!(validate_parse_delay_ms("not_a_number").is_err()); + } + + #[test] + fn test_validate_jitter_ms() { + assert!(validate_parse_jitter_ms("1").is_ok()); + assert!(validate_parse_jitter_ms("0.5").is_ok()); + assert!(validate_parse_jitter_ms("1000").is_ok()); + assert!(validate_parse_jitter_ms("0").is_err()); + assert!(validate_parse_jitter_ms("0.0001").is_err()); + assert!(validate_parse_jitter_ms("1001").is_err()); + assert!(validate_parse_jitter_ms("not_a_number").is_err()); + } + + #[test] + fn test_validate_delay_override_ms() { + assert!(validate_parse_delay_override_ms("0").is_ok()); + assert!(validate_parse_delay_override_ms("0.01").is_ok()); + assert!(validate_parse_delay_override_ms("1").is_ok()); + assert!(validate_parse_delay_override_ms("1000").is_ok()); + assert!(validate_parse_delay_override_ms("0.009").is_err()); + assert!(validate_parse_delay_override_ms("1001").is_err()); + assert!(validate_parse_delay_override_ms("not_a_number").is_err()); + } +} diff --git a/smartcontract/cli/Cargo.toml b/smartcontract/cli/Cargo.toml index b03c6ad126..ce8265b4ad 100644 --- a/smartcontract/cli/Cargo.toml +++ b/smartcontract/cli/Cargo.toml @@ -36,6 +36,7 @@ tabled.workspace = true tokio.workspace = true # Dependencies from this workspace +doublezero-cli-core.workspace = true doublezero-config.workspace = true doublezero-geolocation = { workspace = true, features = ["no-entrypoint"] } doublezero-program-common.workspace = true diff --git a/smartcontract/cli/src/formatters.rs b/smartcontract/cli/src/formatters.rs index 2f17be8072..532ae76462 100644 --- a/smartcontract/cli/src/formatters.rs +++ b/smartcontract/cli/src/formatters.rs @@ -1,26 +1,7 @@ -use std::fmt::{self, Display}; +//! Re-export of the shared display formatters. +//! +//! Implementations live in `doublezero-cli-core::formatters`. This module +//! preserves the existing import path so the serviceability crate's call +//! sites continue to compile unchanged during RFC-20 migration. -pub struct DisplayVec<'a, T: Display>(pub &'a Vec); - -impl<'a, T: Display> Display for DisplayVec<'a, T> { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - let mut iter = self.0.iter(); - if let Some(first) = iter.next() { - write!(f, "{first}")?; - for item in iter { - write!(f, ",{item}")?; - } - } - Ok(()) - } -} - -impl<'a, T: Display> From<&'a Vec> for DisplayVec<'a, T> { - fn from(vec: &'a Vec) -> Self { - DisplayVec(vec) - } -} - -pub fn stringify_vec(v: &Vec) -> String { - format!("{}", DisplayVec(v)) -} +pub use doublezero_cli_core::formatters::{stringify_vec, DisplayVec}; diff --git a/smartcontract/cli/src/validators.rs b/smartcontract/cli/src/validators.rs index 6e3c467a91..2c22a85193 100644 --- a/smartcontract/cli/src/validators.rs +++ b/smartcontract/cli/src/validators.rs @@ -1,141 +1,15 @@ -use doublezero_program_common::{types::parse_utils::bandwidth_parse, validate_account_code}; -use solana_sdk::pubkey::Pubkey; - -pub fn validate_code(val: &str) -> Result { - validate_account_code(val).map_err(String::from) -} - -pub fn validate_pubkey(val: &str) -> Result { - if val.eq("me") { - return Ok(val.to_string()); - } - match val.parse::() { - Ok(_) => Ok(val.to_string()), - Err(_) => Err(String::from("invalid pubkey format")), - } -} - -pub fn validate_pubkey_or_code(val: &str) -> Result { - val.parse::() - .map(|pubkey| pubkey.to_string()) - .or_else(|_| validate_code(val).map_err(|_| "invalid pubkey or code format".to_string())) -} - -pub fn validate_parse_bandwidth(val: &str) -> Result { - if bandwidth_parse(val).is_ok() { - bandwidth_parse(val) - } else { - Err(String::from("invalid bandwidth format")) - } -} - -pub fn validate_parse_delay_ms(val: &str) -> Result { - if let Ok(delay) = val.parse::() { - if (0.01..=1000.0).contains(&delay) { - Ok(delay) - } else { - Err(String::from("Delay must be between 0.01 and 1000 ms")) - } - } else { - Err(String::from("invalid delay format")) - } -} - -pub fn validate_parse_jitter_ms(val: &str) -> Result { - if let Ok(jitter) = val.parse::() { - if (0.01..=1000.0).contains(&jitter) { - Ok(jitter) - } else { - Err(String::from("Jitter must be between 0.01 and 1000 ms")) - } - } else { - Err(String::from("invalid jitter format")) - } -} - -pub fn validate_parse_delay_override_ms(val: &str) -> Result { - if let Ok(delay) = val.parse::() { - if (delay == 0.0) || (0.01..=1000.0).contains(&delay) { - Ok(delay) - } else { - Err(String::from( - "Delay override must be 0 (disabled) or between 0.01 and 1000 ms", - )) - } - } else { - Err(String::from("invalid delay override format")) - } -} - -#[cfg(test)] -mod tests { - use super::*; - use solana_sdk::pubkey::Pubkey; - - #[test] - fn test_validate_code() { - assert!(validate_code("abc_123-:XYZ").is_ok()); - assert!(validate_code("abc@123").is_err()); - assert!(validate_code("abc 123-:XYZ").is_err()); - } - - #[test] - fn test_validate_pubkey() { - let pk = Pubkey::new_unique().to_string(); - assert!(validate_pubkey(&pk).is_ok()); - assert!(validate_pubkey("me").is_ok()); - assert!(validate_pubkey("not_a_pubkey").is_err()); - } - - #[test] - fn test_validate_pubkey_or_code() { - let pk = Pubkey::new_unique().to_string(); - assert!(validate_pubkey_or_code(&pk).is_ok()); - assert!(validate_pubkey_or_code("valid_code-123").is_ok()); - assert!(validate_pubkey_or_code("invalid code!").is_err()); - } - - #[test] - fn test_validate_bandwidth() { - assert!(validate_parse_bandwidth("100Mbps").is_ok()); - assert!(validate_parse_bandwidth("1Gbps").is_ok()); - assert!(validate_parse_bandwidth("1.5Gbps").is_ok()); - assert!(validate_parse_bandwidth("500Kbps").is_ok()); - assert!(validate_parse_bandwidth("200bps").is_ok()); - assert!(validate_parse_bandwidth("invalid").is_err()); - assert!(validate_parse_bandwidth("1000").is_err()); - assert!(validate_parse_bandwidth("0").is_err()); - } - - #[test] - fn test_validate_delay_ms() { - assert!(validate_parse_delay_ms("0.01").is_ok()); - assert!(validate_parse_delay_ms("1").is_ok()); - assert!(validate_parse_delay_ms("1000").is_ok()); - assert!(validate_parse_delay_ms("0.009").is_err()); - assert!(validate_parse_delay_ms("1001").is_err()); - assert!(validate_parse_delay_ms("not_a_number").is_err()); - } - - #[test] - fn test_validate_jitter_ms() { - assert!(validate_parse_jitter_ms("1").is_ok()); - assert!(validate_parse_jitter_ms("0.5").is_ok()); - assert!(validate_parse_jitter_ms("1000").is_ok()); - assert!(validate_parse_jitter_ms("0").is_err()); - assert!(validate_parse_jitter_ms("0.0001").is_err()); - assert!(validate_parse_jitter_ms("1001").is_err()); - assert!(validate_parse_jitter_ms("not_a_number").is_err()); - } - - #[test] - fn test_validate_delay_override_ms() { - assert!(validate_parse_delay_override_ms("0").is_ok()); - assert!(validate_parse_delay_override_ms("0.01").is_ok()); - assert!(validate_parse_delay_override_ms("1").is_ok()); - assert!(validate_parse_delay_override_ms("1000").is_ok()); - assert!(validate_parse_delay_override_ms("0.009").is_err()); - assert!(validate_parse_delay_override_ms("1001").is_err()); - assert!(validate_parse_delay_override_ms("not_a_number").is_err()); - } -} +//! Re-export of the shared `clap` value-parser validators. +//! +//! Per RFC-20 (§Module contract item 6): "Modules MAY define module-specific +//! validators, but the shared validators for pubkey, code, bandwidth, +//! latency, and IPv4 MUST be used wherever those types appear." The +//! implementations now live in `doublezero-cli-core`; this module preserves +//! the existing import path (`use doublezero_serviceability_cli::validators::*`) so the +//! serviceability crate's call sites and any external consumer continue to +//! compile unchanged. + +pub use doublezero_cli_core::validators::{ + validate_code, validate_parse_bandwidth, validate_parse_delay_ms, + validate_parse_delay_override_ms, validate_parse_jitter_ms, validate_pubkey, + validate_pubkey_or_code, +};