From d31b63f95142e601705c556bd8f38bec3104ddfc Mon Sep 17 00:00:00 2001 From: cavemanloverboy Date: Sun, 1 Feb 2026 15:40:15 -0500 Subject: [PATCH] improvements: approve flag, show message hash, display proposals --- Cargo.lock | 1 + cli/Cargo.toml | 1 + cli/src/command/config_transaction_create.rs | 174 +++++++++----- cli/src/command/display_proposals.rs | 228 +++++++++++++++++++ cli/src/command/initiate_program_upgrade.rs | 195 ++++++++++------ cli/src/command/initiate_transfer.rs | 222 +++++++++++------- cli/src/command/mod.rs | 3 + cli/src/command/proposal_vote.rs | 65 +++--- cli/src/command/vault_transaction_create.rs | 169 +++++++++----- cli/src/main.rs | 1 + 10 files changed, 748 insertions(+), 311 deletions(-) create mode 100644 cli/src/command/display_proposals.rs diff --git a/Cargo.lock b/Cargo.lock index db0ae0c7..e35d3904 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5121,6 +5121,7 @@ dependencies = [ name = "squads-multisig-cli" version = "0.1.7" dependencies = [ + "bincode", "clap 3.2.25", "clap 4.5.53", "colored", diff --git a/cli/Cargo.toml b/cli/Cargo.toml index e2a1fddb..5e10f35d 100644 --- a/cli/Cargo.toml +++ b/cli/Cargo.toml @@ -13,6 +13,7 @@ eyre = "0.6.11" dialoguer = "0.11.0" indicatif = "0.17.7" colored = "2.1.0" +bincode = "1.3" # Solana deps solana-sdk = "2.2.20" # local deps diff --git a/cli/src/command/config_transaction_create.rs b/cli/src/command/config_transaction_create.rs index 49fa0742..c78581df 100644 --- a/cli/src/command/config_transaction_create.rs +++ b/cli/src/command/config_transaction_create.rs @@ -6,6 +6,7 @@ use colored::Colorize; use dialoguer::Confirm; use indicatif::ProgressBar; use solana_sdk::compute_budget::ComputeBudgetInstruction; +use solana_sdk::hash::hash; use solana_sdk::instruction::Instruction; use solana_sdk::message::v0::Message; use solana_sdk::message::VersionedMessage; @@ -19,11 +20,15 @@ use squads_multisig::solana_rpc_client::nonblocking::rpc_client::RpcClient; use squads_multisig::squads_multisig_program::accounts::ConfigTransactionCreate as ConfigTransactionCreateAccounts; use squads_multisig::squads_multisig_program::accounts::ProposalCreate as ProposalCreateAccounts; +use squads_multisig::squads_multisig_program::accounts::ProposalVote as ProposalVoteAccounts; use squads_multisig::squads_multisig_program::anchor_lang::ToAccountMetas; use squads_multisig::squads_multisig_program::instruction::ConfigTransactionCreate as ConfigTransactionCreateData; +use squads_multisig::squads_multisig_program::instruction::ProposalApprove; use squads_multisig::squads_multisig_program::instruction::ProposalCreate as ProposalCreateData; -use squads_multisig::squads_multisig_program::{ConfigTransactionCreateArgs, ProposalCreateArgs}; -use squads_multisig::state::{ConfigAction, Period, Permissions}; +use squads_multisig::squads_multisig_program::{ + ConfigTransactionCreateArgs, ProposalCreateArgs, ProposalVoteArgs, +}; +use squads_multisig::state::{ConfigAction, Period, Permission, Permissions}; use crate::utils::{create_signer_from_path, send_and_confirm_transaction}; @@ -59,6 +64,11 @@ pub struct ConfigTransactionCreate { #[arg(long)] priority_fee_lamports: Option, + + /// Approve the proposal atomically in the same transaction. + /// Note: This only works if the proposer has Vote permission. + #[arg(long)] + approve: bool, } impl ConfigTransactionCreate { @@ -72,6 +82,7 @@ impl ConfigTransactionCreate { action, memo, priority_fee_lamports, + approve, } = self; let program_id = @@ -81,7 +92,8 @@ impl ConfigTransactionCreate { let transaction_creator_keypair = create_signer_from_path(keypair).unwrap(); let transaction_creator = transaction_creator_keypair.pubkey(); - let fee_payer_keypair = fee_payer_keypair.map(|path| create_signer_from_path(path).unwrap()); + let fee_payer_keypair = + fee_payer_keypair.map(|path| create_signer_from_path(path).unwrap()); let fee_payer = fee_payer_keypair.as_ref().map(|kp| kp.pubkey()); let rpc_url = rpc_url.unwrap_or_else(|| "https://api.mainnet-beta.solana.com".to_string()); @@ -92,6 +104,20 @@ impl ConfigTransactionCreate { let multisig_data = get_multisig(rpc_client, &multisig).await?; + // Check if the proposer has Vote permission when --approve is used + if approve { + let has_vote_permission = multisig_data + .members + .iter() + .any(|m| m.key == transaction_creator && m.permissions.has(Permission::Vote)); + if !has_vote_permission { + return Err(eyre::eyre!( + "Cannot use --approve: {} does not have Vote permission in this multisig", + transaction_creator + )); + } + } + let transaction_index = multisig_data.transaction_index + 1; let proposal_pda = get_proposal_pda(&multisig, transaction_index, Some(&program_id)); @@ -100,10 +126,77 @@ impl ConfigTransactionCreate { let config_action = parse_action(&action); + // Build the message first so we can show the hash before confirmation + let payer = fee_payer.unwrap_or(transaction_creator); + + let mut instructions = vec![ + ComputeBudgetInstruction::set_compute_unit_price(priority_fee_lamports.unwrap_or(5000)), + Instruction { + accounts: ConfigTransactionCreateAccounts { + creator: transaction_creator, + multisig, + rent_payer: fee_payer.unwrap_or(transaction_creator), + transaction: transaction_pda.0, + system_program: solana_sdk::system_program::id(), + } + .to_account_metas(Some(false)), + data: ConfigTransactionCreateData { + args: ConfigTransactionCreateArgs { + actions: vec![config_action.unwrap()], + memo: memo.clone(), + }, + } + .data(), + program_id, + }, + Instruction { + accounts: ProposalCreateAccounts { + creator: transaction_creator, + multisig, + rent_payer: fee_payer.unwrap_or(transaction_creator), + proposal: proposal_pda.0, + system_program: solana_sdk::system_program::id(), + } + .to_account_metas(Some(false)), + data: ProposalCreateData { + args: ProposalCreateArgs { + draft: false, + transaction_index, + }, + } + .data(), + program_id, + }, + ]; + + if approve { + instructions.push(Instruction { + accounts: ProposalVoteAccounts { + member: transaction_creator, + multisig, + proposal: proposal_pda.0, + } + .to_account_metas(Some(false)), + data: ProposalApprove { + args: ProposalVoteArgs { memo }, + } + .data(), + program_id, + }); + } + + let blockhash = rpc_client + .get_latest_blockhash() + .await + .expect("Failed to get blockhash"); + + let message = Message::try_compile(&payer, &instructions, &[], blockhash).unwrap(); + let message_hash = hash(&message.serialize()); + println!(); println!( "{}", - "👀 You're about to execute a vault transaction, please review the details:".yellow() + "👀 You're about to execute a config transaction, please review the details:".yellow() ); println!(); println!("RPC Cluster URL: {}", rpc_url_clone); @@ -114,6 +207,9 @@ impl ConfigTransactionCreate { println!("Multisig Key: {}", multisig_pubkey); println!("Transaction Index: {}", transaction_index); println!("Action Type: {}", action); + println!("Auto-approve: {}", approve); + println!(); + println!("Message Hash (verify on hardware wallet): {}", message_hash); println!(); let proceed = Confirm::new() @@ -127,61 +223,6 @@ impl ConfigTransactionCreate { println!(); let progress = ProgressBar::new_spinner().with_message("Sending transaction..."); - progress.enable_steady_tick(Duration::from_millis(100)); - - let blockhash = rpc_client - .get_latest_blockhash() - .await - .expect("Failed to get blockhash"); - - let payer = fee_payer.unwrap_or(transaction_creator); - let message = Message::try_compile( - &payer, - &[ - ComputeBudgetInstruction::set_compute_unit_price( - priority_fee_lamports.unwrap_or(5000), - ), - Instruction { - accounts: ConfigTransactionCreateAccounts { - creator: transaction_creator, - multisig, - rent_payer: fee_payer.unwrap_or(transaction_creator), - transaction: transaction_pda.0, - system_program: solana_sdk::system_program::id(), - } - .to_account_metas(Some(false)), - data: ConfigTransactionCreateData { - args: ConfigTransactionCreateArgs { - actions: vec![config_action.unwrap()], - memo, - }, - } - .data(), - program_id, - }, - Instruction { - accounts: ProposalCreateAccounts { - creator: transaction_creator, - multisig, - rent_payer: fee_payer.unwrap_or(transaction_creator), - proposal: proposal_pda.0, - system_program: solana_sdk::system_program::id(), - } - .to_account_metas(Some(false)), - data: ProposalCreateData { - args: ProposalCreateArgs { - draft: false, - transaction_index, - }, - } - .data(), - program_id, - }, - ], - &[], - blockhash, - ) - .unwrap(); let mut signers = vec![&*transaction_creator_keypair]; if let Some(ref fee_payer_kp) = fee_payer_keypair { @@ -193,10 +234,17 @@ impl ConfigTransactionCreate { let signature = send_and_confirm_transaction(&transaction, &rpc_client).await?; - println!( - "✅ Created Config Transaction. Signature: {}", - signature.green() - ); + if approve { + println!( + "✅ Created and approved Config Transaction. Signature: {}", + signature.green() + ); + } else { + println!( + "✅ Created Config Transaction. Signature: {}", + signature.green() + ); + } Ok(()) } } diff --git a/cli/src/command/display_proposals.rs b/cli/src/command/display_proposals.rs new file mode 100644 index 00000000..8d500640 --- /dev/null +++ b/cli/src/command/display_proposals.rs @@ -0,0 +1,228 @@ +use std::str::FromStr; + +use clap::Args; +use colored::Colorize; +use solana_sdk::clock::Clock; +use solana_sdk::pubkey::Pubkey; +use solana_sdk::sysvar; +use squads_multisig::anchor_lang::AccountDeserialize; +use squads_multisig::pda::get_proposal_pda; +use squads_multisig::solana_rpc_client::nonblocking::rpc_client::RpcClient; +use squads_multisig::state::{Multisig, Proposal, ProposalStatus}; + +#[derive(Args)] +pub struct DisplayProposals { + /// RPC URL + #[arg(long)] + rpc_url: Option, + + /// Multisig Program ID + #[arg(long)] + program_id: Option, + + /// The multisig to query proposals for + #[arg(long)] + multisig_pubkey: String, + + /// Maximum number of recent transactions to check (default: 20) + #[arg(short = 'n', long, default_value_t = 20)] + limit: u64, +} + +impl DisplayProposals { + pub async fn execute(self) -> eyre::Result<()> { + let Self { + rpc_url, + program_id, + multisig_pubkey, + limit, + } = self; + + let program_id = + program_id.unwrap_or_else(|| "SQDS4ep65T869zMMBKyuUq6aD6EgTu8psMjkvj52pCf".to_string()); + + let program_id = Pubkey::from_str(&program_id).expect("Invalid program ID"); + + let rpc_url = rpc_url.unwrap_or_else(|| "https://api.mainnet-beta.solana.com".to_string()); + let rpc_client = RpcClient::new(rpc_url.clone()); + + let multisig = Pubkey::from_str(&multisig_pubkey).expect("Invalid multisig address"); + + println!(); + println!("{}", "🔍 Fetching outstanding proposals...".yellow()); + println!(); + println!("RPC Cluster URL: {}", rpc_url); + println!("Program ID: {}", program_id); + println!("Multisig Key: {}", multisig_pubkey); + println!(); + + // Fetch the multisig account and clock sysvar together + let accounts = rpc_client + .get_multiple_accounts(&[multisig, sysvar::clock::ID]) + .await + .map_err(|e| eyre::eyre!("Failed to fetch accounts: {}", e))?; + + let multisig_account_data = accounts[0] + .as_ref() + .ok_or_else(|| eyre::eyre!("Multisig account not found"))?; + + let clock_account_data = accounts[1] + .as_ref() + .ok_or_else(|| eyre::eyre!("Clock sysvar not found"))?; + + let multisig_account = + Multisig::try_deserialize(&mut multisig_account_data.data.as_slice()) + .map_err(|e| eyre::eyre!("Failed to deserialize multisig: {}", e))?; + + let clock: Clock = bincode::deserialize(&clock_account_data.data) + .map_err(|e| eyre::eyre!("Failed to deserialize clock: {}", e))?; + + let current_timestamp = clock.unix_timestamp; + + let transaction_index = multisig_account.transaction_index; + println!("Total transactions: {}", transaction_index); + println!("Checking last {} transactions...", limit); + println!(); + + if transaction_index == 0 { + println!("No transactions found for this multisig."); + return Ok(()); + } + + let mut outstanding_proposals = Vec::new(); + + // Calculate the starting index (don't go below 1) + let start_idx = transaction_index.saturating_sub(limit - 1).max(1); + + // Iterate through recent transaction indices in reverse order (most recent first) + for idx in (start_idx..=transaction_index).rev() { + let proposal_pda = get_proposal_pda(&multisig, idx, Some(&program_id)); + + // Try to fetch the proposal account + match rpc_client.get_account(&proposal_pda.0).await { + Ok(account) => { + // Deserialize the proposal + match Proposal::try_deserialize(&mut account.data.as_slice()) { + Ok(proposal) => { + // Check if proposal is outstanding (Draft, Active, or Approved) + let is_outstanding = matches!( + proposal.status, + ProposalStatus::Draft { .. } + | ProposalStatus::Active { .. } + | ProposalStatus::Approved { .. } + ); + + if is_outstanding { + outstanding_proposals.push((idx, proposal)); + } + } + Err(_) => { + // Account exists but failed to deserialize - skip + continue; + } + } + } + Err(_) => { + // Account doesn't exist - skip + continue; + } + } + } + + if outstanding_proposals.is_empty() { + println!("{}", "No outstanding proposals found.".green()); + return Ok(()); + } + + println!( + "{}", + format!( + "Found {} outstanding proposal(s):", + outstanding_proposals.len() + ) + .green() + ); + println!(); + + // Display proposals + for (idx, proposal) in outstanding_proposals { + println!("{}", "─".repeat(80).bright_black()); + println!("Transaction Index: {}", idx.to_string().cyan()); + println!( + "Proposal PDA: {}", + get_proposal_pda(&multisig, idx, Some(&program_id)).0 + ); + + // Display status + let status_str = match &proposal.status { + ProposalStatus::Draft { timestamp } => { + format!( + "Draft (created: {})", + format_timestamp(*timestamp, current_timestamp) + ) + } + ProposalStatus::Active { timestamp } => { + format!( + "Active (activated: {})", + format_timestamp(*timestamp, current_timestamp) + ) + } + ProposalStatus::Approved { timestamp } => { + format!( + "Approved (approved: {})", + format_timestamp(*timestamp, current_timestamp) + ) + } + // Filtered out by is_outstanding check above + _ => unreachable!( + "We filtered proposals by status above, so this should never happen" + ), + }; + println!("Status: {}", status_str.yellow()); + + // Display votes + println!("Approved by: {} member(s)", proposal.approved.len()); + if !proposal.approved.is_empty() { + for pubkey in &proposal.approved { + println!(" - {}", pubkey); + } + } + + println!("Rejected by: {} member(s)", proposal.rejected.len()); + if !proposal.rejected.is_empty() { + for pubkey in &proposal.rejected { + println!(" - {}", pubkey); + } + } + + println!("Cancelled by: {} member(s)", proposal.cancelled.len()); + if !proposal.cancelled.is_empty() { + for pubkey in &proposal.cancelled { + println!(" - {}", pubkey); + } + } + + println!(); + } + + println!("{}", "─".repeat(80).bright_black()); + + Ok(()) + } +} + +fn format_timestamp(timestamp: i64, current_timestamp: i64) -> String { + let diff = current_timestamp - timestamp; + + if diff < 0 { + format!("in {} seconds", -diff) + } else if diff < 60 { + format!("{} seconds ago", diff) + } else if diff < 3600 { + format!("{} minutes ago", diff / 60) + } else if diff < 86400 { + format!("{} hours ago", diff / 3600) + } else { + format!("{} days ago", diff / 86400) + } +} diff --git a/cli/src/command/initiate_program_upgrade.rs b/cli/src/command/initiate_program_upgrade.rs index 3ba2a3d1..fd8ebb51 100644 --- a/cli/src/command/initiate_program_upgrade.rs +++ b/cli/src/command/initiate_program_upgrade.rs @@ -7,6 +7,7 @@ use dialoguer::Confirm; use indicatif::ProgressBar; use solana_program::bpf_loader_upgradeable::upgrade; use solana_sdk::compute_budget::ComputeBudgetInstruction; +use solana_sdk::hash::hash; use solana_sdk::instruction::Instruction; use solana_sdk::message::v0::Message; use solana_sdk::message::VersionedMessage; @@ -18,12 +19,16 @@ use squads_multisig::client::get_multisig; use squads_multisig::pda::{get_proposal_pda, get_transaction_pda, get_vault_pda}; use squads_multisig::solana_rpc_client::nonblocking::rpc_client::RpcClient; use squads_multisig::squads_multisig_program::accounts::ProposalCreate as ProposalCreateAccounts; +use squads_multisig::squads_multisig_program::accounts::ProposalVote as ProposalVoteAccounts; use squads_multisig::squads_multisig_program::accounts::VaultTransactionCreate as VaultTransactionCreateAccounts; use squads_multisig::squads_multisig_program::anchor_lang::ToAccountMetas; +use squads_multisig::squads_multisig_program::instruction::ProposalApprove; use squads_multisig::squads_multisig_program::instruction::ProposalCreate as ProposalCreateData; use squads_multisig::squads_multisig_program::instruction::VaultTransactionCreate as VaultTransactionCreateData; use squads_multisig::squads_multisig_program::ProposalCreateArgs; +use squads_multisig::squads_multisig_program::ProposalVoteArgs; use squads_multisig::squads_multisig_program::VaultTransactionCreateArgs; +use squads_multisig::state::Permission; use crate::utils::{create_signer_from_path, send_and_confirm_transaction}; @@ -70,6 +75,11 @@ pub struct InitiateProgramUpgrade { #[arg(long)] priority_fee_lamports: Option, + + /// Approve the proposal atomically in the same transaction. + /// Note: This only works if the proposer has Vote permission. + #[arg(long)] + approve: bool, } impl InitiateProgramUpgrade { @@ -86,6 +96,7 @@ impl InitiateProgramUpgrade { buffer_address, program_to_upgrade_id, spill_address, + approve, } = self; let program_id = squads_program_id @@ -111,10 +122,108 @@ impl InitiateProgramUpgrade { let multisig_data = get_multisig(rpc_client, &multisig).await?; + // Check if the proposer has Vote permission when --approve is used + if approve { + let has_vote_permission = multisig_data + .members + .iter() + .any(|m| m.key == transaction_creator && m.permissions.has(Permission::Vote)); + if !has_vote_permission { + return Err(eyre::eyre!( + "Cannot use --approve: {} does not have Vote permission in this multisig", + transaction_creator + )); + } + } + let transaction_index = multisig_data.transaction_index + 1; let transaction_pda = get_transaction_pda(&multisig, transaction_index, Some(&program_id)); let proposal_pda = get_proposal_pda(&multisig, transaction_index, Some(&program_id)); + + // Build the message first so we can show the hash before confirmation + let vault_pda = get_vault_pda(&multisig, vault_index, Some(&program_id)); + + let blockhash = rpc_client + .get_latest_blockhash() + .await + .expect("Failed to get blockhash"); + + let instruction = upgrade( + &program_to_upgrade, + &buffer_address_id, + &vault_pda.0, + &spill_address_id, + ); + + let upgrade_program_message = + Message::try_compile(&vault_pda.0, &[instruction], &[], blockhash).unwrap(); + + let payer = fee_payer.unwrap_or(transaction_creator); + + let mut instructions = vec![ + ComputeBudgetInstruction::set_compute_unit_price( + priority_fee_lamports.unwrap_or(200_000), + ), + Instruction { + accounts: VaultTransactionCreateAccounts { + creator: transaction_creator, + rent_payer: fee_payer.unwrap_or(transaction_creator), + transaction: transaction_pda.0, + multisig, + system_program: solana_sdk::system_program::id(), + } + .to_account_metas(Some(false)), + data: VaultTransactionCreateData { + args: VaultTransactionCreateArgs { + ephemeral_signers: 0, + vault_index, + memo: memo.clone(), + transaction_message: upgrade_program_message.serialize(), + }, + } + .data(), + program_id, + }, + Instruction { + accounts: ProposalCreateAccounts { + creator: transaction_creator, + rent_payer: fee_payer.unwrap_or(transaction_creator), + proposal: proposal_pda.0, + multisig, + system_program: solana_sdk::system_program::id(), + } + .to_account_metas(Some(false)), + data: ProposalCreateData { + args: ProposalCreateArgs { + draft: false, + transaction_index, + }, + } + .data(), + program_id, + }, + ]; + + if approve { + instructions.push(Instruction { + accounts: ProposalVoteAccounts { + member: transaction_creator, + multisig, + proposal: proposal_pda.0, + } + .to_account_metas(Some(false)), + data: ProposalApprove { + args: ProposalVoteArgs { memo }, + } + .data(), + program_id, + }); + } + + let message = Message::try_compile(&payer, &instructions, &[], blockhash).unwrap(); + let message_hash = hash(&message.serialize()); + println!(); println!( "{}", @@ -132,6 +241,9 @@ impl InitiateProgramUpgrade { println!("To upgrade program ID: {}", program_to_upgrade_id); println!("Buffer Address: {}", buffer_address); println!("Spill Address: {}", spill_address); + println!("Auto-approve: {}", approve); + println!(); + println!("Message Hash (verify on hardware wallet): {}", message_hash); println!(); let proceed = Confirm::new() @@ -147,74 +259,6 @@ impl InitiateProgramUpgrade { let progress = ProgressBar::new_spinner().with_message("Sending transaction..."); progress.enable_steady_tick(Duration::from_millis(100)); - let blockhash = rpc_client - .get_latest_blockhash() - .await - .expect("Failed to get blockhash"); - - let vault_pda = get_vault_pda(&multisig, vault_index, Some(&program_id)); - - let instruction = upgrade( - &program_to_upgrade, - &buffer_address_id, - &vault_pda.0, - &spill_address_id, - ); - - let upgrade_program_message = - Message::try_compile(&vault_pda.0, &[instruction], &[], blockhash).unwrap(); - - let payer = fee_payer.unwrap_or(transaction_creator); - let message = Message::try_compile( - &payer, - &[ - ComputeBudgetInstruction::set_compute_unit_price( - priority_fee_lamports.unwrap_or(200_000), - ), - Instruction { - accounts: VaultTransactionCreateAccounts { - creator: transaction_creator, - rent_payer: fee_payer.unwrap_or(transaction_creator), - transaction: transaction_pda.0, - multisig, - system_program: solana_sdk::system_program::id(), - } - .to_account_metas(Some(false)), - data: VaultTransactionCreateData { - args: VaultTransactionCreateArgs { - ephemeral_signers: 0, - vault_index, - memo, - transaction_message: upgrade_program_message.serialize(), - }, - } - .data(), - program_id, - }, - Instruction { - accounts: ProposalCreateAccounts { - creator: transaction_creator, - rent_payer: fee_payer.unwrap_or(transaction_creator), - proposal: proposal_pda.0, - multisig, - system_program: solana_sdk::system_program::id(), - } - .to_account_metas(Some(false)), - data: ProposalCreateData { - args: ProposalCreateArgs { - draft: false, - transaction_index, - }, - } - .data(), - program_id, - }, - ], - &[], - blockhash, - ) - .unwrap(); - let mut signers = vec![&*transaction_creator_keypair]; if let Some(ref fee_payer_kp) = fee_payer_keypair { signers.push(&**fee_payer_kp); @@ -225,10 +269,17 @@ impl InitiateProgramUpgrade { let signature = send_and_confirm_transaction(&transaction, &rpc_client).await?; - println!( - "✅ Transaction created successfully. Signature: {}", - signature.green() - ); + if approve { + println!( + "✅ Transaction created and approved. Signature: {}", + signature.green() + ); + } else { + println!( + "✅ Transaction created successfully. Signature: {}", + signature.green() + ); + } Ok(()) } } diff --git a/cli/src/command/initiate_transfer.rs b/cli/src/command/initiate_transfer.rs index 374f8419..679d277c 100644 --- a/cli/src/command/initiate_transfer.rs +++ b/cli/src/command/initiate_transfer.rs @@ -7,6 +7,7 @@ use dialoguer::Confirm; use indicatif::ProgressBar; use solana_sdk::commitment_config::CommitmentConfig; use solana_sdk::compute_budget::ComputeBudgetInstruction; +use solana_sdk::hash::hash; use solana_sdk::instruction::Instruction; use solana_sdk::message::v0::Message; use solana_sdk::message::VersionedMessage; @@ -21,15 +22,18 @@ use squads_multisig::anchor_lang::{AnchorSerialize, InstructionData}; use squads_multisig::client::get_multisig; use squads_multisig::pda::{get_proposal_pda, get_transaction_pda, get_vault_pda}; use squads_multisig::solana_rpc_client::nonblocking::rpc_client::RpcClient; -use squads_multisig::solana_rpc_client_api::request::RpcError; use squads_multisig::squads_multisig_program::accounts::ProposalCreate as ProposalCreateAccounts; +use squads_multisig::squads_multisig_program::accounts::ProposalVote as ProposalVoteAccounts; use squads_multisig::squads_multisig_program::accounts::VaultTransactionCreate as VaultTransactionCreateAccounts; use squads_multisig::squads_multisig_program::anchor_lang::ToAccountMetas; +use squads_multisig::squads_multisig_program::instruction::ProposalApprove; use squads_multisig::squads_multisig_program::instruction::ProposalCreate as ProposalCreateData; use squads_multisig::squads_multisig_program::instruction::VaultTransactionCreate as VaultTransactionCreateData; use squads_multisig::squads_multisig_program::ProposalCreateArgs; +use squads_multisig::squads_multisig_program::ProposalVoteArgs; use squads_multisig::squads_multisig_program::TransactionMessage; use squads_multisig::squads_multisig_program::VaultTransactionCreateArgs; +use squads_multisig::state::Permission; use squads_multisig::vault_transaction::VaultTransactionMessageExt; use crate::utils::{create_signer_from_path, send_and_confirm_transaction}; @@ -82,6 +86,11 @@ pub struct InitiateTransfer { #[arg(long)] priority_fee_lamports: Option, + + /// Approve the proposal atomically in the same transaction. + /// Note: This only works if the proposer has Vote permission. + #[arg(long)] + approve: bool, } impl InitiateTransfer { @@ -99,6 +108,7 @@ impl InitiateTransfer { token_amount_u64, token_mint_address, recipient, + approve, } = self; let program_id = @@ -155,14 +165,123 @@ impl InitiateTransfer { let multisig_data = get_multisig(rpc_client, &multisig).await?; + // Check if the proposer has Vote permission when --approve is used + if approve { + let has_vote_permission = multisig_data + .members + .iter() + .any(|m| m.key == transaction_creator && m.permissions.has(Permission::Vote)); + if !has_vote_permission { + return Err(eyre::eyre!( + "Cannot use --approve: {} does not have Vote permission in this multisig", + transaction_creator + )); + } + } + let transaction_index = multisig_data.transaction_index + 1; let transaction_pda = get_transaction_pda(&multisig, transaction_index, Some(&program_id)); let proposal_pda = get_proposal_pda(&multisig, transaction_index, Some(&program_id)); + + // Build the message first so we can show the hash before confirmation + let vault_pda = get_vault_pda(&multisig, vault_index, Some(&program_id)); + + let sender_ata = get_associated_token_address_with_program_id( + &vault_pda.0, + &token_mint, + &token_program_id, + ); + + let transfer_message = TransactionMessage::try_compile( + &vault_pda.0, + &[transfer( + &token_program_id, + &sender_ata, + &recipient_ata, + &vault_pda.0, + &[&vault_pda.0], + token_amount_u64, + ) + .unwrap()], + &[], + ) + .unwrap(); + + let payer = fee_payer.unwrap_or(transaction_creator); + + let mut instructions = vec![ + ComputeBudgetInstruction::set_compute_unit_price( + priority_fee_lamports.unwrap_or(200_000), + ), + Instruction { + accounts: VaultTransactionCreateAccounts { + creator: transaction_creator, + rent_payer: fee_payer.unwrap_or(transaction_creator), + transaction: transaction_pda.0, + multisig, + system_program: solana_sdk::system_program::id(), + } + .to_account_metas(Some(false)), + data: VaultTransactionCreateData { + args: VaultTransactionCreateArgs { + ephemeral_signers: 0, + vault_index, + memo: memo.clone(), + transaction_message: transfer_message.try_to_vec().unwrap(), + }, + } + .data(), + program_id, + }, + Instruction { + accounts: ProposalCreateAccounts { + creator: transaction_creator, + rent_payer: fee_payer.unwrap_or(transaction_creator), + proposal: proposal_pda.0, + multisig, + system_program: solana_sdk::system_program::id(), + } + .to_account_metas(Some(false)), + data: ProposalCreateData { + args: ProposalCreateArgs { + draft: false, + transaction_index, + }, + } + .data(), + program_id, + }, + ]; + + if approve { + instructions.push(Instruction { + accounts: ProposalVoteAccounts { + member: transaction_creator, + multisig, + proposal: proposal_pda.0, + } + .to_account_metas(Some(false)), + data: ProposalApprove { + args: ProposalVoteArgs { memo }, + } + .data(), + program_id, + }); + } + + let blockhash = rpc_client + .get_latest_blockhash() + .await + .expect("Failed to get blockhash"); + + let message = Message::try_compile(&payer, &instructions, &[], blockhash).unwrap(); + let message_hash = hash(&message.serialize()); + println!(); println!( "{}", - "👀 You're about to create a vault transaction, please review the details:".yellow() + "👀 You're about to create a transfer transaction, please review the details:".yellow() ); println!(); println!("RPC Cluster URL: {}", rpc_url_clone); @@ -174,13 +293,16 @@ impl InitiateTransfer { println!("Transaction Index: {}", transaction_index); println!("Vault Index: {}", vault_index); println!(); - println!("Recipient: {}", resolved_recipient.authority); println!("Recipient Token Account: {}", recipient_ata); println!( "Transfer Amount: {}", format_token_amount(token_amount_u64, decimals) ); + println!("Auto-approve: {}", approve); + println!(); + println!("Message Hash (verify on hardware wallet): {}", message_hash); + println!(); let proceed = Confirm::new() .with_prompt("Do you want to proceed?") @@ -195,85 +317,6 @@ impl InitiateTransfer { let progress = ProgressBar::new_spinner().with_message("Sending transaction..."); progress.enable_steady_tick(Duration::from_millis(100)); - let blockhash = rpc_client - .get_latest_blockhash() - .await - .expect("Failed to get blockhash"); - - let vault_pda = get_vault_pda(&multisig, vault_index, Some(&program_id)); - - let sender_ata = get_associated_token_address_with_program_id( - &vault_pda.0, - &token_mint, - &token_program_id, - ); - - let transfer_message = TransactionMessage::try_compile( - &vault_pda.0, - &[transfer( - &token_program_id, - &sender_ata, - &recipient_ata, - &vault_pda.0, - &[&vault_pda.0], - token_amount_u64, - ) - .unwrap()], - &[], - ) - .unwrap(); - - let payer = fee_payer.unwrap_or(transaction_creator); - let message = Message::try_compile( - &payer, - &[ - ComputeBudgetInstruction::set_compute_unit_price( - priority_fee_lamports.unwrap_or(200_000), - ), - Instruction { - accounts: VaultTransactionCreateAccounts { - creator: transaction_creator, - rent_payer: fee_payer.unwrap_or(transaction_creator), - transaction: transaction_pda.0, - multisig, - system_program: solana_sdk::system_program::id(), - } - .to_account_metas(Some(false)), - data: VaultTransactionCreateData { - args: VaultTransactionCreateArgs { - ephemeral_signers: 0, - vault_index, - memo, - transaction_message: transfer_message.try_to_vec().unwrap(), - }, - } - .data(), - program_id, - }, - Instruction { - accounts: ProposalCreateAccounts { - creator: transaction_creator, - rent_payer: fee_payer.unwrap_or(transaction_creator), - proposal: proposal_pda.0, - multisig, - system_program: solana_sdk::system_program::id(), - } - .to_account_metas(Some(false)), - data: ProposalCreateData { - args: ProposalCreateArgs { - draft: false, - transaction_index, - }, - } - .data(), - program_id, - }, - ], - &[], - blockhash, - ) - .unwrap(); - let mut signers = vec![&*transaction_creator_keypair]; if let Some(ref fee_payer_kp) = fee_payer_keypair { signers.push(&**fee_payer_kp); @@ -284,10 +327,17 @@ impl InitiateTransfer { let signature = send_and_confirm_transaction(&transaction, &rpc_client).await?; - println!( - "✅ Transaction created successfully. Signature: {}", - signature.green() - ); + if approve { + println!( + "✅ Transaction created and approved. Signature: {}", + signature.green() + ); + } else { + println!( + "✅ Transaction created successfully. Signature: {}", + signature.green() + ); + } Ok(()) } } diff --git a/cli/src/command/mod.rs b/cli/src/command/mod.rs index c5c81912..a3bc34ba 100644 --- a/cli/src/command/mod.rs +++ b/cli/src/command/mod.rs @@ -1,5 +1,6 @@ use crate::command::config_transaction_create::ConfigTransactionCreate; use crate::command::config_transaction_execute::ConfigTransactionExecute; +use crate::command::display_proposals::DisplayProposals; use crate::command::display_vault::DisplayVault; use crate::command::initiate_program_upgrade::InitiateProgramUpgrade; use crate::command::initiate_transfer::InitiateTransfer; @@ -14,6 +15,7 @@ use clap::Subcommand; pub mod config_transaction_create; pub mod config_transaction_execute; +pub mod display_proposals; pub mod display_vault; pub mod initiate_program_upgrade; pub mod initiate_transfer; @@ -37,4 +39,5 @@ pub enum Command { InitiateTransfer(InitiateTransfer), InitiateProgramUpgrade(InitiateProgramUpgrade), DisplayVault(DisplayVault), + DisplayProposals(DisplayProposals), } diff --git a/cli/src/command/proposal_vote.rs b/cli/src/command/proposal_vote.rs index d13960cd..f8945f69 100644 --- a/cli/src/command/proposal_vote.rs +++ b/cli/src/command/proposal_vote.rs @@ -6,6 +6,7 @@ use colored::Colorize; use dialoguer::Confirm; use indicatif::ProgressBar; use solana_sdk::compute_budget::ComputeBudgetInstruction; +use solana_sdk::hash::hash; use solana_sdk::instruction::Instruction; use solana_sdk::message::v0::Message; use solana_sdk::message::VersionedMessage; @@ -94,36 +95,8 @@ impl ProposalVote { let fee_payer_keypair = fee_payer_keypair.map(|path| create_signer_from_path(path).unwrap()); let fee_payer = fee_payer_keypair.as_ref().map(|kp| kp.pubkey()); - println!(); - println!( - "{}", - "👀 You're about to vote on a proposal, please review the details:".yellow() - ); - println!(); - println!("RPC Cluster URL: {}", rpc_url); - println!("Program ID: {}", program_id); - println!("Your Public Key: {}", transaction_creator); - println!(); - println!("⚙️ Config Parameters"); - println!("Multisig Key: {}", multisig_pubkey); - println!("Transaction Index: {}", transaction_index); - println!("Vote Type: {}", action); - println!(); - - let proceed = Confirm::new() - .with_prompt("Do you want to proceed?") - .default(false) - .interact()?; - if !proceed { - println!("OK, aborting."); - return Ok(()); - } - println!(); - - let rpc_client = RpcClient::new(rpc_url); - - let progress = ProgressBar::new_spinner().with_message("Sending transaction..."); - progress.enable_steady_tick(Duration::from_millis(100)); + // Build the message first so we can show the hash before confirmation + let rpc_client = RpcClient::new(rpc_url.clone()); let blockhash = rpc_client .get_latest_blockhash() @@ -172,6 +145,38 @@ impl ProposalVote { blockhash, ) .unwrap(); + let message_hash = hash(&message.serialize()); + + println!(); + println!( + "{}", + "👀 You're about to vote on a proposal, please review the details:".yellow() + ); + println!(); + println!("RPC Cluster URL: {}", rpc_url); + println!("Program ID: {}", program_id); + println!("Your Public Key: {}", transaction_creator); + println!(); + println!("⚙️ Config Parameters"); + println!("Multisig Key: {}", multisig_pubkey); + println!("Transaction Index: {}", transaction_index); + println!("Vote Type: {}", action); + println!(); + println!("Message Hash (verify on hardware wallet): {}", message_hash); + println!(); + + let proceed = Confirm::new() + .with_prompt("Do you want to proceed?") + .default(false) + .interact()?; + if !proceed { + println!("OK, aborting."); + return Ok(()); + } + println!(); + + let progress = ProgressBar::new_spinner().with_message("Sending transaction..."); + progress.enable_steady_tick(Duration::from_millis(100)); let mut signers = vec![&*transaction_creator_keypair]; if let Some(ref fee_payer_kp) = fee_payer_keypair { diff --git a/cli/src/command/vault_transaction_create.rs b/cli/src/command/vault_transaction_create.rs index 490eb4ad..ffb8ddfd 100644 --- a/cli/src/command/vault_transaction_create.rs +++ b/cli/src/command/vault_transaction_create.rs @@ -6,6 +6,7 @@ use colored::Colorize; use dialoguer::Confirm; use indicatif::ProgressBar; use solana_sdk::compute_budget::ComputeBudgetInstruction; +use solana_sdk::hash::hash; use solana_sdk::instruction::Instruction; use solana_sdk::message::v0::Message; use solana_sdk::message::VersionedMessage; @@ -17,12 +18,16 @@ use squads_multisig::client::get_multisig; use squads_multisig::pda::{get_proposal_pda, get_transaction_pda}; use squads_multisig::solana_rpc_client::nonblocking::rpc_client::RpcClient; use squads_multisig::squads_multisig_program::accounts::ProposalCreate as ProposalCreateAccounts; +use squads_multisig::squads_multisig_program::accounts::ProposalVote as ProposalVoteAccounts; use squads_multisig::squads_multisig_program::accounts::VaultTransactionCreate as VaultTransactionCreateAccounts; use squads_multisig::squads_multisig_program::anchor_lang::ToAccountMetas; +use squads_multisig::squads_multisig_program::instruction::ProposalApprove; use squads_multisig::squads_multisig_program::instruction::ProposalCreate as ProposalCreateData; use squads_multisig::squads_multisig_program::instruction::VaultTransactionCreate as VaultTransactionCreateData; use squads_multisig::squads_multisig_program::ProposalCreateArgs; +use squads_multisig::squads_multisig_program::ProposalVoteArgs; use squads_multisig::squads_multisig_program::VaultTransactionCreateArgs; +use squads_multisig::state::Permission; use crate::utils::{create_signer_from_path, send_and_confirm_transaction}; @@ -60,6 +65,11 @@ pub struct VaultTransactionCreate { #[arg(long)] priority_fee_lamports: Option, + + /// Approve the proposal atomically in the same transaction. + /// Note: This only works if the proposer has Vote permission. + #[arg(long)] + approve: bool, } impl VaultTransactionCreate { @@ -74,6 +84,7 @@ impl VaultTransactionCreate { transaction_message, vault_index, priority_fee_lamports, + approve, } = self; let program_id = @@ -95,10 +106,94 @@ impl VaultTransactionCreate { let multisig_data = get_multisig(rpc_client, &multisig).await?; + // Check if the proposer has Vote permission when --approve is used + if approve { + let has_vote_permission = multisig_data + .members + .iter() + .any(|m| m.key == transaction_creator && m.permissions.has(Permission::Vote)); + if !has_vote_permission { + return Err(eyre::eyre!( + "Cannot use --approve: {} does not have Vote permission in this multisig", + transaction_creator + )); + } + } + let transaction_index = multisig_data.transaction_index + 1; let transaction_pda = get_transaction_pda(&multisig, transaction_index, Some(&program_id)); let proposal_pda = get_proposal_pda(&multisig, transaction_index, Some(&program_id)); + + // Build the message first so we can show the hash before confirmation + let payer = fee_payer.unwrap_or(transaction_creator); + + let mut instructions = vec![ + ComputeBudgetInstruction::set_compute_unit_price(priority_fee_lamports.unwrap_or(5000)), + Instruction { + accounts: VaultTransactionCreateAccounts { + creator: transaction_creator, + rent_payer: fee_payer.unwrap_or(transaction_creator), + transaction: transaction_pda.0, + multisig, + system_program: solana_sdk::system_program::id(), + } + .to_account_metas(Some(false)), + data: VaultTransactionCreateData { + args: VaultTransactionCreateArgs { + ephemeral_signers: 0, + vault_index, + memo: memo.clone(), + transaction_message, + }, + } + .data(), + program_id, + }, + Instruction { + accounts: ProposalCreateAccounts { + creator: transaction_creator, + rent_payer: fee_payer.unwrap_or(transaction_creator), + proposal: proposal_pda.0, + multisig, + system_program: solana_sdk::system_program::id(), + } + .to_account_metas(Some(false)), + data: ProposalCreateData { + args: ProposalCreateArgs { + draft: false, + transaction_index, + }, + } + .data(), + program_id, + }, + ]; + + if approve { + instructions.push(Instruction { + accounts: ProposalVoteAccounts { + member: transaction_creator, + multisig, + proposal: proposal_pda.0, + } + .to_account_metas(Some(false)), + data: ProposalApprove { + args: ProposalVoteArgs { memo }, + } + .data(), + program_id, + }); + } + + let blockhash = rpc_client + .get_latest_blockhash() + .await + .expect("Failed to get blockhash"); + + let message = Message::try_compile(&payer, &instructions, &[], blockhash).unwrap(); + let message_hash = hash(&message.serialize()); + println!(); println!( "{}", @@ -113,6 +208,9 @@ impl VaultTransactionCreate { println!("Multisig Key: {}", multisig_pubkey); println!("Transaction Index: {}", transaction_index); println!("Vault Index: {}", vault_index); + println!("Auto-approve: {}", approve); + println!(); + println!("Message Hash (verify on hardware wallet): {}", message_hash); println!(); let proceed = Confirm::new() @@ -128,62 +226,6 @@ impl VaultTransactionCreate { let progress = ProgressBar::new_spinner().with_message("Sending transaction..."); progress.enable_steady_tick(Duration::from_millis(100)); - let blockhash = rpc_client - .get_latest_blockhash() - .await - .expect("Failed to get blockhash"); - - let payer = fee_payer.unwrap_or(transaction_creator); - let message = Message::try_compile( - &payer, - &[ - ComputeBudgetInstruction::set_compute_unit_price( - priority_fee_lamports.unwrap_or(5000), - ), - Instruction { - accounts: VaultTransactionCreateAccounts { - creator: transaction_creator, - rent_payer: fee_payer.unwrap_or(transaction_creator), - transaction: transaction_pda.0, - multisig, - system_program: solana_sdk::system_program::id(), - } - .to_account_metas(Some(false)), - data: VaultTransactionCreateData { - args: VaultTransactionCreateArgs { - ephemeral_signers: 0, - vault_index, - memo, - transaction_message, - }, - } - .data(), - program_id, - }, - Instruction { - accounts: ProposalCreateAccounts { - creator: transaction_creator, - rent_payer: fee_payer.unwrap_or(transaction_creator), - proposal: proposal_pda.0, - multisig, - system_program: solana_sdk::system_program::id(), - } - .to_account_metas(Some(false)), - data: ProposalCreateData { - args: ProposalCreateArgs { - draft: false, - transaction_index, - }, - } - .data(), - program_id, - }, - ], - &[], - blockhash, - ) - .unwrap(); - let mut signers = vec![&*transaction_creator_keypair]; if let Some(ref fee_payer_kp) = fee_payer_keypair { signers.push(&**fee_payer_kp); @@ -194,10 +236,17 @@ impl VaultTransactionCreate { let signature = send_and_confirm_transaction(&transaction, &rpc_client).await?; - println!( - "✅ Transaction created successfully. Signature: {}", - signature.green() - ); + if approve { + println!( + "✅ Transaction created and approved. Signature: {}", + signature.green() + ); + } else { + println!( + "✅ Transaction created successfully. Signature: {}", + signature.green() + ); + } Ok(()) } } diff --git a/cli/src/main.rs b/cli/src/main.rs index b52a59e4..05bd61d9 100644 --- a/cli/src/main.rs +++ b/cli/src/main.rs @@ -27,5 +27,6 @@ async fn main() -> eyre::Result<()> { Command::InitiateTransfer(command) => command.execute().await, Command::InitiateProgramUpgrade(command) => command.execute().await, Command::DisplayVault(command) => command.execute().await, + Command::DisplayProposals(command) => command.execute().await, } }