diff --git a/crates/solana-cli/CHANGELOG.md b/crates/solana-cli/CHANGELOG.md index e4f18096..10ef2b5f 100644 --- a/crates/solana-cli/CHANGELOG.md +++ b/crates/solana-cli/CHANGELOG.md @@ -9,6 +9,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - uptick to v0.3.0 ([#210](https://github.com/doublezerofoundation/doublezero-offchain/pull/210)) - add `revenue-distribution fetch validator-debts` command ([#201](https://github.com/doublezerofoundation/doublezero-offchain/pull/201)) +- solana-cli: add shared validator access validation for `prepare-validator-access` and `request-validator-access` commands, with a hidden `--force` flag on the request command ([#211](https://github.com/doublezerofoundation/doublezero-offchain/pull/211)) + ## [0.2.2](https://github.com/doublezerofoundation/doublezero-offchain/releases/tag/doublezero-solana/v0.2.2) - 2025-11-12 @@ -50,4 +52,4 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - add Solana validator deposit commands ([#111](https://github.com/doublezerofoundation/doublezero-offchain/pull/111)) - add `find` subcommand to locate nodes by ID or IP address ([#108](https://github.com/doublezerofoundation/doublezero-offchain/pull/108)) - handle requests with backup IDs ([#105](https://github.com/doublezerofoundation/doublezero-offchain/pull/105)) -- clean up ([#104](https://github.com/doublezerofoundation/doublezero-offchain/pull/104)) +- clean up ([#104](https://github.com/doublezerofoundation/doublezero-offchain/pull/104)) \ No newline at end of file diff --git a/crates/solana-cli/src/command/passport/access_validation.rs b/crates/solana-cli/src/command/passport/access_validation.rs new file mode 100644 index 00000000..3b99b250 --- /dev/null +++ b/crates/solana-cli/src/command/passport/access_validation.rs @@ -0,0 +1,282 @@ +use anyhow::{Result, bail}; +use doublezero_ledger_sentinel::{ + client::solana::SolRpcClientType, constants::ENV_PREVIOUS_LEADER_EPOCHS, +}; +use doublezero_solana_client_tools::rpc::SolanaConnection; +use solana_client::rpc_response::RpcContactInfo; +use solana_sdk::pubkey::Pubkey; + +use crate::utils::find_node_by_node_id; + +pub async fn validate_validator_access( + connection: &SolanaConnection, + sol_client: &C, + primary_validator_id: &Pubkey, + backup_validator_ids: &[Pubkey], + leader_schedule_epochs: Option, +) -> Result> +where + C: SolRpcClientType + Sync, +{ + let nodes = connection.get_cluster_nodes().await?; + if nodes.is_empty() { + bail!("Unable to fetch cluster nodes. Is your RPC endpoint correct?"); + } + + validate_validator_access_with_nodes( + &nodes, + sol_client, + primary_validator_id, + backup_validator_ids, + leader_schedule_epochs, + ) + .await +} + +pub async fn validate_validator_access_with_nodes( + nodes: &[RpcContactInfo], + sol_client: &C, + primary_validator_id: &Pubkey, + backup_validator_ids: &[Pubkey], + leader_schedule_epochs: Option, +) -> Result> +where + C: SolRpcClientType + Sync, +{ + let mut errors = Vec::::new(); + let leader_schedule_epochs = leader_schedule_epochs.unwrap_or(ENV_PREVIOUS_LEADER_EPOCHS); + + println!("Primary validator 🖥️ 💎:\n ID: {primary_validator_id} "); + if let Some(node) = find_node_by_node_id(nodes, primary_validator_id) { + println!( + " Gossip: ✅ OK ({})", + node.gossip.as_ref().map(|g| g.ip()).unwrap() + ); + print!(" Leader scheduler: "); + + if sol_client + .is_scheduled_leader(primary_validator_id, leader_schedule_epochs) + .await? + { + print!(" ✅ OK "); + } else { + print!(" ❌ Invalid "); + errors.push(format!( + "Primary validator ID ({}) is not an active staked validator. The primary must have stake delegated and be participating in the leader scheduler.", + primary_validator_id + )); + } + } else { + println!(" ❌ Gossip Fail",); + errors.push(format!( + "Primary validator ID ({}) is not visible in gossip. The primary validator must appear in gossip to be considered active.", + primary_validator_id + )); + } + println!(); + + if !backup_validator_ids.is_empty() { + println!("\nBackup validator 🖥️ 🛟: "); + + for backup_id in backup_validator_ids { + print!(" ID: {backup_id}\n Gossip: "); + + if let Some(ip) = sol_client.get_validator_ip(backup_id).await? { + println!(" ✅ OK ({})", ip); + print!(" Leader scheduler: "); + + if sol_client + .is_scheduled_leader(backup_id, leader_schedule_epochs) + .await? + { + println!(" ❌ Fail (on leader scheduler)"); + errors.push(format!( + "Backup validator ID ({}) should not be on leader scheduler. It must be a non-leader scheduled validator.", + backup_id + )); + } else { + println!(" ✅ OK (not a leader scheduled validator)"); + } + } else { + println!("❌ Gossip Fail",); + errors.push(format!( + "Backup validator ID ({}) is not visible in gossip. Backup validators must appear in gossip to be considered valid.", + backup_id + )); + } + } + } + + Ok(errors) +} + +pub fn should_continue_after_validation(errors: &[String], force: bool) -> bool { + if errors.is_empty() { + return true; + } + + println!("\nErrors found:"); + for error in errors { + println!(" - {}", error); + } + + if force { + println!("Proceeding despite validation errors (--force)."); + true + } else { + false + } +} + +#[cfg(test)] +mod tests { + use std::net::{Ipv4Addr, SocketAddr}; + + use doublezero_ledger_sentinel::{ + client::solana::MockSolRpcClientType, constants::ENV_PREVIOUS_LEADER_EPOCHS, + }; + use solana_client::rpc_response::RpcContactInfo; + use solana_sdk::pubkey::Pubkey; + + use super::validate_validator_access_with_nodes; + use crate::command::passport::access_validation::should_continue_after_validation; + + fn make_contact_info(pubkey: &Pubkey, gossip: Option) -> RpcContactInfo { + RpcContactInfo { + pubkey: pubkey.to_string(), + gossip, + tvu: None, + tpu: None, + tpu_quic: None, + tpu_forwards: None, + tpu_forwards_quic: None, + tpu_vote: None, + serve_repair: None, + rpc: None, + pubsub: None, + version: None, + feature_set: None, + shred_version: None, + } + } + + #[tokio::test] + async fn validation_succeeds_with_default_leader_schedule_epochs() { + let primary = Pubkey::new_unique(); + let backup = Pubkey::new_unique(); + let nodes = vec![ + make_contact_info( + &primary, + Some(SocketAddr::from((Ipv4Addr::LOCALHOST, 8001))), + ), + make_contact_info(&backup, Some(SocketAddr::from((Ipv4Addr::LOCALHOST, 8002)))), + ]; + + let mut client = MockSolRpcClientType::new(); + + { + let primary_clone = primary; + client + .expect_is_scheduled_leader() + .withf(move |validator_id, epochs| { + validator_id == &primary_clone && *epochs == ENV_PREVIOUS_LEADER_EPOCHS + }) + .returning(|_, _| Ok(true)); + } + { + let backup_clone = backup; + client + .expect_get_validator_ip() + .withf(move |validator_id| validator_id == &backup_clone) + .returning(|_| Ok(Some(Ipv4Addr::LOCALHOST))); + } + { + let backup_clone = backup; + client + .expect_is_scheduled_leader() + .withf(move |validator_id, epochs| { + validator_id == &backup_clone && *epochs == ENV_PREVIOUS_LEADER_EPOCHS + }) + .returning(|_, _| Ok(false)); + } + + let errors = + validate_validator_access_with_nodes(&nodes, &client, &primary, &[backup], None) + .await + .unwrap(); + + assert!(errors.is_empty()); + } + + #[tokio::test] + async fn validation_fails_for_missing_primary_and_leader_backup() { + let primary = Pubkey::new_unique(); + let backup = Pubkey::new_unique(); + let nodes = vec![make_contact_info( + &backup, + Some(SocketAddr::from((Ipv4Addr::LOCALHOST, 8002))), + )]; + + let mut client = MockSolRpcClientType::new(); + { + let backup_clone = backup; + client + .expect_get_validator_ip() + .withf(move |validator_id| validator_id == &backup_clone) + .returning(|_| Ok(Some(Ipv4Addr::LOCALHOST))); + } + { + let backup_clone = backup; + client + .expect_is_scheduled_leader() + .withf(move |validator_id, epochs| { + validator_id == &backup_clone && *epochs == ENV_PREVIOUS_LEADER_EPOCHS + }) + .returning(|_, _| Ok(true)); + } + + let errors = + validate_validator_access_with_nodes(&nodes, &client, &primary, &[backup], None) + .await + .unwrap(); + + assert_eq!(errors.len(), 2); + assert!(errors.iter().any(|e| e.contains("not visible in gossip"))); + assert!( + errors + .iter() + .any(|e| e.contains("should not be on leader scheduler")) + ); + } + + #[test] + fn should_continue_respects_force_flag() { + let errors = vec!["some error".to_string()]; + assert!(!should_continue_after_validation(&errors, false)); + assert!(should_continue_after_validation(&errors, true)); + } + + #[tokio::test] + async fn validation_uses_custom_leader_schedule_epochs() { + let primary = Pubkey::new_unique(); + let nodes = vec![make_contact_info( + &primary, + Some(SocketAddr::from((Ipv4Addr::LOCALHOST, 8001))), + )]; + + let mut client = MockSolRpcClientType::new(); + { + let primary_clone = primary; + client + .expect_is_scheduled_leader() + .withf(move |validator_id, epochs| validator_id == &primary_clone && *epochs == 1) + .returning(|_, _| Ok(true)); + } + + let errors = validate_validator_access_with_nodes(&nodes, &client, &primary, &[], Some(1)) + .await + .unwrap(); + + assert!(errors.is_empty()); + } +} diff --git a/crates/solana-cli/src/command/passport/mod.rs b/crates/solana-cli/src/command/passport/mod.rs index 82343615..7133a59f 100644 --- a/crates/solana-cli/src/command/passport/mod.rs +++ b/crates/solana-cli/src/command/passport/mod.rs @@ -4,11 +4,28 @@ use doublezero_passport::state::{AccessRequest, ProgramConfig}; use doublezero_solana_client_tools::rpc::SolanaConnection; use solana_sdk::pubkey::Pubkey; +mod access_validation; pub mod fetch; pub mod find_validator; pub mod prepare_access; pub mod request_access; +#[derive(Debug, Args, Clone)] +pub struct SharedAccessArgs { + /// The DoubleZero service key to request access from + #[arg(long)] + pub doublezero_address: Pubkey, + /// The validator's node ID (identity pubkey) + #[arg(long, value_name = "PUBKEY")] + pub primary_validator_id: Pubkey, + /// Optional backup validator IDs (identity pubkeys) + #[arg(long, value_name = "PUBKEY,PUBKEY,PUBKEY", value_delimiter = ',')] + pub backup_validator_ids: Vec, + /// Number of previous epochs to check when evaluating the leader schedule (defaults to ENV_PREVIOUS_LEADER_EPOCHS) + #[arg(long, hide = true)] + pub leader_schedule_epochs: Option, +} + #[derive(Debug, Args)] pub struct PassportCommand { #[command(subcommand)] diff --git a/crates/solana-cli/src/command/passport/prepare_access.rs b/crates/solana-cli/src/command/passport/prepare_access.rs index 1c817af1..fac0fa52 100644 --- a/crates/solana-cli/src/command/passport/prepare_access.rs +++ b/crates/solana-cli/src/command/passport/prepare_access.rs @@ -2,18 +2,20 @@ use std::sync::Arc; use anyhow::Result; use clap::Args; -use doublezero_ledger_sentinel::{ - client::solana::SolRpcClient, constants::ENV_PREVIOUS_LEADER_EPOCHS, -}; +use doublezero_ledger_sentinel::client::solana::SolRpcClient; use doublezero_passport::{ instruction::{AccessMode, SolanaValidatorAttestation}, state::AccessRequest, }; use doublezero_solana_client_tools::rpc::{SolanaConnection, SolanaConnectionOptions}; -use solana_sdk::{pubkey::Pubkey, signature::Keypair}; +use solana_sdk::signature::Keypair; use url::Url; -use crate::utils::{find_node_by_node_id, identify_cluster}; +use super::{ + SharedAccessArgs, + access_validation::{should_continue_after_validation, validate_validator_access}, +}; +use crate::utils::identify_cluster; /* doublezero-solana passport request-access --doublezero-address SSSS --primary-validator-id AAA --backup-validator-ids BBB,CCC --signature XXXXX @@ -21,15 +23,8 @@ use crate::utils::{find_node_by_node_id, identify_cluster}; #[derive(Debug, Args)] pub struct PrepareValidatorAccessCommand { - /// The DoubleZero service key to request access from - #[arg(long)] - doublezero_address: Pubkey, - /// The validator's node ID (identity pubkey) - #[arg(long, value_name = "PUBKEY")] - primary_validator_id: Pubkey, - /// Optional backup validator IDs (identity pubkeys) - #[arg(long, value_name = "PUBKEY,PUBKEY,PUBKEY", value_delimiter = ',')] - backup_validator_ids: Vec, + #[command(flatten)] + shared: SharedAccessArgs, #[arg(long, default_value_t = false)] force: bool, @@ -41,9 +36,13 @@ pub struct PrepareValidatorAccessCommand { impl PrepareValidatorAccessCommand { pub async fn try_into_execute(self) -> Result<()> { let PrepareValidatorAccessCommand { - doublezero_address, - primary_validator_id, - backup_validator_ids, + shared: + SharedAccessArgs { + doublezero_address, + primary_validator_id, + backup_validator_ids, + leader_schedule_epochs, + }, solana_connection_options, force, } = self; @@ -58,87 +57,21 @@ impl PrepareValidatorAccessCommand { // Identify the cluster let cluster = identify_cluster(&connection).await; // Fetch the cluster nodes - let nodes = connection.get_cluster_nodes().await?; - if nodes.is_empty() { - anyhow::bail!("Unable to fetch cluster nodes. Is your RPC endpoint correct?"); - } - // Collect errors - let mut errors = Vec::::new(); - println!("DoubleZero Passport - Prepare Validator Access Request"); println!("Connected to Solana: {:}", cluster); println!("\nDoubleZero Address: {doublezero_address}\n"); - println!("Primary validator 🖥️ 💎:\n ID: {primary_validator_id} "); - if let Some(node) = find_node_by_node_id(&nodes, &primary_validator_id) { - println!( - " Gossip: ✅ OK ({})", - node.gossip.as_ref().map(|g| g.ip()).unwrap() - ); - print!(" Leader scheduler: "); - - if sol_client - .is_scheduled_leader(&primary_validator_id, ENV_PREVIOUS_LEADER_EPOCHS) - .await? - { - print!(" ✅ OK "); - } else { - print!(" ❌ Invalid "); - errors.push(format!( - "Primary validator ID ({}) is not an active staked validator. The primary must have stake delegated and be participating in the leader scheduler.", - primary_validator_id - )); - } - } else { - println!(" ❌ Gossip Fail",); - errors.push(format!( - "Primary validator ID ({}) is not visible in gossip. The primary validator must appear in gossip to be considered active.", - primary_validator_id - )); - } - println!(); - - if !backup_validator_ids.is_empty() { - println!("\nBackup validator 🖥️ 🛟: "); - - for backup_id in &backup_validator_ids { - print!(" ID: {backup_id}\n Gossip: "); - - if let Some(ip) = sol_client.get_validator_ip(backup_id).await? { - println!(" ✅ OK ({})", ip); - print!(" Leader scheduler: "); - - if sol_client - .is_scheduled_leader(backup_id, ENV_PREVIOUS_LEADER_EPOCHS) - .await? - { - println!(" ❌ Fail (on leader scheduler)"); - errors.push(format!( - "Backup validator ID ({}) should not be on leader scheduler. It must be a non-leader scheduled validator.", - backup_id - )); - } else { - println!(" ✅ OK (not a leader scheduled validator)"); - } - } else { - println!("❌ Gossip Fail",); - errors.push(format!( - "Backup validator ID ({}) is not visible in gossip. Backup validators must appear in gossip to be considered valid.", - backup_id - )); - } - } - } - - if !errors.is_empty() { - println!("\nErrors found:"); - for error in errors { - println!(" - {}", error); - } - if !force { - return Ok(()); - } + let errors = validate_validator_access( + &connection, + &sol_client, + &primary_validator_id, + &backup_validator_ids, + leader_schedule_epochs, + ) + .await?; + if !should_continue_after_validation(&errors, force) { + return Ok(()); } println!( diff --git a/crates/solana-cli/src/command/passport/request_access.rs b/crates/solana-cli/src/command/passport/request_access.rs index bc5df9ab..257d0177 100644 --- a/crates/solana-cli/src/command/passport/request_access.rs +++ b/crates/solana-cli/src/command/passport/request_access.rs @@ -1,7 +1,8 @@ -use std::str::FromStr; +use std::{str::FromStr, sync::Arc}; use anyhow::{Result, bail}; use clap::Args; +use doublezero_ledger_sentinel::client::solana::SolRpcClient; use doublezero_passport::{ ID, instruction::{ @@ -13,29 +14,33 @@ use doublezero_passport::{ use doublezero_program_tools::instruction::try_build_instruction; use doublezero_solana_client_tools::payer::{SolanaPayerOptions, TransactionOutcome, Wallet}; use solana_sdk::{ - compute_budget::ComputeBudgetInstruction, offchain_message::OffchainMessage, pubkey::Pubkey, - signature::Signature, + compute_budget::ComputeBudgetInstruction, + offchain_message::OffchainMessage, + signature::{Keypair, Signature}, }; +use url::Url; +use super::{ + SharedAccessArgs, + access_validation::{should_continue_after_validation, validate_validator_access}, +}; +use crate::utils::identify_cluster; /* doublezero-solana passport request-access --doublezero-address SSSS --primary-validator-id AAA --backup-validator-ids BBB,CCC --signature XXXXX */ #[derive(Debug, Args)] pub struct RequestValidatorAccessCommand { - /// The DoubleZero service key to request access from - #[arg(long)] - doublezero_address: Pubkey, - /// The validator's node ID (identity pubkey) - #[arg(long, value_name = "PUBKEY")] - primary_validator_id: Pubkey, - /// Optional backup validator IDs (identity pubkeys) - #[arg(long, value_name = "PUBKEY,PUBKEY,PUBKEY", value_delimiter = ',')] - backup_validator_ids: Vec, + #[command(flatten)] + shared: SharedAccessArgs, /// Base58-encoded ed25519 signature of the access request message (service_key=AAA,backup_ids=BBBB,CCCC,DDDD) #[arg(long, short = 's', value_name = "BASE58_STRING")] signature: String, + /// Continue and submit transaction even if validation fails + #[arg(long = "force", hide = true, default_value_t = false)] + force: bool, + /// Offchain message version. ONLY 0 IS SUPPORTED. #[arg(long, value_name = "U8", default_value = "0")] message_version: u8, @@ -48,7 +53,30 @@ impl RequestValidatorAccessCommand { pub async fn try_into_execute(self) -> Result<()> { let wallet = Wallet::try_from(self.solana_payer_options.clone())?; - let (address, _) = AccessRequest::find_address(&self.doublezero_address); + println!("DoubleZero Passport - Request Validator Access"); + + let cluster = identify_cluster(&wallet.connection).await; + println!("Connected to Solana: {:}", cluster); + println!("\nDoubleZero Address: {}\n", self.shared.doublezero_address); + + let sol_client = SolRpcClient::new( + Url::parse(&wallet.connection.url()).unwrap(), + Arc::new(Keypair::new()), + ); + + let validation_errors = validate_validator_access( + &wallet.connection, + &sol_client, + &self.shared.primary_validator_id, + &self.shared.backup_validator_ids, + self.shared.leader_schedule_epochs, + ) + .await?; + if !should_continue_after_validation(&validation_errors, self.force) { + return Ok(()); + } + + let (address, _) = AccessRequest::find_address(&self.shared.doublezero_address); let request_account = wallet.connection.get_account(&address).await; if request_account.is_ok() { @@ -67,23 +95,23 @@ impl RequestValidatorAccessCommand { } async fn request_access(&self, wallet: &Wallet) -> Result { - let wallet_key = wallet.pubkey(); let ed25519_signature = Signature::from_str(&self.signature)?; + let wallet_key = wallet.pubkey(); // Create attestation let attestation = SolanaValidatorAttestation { - validator_id: self.primary_validator_id, - service_key: self.doublezero_address, + validator_id: self.shared.primary_validator_id, + service_key: self.shared.doublezero_address, ed25519_signature: ed25519_signature.into(), }; // Verify the signature. - let access_mode = if self.backup_validator_ids.is_empty() { + let access_mode = if self.shared.backup_validator_ids.is_empty() { AccessMode::SolanaValidator(attestation) } else { AccessMode::SolanaValidatorWithBackupIds { attestation, - backup_ids: self.backup_validator_ids.clone(), + backup_ids: self.shared.backup_validator_ids.clone(), } }; @@ -96,19 +124,25 @@ impl RequestValidatorAccessCommand { let message = OffchainMessage::new(self.message_version, raw_message.as_bytes())?; let serialized_message = message.serialize()?; - if !ed25519_signature.verify(self.primary_validator_id.as_array(), &serialized_message) { + if !ed25519_signature.verify( + self.shared.primary_validator_id.as_array(), + &serialized_message, + ) { bail!("Signature verification failed"); } else if self.solana_payer_options.signer_options.verbose { - println!("Signature recovers node ID: {}", self.primary_validator_id); + println!( + "Signature recovers node ID: {}", + self.shared.primary_validator_id + ); } let request_access_ix = try_build_instruction( &ID, - RequestAccessAccounts::new(&wallet_key, &self.doublezero_address), + RequestAccessAccounts::new(&wallet_key, &self.shared.doublezero_address), &PassportInstructionData::RequestAccess(access_mode), )?; - let (_, bump) = AccessRequest::find_address(&self.doublezero_address); + let (_, bump) = AccessRequest::find_address(&self.shared.doublezero_address); let mut compute_unit_limit = 10_000; compute_unit_limit += Wallet::compute_units_for_bump_seed(bump); diff --git a/sh/test_doublezero_solana_clean.sh b/sh/test_doublezero_solana_clean.sh index 0f686933..4926ad31 100755 --- a/sh/test_doublezero_solana_clean.sh +++ b/sh/test_doublezero_solana_clean.sh @@ -72,13 +72,14 @@ NODE_ID=$(solana address -k $VALIDATOR_KEYPAIR) MESSAGE="service_key=$DUMMY_KEY" SIGNATURE=$(solana sign-offchain-message -k $VALIDATOR_KEYPAIR service_key=$DUMMY_KEY) -echo "doublezero-solana passport request-validator-access -ul -v --primary-validator-id $NODE_ID --signature $SIGNATURE --doublezero-address $DUMMY_KEY" +echo "doublezero-solana passport request-validator-access -ul -v --primary-validator-id $NODE_ID --signature $SIGNATURE --doublezero-address $DUMMY_KEY --leader-schedule-epochs 1" $CLI_BIN passport request-validator-access \ -ul \ -v \ --primary-validator-id $NODE_ID \ --signature $SIGNATURE \ - --doublezero-address $DUMMY_KEY + --doublezero-address $DUMMY_KEY \ + --leader-schedule-epochs 1 echo echo "doublezero-solana passport fetch -ul --access-request $DUMMY_KEY"