From c236939469fd193c36c7db5e74ef3bd44ce63f4b Mon Sep 17 00:00:00 2001 From: Juan Olveira Date: Fri, 22 May 2026 10:53:28 +0000 Subject: [PATCH 1/7] cli: add --solana-url and --log-verbose global flags --- CHANGELOG.md | 3 +++ Cargo.lock | 2 ++ client/doublezero/Cargo.toml | 2 ++ client/doublezero/src/main.rs | 14 +++++++++++++- 4 files changed, 20 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7aa2d168d6..b756a0b005 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,9 @@ All notable changes to this project will be documented in this file. - SDK (Rust) - Drop the pre-submit `simulate_transaction` call in `DZClient::execute_transaction_inner` and submit with `skip_preflight: true`, eliminating the redundant double-simulation (the explicit simulate plus `send_and_confirm_transaction`'s default preflight) on the happy path. Program logs are now recovered from `get_transaction` on the failure path so `SimulationError` / `SimulationTransactionError` and `DoubleZeroError` mapping in CLI output are unchanged. Trade-off: failing transactions now land onchain and burn fees instead of failing for free at simulation ([#3750](https://github.com/malbeclabs/doublezero/pull/3750)) - e2e/qa: remove client-side capacity pre-filtering from `ValidDevices`, because the QA user pubkey bypasses capacity limits using the serviceability global-config qa-allowlist. Individual device failures no longer fail the test; instead, overall and per-host failure rates are evaluated after all batches and the test only fails if either exceeds `--failure-threshold` (default 10%) or `--per-host-failure-threshold` (default 20%). +- CLI + - Add `--solana-url ` global flag to `doublezero` per RFC-20 §Global flags. Distinct from `--url`, which continues to override the DZ ledger transport; `--solana-url` targets the Solana L1 transport. The flag is parsed and exposed on the binary's `App` struct; per-verb consumption lands when verbs migrate to construct typed Solana L1 clients from `CliContext`. + - Add `--log-verbose` (repeatable) global flag and initialize the `tracing` subscriber at startup. Default level is `warn`; one `--log-verbose` raises to `debug`, two raise to `trace`. Diagnostic logs go to stderr so `--json` output on stdout remains parseable. Honors the `RUST_LOG` environment variable when set, overriding the CLI-flag verbosity for per-module filtering. Replaces the previous `println!("using keypair: ...")` stdout line with a `tracing::info!` event; the keypair confirmation now appears only at `--log-verbose` or higher and no longer pollutes parseable stdout. (Named `--log-verbose` rather than the RFC-20 §Global-flags suggested `--verbose` / `-v` because the existing `doublezero connect` / `disconnect` subcommands already own a `--verbose` flag with `bool` type; the global flag deviation will be revisited when the daemon-control module crate is carved out.) ## [v0.24.0](https://github.com/malbeclabs/doublezero/compare/client/v0.23.0...client/v0.24.0) - 2026-05-22 diff --git a/Cargo.lock b/Cargo.lock index 57a003c01f..4f86e2d2ec 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1480,6 +1480,7 @@ dependencies = [ "clap_complete", "console", "ctor", + "doublezero-cli-core", "doublezero-config", "doublezero-program-common", "doublezero-serviceability", @@ -1506,6 +1507,7 @@ dependencies = [ "tabled", "tempfile", "tokio", + "tracing", ] [[package]] diff --git a/client/doublezero/Cargo.toml b/client/doublezero/Cargo.toml index aa41d6cafb..8e669cf5e3 100644 --- a/client/doublezero/Cargo.toml +++ b/client/doublezero/Cargo.toml @@ -42,9 +42,11 @@ tokio.workspace = true # Dependencies from this workspace doublezero_sdk.workspace = true doublezero_cli.workspace = true +doublezero-cli-core.workspace = true doublezero-config.workspace = true doublezero-serviceability.workspace = true doublezero-program-common.workspace = true +tracing.workspace = true [features] default-mainnet-beta = ["doublezero_sdk/default-mainnet-beta", "doublezero_cli/default-mainnet-beta"] diff --git a/client/doublezero/src/main.rs b/client/doublezero/src/main.rs index 9e7a9ba521..1d6772c47b 100644 --- a/client/doublezero/src/main.rs +++ b/client/doublezero/src/main.rs @@ -49,6 +49,9 @@ struct App { /// DZ ledger WebSocket URL #[arg(long, value_name = "WEBSOCKET_URL", global = true)] ws: Option, + /// Solana L1 RPC URL override (does not affect the DZ ledger) + #[arg(long, value_name = "SOLANA_RPC_URL", global = true)] + solana_url: Option, /// DZ program ID (testnet or devnet) #[arg(long, value_name = "PROGRAM_ID", global = true)] program_id: Option, @@ -70,6 +73,13 @@ struct App { /// Suppress version warning output #[arg(long, global = true)] no_version_warning: bool, + /// Increase diagnostic logging verbosity. Repeat for higher levels: + /// `--log-verbose` raises to debug, `--log-verbose --log-verbose` to trace. + /// Renamed from `--verbose` to avoid colliding with the per-subcommand + /// `--verbose` flags inherited from earlier releases of `doublezero + /// connect` / `disconnect`. + #[arg(long = "log-verbose", action = clap::ArgAction::Count, global = true)] + log_verbosity: u8, /// Print version information #[arg(short = 'V', long = "version", action = clap::ArgAction::SetTrue)] version: bool, @@ -83,12 +93,14 @@ async fn main() -> eyre::Result<()> { let app = App::parse(); + doublezero_cli_core::init_logging(app.log_verbosity); + if let Some(sock_file) = &app.sock_file { ServiceControllerImpl::set_global_socket_path(sock_file.to_string_lossy()); } if let Some(keypair) = &app.keypair { - println!("using keypair: {}", keypair.display()); + tracing::info!(keypair = %keypair.display(), "using keypair"); } let (url, ws, program_id) = if let Some(env) = app.env { From 2c8bb4023dc13e9700a8d8ca8f1bb75c83c40154 Mon Sep 17 00:00:00 2001 From: Juan Olveira Date: Sun, 24 May 2026 14:34:02 +0000 Subject: [PATCH 2/7] cli: make --env mutually exclusive with per-field URL/program overrides `--env` now conflicts with `--url`, `--ws`, `--solana-url`, `--program-id`, and `--geo-program-id` via clap. Combining them used to be silently inconsistent: per-field overrides won over env defaults on some fields and not others, so e.g. `--env devnet --url ` would pair the custom RPC URL with devnet's default WebSocket URL. Clap now rejects the combination upfront with a clear usage error, matching the design rule that `--env` selects a full preset wholesale. Adds clap-parse tests covering each conflict pair and the positive single-flag cases. --- client/doublezero/src/main.rs | 78 ++++++++++++++++++++++++++++++++++- 1 file changed, 76 insertions(+), 2 deletions(-) diff --git a/client/doublezero/src/main.rs b/client/doublezero/src/main.rs index 1d6772c47b..d6b8b7dae4 100644 --- a/client/doublezero/src/main.rs +++ b/client/doublezero/src/main.rs @@ -40,8 +40,19 @@ use servicecontroller::ServiceControllerImpl; struct App { #[command(subcommand)] command: Option, - /// DZ env (testnet, devnet, or mainnet-beta) - #[arg(short, long, value_name = "ENV", global = true)] + /// DZ env (testnet, devnet, or mainnet-beta). + /// + /// Mutually exclusive with the per-field URL and program-ID overrides + /// (`--url`, `--ws`, `--solana-url`, `--program-id`, `--geo-program-id`). + /// Pass `--env` to use a network's defaults wholesale, or pass the + /// individual overrides; combining the two yields an error from clap. + #[arg( + short, + long, + value_name = "ENV", + global = true, + conflicts_with_all = ["url", "ws", "solana_url", "program_id", "geo_program_id"], + )] env: Option, /// DZ ledger RPC URL #[arg(long, value_name = "RPC_URL", global = true)] @@ -440,3 +451,66 @@ async fn main() -> eyre::Result<()> { Ok(()) } + +#[cfg(test)] +mod tests { + use super::App; + use clap::{error::ErrorKind, Parser}; + + fn parse_err(args: &[&str]) -> clap::Error { + App::try_parse_from(args).expect_err("expected clap to reject these arguments") + } + + #[test] + fn env_conflicts_with_url() { + let err = parse_err(&[ + "doublezero", + "--env", + "devnet", + "--url", + "https://x.invalid/", + ]); + assert_eq!(err.kind(), ErrorKind::ArgumentConflict); + } + + #[test] + fn env_conflicts_with_ws() { + let err = parse_err(&["doublezero", "--env", "devnet", "--ws", "wss://x.invalid/"]); + assert_eq!(err.kind(), ErrorKind::ArgumentConflict); + } + + #[test] + fn env_conflicts_with_solana_url() { + let err = parse_err(&[ + "doublezero", + "--env", + "devnet", + "--solana-url", + "https://x.invalid/", + ]); + assert_eq!(err.kind(), ErrorKind::ArgumentConflict); + } + + #[test] + fn env_conflicts_with_program_id() { + let err = parse_err(&[ + "doublezero", + "--env", + "devnet", + "--program-id", + "11111111111111111111111111111111", + ]); + assert_eq!(err.kind(), ErrorKind::ArgumentConflict); + } + + #[test] + fn env_alone_parses() { + App::try_parse_from(["doublezero", "--env", "devnet"]).expect("--env alone should parse"); + } + + #[test] + fn url_alone_parses() { + App::try_parse_from(["doublezero", "--url", "https://x.invalid/"]) + .expect("--url alone should parse"); + } +} From f1f6f3d4bb96b05f0baaa93958348572158234eb Mon Sep 17 00:00:00 2001 From: Juan Olveira Date: Tue, 26 May 2026 21:03:26 +0000 Subject: [PATCH 3/7] cli: replace --log-verbose count with --log-level Swap the repeatable --log-verbose flag for --log-level taking one of off/error/warn/info/debug/trace. init_logging now takes a LogLevel enum (clap ValueEnum) defined in doublezero-cli-core. Default level remains warn; RUST_LOG still overrides. --- CHANGELOG.md | 2 +- client/doublezero/src/main.rs | 19 +++++---- crates/doublezero-cli-core/src/lib.rs | 2 +- crates/doublezero-cli-core/src/logging.rs | 48 +++++++++++++++++------ 4 files changed, 48 insertions(+), 23 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b756a0b005..0d96a250df 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,7 +11,7 @@ All notable changes to this project will be documented in this file. - e2e/qa: remove client-side capacity pre-filtering from `ValidDevices`, because the QA user pubkey bypasses capacity limits using the serviceability global-config qa-allowlist. Individual device failures no longer fail the test; instead, overall and per-host failure rates are evaluated after all batches and the test only fails if either exceeds `--failure-threshold` (default 10%) or `--per-host-failure-threshold` (default 20%). - CLI - Add `--solana-url ` global flag to `doublezero` per RFC-20 §Global flags. Distinct from `--url`, which continues to override the DZ ledger transport; `--solana-url` targets the Solana L1 transport. The flag is parsed and exposed on the binary's `App` struct; per-verb consumption lands when verbs migrate to construct typed Solana L1 clients from `CliContext`. - - Add `--log-verbose` (repeatable) global flag and initialize the `tracing` subscriber at startup. Default level is `warn`; one `--log-verbose` raises to `debug`, two raise to `trace`. Diagnostic logs go to stderr so `--json` output on stdout remains parseable. Honors the `RUST_LOG` environment variable when set, overriding the CLI-flag verbosity for per-module filtering. Replaces the previous `println!("using keypair: ...")` stdout line with a `tracing::info!` event; the keypair confirmation now appears only at `--log-verbose` or higher and no longer pollutes parseable stdout. (Named `--log-verbose` rather than the RFC-20 §Global-flags suggested `--verbose` / `-v` because the existing `doublezero connect` / `disconnect` subcommands already own a `--verbose` flag with `bool` type; the global flag deviation will be revisited when the daemon-control module crate is carved out.) + - Add `--log-level ` global flag and initialize the `tracing` subscriber at startup. `LEVEL` is one of `off`, `error`, `warn` (default), `info`, `debug`, `trace`. Diagnostic logs go to stderr so `--json` output on stdout remains parseable. Honors the `RUST_LOG` environment variable when set, overriding the CLI-flag level for per-module filtering. Replaces the previous `println!("using keypair: ...")` stdout line with a `tracing::info!` event; the keypair confirmation now appears only at `--log-level info` or higher and no longer pollutes parseable stdout. (Named `--log-level` rather than the RFC-20 §Global-flags suggested `--verbose` / `-v` because the existing `doublezero connect` / `disconnect` subcommands already own a `--verbose` flag with `bool` type; the global flag deviation will be revisited when the daemon-control module crate is carved out.) ## [v0.24.0](https://github.com/malbeclabs/doublezero/compare/client/v0.23.0...client/v0.24.0) - 2026-05-22 diff --git a/client/doublezero/src/main.rs b/client/doublezero/src/main.rs index d6b8b7dae4..7fcaafd20b 100644 --- a/client/doublezero/src/main.rs +++ b/client/doublezero/src/main.rs @@ -28,6 +28,7 @@ use doublezero_cli::{ checkversion::check_version, doublezerocommand::CliCommandImpl, geoclicommand::GeoCliCommandImpl, version::VersionCliCommand, }; +use doublezero_cli_core::LogLevel; use doublezero_sdk::{geolocation::client::GeoClient, DZClient, ProgramVersion}; use doublezero_serviceability::pda::get_globalstate_pda; use servicecontroller::ServiceControllerImpl; @@ -84,13 +85,15 @@ struct App { /// Suppress version warning output #[arg(long, global = true)] no_version_warning: bool, - /// Increase diagnostic logging verbosity. Repeat for higher levels: - /// `--log-verbose` raises to debug, `--log-verbose --log-verbose` to trace. - /// Renamed from `--verbose` to avoid colliding with the per-subcommand - /// `--verbose` flags inherited from earlier releases of `doublezero - /// connect` / `disconnect`. - #[arg(long = "log-verbose", action = clap::ArgAction::Count, global = true)] - log_verbosity: u8, + /// Diagnostic logging level. One of: `off`, `error`, `warn` (default), `info`, `debug`, `trace`. + #[arg( + long = "log-level", + value_name = "LEVEL", + value_enum, + default_value_t = LogLevel::default(), + global = true, + )] + log_level: LogLevel, /// Print version information #[arg(short = 'V', long = "version", action = clap::ArgAction::SetTrue)] version: bool, @@ -104,7 +107,7 @@ async fn main() -> eyre::Result<()> { let app = App::parse(); - doublezero_cli_core::init_logging(app.log_verbosity); + doublezero_cli_core::init_logging(app.log_level); if let Some(sock_file) = &app.sock_file { ServiceControllerImpl::set_global_socket_path(sock_file.to_string_lossy()); diff --git a/crates/doublezero-cli-core/src/lib.rs b/crates/doublezero-cli-core/src/lib.rs index 9dfa0c5047..8eeedbcd5c 100644 --- a/crates/doublezero-cli-core/src/lib.rs +++ b/crates/doublezero-cli-core/src/lib.rs @@ -17,5 +17,5 @@ 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 logging::{init_logging, LogLevel}; pub use requirements::RequirementCheck; diff --git a/crates/doublezero-cli-core/src/logging.rs b/crates/doublezero-cli-core/src/logging.rs index 61a9fdaa17..fe5237c445 100644 --- a/crates/doublezero-cli-core/src/logging.rs +++ b/crates/doublezero-cli-core/src/logging.rs @@ -2,20 +2,47 @@ //! //! 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!`, +//! `--log-level`; 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 clap::ValueEnum; use tracing_subscriber::EnvFilter; -/// Configure the global `tracing` subscriber from a verbosity count. +/// Diagnostic log level selectable from `--log-level`. /// -/// - `0` → `warn` (default for non-verbose runs) -/// - `1` → `debug` (`-v`) -/// - `2` or more → `trace` (`-vv`) +/// Mirrors the `tracing` level hierarchy plus an explicit `Off` that silences +/// every level. `Warn` is the default for non-verbose runs; `Debug` and +/// `Trace` are the levels operators reach for when chasing a bug. +#[derive(Copy, Clone, Debug, Default, Eq, PartialEq, ValueEnum)] +#[value(rename_all = "lower")] +pub enum LogLevel { + Off, + Error, + #[default] + Warn, + Info, + Debug, + Trace, +} + +impl LogLevel { + fn as_filter_str(self) -> &'static str { + match self { + LogLevel::Off => "off", + LogLevel::Error => "error", + LogLevel::Warn => "warn", + LogLevel::Info => "info", + LogLevel::Debug => "debug", + LogLevel::Trace => "trace", + } + } +} + +/// Configure the global `tracing` subscriber from a `LogLevel`. /// -/// If the `RUST_LOG` environment variable is set, it overrides the verbosity +/// If the `RUST_LOG` environment variable is set, it overrides the level /// 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. @@ -25,16 +52,11 @@ use tracing_subscriber::EnvFilter; /// /// Safe to call multiple times: subsequent calls are no-ops because the /// subscriber registration uses `try_init`. -pub fn init_logging(verbosity: u8) { +pub fn init_logging(level: LogLevel) { 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) + EnvFilter::new(level.as_filter_str()) }; let _ = tracing_subscriber::fmt() From c84332a93bc611ebe0c33375ab73feeb5541a3a8 Mon Sep 17 00:00:00 2001 From: Juan Olveira Date: Fri, 22 May 2026 10:55:11 +0000 Subject: [PATCH 4/7] cli: build CliContext in main and centralize error rendering --- CHANGELOG.md | 2 + client/doublezero/src/main.rs | 75 +++++++++++++++++++++++++---------- 2 files changed, 55 insertions(+), 22 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0d96a250df..b771937ffe 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,8 @@ All notable changes to this project will be documented in this file. - CLI - Add `--solana-url ` global flag to `doublezero` per RFC-20 §Global flags. Distinct from `--url`, which continues to override the DZ ledger transport; `--solana-url` targets the Solana L1 transport. The flag is parsed and exposed on the binary's `App` struct; per-verb consumption lands when verbs migrate to construct typed Solana L1 clients from `CliContext`. - Add `--log-level ` global flag and initialize the `tracing` subscriber at startup. `LEVEL` is one of `off`, `error`, `warn` (default), `info`, `debug`, `trace`. Diagnostic logs go to stderr so `--json` output on stdout remains parseable. Honors the `RUST_LOG` environment variable when set, overriding the CLI-flag level for per-module filtering. Replaces the previous `println!("using keypair: ...")` stdout line with a `tracing::info!` event; the keypair confirmation now appears only at `--log-level info` or higher and no longer pollutes parseable stdout. (Named `--log-level` rather than the RFC-20 §Global-flags suggested `--verbose` / `-v` because the existing `doublezero connect` / `disconnect` subcommands already own a `--verbose` flag with `bool` type; the global flag deviation will be revisited when the daemon-control module crate is carved out.) + - Build a `CliContext` once at binary startup from `--env` and the per-field global overrides (`--url`, `--ws`, `--solana-url`, `--keypair`, `--sock-file`), per RFC-20 (§CliContext). The context resolves to `Environment::default()` (`devnet`) when `--env` is absent. `DZClient` continues to consume the legacy `Option` tuple via a thin bridge that forwards `None` when neither `--env` nor a per-field override is set, preserving today's fall-back to `~/.config/solana/cli/config.yml`. Verbs that migrate to the RFC-20 module contract will consume `CliContext` directly and the bridge shrinks. + - Centralize top-level error rendering through `doublezero_cli_core::error::render_eyre`. Replaces three ad-hoc `eprintln!("Error: {e}")` sites in `client/doublezero/src/main.rs` (env-parse failure, env-config resolution failure, top-level command failure) with a single helper that prints `Error: ` followed by the full chain of causes on stderr. ## [v0.24.0](https://github.com/malbeclabs/doublezero/compare/client/v0.23.0...client/v0.24.0) - 2026-05-22 diff --git a/client/doublezero/src/main.rs b/client/doublezero/src/main.rs index 7fcaafd20b..a3356fb767 100644 --- a/client/doublezero/src/main.rs +++ b/client/doublezero/src/main.rs @@ -117,30 +117,61 @@ async fn main() -> eyre::Result<()> { tracing::info!(keypair = %keypair.display(), "using keypair"); } - let (url, ws, program_id) = if let Some(env) = app.env { - let config = match env.parse::() { - Ok(env) => match env.config() { - Ok(config) => config, - Err(e) => { - eprintln!("Error: {e}"); - std::process::exit(1); - } - }, - Err(e) => { - eprintln!("Error: {e}"); - std::process::exit(1); - } - }; - ( - Some(config.ledger_public_rpc_url), - Some(config.ledger_public_ws_rpc_url), - Some(config.serviceability_program_id.to_string()), - ) + // Resolve global configuration into a CliContext per RFC-20 (§CliContext). + // The binary populates it once at startup; future verbs read from it. + let env_explicit = app.env.is_some(); + let env = match app.env.as_deref() { + Some(s) => s.parse::().unwrap_or_else(|e| { + doublezero_cli_core::error::render_eyre(&e); + std::process::exit(1); + }), + None => Environment::default(), + }; + let mut ctx_builder = doublezero_cli_core::CliContextBuilder::new().with_env(env); + if let Some(u) = app.url.clone() { + ctx_builder = ctx_builder.with_ledger_rpc_url(u); + } + if let Some(w) = app.ws.clone() { + ctx_builder = ctx_builder.with_ledger_ws_rpc_url(w); + } + if let Some(s) = app.solana_url.clone() { + ctx_builder = ctx_builder.with_solana_l1_rpc_url(s); + } + if let Some(k) = app.keypair.clone() { + ctx_builder = ctx_builder.with_keypair_path(k); + } + if let Some(s) = app.sock_file.clone() { + ctx_builder = ctx_builder.with_daemon_socket_path(s); + } + let ctx = ctx_builder.build().unwrap_or_else(|e| { + doublezero_cli_core::error::render_eyre(&e); + std::process::exit(1); + }); + + // Bridge to the legacy `DZClient::new(Option, ...)` signature. + // When neither `--env` nor a per-field override is set, forward `None` + // so `DZClient` keeps falling back to the user's + // `~/.config/solana/cli/config.yml`. As verbs migrate to construct typed + // clients from `CliContext` directly, this bridge shrinks. + let url = if env_explicit || app.url.is_some() { + Some(ctx.ledger_rpc_url.clone()) } else { - (app.url, app.ws, app.program_id) + None }; + let ws = if env_explicit || app.ws.is_some() { + Some(ctx.ledger_ws_rpc_url.clone()) + } else { + None + }; + let program_id = app.program_id.clone().or_else(|| { + if env_explicit { + Some(ctx.serviceability_program_id.to_string()) + } else { + None + } + }); - let dzclient = DZClient::new(url.clone(), ws, program_id, app.keypair.clone())?; + let dzclient = DZClient::new(url.clone(), ws, program_id, ctx.keypair_path.clone())?; let client = CliCommandImpl::new(&dzclient); let stdout = std::io::stdout(); @@ -447,7 +478,7 @@ async fn main() -> eyre::Result<()> { match res { Ok(_) => {} Err(e) => { - eprintln!("Error: {e}"); + doublezero_cli_core::error::render_eyre(&e); std::process::exit(1); } }; From 695c767b612498317d4333796842051c3bb75958 Mon Sep 17 00:00:00 2001 From: Juan Olveira Date: Sun, 24 May 2026 14:36:17 +0000 Subject: [PATCH 5/7] cli: collapse CliContext bridge and fix config-path comment Replaces the three explicit if/else blocks that translated CliContext into DZClient::new arguments with a flat `any_url_explicit.then(...)` form. The behavior is identical: when no env or per-field override is present, all three fields stay None so DZClient falls through to the on-disk config; otherwise the resolved CliContext values flow through. Drops the assumption that env_explicit alone resolves URL/program-id fields: any explicit override (--url, --ws, --program-id) now also opts into using the resolved context, which keeps the bridge in step with the builder's WS-from-RPC derivation introduced in jo/1. Also corrects the stale comment that referenced `~/.config/solana/cli/config.yml`; DZClient actually reads `~/.config/doublezero/cli/config.yml`. --- client/doublezero/src/main.rs | 30 +++++++++++------------------- 1 file changed, 11 insertions(+), 19 deletions(-) diff --git a/client/doublezero/src/main.rs b/client/doublezero/src/main.rs index a3356fb767..a21f36ff9d 100644 --- a/client/doublezero/src/main.rs +++ b/client/doublezero/src/main.rs @@ -151,25 +151,17 @@ async fn main() -> eyre::Result<()> { // Bridge to the legacy `DZClient::new(Option, ...)` signature. // When neither `--env` nor a per-field override is set, forward `None` // so `DZClient` keeps falling back to the user's - // `~/.config/solana/cli/config.yml`. As verbs migrate to construct typed - // clients from `CliContext` directly, this bridge shrinks. - let url = if env_explicit || app.url.is_some() { - Some(ctx.ledger_rpc_url.clone()) - } else { - None - }; - let ws = if env_explicit || app.ws.is_some() { - Some(ctx.ledger_ws_rpc_url.clone()) - } else { - None - }; - let program_id = app.program_id.clone().or_else(|| { - if env_explicit { - Some(ctx.serviceability_program_id.to_string()) - } else { - None - } - }); + // `~/.config/doublezero/cli/config.yml`. As verbs migrate to construct + // typed clients from `CliContext` directly, this bridge shrinks. + // + // `CliContextBuilder::build` derives WS from RPC when only `--url` is + // overridden, so `ctx.ledger_ws_rpc_url` stays consistent with + // `ctx.ledger_rpc_url` on every path that reaches here. + let any_url_explicit = env_explicit || app.url.is_some() || app.ws.is_some(); + let url = any_url_explicit.then(|| ctx.ledger_rpc_url.clone()); + let ws = any_url_explicit.then(|| ctx.ledger_ws_rpc_url.clone()); + let program_id = (env_explicit || app.program_id.is_some()) + .then(|| ctx.serviceability_program_id.to_string()); let dzclient = DZClient::new(url.clone(), ws, program_id, ctx.keypair_path.clone())?; let client = CliCommandImpl::new(&dzclient); From 140c77b0c45f6cdf52ea55e2891122b8567ec430 Mon Sep 17 00:00:00 2001 From: Juan Olveira Date: Tue, 26 May 2026 15:43:34 +0000 Subject: [PATCH 6/7] cli: read config.yml into CliContext with layered precedence MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Read the persisted ~/.config/doublezero/cli/config.yml (or DOUBLEZERO_CONFIG_FILE) at binary startup and feed it into CliContextBuilder, so verbs that read from CliContext directly see the same backend as the legacy DZClient bridge. Precedence (highest wins): CLI flag > persisted config > env-derived default. When --env is absent and the persisted config has a serviceability program ID, the environment is derived via Environment::from_program_id; otherwise it falls back to Environment::default(). DZClient is now constructed from fully resolved CliContext values, so the legacy bridge and the new context agree on every path. File reads remain confined to the binary; module crates do not touch the filesystem (RFC-20 §67). --- CHANGELOG.md | 2 +- client/doublezero/src/main.rs | 83 +++++++++++++++++++++++++++++------ 2 files changed, 71 insertions(+), 14 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b771937ffe..7a3c7437b6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,7 +12,7 @@ All notable changes to this project will be documented in this file. - CLI - Add `--solana-url ` global flag to `doublezero` per RFC-20 §Global flags. Distinct from `--url`, which continues to override the DZ ledger transport; `--solana-url` targets the Solana L1 transport. The flag is parsed and exposed on the binary's `App` struct; per-verb consumption lands when verbs migrate to construct typed Solana L1 clients from `CliContext`. - Add `--log-level ` global flag and initialize the `tracing` subscriber at startup. `LEVEL` is one of `off`, `error`, `warn` (default), `info`, `debug`, `trace`. Diagnostic logs go to stderr so `--json` output on stdout remains parseable. Honors the `RUST_LOG` environment variable when set, overriding the CLI-flag level for per-module filtering. Replaces the previous `println!("using keypair: ...")` stdout line with a `tracing::info!` event; the keypair confirmation now appears only at `--log-level info` or higher and no longer pollutes parseable stdout. (Named `--log-level` rather than the RFC-20 §Global-flags suggested `--verbose` / `-v` because the existing `doublezero connect` / `disconnect` subcommands already own a `--verbose` flag with `bool` type; the global flag deviation will be revisited when the daemon-control module crate is carved out.) - - Build a `CliContext` once at binary startup from `--env` and the per-field global overrides (`--url`, `--ws`, `--solana-url`, `--keypair`, `--sock-file`), per RFC-20 (§CliContext). The context resolves to `Environment::default()` (`devnet`) when `--env` is absent. `DZClient` continues to consume the legacy `Option` tuple via a thin bridge that forwards `None` when neither `--env` nor a per-field override is set, preserving today's fall-back to `~/.config/solana/cli/config.yml`. Verbs that migrate to the RFC-20 module contract will consume `CliContext` directly and the bridge shrinks. + - Build a `CliContext` once at binary startup from `--env`, the per-field global overrides (`--url`, `--ws`, `--solana-url`, `--program-id`, `--geo-program-id`, `--keypair`, `--sock-file`), and the persisted `~/.config/doublezero/cli/config.yml` (overridable via `DOUBLEZERO_CONFIG_FILE`), per RFC-20 (§CliContext). Precedence (highest wins): CLI flag > persisted config > env-derived default. When `--env` is not set and the persisted config has a serviceability program ID, the environment is derived from that program ID via `Environment::from_program_id`; otherwise the binary falls back to `Environment::default()`. The legacy `DZClient` is now constructed from the fully resolved `CliContext` values directly, so verbs that migrate to read `CliContext` see the same backend as the legacy bridge. File reads happen only in the binary; module crates remain forbidden from touching the filesystem (RFC-20 §67). - Centralize top-level error rendering through `doublezero_cli_core::error::render_eyre`. Replaces three ad-hoc `eprintln!("Error: {e}")` sites in `client/doublezero/src/main.rs` (env-parse failure, env-config resolution failure, top-level command failure) with a single helper that prints `Error: ` followed by the full chain of causes on stderr. ## [v0.24.0](https://github.com/malbeclabs/doublezero/compare/client/v0.23.0...client/v0.24.0) - 2026-05-22 diff --git a/client/doublezero/src/main.rs b/client/doublezero/src/main.rs index a21f36ff9d..94525984cc 100644 --- a/client/doublezero/src/main.rs +++ b/client/doublezero/src/main.rs @@ -119,15 +119,66 @@ async fn main() -> eyre::Result<()> { // Resolve global configuration into a CliContext per RFC-20 (§CliContext). // The binary populates it once at startup; future verbs read from it. + // + // Precedence (highest wins): CLI flag > persisted `config.yml` > env-derived + // default. File reads happen here in the binary; module crates only read + // resolved values from `CliContext` (RFC-20 §67). + let (persisted_path, persisted) = + doublezero_sdk::read_doublezero_config().unwrap_or_else(|_| { + ( + std::path::PathBuf::new(), + doublezero_sdk::ClientConfig::default(), + ) + }); + let persisted_exists = persisted_path.is_file(); + let env_explicit = app.env.is_some(); let env = match app.env.as_deref() { Some(s) => s.parse::().unwrap_or_else(|e| { doublezero_cli_core::error::render_eyre(&e); std::process::exit(1); }), + None if persisted_exists => persisted + .program_id + .as_deref() + .and_then(|pid| Environment::from_program_id(pid).ok()) + .unwrap_or_default(), None => Environment::default(), }; + let mut ctx_builder = doublezero_cli_core::CliContextBuilder::new().with_env(env); + + // Layer the persisted config when the file exists. When the user is + // selecting an environment wholesale via `--env`, skip persisted URL and + // program-ID values so we never mix environments; the keypair path is + // orthogonal to env and stays. + if persisted_exists { + if !env_explicit { + ctx_builder = ctx_builder.with_ledger_rpc_url(persisted.json_rpc_url.clone()); + if let Some(ws) = persisted.websocket_url.clone() { + ctx_builder = ctx_builder.with_ledger_ws_rpc_url(ws); + } + if let Some(pid) = persisted + .program_id + .as_deref() + .and_then(|s| s.parse::().ok()) + { + ctx_builder = ctx_builder.with_serviceability_program_id(pid); + } + if let Some(pid) = persisted + .geo_program_id + .as_deref() + .and_then(|s| s.parse::().ok()) + { + ctx_builder = ctx_builder.with_geolocation_program_id(pid); + } + } + ctx_builder = ctx_builder.with_keypair_path(persisted.keypair_path.clone()); + } + + // CLI-flag overrides win. `--env` is mutually exclusive with the per-field + // URL and program-ID flags at the clap layer, so at most one branch of each + // pair fires per invocation. if let Some(u) = app.url.clone() { ctx_builder = ctx_builder.with_ledger_rpc_url(u); } @@ -137,6 +188,20 @@ async fn main() -> eyre::Result<()> { if let Some(s) = app.solana_url.clone() { ctx_builder = ctx_builder.with_solana_l1_rpc_url(s); } + if let Some(pid) = app + .program_id + .as_deref() + .and_then(|s| s.parse::().ok()) + { + ctx_builder = ctx_builder.with_serviceability_program_id(pid); + } + if let Some(pid) = app + .geo_program_id + .as_deref() + .and_then(|s| s.parse::().ok()) + { + ctx_builder = ctx_builder.with_geolocation_program_id(pid); + } if let Some(k) = app.keypair.clone() { ctx_builder = ctx_builder.with_keypair_path(k); } @@ -149,19 +214,11 @@ async fn main() -> eyre::Result<()> { }); // Bridge to the legacy `DZClient::new(Option, ...)` signature. - // When neither `--env` nor a per-field override is set, forward `None` - // so `DZClient` keeps falling back to the user's - // `~/.config/doublezero/cli/config.yml`. As verbs migrate to construct - // typed clients from `CliContext` directly, this bridge shrinks. - // - // `CliContextBuilder::build` derives WS from RPC when only `--url` is - // overridden, so `ctx.ledger_ws_rpc_url` stays consistent with - // `ctx.ledger_rpc_url` on every path that reaches here. - let any_url_explicit = env_explicit || app.url.is_some() || app.ws.is_some(); - let url = any_url_explicit.then(|| ctx.ledger_rpc_url.clone()); - let ws = any_url_explicit.then(|| ctx.ledger_ws_rpc_url.clone()); - let program_id = (env_explicit || app.program_id.is_some()) - .then(|| ctx.serviceability_program_id.to_string()); + // CliContext now carries the fully resolved values, so we forward them + // directly. Legacy and conforming verbs agree on what they are talking to. + let url = Some(ctx.ledger_rpc_url.clone()); + let ws = Some(ctx.ledger_ws_rpc_url.clone()); + let program_id = Some(ctx.serviceability_program_id.to_string()); let dzclient = DZClient::new(url.clone(), ws, program_id, ctx.keypair_path.clone())?; let client = CliCommandImpl::new(&dzclient); From 3ca7bc49774f1f6dffd5a4cd6713c8f69b00b86c Mon Sep 17 00:00:00 2001 From: Juan Olveira Date: Tue, 26 May 2026 22:12:42 +0000 Subject: [PATCH 7/7] cli: preserve DOUBLEZERO_KEYPAIR env precedence in CliContext bridge The CliContext refactor started passing ctx.keypair_path to DZClient::new as if it were the --keypair CLI flag. When the persisted config.yml exists, that short-circuits load_keypair's precedence chain and bypasses the DOUBLEZERO_KEYPAIR env var, which the e2e contributor-auth negative-authz tests rely on. Pass app.keypair.clone() instead so the env var continues to override the persisted keypair path; CliContext still carries the resolved value for other consumers. --- CHANGELOG.md | 2 +- client/doublezero/src/main.rs | 11 ++++++++--- 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7a3c7437b6..42c4e52e05 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,7 +12,7 @@ All notable changes to this project will be documented in this file. - CLI - Add `--solana-url ` global flag to `doublezero` per RFC-20 §Global flags. Distinct from `--url`, which continues to override the DZ ledger transport; `--solana-url` targets the Solana L1 transport. The flag is parsed and exposed on the binary's `App` struct; per-verb consumption lands when verbs migrate to construct typed Solana L1 clients from `CliContext`. - Add `--log-level ` global flag and initialize the `tracing` subscriber at startup. `LEVEL` is one of `off`, `error`, `warn` (default), `info`, `debug`, `trace`. Diagnostic logs go to stderr so `--json` output on stdout remains parseable. Honors the `RUST_LOG` environment variable when set, overriding the CLI-flag level for per-module filtering. Replaces the previous `println!("using keypair: ...")` stdout line with a `tracing::info!` event; the keypair confirmation now appears only at `--log-level info` or higher and no longer pollutes parseable stdout. (Named `--log-level` rather than the RFC-20 §Global-flags suggested `--verbose` / `-v` because the existing `doublezero connect` / `disconnect` subcommands already own a `--verbose` flag with `bool` type; the global flag deviation will be revisited when the daemon-control module crate is carved out.) - - Build a `CliContext` once at binary startup from `--env`, the per-field global overrides (`--url`, `--ws`, `--solana-url`, `--program-id`, `--geo-program-id`, `--keypair`, `--sock-file`), and the persisted `~/.config/doublezero/cli/config.yml` (overridable via `DOUBLEZERO_CONFIG_FILE`), per RFC-20 (§CliContext). Precedence (highest wins): CLI flag > persisted config > env-derived default. When `--env` is not set and the persisted config has a serviceability program ID, the environment is derived from that program ID via `Environment::from_program_id`; otherwise the binary falls back to `Environment::default()`. The legacy `DZClient` is now constructed from the fully resolved `CliContext` values directly, so verbs that migrate to read `CliContext` see the same backend as the legacy bridge. File reads happen only in the binary; module crates remain forbidden from touching the filesystem (RFC-20 §67). + - Build a `CliContext` once at binary startup from `--env`, the per-field global overrides (`--url`, `--ws`, `--solana-url`, `--program-id`, `--geo-program-id`, `--keypair`, `--sock-file`), and the persisted `~/.config/doublezero/cli/config.yml` (overridable via `DOUBLEZERO_CONFIG_FILE`), per RFC-20 (§CliContext). Precedence (highest wins): CLI flag > persisted config > env-derived default. When `--env` is not set and the persisted config has a serviceability program ID, the environment is derived from that program ID via `Environment::from_program_id`; otherwise the binary falls back to `Environment::default()`. The legacy `DZClient` is now constructed from the fully resolved `CliContext` URL, WebSocket, and program-ID values directly, so verbs that migrate to read `CliContext` see the same backend as the legacy bridge. Keypair resolution is intentionally left to `DZClient::new`'s internal `load_keypair` precedence (CLI `--keypair` flag > `DOUBLEZERO_KEYPAIR` env var > stdin > persisted config) so the `DOUBLEZERO_KEYPAIR` env var continues to override the persisted keypair path, as relied on by the e2e contributor-auth negative-authz suite. File reads happen only in the binary; module crates remain forbidden from touching the filesystem (RFC-20 §67). - Centralize top-level error rendering through `doublezero_cli_core::error::render_eyre`. Replaces three ad-hoc `eprintln!("Error: {e}")` sites in `client/doublezero/src/main.rs` (env-parse failure, env-config resolution failure, top-level command failure) with a single helper that prints `Error: ` followed by the full chain of causes on stderr. ## [v0.24.0](https://github.com/malbeclabs/doublezero/compare/client/v0.23.0...client/v0.24.0) - 2026-05-22 diff --git a/client/doublezero/src/main.rs b/client/doublezero/src/main.rs index 94525984cc..d0c1234434 100644 --- a/client/doublezero/src/main.rs +++ b/client/doublezero/src/main.rs @@ -214,13 +214,18 @@ async fn main() -> eyre::Result<()> { }); // Bridge to the legacy `DZClient::new(Option, ...)` signature. - // CliContext now carries the fully resolved values, so we forward them - // directly. Legacy and conforming verbs agree on what they are talking to. + // CliContext now carries the fully resolved values for URL/WS/program-ID, + // so we forward them directly. The keypair argument is an exception: it + // must reflect only the `--keypair` CLI flag so that `DZClient::new`'s + // internal `load_keypair` precedence chain (CLI flag > `DOUBLEZERO_KEYPAIR` + // env var > stdin > persisted config) is preserved. Passing the layered + // ctx value here would mask the env var, which the e2e contributor-auth + // suite relies on for negative-authz checks. let url = Some(ctx.ledger_rpc_url.clone()); let ws = Some(ctx.ledger_ws_rpc_url.clone()); let program_id = Some(ctx.serviceability_program_id.to_string()); - let dzclient = DZClient::new(url.clone(), ws, program_id, ctx.keypair_path.clone())?; + let dzclient = DZClient::new(url.clone(), ws, program_id, app.keypair.clone())?; let client = CliCommandImpl::new(&dzclient); let stdout = std::io::stdout();