From 748b00e5cd8254753ffae565bf2fdf808947b368 Mon Sep 17 00:00:00 2001 From: Rick Glenn Date: Tue, 20 Oct 2026 08:36:46 -0700 Subject: [PATCH 1/2] Add multisig propose approve for a multisig that signs another multisig A multisig has no key, so when it is a signer of another multisig it cannot call approve. Its vote is Multisig::approve dispatched from its own account when one of its proposals executes. propose custom cannot build that call, so recovering such a setup meant hand-encoding it. propose approve checks that the proposer signs the member multisig, that the member signs the target, and that the target proposal exists, is live and is not already approved by the member. It fetches the target proposal's stored call to bind the approval, and caps the default expiry at the target proposal's expiry. Refs Quantus-Network/quantus-apps#708 --- README.md | 23 ++++ src/cli/multisig.rs | 285 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 308 insertions(+) diff --git a/README.md b/README.md index 9c92712..e4beed6 100644 --- a/README.md +++ b/README.md @@ -1222,6 +1222,29 @@ quantus multisig approve \ --from signer_wallet ``` +#### Approve From a Multisig (multisig as a signer) +A multisig holds no key, so when it is a signer of another multisig it cannot run +`approve` itself. Instead it proposes `Multisig::approve` on its own account; executing +that proposal records the approval on the target. The stored call of the target +proposal is fetched and bound automatically. +```bash +# 1. A signer of the member multisig proposes the approval +quantus multisig propose approve \ + --address \ + --target \ + --proposal-id \ + --from signer_wallet + +# 2. Other signers of the member multisig approve it, then any of them executes it +quantus multisig approve --address --proposal-id --from signer_wallet +quantus multisig execute --address --proposal-id --from signer_wallet + +# 3. Once the target reaches threshold, a keyed signer of it executes the original proposal +quantus multisig execute --address --proposal-id --from signer_wallet +``` +`--expiry` defaults to head + ~2h, capped at the target proposal's expiry: the member +proposal must execute before the target proposal expires. + #### Cancel Proposal (proposer only) ```bash quantus multisig cancel \ diff --git a/src/cli/multisig.rs b/src/cli/multisig.rs index bf363be..87b0908 100644 --- a/src/cli/multisig.rs +++ b/src/cli/multisig.rs @@ -203,6 +203,41 @@ pub enum ProposeSubcommand { #[arg(long)] password_file: Option, }, + + /// Propose that this multisig approve a proposal on another multisig it signs for. + /// + /// A multisig holds no key, so it cannot sign an approval. Its vote is a + /// `Multisig::approve` that it dispatches itself when this proposal executes. + Approve { + /// This multisig: a signer of the target multisig (SS58 format) + #[arg(long, value_name = "MULTISIG_ADDRESS")] + address: String, + + /// The multisig whose proposal is being approved (SS58 format) + #[arg(long, value_name = "TARGET_MULTISIG_ADDRESS")] + target: String, + + /// Proposal ID on the target multisig + #[arg(long)] + proposal_id: u32, + + /// Expiry block number for this proposal. If omitted, defaults to head + ~2h, + /// capped at the target proposal's expiry + #[arg(long, value_name = "EXPIRY_BLOCK")] + expiry: Option, + + /// Proposer wallet name (must be a signer of this multisig) + #[arg(long)] + from: String, + + /// Password for the wallet + #[arg(short, long, hide = true)] + password: Option, + + /// Read password from file + #[arg(long)] + password_file: Option, + }, } /// Multisig-related commands @@ -1030,6 +1065,27 @@ pub async fn handle_multisig_command( execution_mode, ) .await, + ProposeSubcommand::Approve { + address, + target, + proposal_id, + expiry, + from, + password, + password_file, + } => + handle_propose_nested_approve( + address, + target, + proposal_id, + expiry, + from, + password, + password_file, + node_url, + execution_mode, + ) + .await, }, MultisigCommands::Approve { address, proposal_id, from, password, password_file } => handle_approve( @@ -1721,6 +1777,191 @@ async fn handle_propose_with_call_data( Ok(()) } +/// Builds the call a multisig proposes in order to approve `proposal_id` on `target`. +/// +/// When that proposal executes, the call is dispatched with the proposing multisig as +/// origin, which is what counts as its approval. The chain binds every approval to the +/// target proposal's stored call, so `target_call` must be exactly those bytes. +pub(crate) fn nested_approve_call( + target: subxt::ext::subxt_core::utils::AccountId32, + proposal_id: u32, + target_call: Vec, +) -> quantus_subxt::api::runtime_types::quantus_runtime::RuntimeCall { + use quantus_subxt::api::runtime_types::{ + bounded_collections::bounded_vec::BoundedVec, pallet_multisig::pallet::Call, + quantus_runtime::RuntimeCall, + }; + RuntimeCall::Multisig(Call::approve { + multisig_address: target, + proposal_id, + call: BoundedVec(target_call), + }) +} + +fn parse_multisig_address( + address: &str, + label: &str, +) -> crate::error::Result<(String, subxt::ext::subxt_core::utils::AccountId32)> { + let ss58 = crate::cli::common::resolve_address(address)?; + let (id, _) = SpAccountId32::from_ss58check_with_version(&ss58).map_err(|e| { + crate::error::QuantusError::Generic(format!("Invalid {} address: {:?}", label, e)) + })?; + let bytes: [u8; 32] = *id.as_ref(); + Ok((ss58, subxt::ext::subxt_core::utils::AccountId32::from(bytes))) +} + +/// Propose that a multisig approve a proposal on another multisig it is a signer of +#[allow(clippy::too_many_arguments)] +async fn handle_propose_nested_approve( + address: String, + target: String, + proposal_id: u32, + expiry: Option, + from: String, + password: Option, + password_file: Option, + node_url: &str, + execution_mode: ExecutionMode, +) -> crate::error::Result<()> { + use codec::Encode; + + log_print!("📝 {} Proposing a nested approval...", "MULTISIG".bright_magenta().bold()); + + let (multisig_ss58, multisig_id) = parse_multisig_address(&address, "multisig")?; + let (target_ss58, target_id) = parse_multisig_address(&target, "target multisig")?; + let (proposer_ss58, proposer_id) = parse_multisig_address(&from, "proposer")?; + + if multisig_id == target_id { + return Err(crate::error::QuantusError::Generic( + "--address and --target are the same multisig".to_string(), + )); + } + + let quantus_client = crate::chain::client::QuantusClient::new(node_url).await?; + let latest_block_hash = quantus_client.get_latest_block().await?; + let latest_block = quantus_client.client().blocks().at(latest_block_hash).await?; + let current_block_number = latest_block.number(); + let storage_at = quantus_client.client().storage().at(latest_block_hash); + + let multisig = storage_at + .fetch(&quantus_subxt::api::storage().multisig().multisigs(multisig_id.clone())) + .await? + .ok_or_else(|| { + crate::error::QuantusError::Generic(format!( + "Multisig not found at address: {}", + multisig_ss58 + )) + })?; + if !multisig.signers.0.contains(&proposer_id) { + return Err(crate::error::QuantusError::Generic(format!( + "{} is not a signer of {}", + proposer_ss58, multisig_ss58 + ))); + } + + let target_multisig = storage_at + .fetch(&quantus_subxt::api::storage().multisig().multisigs(target_id.clone())) + .await? + .ok_or_else(|| { + crate::error::QuantusError::Generic(format!( + "Target multisig not found at address: {}", + target_ss58 + )) + })?; + if !target_multisig.signers.0.contains(&multisig_id) { + return Err(crate::error::QuantusError::Generic(format!( + "{} is not a signer of {}, so it cannot approve there", + multisig_ss58, target_ss58 + ))); + } + + let proposal = storage_at + .fetch( + &quantus_subxt::api::storage() + .multisig() + .proposals(target_id.clone(), proposal_id), + ) + .await? + .ok_or_else(|| { + crate::error::QuantusError::Generic(format!( + "Proposal {} does not exist on {}", + proposal_id, target_ss58 + )) + })?; + if proposal.approvals.0.contains(&multisig_id) { + return Err(crate::error::QuantusError::Generic(format!( + "{} has already approved proposal {} on {}", + multisig_ss58, proposal_id, target_ss58 + ))); + } + if proposal.expiry <= current_block_number { + return Err(crate::error::QuantusError::Generic(format!( + "Proposal {} on {} expired at block {} (current block: {})", + proposal_id, target_ss58, proposal.expiry, current_block_number + ))); + } + + // This proposal has to execute before the target proposal expires, or the + // approval it dispatches is rejected. + let expiry = match expiry { + Some(expiry) => { + if expiry > proposal.expiry { + log_print!( + "⚠️ {} --expiry {} is after the target proposal's expiry (block {}); execute this proposal before then", + "WARNING".bright_yellow().bold(), + expiry, + proposal.expiry + ); + } + expiry + }, + None => { + let default_expiry = current_block_number + .saturating_add(DEFAULT_TRANSFER_EXPIRY_BLOCKS) + .min(proposal.expiry); + log_print!( + "⏱️ {} No --expiry provided; using block {} (target proposal expires at {})", + "DEFAULT".bright_blue().bold(), + default_expiry, + proposal.expiry + ); + default_expiry + }, + }; + + let call_data = nested_approve_call(target_id, proposal_id, proposal.call.0).encode(); + + handle_propose_with_call_data( + multisig_ss58.clone(), + call_data, + expiry, + from, + password, + password_file, + &quantus_client, + execution_mode, + ) + .await?; + + log_print!(""); + log_print!( + " Once this proposal reaches threshold, execute it to record the approval on {}:", + target_ss58.bright_cyan() + ); + log_print!( + " quantus multisig execute --address {} --proposal-id --from ", + multisig_ss58.bright_cyan() + ); + log_print!( + " Then, if {} has reached threshold, a signer of it runs: quantus multisig execute --address {} --proposal-id {} --from ", + target_ss58.bright_cyan(), + target_ss58.bright_cyan(), + proposal_id + ); + + Ok(()) +} + /// Approve a proposal async fn handle_approve( multisig_address: String, @@ -3596,3 +3837,47 @@ mod execute_call_tests { assert_eq!(parse_amount("10000000000000").unwrap(), 10_000_000_000_000); } } + +#[cfg(test)] +mod nested_approve_tests { + use super::*; + use codec::Encode; + use quantus_subxt::api::runtime_types::{ + pallet_multisig::pallet::Call as MultisigCall, quantus_runtime::RuntimeCall, + }; + + #[test] + fn nested_approve_call_carries_the_target_proposal_and_its_stored_call() { + let target = subxt::ext::subxt_core::utils::AccountId32([0x42u8; 32]); + let stored = vec![1u8, 2, 3, 4]; + + let bytes = nested_approve_call(target.clone(), 7, stored.clone()).encode(); + let decoded = decode_proposal_call(&bytes).expect("decodes as a runtime call"); + + match decoded { + RuntimeCall::Multisig(MultisigCall::approve { + multisig_address, + proposal_id, + call, + }) => { + assert_eq!(multisig_address, target); + assert_eq!(proposal_id, 7); + assert_eq!(call.0, stored); + }, + _ => panic!("expected Multisig::approve"), + } + } + + #[test] + fn nested_approve_call_uses_the_metadata_indices_for_multisig_approve() { + let md = crate::chain::test_metadata(); + let pallet = md.pallet_by_name("Multisig").expect("Multisig pallet"); + let approve = pallet.call_variant_by_name("approve").expect("approve call"); + + let target = subxt::ext::subxt_core::utils::AccountId32([0x42u8; 32]); + let bytes = nested_approve_call(target, 0, vec![]).encode(); + + assert_eq!(bytes[0], pallet.index()); + assert_eq!(bytes[1], approve.index); + } +} From 0810e5cefe666cbfed2dc109846d2b3ae52d358c Mon Sep 17 00:00:00 2001 From: Rick Glenn Date: Tue, 20 Oct 2026 08:38:04 -0700 Subject: [PATCH 2/2] Refuse propose approve from a high-security multisig before it burns the fee --- src/cli/multisig.rs | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/src/cli/multisig.rs b/src/cli/multisig.rs index 87b0908..209a282 100644 --- a/src/cli/multisig.rs +++ b/src/cli/multisig.rs @@ -1859,6 +1859,18 @@ async fn handle_propose_nested_approve( ))); } + // A high-security multisig may only propose whitelisted calls; propose would + // reject Multisig::approve and burn the fee. + let hs_query = quantus_subxt::api::storage() + .reversible_transfers() + .high_security_accounts(multisig_id.clone()); + if storage_at.fetch(&hs_query).await?.is_some() { + return Err(crate::error::QuantusError::Generic(format!( + "{} is high-security and cannot propose Multisig::approve", + multisig_ss58 + ))); + } + let target_multisig = storage_at .fetch(&quantus_subxt::api::storage().multisig().multisigs(target_id.clone())) .await?