diff --git a/LIBRARY_USAGE.md b/LIBRARY_USAGE.md index 16ae1bb..4df3086 100644 --- a/LIBRARY_USAGE.md +++ b/LIBRARY_USAGE.md @@ -291,6 +291,445 @@ The library is designed to be thread-safe: - `QuantusClient` can be shared using `Arc>` - Wallet operations are safe to call concurrently +### Multisig Operations + +The library provides full programmatic access to multisig functionality. + +#### Predicting Multisig Address (Deterministic) + +Multisig addresses are deterministically calculated from signers, threshold, and nonce. You can predict the address before creating: + +```rust +use quantus_cli::predict_multisig_address; + +fn predict_address_example() -> Result<(), Box> { + let alice_account = parse_address("qzkaf...")?; + let bob_account = parse_address("qzmqr...")?; + let charlie_account = parse_address("qzo4j...")?; + + let signers = vec![alice_account, bob_account, charlie_account]; + let threshold = 2; + let nonce = 0; // Default nonce + + // Calculate predicted address + let predicted_address = predict_multisig_address(signers.clone(), threshold, nonce); + println!("Predicted address: {}", predicted_address); + + // Now create with the same parameters - address will match! + Ok(()) +} + +fn parse_address(ss58: &str) -> Result> { + use sp_core::crypto::{AccountId32, Ss58Codec}; + let (account_id, _) = AccountId32::from_ss58check_with_version(ss58)?; + let bytes: [u8; 32] = *account_id.as_ref(); + Ok(subxt::utils::AccountId32::from(bytes)) +} +``` + +**Key points:** +- Same signers + threshold + nonce = same address (deterministic) +- Order of signers doesn't matter (automatically sorted) +- Use different nonce to create multiple multisigs with same signers + +#### Creating a Multisig + +```rust +use quantus_cli::{create_multisig, predict_multisig_address, QuantusClient}; + +async fn create_multisig_example() -> Result<(), Box> { + let client = QuantusClient::new("ws://127.0.0.1:9944").await?; + let keypair = quantus_cli::wallet::load_keypair_from_wallet("alice", None, None)?; + + // Parse signer addresses + let alice_account = parse_address("qzkaf...")?; + let bob_account = parse_address("qzmqr...")?; + let charlie_account = parse_address("qzo4j...")?; + + let signers = vec![alice_account, bob_account, charlie_account]; + let threshold = 2; // 2-of-3 + let nonce = 0; // Default: 0. Use different values to create multiple multisigs + + // Optional: Predict address before creating + let predicted = predict_multisig_address(signers.clone(), threshold, nonce); + println!("Will create at: {}", predicted); + + // Create multisig (wait_for_inclusion=true to get address from event) + let (tx_hash, multisig_address) = create_multisig( + &client, + &keypair, + signers, + threshold, + nonce, // NEW: nonce parameter for deterministic addresses + true // wait for address from event + ).await?; + + println!("Multisig created at: {:?}", multisig_address); + assert_eq!(multisig_address.unwrap(), predicted); // Should match! + Ok(()) +} +``` + +#### Querying Multisig Info + +```rust +use quantus_cli::{get_multisig_info, MultisigInfo}; + +async fn query_multisig() -> Result<(), Box> { + let client = QuantusClient::new("ws://127.0.0.1:9944").await?; + let multisig_account = parse_address("qz...")?; + + if let Some(info) = get_multisig_info(&client, multisig_account).await? { + println!("Address: {}", info.address); + println!("Balance: {} (raw units)", info.balance); + println!("Threshold: {}", info.threshold); + println!("Creator: {}", info.creator); + println!("Signers: {:?}", info.signers); + println!("Active Proposals: {}", info.active_proposals); + println!("Deposit: {} (returned to creator on dissolve)", info.deposit); + println!("๐Ÿ’ก INFO: Deposit will be returned to creator when multisig is dissolved"); + } + + Ok(()) +} +``` + +#### Creating a Transfer Proposal + +```rust +use quantus_cli::{propose_transfer, parse_multisig_amount}; + +async fn create_proposal() -> Result<(), Box> { + let client = QuantusClient::new("ws://127.0.0.1:9944").await?; + let keypair = quantus_cli::wallet::load_keypair_from_wallet("alice", None, None)?; + + let multisig_account = parse_address("qz...")?; + let recipient = parse_address("qzmqr...")?; + + // Parse amount (supports "10", "10.5", "0.001" format) + let amount = parse_multisig_amount("10")?; // 10 QUAN + + let expiry = 1000; // Block number + + let tx_hash = propose_transfer( + &client, + &keypair, + multisig_account, + recipient, + amount, + expiry + ).await?; + + println!("Proposal created: 0x{}", hex::encode(tx_hash)); + Ok(()) +} +``` + +#### Approving a Proposal + +When a proposal reaches the approval threshold, its status becomes **Approved**. Execution is **not** automatic: any signer must then call **execute** to dispatch the call (CLI: `quantus multisig execute --address --proposal-id --from `). + +```rust +use quantus_cli::approve_proposal; + +async fn approve_example() -> Result<(), Box> { + let client = QuantusClient::new("ws://127.0.0.1:9944").await?; + let keypair = quantus_cli::wallet::load_keypair_from_wallet("bob", None, None)?; + + let multisig_account = parse_address("qz...")?; + let proposal_id = 0u32; + + let tx_hash = approve_proposal( + &client, + &keypair, + multisig_account, + proposal_id + ).await?; + + println!("Approval submitted: 0x{}", hex::encode(tx_hash)); + // Once threshold is reached, status becomes Approved; any signer must call execute to dispatch + Ok(()) +} +``` + +#### Executing an Approved Proposal + +After enough signers have approved, the proposal status is **Approved**. Any signer must then submit an **execute** transaction to actually run the call. From the CLI: + +```bash +quantus multisig execute --address --proposal-id --from +``` + +Proposal statuses: **Active** (collecting approvals), **Approved** (threshold reached, ready to execute), **Executed**, **Cancelled**. + +#### Listing Proposals + +```rust +use quantus_cli::{list_proposals, ProposalInfo, ProposalStatus}; + +async fn list_all_proposals() -> Result<(), Box> { + let client = QuantusClient::new("ws://127.0.0.1:9944").await?; + let multisig_account = parse_address("qz...")?; + + let proposals = list_proposals(&client, multisig_account).await?; + + println!("Found {} proposal(s)", proposals.len()); + for proposal in proposals { + println!("Proposal #{}:", proposal.id); + println!(" Proposer: {}", proposal.proposer); + println!(" Expiry: block {}", proposal.expiry); + println!(" Status: {:?}", proposal.status); + println!(" Approvals: {}", proposal.approvals.len()); + } + + Ok(()) +} +``` + +#### Getting Specific Proposal Info + +```rust +use quantus_cli::get_proposal_info; + +async fn query_proposal() -> Result<(), Box> { + let client = QuantusClient::new("ws://127.0.0.1:9944").await?; + let multisig_account = parse_address("qz...")?; + let proposal_id = 0u32; + + if let Some(proposal) = get_proposal_info(&client, multisig_account, proposal_id).await? { + println!("Proposer: {}", proposal.proposer); + println!("Call data size: {} bytes", proposal.call_data.len()); + println!("Expiry: block {}", proposal.expiry); + println!("Approvals: {:?}", proposal.approvals); + println!("Status: {:?}", proposal.status); + } + + Ok(()) +} +``` + +#### Canceling a Proposal + +```rust +use quantus_cli::cancel_proposal; + +async fn cancel_example() -> Result<(), Box> { + let client = QuantusClient::new("ws://127.0.0.1:9944").await?; + let keypair = quantus_cli::wallet::load_keypair_from_wallet("alice", None, None)?; + + let multisig_account = parse_address("qz...")?; + let proposal_id = 0u32; + + let tx_hash = cancel_proposal( + &client, + &keypair, + multisig_account, + proposal_id + ).await?; + + println!("Proposal canceled: 0x{}", hex::encode(tx_hash)); + Ok(()) +} +``` + +#### Dissolving a Multisig + +**IMPORTANT:** Dissolution now requires threshold approvals and the deposit is **RETURNED** to the creator. + +```rust +use quantus_cli::approve_dissolve_multisig; + +async fn dissolve_example() -> Result<(), Box> { + let client = QuantusClient::new("ws://127.0.0.1:9944").await?; + + // Each signer must approve dissolution + let multisig_account = parse_address("qz...")?; + + // Alice approves (1/2) + let alice_keypair = quantus_cli::wallet::load_keypair_from_wallet("alice", None, None)?; + let tx_hash1 = approve_dissolve_multisig( + &client, + &alice_keypair, + multisig_account.clone() + ).await?; + println!("Alice approved dissolution: 0x{}", hex::encode(tx_hash1)); + + // Bob approves (2/2) - threshold reached, multisig dissolved automatically + let bob_keypair = quantus_cli::wallet::load_keypair_from_wallet("bob", None, None)?; + let tx_hash2 = approve_dissolve_multisig( + &client, + &bob_keypair, + multisig_account + ).await?; + println!("Bob approved - Multisig dissolved: 0x{}", hex::encode(tx_hash2)); + + Ok(()) +} +``` + +**Requirements for dissolution:** +- โœ… No proposals (any status: active, executed, or cancelled) +- โœ… Balance must be zero +- โœ… Threshold approvals required +- ๐Ÿ’ก **Deposit is RETURNED** to creator on successful dissolution + +**Note:** If proposals exist, you must first cancel or claim them before dissolution can proceed. + +#### Multisig Errors (Chain) + +When a multisig extrinsic fails, the CLI (and any code using the chain) receives a dispatch error. The runtime returns named errors; after metadata is up to date, you will see messages such as: + +| Error | When | +|-------|------| +| `ExpiryTooFar` | Proposal expiry is beyond the chain's `MaxExpiryDuration` (e.g. ~2 weeks in blocks). | +| `TooManyProposalsInStorage` | Multisig has reached the max total proposals in storage; cleanup via `claim_deposits` or `remove_expired` first. | +| `TooManyProposalsPerSigner` | This signer has too many proposals (per-signer limit for filibuster protection). | +| `ProposalNotApproved` | `execute` was called but the proposal is not in **Approved** status (e.g. still Active or already removed). | +| `ProposalNotFound` | No proposal with the given ID for this multisig. | +| `CallNotAllowedForHighSecurityMultisig` | Multisig is high-security and the proposed call is not whitelisted (e.g. use `schedule_transfer` instead of `transfer_allow_death`). | +| `ProposalsExist` | Cannot dissolve: there are still proposals; clear them first. | +| `MultisigAccountNotZero` | Cannot dissolve: multisig balance is not zero. | + +Other errors (e.g. `NotASigner`, `AlreadyApproved`, `ExpiryInPast`, `ProposalExpired`) are self-explanatory. Error text is resolved from runtime metadata when available. + +#### High-Security Operations for Multisig + +Multisig accounts can be configured with high-security mode, which delays all transfers and allows a guardian to intercept suspicious transactions. + +##### Checking High-Security Status + +```bash +# CLI usage +quantus multisig high-security status --address qz... +``` + +Example output: +``` +๐Ÿ” MULTISIG Checking High-Security status... + +๐Ÿ“‹ Multisig: qz... + +โœ… High-Security: ENABLED + +๐Ÿ›ก๏ธ Guardian/Interceptor: qzmqr... +โฑ๏ธ Delay: 100 blocks + +๐Ÿ’ก INFO All transfers from this multisig will be delayed and reversible + The guardian can intercept suspicious transactions during the delay period +``` + +##### Enabling High-Security via Proposal + +To enable high-security for a multisig, you need to create a proposal that will call `reversible_transfers.set_high_security`. This requires approval from threshold signers. + +```bash +# CLI usage - Create proposal to enable high-security +quantus multisig propose high-security \ + --address qz... \ + --interceptor qzmqr... \ + --delay-blocks 100 \ + --expiry 2000 \ + --from alice \ + -p password + +# Alternative: delay in seconds instead of blocks +quantus multisig propose high-security \ + --address qz... \ + --interceptor qzmqr... \ + --delay-seconds 600 \ + --expiry 2000 \ + --from alice \ + -p password +``` + +Example workflow: +```bash +# 1. Alice (signer) proposes high-security +quantus multisig propose high-security \ + --address qz123... \ + --interceptor qzguardian... \ + --delay-blocks 100 \ + --expiry 2000 \ + --from alice + +# 2. Check proposals to find the ID +quantus multisig list-proposals --address qz123... + +# 3. Bob (another signer) approves +quantus multisig approve \ + --address qz123... \ + --proposal-id 0 \ + --from bob + +# 4. Once threshold is reached, high-security is automatically enabled +# 5. Verify it's enabled +quantus multisig high-security status --address qz123... +``` + +##### Key Concepts + +- **Guardian/Interceptor**: An account that can intercept (reverse) transactions during the delay period +- **Delay**: Time window during which transactions are reversible (in blocks or seconds) +- **Delayed Transfers**: All transfers from a high-security multisig are scheduled for delayed execution +- **Interception**: Guardian can cancel suspicious transactions and recover funds + +##### Disabling High-Security + +**Note:** There is currently no `remove` command for disabling high-security mode. The runtime does not expose a `remove_high_security` extrinsic. + +If you need to disable high-security for a multisig: +1. Create a new multisig without high-security +2. Transfer funds from the HS multisig to the new one +3. Dissolve the old HS multisig (after cleanup) + +Alternatively, request a runtime upgrade to add `remove_high_security` functionality. + +##### Security Considerations + +- Choose a trusted guardian account (can be another multisig) +- Set an appropriate delay period (longer = more secure, but less convenient) +- Guardian has full control to intercept transactions during delay +- Once enabled, only whitelisted calls are allowed from high-security multisigs +- **High-security cannot be disabled** - consider this permanent for the multisig account + +##### Programmatic Usage (Library) + +Currently, high-security operations are best performed via CLI. For programmatic access, you can build the runtime call manually: + +```rust +use quantus_cli::{chain::client::QuantusClient, chain::quantus_subxt}; + +async fn enable_hs_via_proposal() -> Result<(), Box> { + let client = QuantusClient::new("ws://127.0.0.1:9944").await?; + + // Build set_high_security call + use quantus_subxt::api::reversible_transfers::calls::types::set_high_security::Delay; + let delay = Delay::BlockNumber(100); + let interceptor = parse_address("qzguardian...")?; + + let set_hs_call = quantus_subxt::api::tx() + .reversible_transfers() + .set_high_security(delay, interceptor); + + // Encode as call data + use subxt::tx::Payload; + let call_data = set_hs_call.encode_call_data(&client.client().metadata())?; + + // Create multisig proposal with this call + let multisig_account = parse_address("qz...")?; + let expiry = 2000; + + let propose_tx = quantus_subxt::api::tx() + .multisig() + .propose(multisig_account, call_data, expiry); + + // Submit via your signer keypair + // ... (submit transaction) + + Ok(()) +} +``` + ## Examples See the `examples/` directory for complete working examples: @@ -298,6 +737,8 @@ See the `examples/` directory for complete working examples: - `examples/basic_usage.rs` - Basic library usage - `examples/wallet_ops.rs` - Advanced wallet operations - `examples/service.rs` - Service architecture example +- `examples/multisig_library_usage.rs` - Multisig operations +- `examples/multisig_usage.rs` - Multisig CLI usage reference ## Running Examples @@ -310,6 +751,9 @@ cargo run --example wallet_ops # Run service example cargo run --example service + +# Run multisig library usage example +cargo run --example multisig_library_usage ``` ## Key Features diff --git a/README.md b/README.md index 92f6dd7..bf20aba 100644 --- a/README.md +++ b/README.md @@ -476,6 +476,215 @@ The CLI provides a comprehensive set of commands for blockchain interaction. Sta The CLI supports both simple commands and complex workflows, with built-in help and error recovery at every level. +## ๐Ÿ” Multisig Wallets + +The Quantus CLI provides comprehensive support for multi-signature wallets, allowing you to create shared accounts that require multiple approvals before executing transactions. + +### Key Features + +- **Deterministic Address Generation**: Multisig addresses are derived from signers + threshold + nonce +- **Flexible Threshold**: Configure how many approvals are needed (e.g., 2-of-3, 5-of-7) +- **Full Call Transparency**: Complete transaction data stored on-chain (no blind signing) +- **Auto-Execution**: Proposals execute automatically when threshold is reached +- **Human-Readable Amounts**: Use simple formats like `10` instead of `10000000000000` +- **Smart Address Display**: Automatic SS58 formatting with proper network prefix (`qz...`) +- **Balance Tracking**: View multisig balance directly in `info` command +- **Expiry Validation**: Client-side checks prevent expired proposals +- **Deposit Management**: Refundable deposits incentivize cleanup +- **Query Support**: Inspect multisig configuration, proposals, and balances + +### Quick Start Example + +```bash +# 1. Create a 2-of-3 multisig (waits for confirmation by default) +quantus multisig create \ + --signers "alice,bob,charlie" \ + --threshold 2 \ + --from alice \ + --wait-for-transaction + +# Output: ๐Ÿ“ Multisig address: qz... (with proper network prefix) + +# 2. Fund the multisig (anyone can send funds) +quantus send \ + --from alice \ + --to qz... \ + --amount 1000 + +# 3. Create a transfer proposal (human-readable amount) +quantus multisig propose transfer \ + --address qz... \ + --to dave \ + --amount 10 \ + --expiry 1500 \ + --from alice + +# Note: Expiry is BLOCK NUMBER (e.g., current block + 1000) + +# 4. Check proposal details (shows current block + blocks remaining) +quantus multisig info --address qz... --proposal-id 0 + +# Output shows: +# Current Block: 450 +# Expiry: block 1500 (1050 blocks remaining) + +# 5. Second signer approves (auto-executes at threshold) +quantus multisig approve \ + --address qz... \ + --proposal-id 0 \ + --from bob +``` + +### Available Commands + +#### Create Multisig +```bash +# Default: Wait for transaction and extract address from event +quantus multisig create \ + --signers "addr1,addr2,addr3" \ + --threshold 2 \ + --from creator_wallet + +# Fast mode: Predict address immediately (may be wrong if concurrent creation) +quantus multisig create \ + --signers "addr1,addr2,addr3" \ + --threshold 2 \ + --from creator_wallet \ + --predict +``` + +#### Propose Transfer (Recommended for simple transfers) +```bash +quantus multisig propose transfer \ + --address \ + --to \ + --amount 10 \ + --expiry \ + --from signer_wallet + +# Amount formats supported: +# 10 โ†’ 10 QUAN +# 10.5 โ†’ 10.5 QUAN +# 0.001 โ†’ 0.001 QUAN +# 10000000000000 โ†’ raw format (auto-detected) +``` + +#### Propose Custom Transaction (Full flexibility) +```bash +quantus multisig propose custom \ + --address \ + --pallet System \ + --call remark \ + --args '["Hello from multisig"]' \ + --expiry \ + --from signer_wallet +``` + +#### Approve Proposal +```bash +quantus multisig approve \ + --address \ + --proposal-id \ + --from signer_wallet +``` + +#### Cancel Proposal (proposer only) +```bash +quantus multisig cancel \ + --address \ + --proposal-id \ + --from proposer_wallet +``` + +#### Query Multisig Info +```bash +# Show multisig details (signers, threshold, balance, etc.) +quantus multisig info --address + +# Show specific proposal details (includes current block + time remaining) +quantus multisig info --address --proposal-id +``` + +#### List All Proposals +```bash +quantus multisig list-proposals --address +``` + +#### Cleanup (Recover Deposits) +```bash +# Remove single expired proposal +quantus multisig remove-expired \ + --address \ + --proposal-id \ + --from signer_wallet + +# Batch cleanup all expired proposals +quantus multisig claim-deposits \ + --address \ + --from any_signer_wallet +``` + +#### Dissolve Multisig +```bash +# Requires: no proposals exist, zero balance +quantus multisig dissolve \ + --address \ + --from creator_or_signer_wallet +``` + +### Economics + +The multisig pallet uses an economic model to prevent spam and incentivize cleanup: + +- **MultisigFee**: Non-refundable fee paid to treasury on creation +- **MultisigDeposit**: Refundable deposit (locked, returned on dissolution) +- **ProposalFee**: Non-refundable fee per proposal (scales with signer count) +- **ProposalDeposit**: Refundable deposit per proposal (locked, returned after cleanup) + +**Deposits are visible in `multisig info` output:** +``` +Balance: 1000 QUAN โ† Spendable balance +Deposit: 0.5 QUAN (locked) โ† Refundable creation deposit +``` + +### Best Practices + +1. **Use Descriptive Names**: Use wallet names instead of raw addresses for better readability +2. **Set Reasonable Expiry**: Use future block numbers (current + 1000 for ~3.3 hours at 12s/block) +3. **Verify Proposals**: Use `info --proposal-id` to decode and verify proposal contents before approving +4. **Cleanup Regularly**: Use `claim-deposits` to recover deposits from expired proposals +5. **Monitor Balances**: Check multisig balance with `info --address` command +6. **High Security**: For high-value multisigs, use higher thresholds (e.g., 5-of-7 or 4-of-6) + +### Security Considerations + +- **Immutable Configuration**: Signers and threshold cannot be changed after creation +- **Full Transparency**: All call data is stored and decoded on-chain (no blind signing) +- **Auto-Execution**: Proposals execute automatically when threshold is reached +- **Access Control**: Only signers can propose/approve, only proposer can cancel +- **Expiry Protection**: Client validates expiry before submission to prevent wasted fees +- **Deterministic Addresses**: Multisig addresses are derived from signers + threshold + nonce and are verifiable + +### Advanced Features + +**Decoding Proposals**: The CLI automatically decodes common call types: +```bash +$ quantus multisig info --address qz... --proposal-id 0 + +๐Ÿ“ PROPOSAL Information: + Current Block: 450 + Call: Balances::transfer_allow_death + To: qzmqr... + Amount: 10 QUAN + Expiry: block 1500 (1050 blocks remaining) +``` + +**SS58 Address Format**: All addresses use the Quantus network prefix (`qz...` for prefix 189) automatically. + +**Password Convenience**: Omit `--password ""` for wallets with no password. + +For more details, see `quantus multisig --help` and explore subcommands with `--help`. + ## ๐Ÿ—๏ธ Architecture ### Quantum-Safe Cryptography diff --git a/examples/multisig_library_usage.rs b/examples/multisig_library_usage.rs new file mode 100644 index 0000000..7842e09 --- /dev/null +++ b/examples/multisig_library_usage.rs @@ -0,0 +1,200 @@ +//! Multisig library usage example +//! +//! This example demonstrates using quantus-cli as a library for multisig operations + +use quantus_cli::{ + approve_proposal, create_multisig, get_multisig_info, get_proposal_info, list_proposals, + parse_multisig_amount, predict_multisig_address, propose_transfer, + wallet::{load_keypair_from_wallet, WalletManager}, + QuantusClient, Result, +}; +use sp_core::crypto::Ss58Codec; + +#[tokio::main] +async fn main() -> Result<()> { + println!("๐Ÿ” Quantus Multisig Library Usage Example"); + println!("==========================================\n"); + + // 1. Setup: Connect to node + let node_url = "ws://127.0.0.1:9944"; + let quantus_client = QuantusClient::new(node_url).await?; + println!("๐Ÿ“ก Connected to node: {}", node_url); + println!(); + + // 2. Load wallet manager and keypairs + let wallet_manager = WalletManager::new()?; + + // Ensure test wallets exist + println!("๐Ÿ‘ฅ Loading test wallets..."); + let alice_keypair = load_keypair_from_wallet("crystal_alice", None, None)?; + let bob_keypair = load_keypair_from_wallet("crystal_bob", None, None)?; + let _charlie_keypair = load_keypair_from_wallet("crystal_charlie", None, None)?; + + // Get addresses + let alice_addr = wallet_manager + .find_wallet_address("crystal_alice")? + .expect("Alice wallet not found"); + let bob_addr = wallet_manager + .find_wallet_address("crystal_bob")? + .expect("Bob wallet not found"); + let charlie_addr = wallet_manager + .find_wallet_address("crystal_charlie")? + .expect("Charlie wallet not found"); + + println!(" Alice: {}", alice_addr); + println!(" Bob: {}", bob_addr); + println!(" Charlie: {}", charlie_addr); + println!(); + + // 3. Convert addresses to AccountId32 + let alice_account = parse_address(&alice_addr)?; + let bob_account = parse_address(&bob_addr)?; + let charlie_account = parse_address(&charlie_addr)?; + + // 4. Create multisig (2-of-3) + println!("๐Ÿ” Creating 2-of-3 multisig..."); + let signers = vec![alice_account.clone(), bob_account.clone(), charlie_account.clone()]; + let threshold = 2; + let nonce = 0; // Use different nonce to create multiple multisigs with same signers + + // Predict address before creating + let predicted_address = predict_multisig_address(signers.clone(), threshold, nonce); + println!("๐Ÿ“ Predicted address: {}", predicted_address); + + let (tx_hash, multisig_address) = + create_multisig(&quantus_client, &alice_keypair, signers, threshold, nonce, true).await?; + + println!("โœ… Multisig created!"); + println!(" Tx hash: 0x{}", hex::encode(tx_hash)); + if let Some(addr) = &multisig_address { + println!(" Address: {}", addr); + } + println!(); + + // 5. Get multisig info + if let Some(addr) = &multisig_address { + let multisig_account = parse_address(addr)?; + + println!("๐Ÿ“‹ Querying multisig info..."); + if let Some(info) = get_multisig_info(&quantus_client, multisig_account.clone()).await? { + println!(" Address: {}", info.address); + println!(" Balance: {} (raw units)", info.balance); + println!(" Threshold: {}", info.threshold); + println!(" Signers: {}", info.signers.len()); + for (i, signer) in info.signers.iter().enumerate() { + println!(" {}. {}", i + 1, signer); + } + println!(" Active Proposals: {}", info.active_proposals); + println!(); + } + + // 6. Parse amount using library function + println!("๐Ÿ’ฐ Parsing amounts..."); + let amount_1 = parse_multisig_amount("10")?; // 10 QUAN + let amount_2 = parse_multisig_amount("10.5")?; // 10.5 QUAN + let amount_3 = parse_multisig_amount("0.001")?; // 0.001 QUAN + + println!(" 10 QUAN = {} (raw)", amount_1); + println!(" 10.5 QUAN = {} (raw)", amount_2); + println!(" 0.001 QUAN = {} (raw)", amount_3); + println!(); + + // 7. Create a proposal (transfer 10 QUAN to Bob) + println!("๐Ÿ“ Creating transfer proposal..."); + let expiry = 1000; // Block number + let amount = parse_multisig_amount("10")?; + + let propose_tx_hash = propose_transfer( + &quantus_client, + &alice_keypair, + multisig_account.clone(), + bob_account.clone(), + amount, + expiry, + ) + .await?; + + println!("โœ… Proposal submitted!"); + println!(" Tx hash: 0x{}", hex::encode(propose_tx_hash)); + println!(" Check events for proposal ID"); + println!(); + + // 8. List all proposals + println!("๐Ÿ“‹ Listing all proposals..."); + let proposals = list_proposals(&quantus_client, multisig_account.clone()).await?; + println!(" Found {} proposal(s)", proposals.len()); + + for proposal in &proposals { + println!(); + println!(" Proposal #{}:", proposal.id); + println!(" Proposer: {}", proposal.proposer); + println!(" Expiry: block {}", proposal.expiry); + println!(" Status: {:?}", proposal.status); + println!(" Approvals: {}", proposal.approvals.len()); + println!(" Deposit: {} (raw)", proposal.deposit); + } + println!(); + + // 9. Get specific proposal info + if !proposals.is_empty() { + let proposal_id = proposals[0].id; + println!("๐Ÿ” Querying proposal #{}...", proposal_id); + + if let Some(proposal) = + get_proposal_info(&quantus_client, multisig_account.clone(), proposal_id).await? + { + println!(" Proposer: {}", proposal.proposer); + println!(" Call data size: {} bytes", proposal.call_data.len()); + println!(" Expiry: block {}", proposal.expiry); + println!(" Approvals: {}", proposal.approvals.len()); + } + println!(); + + // 10. Approve proposal (as Bob) + println!("โœ… Approving proposal #{}...", proposal_id); + let approve_tx_hash = approve_proposal( + &quantus_client, + &bob_keypair, + multisig_account.clone(), + proposal_id, + ) + .await?; + + println!("โœ… Approval submitted!"); + println!(" Tx hash: 0x{}", hex::encode(approve_tx_hash)); + println!(" (Will auto-execute at threshold)"); + println!(); + } + } + + println!("โœจ Example complete!"); + println!(); + println!("๐Ÿ“š Available library functions:"); + println!(" - predict_multisig_address() - Calculate address before creating"); + println!(" - create_multisig() - Create with nonce for deterministic addresses"); + println!(" - propose_transfer()"); + println!(" - propose_custom()"); + println!(" - approve_proposal()"); + println!(" - cancel_proposal()"); + println!(" - get_multisig_info()"); + println!(" - get_proposal_info()"); + println!(" - list_proposals()"); + println!(" - approve_dissolve_multisig() - Requires threshold approvals"); + println!(" - parse_multisig_amount()"); + println!(); + println!("๐Ÿ’ก Note: Multisig deposits are RETURNED to creator upon dissolution"); + + Ok(()) +} + +/// Helper: Parse SS58 address to subxt AccountId32 +fn parse_address(ss58: &str) -> Result { + use sp_core::crypto::AccountId32; + + let (account_id, _) = AccountId32::from_ss58check_with_version(ss58).map_err(|e| { + quantus_cli::error::QuantusError::Generic(format!("Invalid address: {:?}", e)) + })?; + + let bytes: [u8; 32] = *account_id.as_ref(); + Ok(subxt::ext::subxt_core::utils::AccountId32::from(bytes)) +} diff --git a/examples/multisig_usage.rs b/examples/multisig_usage.rs new file mode 100644 index 0000000..06626b6 --- /dev/null +++ b/examples/multisig_usage.rs @@ -0,0 +1,181 @@ +//! Multisig wallet operations example +//! +//! This example demonstrates: +//! 1. Creating a multisig wallet +//! 2. Creating a proposal +//! 3. Approving proposals +//! 4. Querying multisig information +//! 5. Managing multisig lifecycle + +use quantus_cli::{ + chain::{client::QuantusClient, quantus_subxt}, + cli::common::ExecutionMode, + error::Result, + wallet::WalletManager, +}; +use sp_core::crypto::Ss58Codec; + +/// Example: Create and use a 2-of-3 multisig wallet +#[tokio::main] +async fn main() -> Result<()> { + println!("๐Ÿ” Quantus Multisig Example"); + println!("================================\n"); + + // 1. Setup: Connect to node and load wallets + let node_url = "ws://127.0.0.1:9944"; + let quantus_client = QuantusClient::new(node_url).await?; + let wallet_manager = WalletManager::new()?; + + println!("๐Ÿ“ก Connected to node: {}", node_url); + println!(); + + // 2. Create or load test wallets + println!("๐Ÿ‘ฅ Setting up test wallets..."); + + // For this example, we assume alice, bob, and charlie wallets exist + // In real usage, create these first: + // wallet_manager.create_wallet("alice", Some("password")).await?; + // wallet_manager.create_wallet("bob", Some("password")).await?; + // wallet_manager.create_wallet("charlie", Some("password")).await?; + + let alice_addr = wallet_manager.find_wallet_address("alice")?.expect("Alice wallet not found"); + let bob_addr = wallet_manager.find_wallet_address("bob")?.expect("Bob wallet not found"); + let charlie_addr = wallet_manager + .find_wallet_address("charlie")? + .expect("Charlie wallet not found"); + + println!(" Alice: {}", alice_addr); + println!(" Bob: {}", bob_addr); + println!(" Charlie: {}", charlie_addr); + println!(); + + // 3. Create multisig (2-of-3) + println!("๐Ÿ” Creating 2-of-3 multisig..."); + + let signers = + vec![parse_address(&alice_addr)?, parse_address(&bob_addr)?, parse_address(&charlie_addr)?]; + let threshold = 2u32; + let nonce = 0u64; // Default nonce. Use different values to create multiple multisigs + + let alice_keypair = + quantus_cli::wallet::load_keypair_from_wallet("alice", Some("password".to_string()), None)?; + + let create_tx = + quantus_subxt::api::tx() + .multisig() + .create_multisig(signers.clone(), threshold, nonce); + + let execution_mode = ExecutionMode { finalized: false, wait_for_transaction: true }; + + let tx_hash = quantus_cli::cli::common::submit_transaction( + &quantus_client, + &alice_keypair, + create_tx, + None, + execution_mode, + ) + .await?; + + println!("โœ… Multisig created! Tx hash: 0x{}", hex::encode(tx_hash)); + println!(); + println!("๐Ÿ’ก NOTE: Multisig addresses are deterministic (hash of signers + threshold + nonce)"); + println!(" Use 'quantus multisig predict-address' to calculate address before creating:"); + println!(" quantus multisig predict-address --signers --threshold 2 --nonce 0"); + println!(); + + // 4. Example: Query multisig info + println!("๐Ÿ“‹ To query multisig information:"); + println!(" quantus multisig info --address "); + println!(); + println!(" Or query specific proposal:"); + println!(" quantus multisig info --address --proposal-id 0"); + println!(); + + // 5. Example: Create a proposal + println!("๐Ÿ“ To create a proposal:"); + println!(" # Simple transfer (recommended - human-readable amounts):"); + println!(" quantus multisig propose transfer \\"); + println!(" --address \\"); + println!(" --to \\"); + println!(" --amount 10 \\"); + println!(" --expiry 1000 \\"); + println!(" --from alice"); + println!(); + println!(" # Custom transaction (full flexibility):"); + println!(" quantus multisig propose custom \\"); + println!(" --address \\"); + println!(" --pallet System \\"); + println!(" --call remark \\"); + println!(" --args '[\"Hello from multisig\"]' \\"); + println!(" --expiry 1000 \\"); + println!(" --from alice"); + println!(); + println!(" NOTE: Expiry is BLOCK NUMBER, not blocks from now!"); + println!(" Use a block number in the future (e.g., current + 1000)"); + println!(); + + // 6. Example: Approve a proposal + println!("โœ… To approve a proposal (auto-executes at threshold):"); + println!(" quantus multisig approve \\"); + println!(" --address \\"); + println!(" --proposal-id \\"); + println!(" --from bob"); + println!(); + + // 7. Example: List proposals + println!("๐Ÿ“‹ To list all proposals:"); + println!(" quantus multisig list-proposals --address "); + println!(); + + // 8. Example: Cleanup (recover deposits from expired proposals) + println!("๐Ÿงน To cleanup and recover deposits:"); + println!(" # Remove single expired proposal"); + println!(" quantus multisig remove-expired \\"); + println!(" --address \\"); + println!(" --proposal-id \\"); + println!(" --from alice"); + println!(); + println!(" # Batch cleanup all expired proposals"); + println!(" quantus multisig claim-deposits \\"); + println!(" --address \\"); + println!(" --from alice"); + println!(); + + // 9. Example: Dissolve multisig (requires threshold approvals) + println!("๐Ÿ—‘๏ธ To dissolve multisig:"); + println!(" Requirements:"); + println!(" - No proposals (any status)"); + println!(" - Zero balance"); + println!(" - Threshold approvals"); + println!(" ๐Ÿ’ก INFO: Deposit is RETURNED to creator on successful dissolution"); + println!(); + println!(" # Each signer must approve:"); + println!(" quantus multisig dissolve --address --from alice # 1/2"); + println!( + " quantus multisig dissolve --address --from bob # 2/2 (dissolved)" + ); + println!(); + println!(" # Check dissolution progress:"); + println!(" quantus multisig info --address "); + println!(); + + println!("โœจ Multisig example complete!"); + println!(); + println!("๐Ÿ“š For more information:"); + println!(" quantus multisig --help"); + println!(" quantus multisig --help"); + + Ok(()) +} + +/// Helper: Parse SS58 address to subxt AccountId32 +fn parse_address(ss58: &str) -> Result { + use sp_core::crypto::AccountId32; + + let (account_id, _) = AccountId32::from_ss58check_with_version(ss58).map_err(|e| { + quantus_cli::error::QuantusError::Generic(format!("Invalid address: {:?}", e)) + })?; + + let bytes: [u8; 32] = *account_id.as_ref(); + Ok(subxt::ext::subxt_core::utils::AccountId32::from(bytes)) +} diff --git a/src/cli/common.rs b/src/cli/common.rs index 32cda72..c82d6b1 100644 --- a/src/cli/common.rs +++ b/src/cli/common.rs @@ -216,7 +216,7 @@ where <_ as subxt::tx::Payload>::encode_call_data(&call, &metadata).map_err(|e| { crate::error::QuantusError::NetworkError(format!("Failed to encode call: {:?}", e)) })?; - crate::log_print!("๐Ÿ“ Encoded call: 0x{}", hex::encode(&encoded_call)); + crate::log_verbose!("๐Ÿ“ Encoded call: 0x{}", hex::encode(&encoded_call)); crate::log_print!("๐Ÿ“ Encoded call size: {} bytes", encoded_call.len()); if execution_mode.wait_for_transaction { @@ -492,12 +492,27 @@ fn format_dispatch_error( match error { DispatchError::Module(module_error) => { - let pallet_name = metadata - .pallet_by_index(module_error.index) - .map(|p| p.name()) - .unwrap_or("Unknown"); + let pallet_index = module_error.index; let error_index = module_error.error[0]; - format!("{}::Error[{}]", pallet_name, error_index) + + // Try to get human-readable error name from metadata + if let Some(pallet) = metadata.pallet_by_index(pallet_index) { + let pallet_name = pallet.name(); + // Look up the error variant name from metadata + if let Some(variant) = pallet.error_variant_by_index(error_index) { + let error_name = &variant.name; + let docs = variant.docs.join(" "); + if docs.is_empty() { + format!("{}::{}", pallet_name, error_name) + } else { + format!("{}::{} - {}", pallet_name, error_name, docs) + } + } else { + format!("{}::Error[{}]", pallet_name, error_index) + } + } else { + format!("Pallet[{}]::Error[{}]", pallet_index, error_index) + } }, DispatchError::BadOrigin => "BadOrigin".to_string(), DispatchError::CannotLookup => "CannotLookup".to_string(), diff --git a/src/cli/mod.rs b/src/cli/mod.rs index 34ce8a7..2d3e436 100644 --- a/src/cli/mod.rs +++ b/src/cli/mod.rs @@ -11,6 +11,7 @@ pub mod generic_call; pub mod high_security; pub mod metadata; pub mod multisend; +pub mod multisig; pub mod preimage; pub mod recovery; pub mod reversible; @@ -79,6 +80,10 @@ pub enum Commands { #[command(subcommand)] Recovery(recovery::RecoveryCommands), + /// Multisig commands (multi-signature wallets) + #[command(subcommand)] + Multisig(multisig::MultisigCommands), + /// Scheduler commands #[command(subcommand)] Scheduler(scheduler::SchedulerCommands), @@ -333,6 +338,8 @@ pub async fn execute_command( high_security::handle_high_security_command(hs_cmd, node_url, execution_mode).await, Commands::Recovery(recovery_cmd) => recovery::handle_recovery_command(recovery_cmd, node_url, execution_mode).await, + Commands::Multisig(multisig_cmd) => + multisig::handle_multisig_command(multisig_cmd, node_url, execution_mode).await, Commands::Scheduler(scheduler_cmd) => scheduler::handle_scheduler_command(scheduler_cmd, node_url, execution_mode).await, Commands::Storage(storage_cmd) => @@ -383,10 +390,17 @@ pub async fn execute_command( // Resolve address (could be wallet name or SS58 address) let resolved_address = common::resolve_address(&address)?; - let balance = send::get_balance(&quantus_client, &resolved_address).await?; - let formatted_balance = - send::format_balance_with_symbol(&quantus_client, balance).await?; - log_print!("๐Ÿ’ฐ Balance: {}", formatted_balance); + let account_data = send::get_account_data(&quantus_client, &resolved_address).await?; + let (symbol, decimals) = send::get_chain_properties(&quantus_client).await?; + + let free_fmt = send::format_balance(account_data.free, decimals); + let reserved_fmt = send::format_balance(account_data.reserved, decimals); + let frozen_fmt = send::format_balance(account_data.frozen, decimals); + + log_print!("๐Ÿ’ฐ {} {}", "Balance".bright_green().bold(), resolved_address.bright_cyan()); + log_print!(" Free: {} {}", free_fmt.bright_green(), symbol); + log_print!(" Reserved: {} {}", reserved_fmt.bright_yellow(), symbol); + log_print!(" Frozen: {} {}", frozen_fmt.bright_red(), symbol); Ok(()) }, Commands::Developer(dev_cmd) => handle_developer_command(dev_cmd).await, diff --git a/src/cli/multisig.rs b/src/cli/multisig.rs new file mode 100644 index 0000000..0f117f1 --- /dev/null +++ b/src/cli/multisig.rs @@ -0,0 +1,3170 @@ +use crate::{ + chain::quantus_subxt::{self}, + cli::common::ExecutionMode, + log_error, log_print, log_success, log_verbose, +}; +use clap::Subcommand; +use colored::Colorize; +use hex; +use sp_core::crypto::{AccountId32 as SpAccountId32, Ss58Codec}; + +// Base unit (QUAN) decimals for amount conversions +const QUAN_DECIMALS: u128 = 1_000_000_000_000; // 10^12 + +// ============================================================================ +// PUBLIC LIBRARY API - Data Structures +// ============================================================================ + +/// Multisig account information +#[allow(dead_code)] +#[derive(Debug, Clone)] +pub struct MultisigInfo { + /// Multisig address (SS58 format) + pub address: String, + /// Creator address (SS58 format) - receives deposit back on dissolve + pub creator: String, + /// Current balance (spendable) + pub balance: u128, + /// Approval threshold + pub threshold: u32, + /// List of signer addresses (SS58 format) + pub signers: Vec, + /// Next proposal ID + pub proposal_nonce: u32, + /// Locked deposit amount (returned to creator on dissolve) + pub deposit: u128, + /// Number of active proposals + pub active_proposals: u32, +} + +/// Proposal status +#[allow(dead_code)] +#[derive(Debug, Clone, PartialEq)] +pub enum ProposalStatus { + Active, + /// Threshold reached; any signer can call execute to dispatch + Approved, + Executed, + Cancelled, +} + +/// Proposal information +#[allow(dead_code)] +#[derive(Debug, Clone)] +pub struct ProposalInfo { + /// Proposal ID + pub id: u32, + /// Proposer address (SS58 format) + pub proposer: String, + /// Encoded call data + pub call_data: Vec, + /// Expiry block number + pub expiry: u32, + /// List of approver addresses (SS58 format) + pub approvals: Vec, + /// Locked deposit amount + pub deposit: u128, + /// Proposal status + pub status: ProposalStatus, +} + +// ============================================================================ +// PUBLIC LIBRARY API - Helper Functions +// ============================================================================ + +/// Parse amount from human-readable format (e.g., "10", "10.5", "0.001") +/// or raw format (e.g., "10000000000000") +pub fn parse_amount(amount: &str) -> crate::error::Result { + // If contains decimal point, parse as float and multiply by QUAN_DECIMALS + if amount.contains('.') { + let amount_f64: f64 = amount + .parse() + .map_err(|e| crate::error::QuantusError::Generic(format!("Invalid amount: {}", e)))?; + + if amount_f64 < 0.0 { + return Err(crate::error::QuantusError::Generic( + "Amount cannot be negative".to_string(), + )); + } + + // Multiply by decimals and convert to u128 + let base_amount = (amount_f64 * QUAN_DECIMALS as f64) as u128; + Ok(base_amount) + } else { + // Try parsing as u128 first (raw format) + if let Ok(raw) = amount.parse::() { + // If the number is very large (>= 10^10), assume it's already in base units + if raw >= 10_000_000_000 { + Ok(raw) + } else { + // Otherwise assume it's in QUAN and convert + Ok(raw * QUAN_DECIMALS) + } + } else { + Err(crate::error::QuantusError::Generic(format!("Invalid amount: {}", amount))) + } + } +} + +/// Subcommands for proposing transactions +#[derive(Subcommand, Debug)] +pub enum ProposeSubcommand { + /// Propose a simple transfer (most common case) + Transfer { + /// Multisig account address (SS58 format) + #[arg(long)] + address: String, + + /// Recipient address (SS58 format) + #[arg(long)] + to: String, + + /// Amount to transfer (e.g., "10", "10.5", or raw "10000000000000") + #[arg(long)] + amount: String, + + /// Expiry block number (when this proposal expires) + #[arg(long)] + expiry: u32, + + /// Proposer wallet name (must be a signer) + #[arg(long)] + from: String, + + /// Password for the wallet + #[arg(short, long)] + password: Option, + + /// Read password from file + #[arg(long)] + password_file: Option, + }, + + /// Propose a custom transaction (full flexibility) + Custom { + /// Multisig account address (SS58 format) + #[arg(long)] + address: String, + + /// Pallet name for the call (e.g., "Balances") + #[arg(long)] + pallet: String, + + /// Call/function name (e.g., "transfer_allow_death") + #[arg(long)] + call: String, + + /// Arguments as JSON array (e.g., '["5GrwvaEF...", "1000000000000"]') + #[arg(long)] + args: Option, + + /// Expiry block number (when this proposal expires) + #[arg(long)] + expiry: u32, + + /// Proposer wallet name (must be a signer) + #[arg(long)] + from: String, + + /// Password for the wallet + #[arg(short, long)] + password: Option, + + /// Read password from file + #[arg(long)] + password_file: Option, + }, + + /// Propose to enable high-security for this multisig + HighSecurity { + /// Multisig account address (SS58 format) + #[arg(long)] + address: String, + + /// Guardian/Interceptor account (SS58 or wallet name) + #[arg(long)] + interceptor: String, + + /// Delay in blocks (mutually exclusive with --delay-seconds) + #[arg(long, conflicts_with = "delay_seconds")] + delay_blocks: Option, + + /// Delay in seconds (mutually exclusive with --delay-blocks) + #[arg(long, conflicts_with = "delay_blocks")] + delay_seconds: Option, + + /// Expiry block number (when this proposal expires) + #[arg(long)] + expiry: u32, + + /// Proposer wallet name (must be a signer) + #[arg(long)] + from: String, + + /// Password for the wallet + #[arg(short, long)] + password: Option, + + /// Read password from file + #[arg(long)] + password_file: Option, + }, +} + +/// Multisig-related commands +#[derive(Subcommand, Debug)] +pub enum MultisigCommands { + /// Create a new multisig account + Create { + /// List of signer addresses (SS58 or wallet names), comma-separated + #[arg(long)] + signers: String, + + /// Number of approvals required to execute transactions + #[arg(long)] + threshold: u32, + + /// Nonce for deterministic address generation (allows creating multiple multisigs with + /// same signers) + #[arg(long, default_value = "0")] + nonce: u64, + + /// Wallet name to pay for multisig creation + #[arg(long)] + from: String, + + /// Password for the wallet + #[arg(short, long)] + password: Option, + + /// Read password from file (for scripting) + #[arg(long)] + password_file: Option, + }, + + /// Predict multisig address without creating it (deterministic calculation) + PredictAddress { + /// List of signer addresses (SS58 or wallet names), comma-separated + #[arg(long)] + signers: String, + + /// Number of approvals required to execute transactions + #[arg(long)] + threshold: u32, + + /// Nonce for deterministic address generation + #[arg(long, default_value = "0")] + nonce: u64, + }, + + /// Propose a transaction to be executed by the multisig + #[command(subcommand)] + Propose(ProposeSubcommand), + + /// Approve a proposed transaction + Approve { + /// Multisig account address + #[arg(long)] + address: String, + + /// Proposal ID (u32 nonce) + #[arg(long)] + proposal_id: u32, + + /// Approver wallet name (must be a signer) + #[arg(long)] + from: String, + + /// Password for the wallet + #[arg(short, long)] + password: Option, + + /// Read password from file + #[arg(long)] + password_file: Option, + }, + + /// Execute an approved proposal (any signer; proposal must have reached threshold) + Execute { + /// Multisig account address + #[arg(long)] + address: String, + + /// Proposal ID (u32 nonce) to execute + #[arg(long)] + proposal_id: u32, + + /// Wallet name (must be a signer) + #[arg(long)] + from: String, + + /// Password for the wallet + #[arg(short, long)] + password: Option, + + /// Read password from file + #[arg(long)] + password_file: Option, + }, + + /// Cancel a proposed transaction (only by proposer) + Cancel { + /// Multisig account address + #[arg(long)] + address: String, + + /// Proposal ID (u32 nonce) to cancel + #[arg(long)] + proposal_id: u32, + + /// Wallet name (must be the proposer) + #[arg(long)] + from: String, + + /// Password for the wallet + #[arg(short, long)] + password: Option, + + /// Read password from file + #[arg(long)] + password_file: Option, + }, + + /// Remove an expired proposal + RemoveExpired { + /// Multisig account address + #[arg(long)] + address: String, + + /// Proposal ID (u32 nonce) to remove + #[arg(long)] + proposal_id: u32, + + /// Wallet name (must be a signer) + #[arg(long)] + from: String, + + /// Password for the wallet + #[arg(short, long)] + password: Option, + + /// Read password from file + #[arg(long)] + password_file: Option, + }, + + /// Claim all deposits from removable proposals (batch operation) + ClaimDeposits { + /// Multisig account address + #[arg(long)] + address: String, + + /// Wallet name (must be the proposer) + #[arg(long)] + from: String, + + /// Password for the wallet + #[arg(short, long)] + password: Option, + + /// Read password from file + #[arg(long)] + password_file: Option, + }, + + /// Dissolve a multisig and recover the creation deposit + Dissolve { + /// Multisig account address + #[arg(long)] + address: String, + + /// Wallet name (must be creator or a signer) + #[arg(long)] + from: String, + + /// Password for the wallet + #[arg(short, long)] + password: Option, + + /// Read password from file + #[arg(long)] + password_file: Option, + }, + + /// Query multisig information (or specific proposal if --proposal-id provided) + Info { + /// Multisig account address + #[arg(long)] + address: String, + + /// Optional: Query specific proposal by ID + #[arg(long)] + proposal_id: Option, + }, + + /// List all proposals for a multisig + ListProposals { + /// Multisig account address + #[arg(long)] + address: String, + }, + + /// High-Security operations for multisig accounts + #[command(subcommand)] + HighSecurity(HighSecuritySubcommands), +} + +/// High-Security subcommands for multisig (query only) +#[derive(Subcommand, Debug)] +pub enum HighSecuritySubcommands { + /// Check if multisig has high-security enabled + Status { + /// Multisig account address + #[arg(long)] + address: String, + }, +} + +// ============================================================================ +// PUBLIC LIBRARY API - Core Functions +// ============================================================================ +// Note: These functions are public library API and may not be used by the CLI binary + +/// Predict multisig address deterministically +/// +/// This function calculates what the multisig address will be BEFORE creating it. +/// The address is computed as: hash(pallet_id || sorted_signers || threshold || nonce) +/// +/// # Arguments +/// * `signers` - List of signer AccountId32 (order doesn't matter - will be sorted) +/// * `threshold` - Number of approvals required +/// * `nonce` - Nonce for uniqueness (allows multiple multisigs with same signers) +/// +/// # Returns +/// Predicted multisig address in SS58 format +#[allow(dead_code)] +pub fn predict_multisig_address( + signers: Vec, + threshold: u32, + nonce: u64, +) -> String { + use codec::Encode; + + // Pallet ID from runtime: py/mltsg + const PALLET_ID: [u8; 8] = *b"py/mltsg"; + + // Convert subxt AccountId32 to sp_core AccountId32 for consistent encoding + use sp_core::crypto::AccountId32 as SpAccountId32; + let sp_signers: Vec = signers + .iter() + .map(|s| { + let bytes: [u8; 32] = *s.as_ref(); + SpAccountId32::from(bytes) + }) + .collect(); + + // Sort signers for deterministic address (same as runtime does) + let mut sorted_signers = sp_signers; + sorted_signers.sort(); + + // Build data to hash: pallet_id || sorted_signers || threshold || nonce + // IMPORTANT: Must match runtime encoding exactly + let mut data = Vec::new(); + data.extend_from_slice(&PALLET_ID); + // Encode Vec - same as runtime! + data.extend_from_slice(&sorted_signers.encode()); + data.extend_from_slice(&threshold.encode()); + data.extend_from_slice(&nonce.encode()); + + // Hash the data and map it deterministically into an AccountId + // CRITICAL: Use PoseidonHasher (same as runtime!) and TrailingZeroInput + use codec::Decode; + use qp_poseidon::PoseidonHasher; + use sp_core::crypto::AccountId32; + use sp_runtime::traits::{Hash as HashT, TrailingZeroInput}; + + let hash = PoseidonHasher::hash(&data); + let account_id = AccountId32::decode(&mut TrailingZeroInput::new(hash.as_ref())) + .expect("TrailingZeroInput provides sufficient bytes; qed"); + + // Convert to SS58 format (network 189 for Quantus) + account_id.to_ss58check_with_version(sp_core::crypto::Ss58AddressFormat::custom(189)) +} + +/// Create a multisig account +/// +/// # Arguments +/// * `quantus_client` - Connected Quantus client +/// * `creator_keypair` - Keypair of the account creating the multisig +/// * `signers` - List of signer addresses (AccountId32) +/// * `threshold` - Number of approvals required +/// * `nonce` - Nonce for deterministic address (allows multiple multisigs with same signers) +/// * `wait_for_inclusion` - Whether to wait for transaction inclusion +/// +/// # Returns +/// Transaction hash and optionally the multisig address (if wait_for_inclusion=true) +#[allow(dead_code)] +pub async fn create_multisig( + quantus_client: &crate::chain::client::QuantusClient, + creator_keypair: &crate::wallet::QuantumKeyPair, + signers: Vec, + threshold: u32, + nonce: u64, + wait_for_inclusion: bool, +) -> crate::error::Result<(subxt::utils::H256, Option)> { + // Build transaction with nonce + let create_tx = + quantus_subxt::api::tx() + .multisig() + .create_multisig(signers.clone(), threshold, nonce); + + // Submit transaction + let execution_mode = + ExecutionMode { finalized: false, wait_for_transaction: wait_for_inclusion }; + let tx_hash = crate::cli::common::submit_transaction( + quantus_client, + creator_keypair, + create_tx, + None, + execution_mode, + ) + .await?; + + // If waiting, extract address from events + let multisig_address = if wait_for_inclusion { + let latest_block_hash = quantus_client.get_latest_block().await?; + let events = quantus_client.client().events().at(latest_block_hash).await?; + + let mut multisig_events = + events.find::(); + + let address: Option = if let Some(Ok(ev)) = multisig_events.next() { + let addr_bytes: &[u8; 32] = ev.multisig_address.as_ref(); + let addr = SpAccountId32::from(*addr_bytes); + Some(addr.to_ss58check_with_version(sp_core::crypto::Ss58AddressFormat::custom(189))) + } else { + None + }; + address + } else { + None + }; + + Ok((tx_hash, multisig_address)) +} + +/// Propose a transfer from multisig +/// +/// # Returns +/// Transaction hash +#[allow(dead_code)] +pub async fn propose_transfer( + quantus_client: &crate::chain::client::QuantusClient, + proposer_keypair: &crate::wallet::QuantumKeyPair, + multisig_address: subxt::ext::subxt_core::utils::AccountId32, + to_address: subxt::ext::subxt_core::utils::AccountId32, + amount: u128, + expiry: u32, +) -> crate::error::Result { + use codec::{Compact, Encode}; + + // Build Balances::transfer_allow_death call + let pallet_index = 5u8; // Balances pallet + let call_index = 0u8; // transfer_allow_death + + let mut call_data = Vec::new(); + call_data.push(pallet_index); + call_data.push(call_index); + + // Encode destination (MultiAddress::Id) + call_data.push(0u8); // MultiAddress::Id variant + call_data.extend_from_slice(to_address.as_ref()); + + // Encode amount (Compact) + Compact(amount).encode_to(&mut call_data); + + // Build propose transaction + let propose_tx = + quantus_subxt::api::tx().multisig().propose(multisig_address, call_data, expiry); + + // Submit transaction + let execution_mode = ExecutionMode { finalized: false, wait_for_transaction: false }; + let tx_hash = crate::cli::common::submit_transaction( + quantus_client, + proposer_keypair, + propose_tx, + None, + execution_mode, + ) + .await?; + + Ok(tx_hash) +} + +/// Propose a custom call from multisig +/// +/// # Returns +/// Transaction hash +#[allow(dead_code)] +pub async fn propose_custom( + quantus_client: &crate::chain::client::QuantusClient, + proposer_keypair: &crate::wallet::QuantumKeyPair, + multisig_address: subxt::ext::subxt_core::utils::AccountId32, + call_data: Vec, + expiry: u32, +) -> crate::error::Result { + // Build propose transaction + let propose_tx = + quantus_subxt::api::tx().multisig().propose(multisig_address, call_data, expiry); + + // Submit transaction + let execution_mode = ExecutionMode { finalized: false, wait_for_transaction: false }; + let tx_hash = crate::cli::common::submit_transaction( + quantus_client, + proposer_keypair, + propose_tx, + None, + execution_mode, + ) + .await?; + + Ok(tx_hash) +} + +/// Approve a proposal +/// +/// # Returns +/// Transaction hash +#[allow(dead_code)] +pub async fn approve_proposal( + quantus_client: &crate::chain::client::QuantusClient, + approver_keypair: &crate::wallet::QuantumKeyPair, + multisig_address: subxt::ext::subxt_core::utils::AccountId32, + proposal_id: u32, +) -> crate::error::Result { + let approve_tx = quantus_subxt::api::tx().multisig().approve(multisig_address, proposal_id); + + let execution_mode = ExecutionMode { finalized: false, wait_for_transaction: false }; + let tx_hash = crate::cli::common::submit_transaction( + quantus_client, + approver_keypair, + approve_tx, + None, + execution_mode, + ) + .await?; + + Ok(tx_hash) +} + +/// Cancel a proposal (only by proposer) +/// +/// # Returns +/// Transaction hash +#[allow(dead_code)] +pub async fn cancel_proposal( + quantus_client: &crate::chain::client::QuantusClient, + proposer_keypair: &crate::wallet::QuantumKeyPair, + multisig_address: subxt::ext::subxt_core::utils::AccountId32, + proposal_id: u32, +) -> crate::error::Result { + let cancel_tx = quantus_subxt::api::tx().multisig().cancel(multisig_address, proposal_id); + + let execution_mode = ExecutionMode { finalized: false, wait_for_transaction: false }; + let tx_hash = crate::cli::common::submit_transaction( + quantus_client, + proposer_keypair, + cancel_tx, + None, + execution_mode, + ) + .await?; + + Ok(tx_hash) +} + +/// Get multisig information +/// +/// # Returns +/// Multisig information or None if not found +#[allow(dead_code)] +pub async fn get_multisig_info( + quantus_client: &crate::chain::client::QuantusClient, + multisig_address: subxt::ext::subxt_core::utils::AccountId32, +) -> crate::error::Result> { + let latest_block_hash = quantus_client.get_latest_block().await?; + let storage_at = quantus_client.client().storage().at(latest_block_hash); + + // Query multisig data + let storage_query = + quantus_subxt::api::storage().multisig().multisigs(multisig_address.clone()); + let multisig_data = storage_at.fetch(&storage_query).await?; + + if let Some(data) = multisig_data { + // Query balance + let balance_query = + quantus_subxt::api::storage().system().account(multisig_address.clone()); + let account_info = storage_at.fetch(&balance_query).await?; + let balance = account_info.map(|info| info.data.free).unwrap_or(0); + + // Convert to SS58 + let multisig_bytes: &[u8; 32] = multisig_address.as_ref(); + let multisig_sp = SpAccountId32::from(*multisig_bytes); + let address = + multisig_sp.to_ss58check_with_version(sp_core::crypto::Ss58AddressFormat::custom(189)); + + // Convert creator to SS58 + let creator_bytes: &[u8; 32] = data.creator.as_ref(); + let creator_sp = SpAccountId32::from(*creator_bytes); + let creator = + creator_sp.to_ss58check_with_version(sp_core::crypto::Ss58AddressFormat::custom(189)); + + let signers: Vec = data + .signers + .0 + .iter() + .map(|signer| { + let signer_bytes: &[u8; 32] = signer.as_ref(); + let signer_sp = SpAccountId32::from(*signer_bytes); + signer_sp.to_ss58check_with_version(sp_core::crypto::Ss58AddressFormat::custom(189)) + }) + .collect(); + + Ok(Some(MultisigInfo { + address, + creator, + balance, + threshold: data.threshold, + signers, + proposal_nonce: data.proposal_nonce, + deposit: data.deposit, + active_proposals: data.active_proposals, + })) + } else { + Ok(None) + } +} + +/// Get proposal information +/// +/// # Returns +/// Proposal information or None if not found +#[allow(dead_code)] +pub async fn get_proposal_info( + quantus_client: &crate::chain::client::QuantusClient, + multisig_address: subxt::ext::subxt_core::utils::AccountId32, + proposal_id: u32, +) -> crate::error::Result> { + let latest_block_hash = quantus_client.get_latest_block().await?; + let storage_at = quantus_client.client().storage().at(latest_block_hash); + + let storage_query = quantus_subxt::api::storage() + .multisig() + .proposals(multisig_address, proposal_id); + + let proposal_data = storage_at.fetch(&storage_query).await?; + + if let Some(data) = proposal_data { + let proposer_bytes: &[u8; 32] = data.proposer.as_ref(); + let proposer_sp = SpAccountId32::from(*proposer_bytes); + let proposer = + proposer_sp.to_ss58check_with_version(sp_core::crypto::Ss58AddressFormat::custom(189)); + + let approvals: Vec = data + .approvals + .0 + .iter() + .map(|approver| { + let approver_bytes: &[u8; 32] = approver.as_ref(); + let approver_sp = SpAccountId32::from(*approver_bytes); + approver_sp + .to_ss58check_with_version(sp_core::crypto::Ss58AddressFormat::custom(189)) + }) + .collect(); + + let status = match data.status { + quantus_subxt::api::runtime_types::pallet_multisig::ProposalStatus::Active => + ProposalStatus::Active, + quantus_subxt::api::runtime_types::pallet_multisig::ProposalStatus::Approved => + ProposalStatus::Approved, + quantus_subxt::api::runtime_types::pallet_multisig::ProposalStatus::Executed => + ProposalStatus::Executed, + quantus_subxt::api::runtime_types::pallet_multisig::ProposalStatus::Cancelled => + ProposalStatus::Cancelled, + }; + + Ok(Some(ProposalInfo { + id: proposal_id, + proposer, + call_data: data.call.0, + expiry: data.expiry, + approvals, + deposit: data.deposit, + status, + })) + } else { + Ok(None) + } +} + +/// List all proposals for a multisig +/// +/// # Returns +/// List of proposals +#[allow(dead_code)] +pub async fn list_proposals( + quantus_client: &crate::chain::client::QuantusClient, + multisig_address: subxt::ext::subxt_core::utils::AccountId32, +) -> crate::error::Result> { + let latest_block_hash = quantus_client.get_latest_block().await?; + let storage = quantus_client.client().storage().at(latest_block_hash); + + let address = quantus_subxt::api::storage() + .multisig() + .proposals_iter1(multisig_address.clone()); + let mut proposals_iter = storage.iter(address).await?; + + let mut proposals = Vec::new(); + + while let Some(result) = proposals_iter.next().await { + if let Ok(kv) = result { + // Extract proposal_id from key + let key_bytes = kv.key_bytes; + if key_bytes.len() >= 4 { + let id_bytes = &key_bytes[key_bytes.len() - 4..]; + let proposal_id = + u32::from_le_bytes([id_bytes[0], id_bytes[1], id_bytes[2], id_bytes[3]]); + + // Use value directly from iterator (more efficient) + let data = kv.value; + + let proposer_bytes: &[u8; 32] = data.proposer.as_ref(); + let proposer_sp = SpAccountId32::from(*proposer_bytes); + let proposer = proposer_sp + .to_ss58check_with_version(sp_core::crypto::Ss58AddressFormat::custom(189)); + + let approvals: Vec = data + .approvals + .0 + .iter() + .map(|approver| { + let approver_bytes: &[u8; 32] = approver.as_ref(); + let approver_sp = SpAccountId32::from(*approver_bytes); + approver_sp.to_ss58check_with_version( + sp_core::crypto::Ss58AddressFormat::custom(189), + ) + }) + .collect(); + + let status = match data.status { + quantus_subxt::api::runtime_types::pallet_multisig::ProposalStatus::Active => + ProposalStatus::Active, + quantus_subxt::api::runtime_types::pallet_multisig::ProposalStatus::Approved => + ProposalStatus::Approved, + quantus_subxt::api::runtime_types::pallet_multisig::ProposalStatus::Executed => + ProposalStatus::Executed, + quantus_subxt::api::runtime_types::pallet_multisig::ProposalStatus::Cancelled => + ProposalStatus::Cancelled, + }; + + proposals.push(ProposalInfo { + id: proposal_id, + proposer, + call_data: data.call.0, + expiry: data.expiry, + approvals, + deposit: data.deposit, + status, + }); + } + } + } + + Ok(proposals) +} + +/// Approve dissolving a multisig +/// +/// Requires threshold approvals. When threshold is reached, multisig is dissolved. +/// Requirements: +/// - No proposals exist (active, executed, or cancelled) +/// - Multisig account balance must be zero +/// - Deposit is returned to creator +/// +/// # Returns +/// Transaction hash +#[allow(dead_code)] +pub async fn approve_dissolve_multisig( + quantus_client: &crate::chain::client::QuantusClient, + caller_keypair: &crate::wallet::QuantumKeyPair, + multisig_address: subxt::ext::subxt_core::utils::AccountId32, +) -> crate::error::Result { + let approve_tx = quantus_subxt::api::tx().multisig().approve_dissolve(multisig_address); + + let execution_mode = ExecutionMode { finalized: false, wait_for_transaction: false }; + let tx_hash = crate::cli::common::submit_transaction( + quantus_client, + caller_keypair, + approve_tx, + None, + execution_mode, + ) + .await?; + + Ok(tx_hash) +} + +// ============================================================================ +// CLI HANDLERS (Internal) +// ============================================================================ + +/// Handle multisig command +pub async fn handle_multisig_command( + command: MultisigCommands, + node_url: &str, + execution_mode: ExecutionMode, +) -> crate::error::Result<()> { + match command { + MultisigCommands::Create { signers, threshold, nonce, from, password, password_file } => + handle_create_multisig( + signers, + threshold, + nonce, + from, + password, + password_file, + node_url, + execution_mode, + ) + .await, + MultisigCommands::PredictAddress { signers, threshold, nonce } => + handle_predict_address(signers, threshold, nonce).await, + MultisigCommands::Propose(subcommand) => match subcommand { + ProposeSubcommand::Transfer { + address, + to, + amount, + expiry, + from, + password, + password_file, + } => + handle_propose_transfer( + address, + to, + amount, + expiry, + from, + password, + password_file, + node_url, + execution_mode, + ) + .await, + ProposeSubcommand::Custom { + address, + pallet, + call, + args, + expiry, + from, + password, + password_file, + } => + handle_propose( + address, + pallet, + call, + args, + expiry, + from, + password, + password_file, + node_url, + execution_mode, + ) + .await, + ProposeSubcommand::HighSecurity { + address, + interceptor, + delay_blocks, + delay_seconds, + expiry, + from, + password, + password_file, + } => + handle_high_security_set( + address, + interceptor, + delay_blocks, + delay_seconds, + expiry, + from, + password, + password_file, + node_url, + execution_mode, + ) + .await, + }, + MultisigCommands::Approve { address, proposal_id, from, password, password_file } => + handle_approve( + address, + proposal_id, + from, + password, + password_file, + node_url, + execution_mode, + ) + .await, + MultisigCommands::Execute { address, proposal_id, from, password, password_file } => + handle_execute( + address, + proposal_id, + from, + password, + password_file, + node_url, + execution_mode, + ) + .await, + MultisigCommands::Cancel { address, proposal_id, from, password, password_file } => + handle_cancel( + address, + proposal_id, + from, + password, + password_file, + node_url, + execution_mode, + ) + .await, + MultisigCommands::RemoveExpired { address, proposal_id, from, password, password_file } => + handle_remove_expired( + address, + proposal_id, + from, + password, + password_file, + node_url, + execution_mode, + ) + .await, + MultisigCommands::ClaimDeposits { address, from, password, password_file } => + handle_claim_deposits(address, from, password, password_file, node_url, execution_mode) + .await, + MultisigCommands::Dissolve { address, from, password, password_file } => + handle_dissolve(address, from, password, password_file, node_url, execution_mode).await, + MultisigCommands::Info { address, proposal_id } => + handle_info(address, proposal_id, node_url).await, + MultisigCommands::ListProposals { address } => + handle_list_proposals(address, node_url).await, + MultisigCommands::HighSecurity(subcommand) => match subcommand { + HighSecuritySubcommands::Status { address } => + handle_high_security_status(address, node_url).await, + }, + } +} + +/// Create a new multisig account +async fn handle_create_multisig( + signers: String, + threshold: u32, + nonce: u64, + from: String, + password: Option, + password_file: Option, + node_url: &str, + execution_mode: ExecutionMode, +) -> crate::error::Result<()> { + log_print!("๐Ÿ” {} Creating multisig...", "MULTISIG".bright_magenta().bold()); + + // Parse signers - convert to AccountId32 + let signer_addresses: Vec = signers + .split(',') + .map(|s| s.trim()) + .map(|addr| { + // Resolve wallet name or SS58 address to SS58 string + let ss58_str = crate::cli::common::resolve_address(addr)?; + // Convert SS58 to AccountId32 + let (account_id, _) = + SpAccountId32::from_ss58check_with_version(&ss58_str).map_err(|e| { + crate::error::QuantusError::Generic(format!( + "Invalid address '{}': {:?}", + addr, e + )) + })?; + // Convert to subxt AccountId32 + let bytes: [u8; 32] = *account_id.as_ref(); + Ok(subxt::ext::subxt_core::utils::AccountId32::from(bytes)) + }) + .collect::, crate::error::QuantusError>>()?; + + log_verbose!("Signers: {} addresses", signer_addresses.len()); + log_verbose!("Threshold: {}", threshold); + log_verbose!("Nonce: {}", nonce); + + // Validate inputs + if signer_addresses.is_empty() { + log_error!("โŒ At least one signer is required"); + return Err(crate::error::QuantusError::Generic("No signers provided".to_string())); + } + + if threshold == 0 { + log_error!("โŒ Threshold must be greater than zero"); + return Err(crate::error::QuantusError::Generic("Invalid threshold".to_string())); + } + + if threshold > signer_addresses.len() as u32 { + log_error!("โŒ Threshold cannot exceed number of signers"); + return Err(crate::error::QuantusError::Generic("Threshold too high".to_string())); + } + + // Calculate predicted address BEFORE submission (deterministic) + let predicted_address = predict_multisig_address(signer_addresses.clone(), threshold, nonce); + + log_print!(""); + log_print!("๐Ÿ“ {} Predicted multisig address:", "DETERMINISTIC".bright_green().bold()); + log_print!(" {}", predicted_address.bright_cyan().bold()); + log_print!(""); + + // Load keypair + let keypair = crate::wallet::load_keypair_from_wallet(&from, password, password_file)?; + + // Connect to chain + let quantus_client = crate::chain::client::QuantusClient::new(node_url).await?; + + // Build transaction with nonce + let create_tx = quantus_subxt::api::tx().multisig().create_multisig( + signer_addresses.clone(), + threshold, + nonce, + ); + + // Always wait for transaction to confirm creation + let create_execution_mode = ExecutionMode { + finalized: execution_mode.finalized, + wait_for_transaction: true, // Always wait to confirm address + }; + + let _tx_hash = crate::cli::common::submit_transaction( + &quantus_client, + &keypair, + create_tx, + None, + create_execution_mode, + ) + .await?; + + log_success!("โœ… Multisig creation transaction confirmed"); + + // Extract and verify address from events + { + log_print!(""); + log_print!("๐Ÿ” Looking for MultisigCreated event..."); + + // Query latest block events + let latest_block_hash = quantus_client.get_latest_block().await?; + let events = quantus_client.client().events().at(latest_block_hash).await?; + + // Find MultisigCreated event + let multisig_events = + events.find::(); + + let mut actual_address: Option = None; + for event_result in multisig_events { + match event_result { + Ok(ev) => { + let addr_bytes: &[u8; 32] = ev.multisig_address.as_ref(); + let addr = SpAccountId32::from(*addr_bytes); + actual_address = Some(addr.to_ss58check_with_version( + sp_core::crypto::Ss58AddressFormat::custom(189), + )); + log_verbose!("Found MultisigCreated event"); + break; + }, + Err(e) => { + log_verbose!("Error parsing event: {:?}", e); + }, + } + } + + if let Some(address) = actual_address { + log_print!(""); + + // Verify address matches prediction + if address == predicted_address { + log_success!("โœ… Confirmed multisig address: {}", address.bright_cyan().bold()); + log_print!(" {} Matches predicted address!", "โœ“".bright_green().bold()); + } else { + log_error!("โš ๏ธ Address mismatch!"); + log_print!(" Expected: {}", predicted_address.bright_yellow()); + log_print!(" Got: {}", address.bright_red()); + log_print!(" This should never happen with deterministic addresses!"); + } + + log_print!(""); + log_print!( + "๐Ÿ’ก {} You can now use this address to propose transactions", + "TIP".bright_blue().bold() + ); + log_print!( + " Example: quantus multisig propose transfer --address {} --to recipient --amount 100", + address.bright_cyan() + ); + } else { + log_error!("โš ๏ธ Couldn't find MultisigCreated event"); + log_print!(" Check events manually: quantus events --latest --pallet Multisig"); + } + } + + log_print!(""); + + Ok(()) +} + +/// Predict multisig address without creating it +async fn handle_predict_address( + signers: String, + threshold: u32, + nonce: u64, +) -> crate::error::Result<()> { + log_print!("๐Ÿ”ฎ {} Predicting multisig address...", "PREDICT".bright_cyan().bold()); + log_print!(""); + + // Parse signers - convert to AccountId32 + let signer_addresses: Vec = signers + .split(',') + .map(|s| s.trim()) + .map(|addr| { + // Resolve wallet name or SS58 address to SS58 string + let ss58_str = crate::cli::common::resolve_address(addr)?; + // Convert SS58 to AccountId32 + let (account_id, _) = + SpAccountId32::from_ss58check_with_version(&ss58_str).map_err(|e| { + crate::error::QuantusError::Generic(format!( + "Invalid address '{}': {:?}", + addr, e + )) + })?; + // Convert to subxt AccountId32 + let bytes: [u8; 32] = *account_id.as_ref(); + Ok(subxt::ext::subxt_core::utils::AccountId32::from(bytes)) + }) + .collect::, crate::error::QuantusError>>()?; + + // Validate inputs + if signer_addresses.is_empty() { + log_error!("โŒ At least one signer is required"); + return Err(crate::error::QuantusError::Generic("No signers provided".to_string())); + } + + if threshold == 0 { + log_error!("โŒ Threshold must be greater than zero"); + return Err(crate::error::QuantusError::Generic("Invalid threshold".to_string())); + } + + if threshold > signer_addresses.len() as u32 { + log_error!("โŒ Threshold cannot exceed number of signers"); + return Err(crate::error::QuantusError::Generic("Threshold too high".to_string())); + } + + // Calculate deterministic address + let predicted_address = predict_multisig_address(signer_addresses.clone(), threshold, nonce); + + log_print!("๐Ÿ“ {} Predicted multisig address:", "RESULT".bright_green().bold()); + log_print!(" {}", predicted_address.bright_cyan().bold()); + log_print!(""); + log_print!("โš™๏ธ {} Configuration:", "PARAMS".bright_blue().bold()); + log_print!(" Signers: {}", signer_addresses.len()); + log_print!(" Threshold: {}", threshold); + log_print!(" Nonce: {}", nonce); + log_print!(""); + log_print!("๐Ÿ’ก {} This address is deterministic:", "INFO".bright_yellow().bold()); + log_print!(" - Same signers + threshold + nonce = same address"); + log_print!(" - Order of signers doesn't matter (automatically sorted)"); + log_print!(" - Use different nonce to create multiple multisigs with same signers"); + log_print!(""); + log_print!("๐Ÿš€ {} To create this multisig, run:", "NEXT".bright_magenta().bold()); + log_print!( + " quantus multisig create --signers \"{}\" --threshold {} --nonce {} --from ", + signers, + threshold, + nonce + ); + log_print!(""); + + Ok(()) +} + +/// Propose a transaction +/// Propose a transfer transaction (simplified interface) +async fn handle_propose_transfer( + multisig_address: String, + to: String, + amount: String, + expiry: u32, + from: String, + password: Option, + password_file: Option, + node_url: &str, + execution_mode: ExecutionMode, +) -> crate::error::Result<()> { + log_print!("๐Ÿ“ {} Creating transfer proposal...", "MULTISIG".bright_magenta().bold()); + + // Resolve recipient address (wallet name or SS58) + let to_address = crate::cli::common::resolve_address(&to)?; + + // Parse amount (supports both human format "10" and raw "10000000000000") + let amount_u128: u128 = parse_amount(&amount)?; + + // Resolve multisig address to check HS status + let multisig_ss58 = crate::cli::common::resolve_address(&multisig_address)?; + let (multisig_id, _) = + SpAccountId32::from_ss58check_with_version(&multisig_ss58).map_err(|e| { + crate::error::QuantusError::Generic(format!("Invalid multisig address: {:?}", e)) + })?; + let multisig_bytes: [u8; 32] = *multisig_id.as_ref(); + let multisig_account_id = subxt::ext::subxt_core::utils::AccountId32::from(multisig_bytes); + + // Connect to chain and check if multisig has High Security enabled + let quantus_client = crate::chain::client::QuantusClient::new(node_url).await?; + let latest_block_hash = quantus_client.get_latest_block().await?; + let storage_at = quantus_client.client().storage().at(latest_block_hash); + + let hs_query = quantus_subxt::api::storage() + .reversible_transfers() + .high_security_accounts(multisig_account_id); + let is_high_security = storage_at.fetch(&hs_query).await?.is_some(); + + if is_high_security { + // HS enabled: use ReversibleTransfers::schedule_transfer (delayed + reversible) + log_print!( + "๐Ÿ›ก๏ธ {} High-Security detected: using delayed transfer (ReversibleTransfers::schedule_transfer)", + "HS".bright_green().bold() + ); + + // Build schedule_transfer call data directly + let (to_id, _) = SpAccountId32::from_ss58check_with_version(&to_address).map_err(|e| { + crate::error::QuantusError::Generic(format!("Invalid recipient address: {:?}", e)) + })?; + let to_bytes: [u8; 32] = *to_id.as_ref(); + let to_account_id = subxt::ext::subxt_core::utils::AccountId32::from(to_bytes); + let dest = subxt::utils::MultiAddress::Id(to_account_id); + + let schedule_call = quantus_subxt::api::tx() + .reversible_transfers() + .schedule_transfer(dest, amount_u128); + + use subxt::tx::Payload; + let call_data = schedule_call + .encode_call_data(&quantus_client.client().metadata()) + .map_err(|e| { + crate::error::QuantusError::Generic(format!("Failed to encode call: {:?}", e)) + })?; + + // Submit as multisig proposal with pre-built call data + handle_propose_with_call_data( + multisig_address, + call_data, + expiry, + from, + password, + password_file, + &quantus_client, + execution_mode, + ) + .await + } else { + // No HS: use standard Balances::transfer_allow_death + let args_json = serde_json::to_string(&vec![ + serde_json::Value::String(to_address), + serde_json::Value::String(amount_u128.to_string()), + ]) + .map_err(|e| { + crate::error::QuantusError::Generic(format!("Failed to serialize args: {}", e)) + })?; + + handle_propose( + multisig_address, + "Balances".to_string(), + "transfer_allow_death".to_string(), + Some(args_json), + expiry, + from, + password, + password_file, + node_url, + execution_mode, + ) + .await + } +} + +/// Propose a custom transaction +async fn handle_propose( + multisig_address: String, + pallet: String, + call: String, + args: Option, + expiry: u32, + from: String, + password: Option, + password_file: Option, + node_url: &str, + execution_mode: ExecutionMode, +) -> crate::error::Result<()> { + log_print!("๐Ÿ“ {} Creating proposal...", "MULTISIG".bright_magenta().bold()); + + // Resolve multisig address + let multisig_ss58 = crate::cli::common::resolve_address(&multisig_address)?; + let (multisig_id, _) = + SpAccountId32::from_ss58check_with_version(&multisig_ss58).map_err(|e| { + crate::error::QuantusError::Generic(format!("Invalid multisig address: {:?}", e)) + })?; + let multisig_bytes: [u8; 32] = *multisig_id.as_ref(); + let multisig_address = subxt::ext::subxt_core::utils::AccountId32::from(multisig_bytes); + + // Parse arguments + let args_vec: Vec = if let Some(args_str) = args { + serde_json::from_str(&args_str).map_err(|e| { + crate::error::QuantusError::Generic(format!("Invalid JSON for arguments: {}", e)) + })? + } else { + vec![] + }; + + log_verbose!("Multisig: {}", multisig_ss58); + log_verbose!("Call: {}::{}", pallet, call); + log_verbose!("Expiry: block {}", expiry); + + // Connect to chain + let quantus_client = crate::chain::client::QuantusClient::new(node_url).await?; + + // Validate expiry is in the future (client-side check) + 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(); + + if expiry <= current_block_number { + log_error!( + "โŒ Expiry block {} is in the past (current block: {})", + expiry, + current_block_number + ); + log_print!(" Use a higher block number, e.g., --expiry {}", current_block_number + 1000); + return Err(crate::error::QuantusError::Generic("Expiry must be in the future".to_string())); + } + + log_verbose!("Current block: {}, expiry valid", current_block_number); + + // Validate proposer is a signer (client-side check before submitting) + let storage_at = quantus_client.client().storage().at(latest_block_hash); + let multisig_query = + quantus_subxt::api::storage().multisig().multisigs(multisig_address.clone()); + let multisig_data = storage_at.fetch(&multisig_query).await?.ok_or_else(|| { + crate::error::QuantusError::Generic(format!( + "Multisig not found at address: {}", + multisig_ss58 + )) + })?; + + // Resolve proposer address + let proposer_ss58 = crate::cli::common::resolve_address(&from)?; + let (proposer_id, _) = + SpAccountId32::from_ss58check_with_version(&proposer_ss58).map_err(|e| { + crate::error::QuantusError::Generic(format!("Invalid proposer address: {:?}", e)) + })?; + let proposer_bytes: [u8; 32] = *proposer_id.as_ref(); + let proposer_account_id = subxt::ext::subxt_core::utils::AccountId32::from(proposer_bytes); + + // Check if proposer is in signers list + if !multisig_data.signers.0.contains(&proposer_account_id) { + log_error!("โŒ Not authorized: {} is not a signer of this multisig", proposer_ss58); + return Err(crate::error::QuantusError::Generic( + "Only multisig signers can create proposals".to_string(), + )); + } + + // Check for expired proposals from this proposer (will be auto-cleaned by runtime) + let mut expired_count = 0; + let proposals_query = quantus_subxt::api::storage().multisig().proposals_iter(); + let mut proposals_stream = storage_at.iter(proposals_query).await?; + + while let Some(Ok(kv)) = proposals_stream.next().await { + let proposal = kv.value; + // Check if this proposal belongs to our proposer + if proposal.proposer == proposer_account_id { + // Check if expired + if proposal.expiry <= current_block_number { + expired_count += 1; + } + } + } + + if expired_count > 0 { + log_print!(""); + log_print!( + "๐Ÿงน {} Auto-cleanup: Runtime will remove your {} expired proposal(s)", + "INFO".bright_blue().bold(), + expired_count.to_string().bright_yellow() + ); + log_print!(" This happens automatically before creating the new proposal"); + log_print!(""); + } + + // Build the call data using runtime metadata + let call_data = build_runtime_call(&quantus_client, &pallet, &call, args_vec).await?; + + log_verbose!("Call data size: {} bytes", call_data.len()); + + // Load keypair + let keypair = crate::wallet::load_keypair_from_wallet(&from, password, password_file)?; + + // Build transaction + let propose_tx = + quantus_subxt::api::tx() + .multisig() + .propose(multisig_address.clone(), call_data, expiry); + + // Always wait for transaction confirmation + let propose_execution_mode = ExecutionMode { wait_for_transaction: true, ..execution_mode }; + + // Submit transaction and wait for on-chain confirmation + crate::cli::common::submit_transaction( + &quantus_client, + &keypair, + propose_tx, + None, + propose_execution_mode, + ) + .await?; + + log_success!("โœ… Proposal confirmed on-chain"); + + Ok(()) +} + +/// Submit a multisig proposal with pre-built call data (used for HS transfers) +async fn handle_propose_with_call_data( + multisig_address: String, + call_data: Vec, + expiry: u32, + from: String, + password: Option, + password_file: Option, + quantus_client: &crate::chain::client::QuantusClient, + execution_mode: ExecutionMode, +) -> crate::error::Result<()> { + log_print!("๐Ÿ“ {} Creating proposal...", "MULTISIG".bright_magenta().bold()); + + // Resolve multisig address + let multisig_ss58 = crate::cli::common::resolve_address(&multisig_address)?; + let (multisig_id, _) = + SpAccountId32::from_ss58check_with_version(&multisig_ss58).map_err(|e| { + crate::error::QuantusError::Generic(format!("Invalid multisig address: {:?}", e)) + })?; + let multisig_bytes: [u8; 32] = *multisig_id.as_ref(); + let multisig_account_id = subxt::ext::subxt_core::utils::AccountId32::from(multisig_bytes); + + // Validate expiry is in the future (client-side check) + 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(); + + if expiry <= current_block_number { + log_error!( + "โŒ Expiry block {} is in the past (current block: {})", + expiry, + current_block_number + ); + log_print!(" Use a higher block number, e.g., --expiry {}", current_block_number + 1000); + return Err(crate::error::QuantusError::Generic("Expiry must be in the future".to_string())); + } + + log_verbose!("Current block: {}, expiry valid", current_block_number); + log_verbose!("Call data size: {} bytes", call_data.len()); + + // Load keypair + let keypair = crate::wallet::load_keypair_from_wallet(&from, password, password_file)?; + + // Build transaction + let propose_tx = + quantus_subxt::api::tx() + .multisig() + .propose(multisig_account_id, call_data, expiry); + + // Always wait for transaction confirmation + let propose_execution_mode = ExecutionMode { wait_for_transaction: true, ..execution_mode }; + + // Submit transaction and wait for on-chain confirmation + crate::cli::common::submit_transaction( + quantus_client, + &keypair, + propose_tx, + None, + propose_execution_mode, + ) + .await?; + + log_success!("โœ… Proposal confirmed on-chain"); + + Ok(()) +} + +/// Approve a proposal +async fn handle_approve( + multisig_address: String, + proposal_id: u32, + from: String, + password: Option, + password_file: Option, + node_url: &str, + execution_mode: ExecutionMode, +) -> crate::error::Result<()> { + log_print!("โœ… {} Approving proposal...", "MULTISIG".bright_magenta().bold()); + + // Resolve multisig address + let multisig_ss58 = crate::cli::common::resolve_address(&multisig_address)?; + let (multisig_id, _) = + SpAccountId32::from_ss58check_with_version(&multisig_ss58).map_err(|e| { + crate::error::QuantusError::Generic(format!("Invalid multisig address: {:?}", e)) + })?; + let multisig_bytes: [u8; 32] = *multisig_id.as_ref(); + let multisig_address = subxt::ext::subxt_core::utils::AccountId32::from(multisig_bytes); + + log_verbose!("Multisig: {}", multisig_ss58); + log_verbose!("Proposal ID: {}", proposal_id); + + // Load keypair + let keypair = crate::wallet::load_keypair_from_wallet(&from, password, password_file)?; + + // Connect to chain + let quantus_client = crate::chain::client::QuantusClient::new(node_url).await?; + + // Validate approver is a signer (client-side check before submitting) + let latest_block_hash = quantus_client.get_latest_block().await?; + let storage_at = quantus_client.client().storage().at(latest_block_hash); + + let multisig_query = + quantus_subxt::api::storage().multisig().multisigs(multisig_address.clone()); + let multisig_data = storage_at.fetch(&multisig_query).await?.ok_or_else(|| { + crate::error::QuantusError::Generic(format!( + "Multisig not found at address: {}", + multisig_ss58 + )) + })?; + + // Resolve approver address + let approver_ss58 = crate::cli::common::resolve_address(&from)?; + let (approver_id, _) = + SpAccountId32::from_ss58check_with_version(&approver_ss58).map_err(|e| { + crate::error::QuantusError::Generic(format!("Invalid approver address: {:?}", e)) + })?; + let approver_bytes: [u8; 32] = *approver_id.as_ref(); + let approver_account_id = subxt::ext::subxt_core::utils::AccountId32::from(approver_bytes); + + // Check if approver is in signers list + if !multisig_data.signers.0.contains(&approver_account_id) { + log_error!("โŒ Not authorized: {} is not a signer of this multisig", approver_ss58); + return Err(crate::error::QuantusError::Generic( + "Only multisig signers can approve proposals".to_string(), + )); + } + + // Check if proposal exists + let proposal_query = quantus_subxt::api::storage() + .multisig() + .proposals(multisig_address.clone(), proposal_id); + let proposal_data = storage_at.fetch(&proposal_query).await?; + if proposal_data.is_none() { + log_error!("โŒ Proposal {} not found", proposal_id); + return Err(crate::error::QuantusError::Generic(format!( + "Proposal {} does not exist", + proposal_id + ))); + } + let proposal = proposal_data.unwrap(); + + // Check if already approved by this signer + if proposal.approvals.0.contains(&approver_account_id) { + log_error!("โŒ Already approved: you have already approved this proposal"); + return Err(crate::error::QuantusError::Generic( + "You have already approved this proposal".to_string(), + )); + } + + // Build transaction + let approve_tx = quantus_subxt::api::tx().multisig().approve(multisig_address, proposal_id); + + // Always wait for transaction confirmation + let approve_execution_mode = ExecutionMode { wait_for_transaction: true, ..execution_mode }; + + // Submit transaction and wait for on-chain confirmation + crate::cli::common::submit_transaction( + &quantus_client, + &keypair, + approve_tx, + None, + approve_execution_mode, + ) + .await?; + + log_success!("โœ… Approval confirmed on-chain"); + log_print!( + " If threshold is reached, the proposal becomes Approved. Any signer can run: quantus multisig execute --address {} --proposal-id {} --from ", + multisig_ss58, + proposal_id + ); + + Ok(()) +} + +/// Execute an approved proposal (any signer) +async fn handle_execute( + multisig_address: String, + proposal_id: u32, + from: String, + password: Option, + password_file: Option, + node_url: &str, + execution_mode: ExecutionMode, +) -> crate::error::Result<()> { + log_print!("โ–ถ๏ธ {} Executing proposal...", "MULTISIG".bright_magenta().bold()); + + // Resolve multisig address + let multisig_ss58 = crate::cli::common::resolve_address(&multisig_address)?; + let (multisig_id, _) = + SpAccountId32::from_ss58check_with_version(&multisig_ss58).map_err(|e| { + crate::error::QuantusError::Generic(format!("Invalid multisig address: {:?}", e)) + })?; + let multisig_bytes: [u8; 32] = *multisig_id.as_ref(); + let multisig_account_id = subxt::ext::subxt_core::utils::AccountId32::from(multisig_bytes); + + log_verbose!("Multisig: {}", multisig_ss58); + log_verbose!("Proposal ID: {}", proposal_id); + + // Load keypair + let keypair = crate::wallet::load_keypair_from_wallet(&from, password, password_file)?; + + // Connect to chain + let quantus_client = crate::chain::client::QuantusClient::new(node_url).await?; + + // Validate executor is a signer (client-side check) + let latest_block_hash = quantus_client.get_latest_block().await?; + let storage_at = quantus_client.client().storage().at(latest_block_hash); + + let multisig_query = + quantus_subxt::api::storage().multisig().multisigs(multisig_account_id.clone()); + let multisig_data = storage_at.fetch(&multisig_query).await?.ok_or_else(|| { + crate::error::QuantusError::Generic(format!( + "Multisig not found at address: {}", + multisig_ss58 + )) + })?; + + let executor_ss58 = crate::cli::common::resolve_address(&from)?; + let (executor_id, _) = + SpAccountId32::from_ss58check_with_version(&executor_ss58).map_err(|e| { + crate::error::QuantusError::Generic(format!("Invalid executor address: {:?}", e)) + })?; + let executor_bytes: [u8; 32] = *executor_id.as_ref(); + let executor_account_id = subxt::ext::subxt_core::utils::AccountId32::from(executor_bytes); + + if !multisig_data.signers.0.contains(&executor_account_id) { + log_error!("โŒ Not authorized: {} is not a signer of this multisig", executor_ss58); + return Err(crate::error::QuantusError::Generic( + "Only multisig signers can execute proposals".to_string(), + )); + } + + // Build transaction + let execute_tx = quantus_subxt::api::tx().multisig().execute(multisig_account_id, proposal_id); + + let exec_execution_mode = ExecutionMode { wait_for_transaction: true, ..execution_mode }; + + crate::cli::common::submit_transaction( + &quantus_client, + &keypair, + execute_tx, + None, + exec_execution_mode, + ) + .await?; + + log_success!("โœ… Proposal executed on-chain"); + + Ok(()) +} + +/// Cancel a proposal +async fn handle_cancel( + multisig_address: String, + proposal_id: u32, + from: String, + password: Option, + password_file: Option, + node_url: &str, + execution_mode: ExecutionMode, +) -> crate::error::Result<()> { + log_print!("๐Ÿšซ {} Cancelling proposal...", "MULTISIG".bright_magenta().bold()); + + // Resolve multisig address + let multisig_ss58 = crate::cli::common::resolve_address(&multisig_address)?; + let (multisig_id, _) = + SpAccountId32::from_ss58check_with_version(&multisig_ss58).map_err(|e| { + crate::error::QuantusError::Generic(format!("Invalid multisig address: {:?}", e)) + })?; + let multisig_bytes: [u8; 32] = *multisig_id.as_ref(); + let multisig_address = subxt::ext::subxt_core::utils::AccountId32::from(multisig_bytes); + + log_verbose!("Proposal ID: {}", proposal_id); + + // Load keypair + let keypair = crate::wallet::load_keypair_from_wallet(&from, password, password_file)?; + + // Connect to chain + let quantus_client = crate::chain::client::QuantusClient::new(node_url).await?; + + // Validate caller is the proposer (client-side check before submitting) + let latest_block_hash = quantus_client.get_latest_block().await?; + let storage_at = quantus_client.client().storage().at(latest_block_hash); + + let proposal_query = quantus_subxt::api::storage() + .multisig() + .proposals(multisig_address.clone(), proposal_id); + let proposal_data = storage_at.fetch(&proposal_query).await?.ok_or_else(|| { + crate::error::QuantusError::Generic(format!("Proposal {} not found", proposal_id)) + })?; + + // Resolve canceller address + let canceller_ss58 = crate::cli::common::resolve_address(&from)?; + let (canceller_id, _) = + SpAccountId32::from_ss58check_with_version(&canceller_ss58).map_err(|e| { + crate::error::QuantusError::Generic(format!("Invalid canceller address: {:?}", e)) + })?; + let canceller_bytes: [u8; 32] = *canceller_id.as_ref(); + let canceller_account_id = subxt::ext::subxt_core::utils::AccountId32::from(canceller_bytes); + + // Check if caller is the proposer + if proposal_data.proposer != canceller_account_id { + log_error!("โŒ Not authorized: only the proposer can cancel this proposal"); + let proposer_bytes: &[u8; 32] = proposal_data.proposer.as_ref(); + let proposer_sp = SpAccountId32::from(*proposer_bytes); + let proposer_ss58 = + proposer_sp.to_ss58check_with_version(sp_core::crypto::Ss58AddressFormat::custom(189)); + log_print!(" Proposer: {}", proposer_ss58); + return Err(crate::error::QuantusError::Generic( + "Only the proposer can cancel their proposal".to_string(), + )); + } + + // Build transaction + let cancel_tx = quantus_subxt::api::tx().multisig().cancel(multisig_address, proposal_id); + + // Always wait for transaction confirmation + let cancel_execution_mode = ExecutionMode { wait_for_transaction: true, ..execution_mode }; + + // Submit transaction and wait for on-chain confirmation + crate::cli::common::submit_transaction( + &quantus_client, + &keypair, + cancel_tx, + None, + cancel_execution_mode, + ) + .await?; + + log_success!("โœ… Proposal cancelled and removed (confirmed on-chain)"); + log_print!(" Deposit returned to proposer"); + + Ok(()) +} + +/// Remove an expired proposal +async fn handle_remove_expired( + multisig_address: String, + proposal_id: u32, + from: String, + password: Option, + password_file: Option, + node_url: &str, + execution_mode: ExecutionMode, +) -> crate::error::Result<()> { + log_print!("๐Ÿงน {} Removing expired proposal...", "MULTISIG".bright_magenta().bold()); + + // Resolve multisig address + let multisig_ss58 = crate::cli::common::resolve_address(&multisig_address)?; + let (multisig_id, _) = + SpAccountId32::from_ss58check_with_version(&multisig_ss58).map_err(|e| { + crate::error::QuantusError::Generic(format!("Invalid multisig address: {:?}", e)) + })?; + let multisig_bytes: [u8; 32] = *multisig_id.as_ref(); + let multisig_address = subxt::ext::subxt_core::utils::AccountId32::from(multisig_bytes); + + log_verbose!("Proposal ID: {}", proposal_id); + + // Load keypair + let keypair = crate::wallet::load_keypair_from_wallet(&from, password, password_file)?; + + // Connect to chain + let quantus_client = crate::chain::client::QuantusClient::new(node_url).await?; + + // Build transaction + let remove_tx = quantus_subxt::api::tx() + .multisig() + .remove_expired(multisig_address, proposal_id); + + // Always wait for transaction confirmation + let remove_execution_mode = ExecutionMode { wait_for_transaction: true, ..execution_mode }; + + // Submit transaction and wait for on-chain confirmation + crate::cli::common::submit_transaction( + &quantus_client, + &keypair, + remove_tx, + None, + remove_execution_mode, + ) + .await?; + + log_success!("โœ… Expired proposal removed and deposit returned (confirmed on-chain)"); + + Ok(()) +} + +/// Claim all deposits (batch cleanup) +async fn handle_claim_deposits( + multisig_address: String, + from: String, + password: Option, + password_file: Option, + node_url: &str, + execution_mode: ExecutionMode, +) -> crate::error::Result<()> { + log_print!("๐Ÿ’ฐ {} Claiming deposits...", "MULTISIG".bright_magenta().bold()); + + // Resolve multisig address + let multisig_ss58 = crate::cli::common::resolve_address(&multisig_address)?; + let (multisig_id, _) = + SpAccountId32::from_ss58check_with_version(&multisig_ss58).map_err(|e| { + crate::error::QuantusError::Generic(format!("Invalid multisig address: {:?}", e)) + })?; + let multisig_bytes: [u8; 32] = *multisig_id.as_ref(); + let multisig_address = subxt::ext::subxt_core::utils::AccountId32::from(multisig_bytes); + + // Load keypair + let keypair = crate::wallet::load_keypair_from_wallet(&from, password, password_file)?; + + // Connect to chain + let quantus_client = crate::chain::client::QuantusClient::new(node_url).await?; + + // Build transaction + let claim_tx = quantus_subxt::api::tx().multisig().claim_deposits(multisig_address); + + // Always wait for transaction confirmation + let claim_execution_mode = ExecutionMode { wait_for_transaction: true, ..execution_mode }; + + // Submit transaction and wait for on-chain confirmation + crate::cli::common::submit_transaction( + &quantus_client, + &keypair, + claim_tx, + None, + claim_execution_mode, + ) + .await?; + + log_success!("โœ… Deposits claimed (confirmed on-chain)"); + log_print!(" All removable proposals have been cleaned up"); + + Ok(()) +} + +/// Approve dissolving a multisig +async fn handle_dissolve( + multisig_address: String, + from: String, + password: Option, + password_file: Option, + node_url: &str, + execution_mode: ExecutionMode, +) -> crate::error::Result<()> { + log_print!("๐Ÿ—‘๏ธ {} Approving multisig dissolution...", "MULTISIG".bright_magenta().bold()); + + // Resolve multisig address + let multisig_ss58 = crate::cli::common::resolve_address(&multisig_address)?; + let (multisig_id, _) = + SpAccountId32::from_ss58check_with_version(&multisig_ss58).map_err(|e| { + crate::error::QuantusError::Generic(format!("Invalid multisig address: {:?}", e)) + })?; + let multisig_bytes: [u8; 32] = *multisig_id.as_ref(); + let multisig_address_id = subxt::ext::subxt_core::utils::AccountId32::from(multisig_bytes); + + // Load keypair + let keypair = crate::wallet::load_keypair_from_wallet(&from, password, password_file)?; + + // Connect to chain + let quantus_client = crate::chain::client::QuantusClient::new(node_url).await?; + + // Get threshold for info message + let latest_block_hash = quantus_client.get_latest_block().await?; + let storage_at = quantus_client.client().storage().at(latest_block_hash); + let multisig_query = + quantus_subxt::api::storage().multisig().multisigs(multisig_address_id.clone()); + let multisig_info = storage_at.fetch(&multisig_query).await?; + + // Build transaction + let approve_tx = quantus_subxt::api::tx() + .multisig() + .approve_dissolve(multisig_address_id.clone()); + + // Always wait for transaction confirmation - runtime validates all conditions + let dissolve_execution_mode = ExecutionMode { wait_for_transaction: true, ..execution_mode }; + + // Submit transaction and wait for on-chain confirmation + crate::cli::common::submit_transaction( + &quantus_client, + &keypair, + approve_tx, + None, + dissolve_execution_mode, + ) + .await?; + + log_success!("โœ… Dissolution approval confirmed on-chain"); + + if let Some(info) = multisig_info { + // Convert creator to SS58 + let creator_bytes: &[u8; 32] = info.creator.as_ref(); + let creator_sp = SpAccountId32::from(*creator_bytes); + let creator_ss58 = creator_sp.to_ss58check(); + + log_print!(" Requires {} total approvals to dissolve", info.threshold); + log_print!(""); + log_print!("๐Ÿ’ก {} When threshold is reached:", "INFO".bright_blue().bold()); + log_print!(" - Multisig will be dissolved automatically"); + log_print!(" - Deposit ({}) will be RETURNED to creator", format_balance(info.deposit)); + log_print!(" - Creator: {}", creator_ss58.bright_cyan()); + log_print!(" - Storage will be removed"); + } + + Ok(()) +} + +/// Query multisig information (or specific proposal if proposal_id provided) +async fn handle_info( + multisig_address: String, + proposal_id: Option, + node_url: &str, +) -> crate::error::Result<()> { + // If proposal_id is provided, delegate to handle_proposal_info + if let Some(id) = proposal_id { + return handle_proposal_info(multisig_address, id, node_url).await; + } + + log_print!("๐Ÿ” {} Querying multisig info...", "MULTISIG".bright_magenta().bold()); + log_print!(""); + + // Resolve multisig address + let multisig_ss58 = crate::cli::common::resolve_address(&multisig_address)?; + let (multisig_id, _) = + SpAccountId32::from_ss58check_with_version(&multisig_ss58).map_err(|e| { + crate::error::QuantusError::Generic(format!("Invalid multisig address: {:?}", e)) + })?; + let multisig_bytes: [u8; 32] = *multisig_id.as_ref(); + let multisig_address = subxt::ext::subxt_core::utils::AccountId32::from(multisig_bytes); + + // Connect to chain + let quantus_client = crate::chain::client::QuantusClient::new(node_url).await?; + + // Query storage using direct fetch with explicit block hash + crate::log_verbose!("๐Ÿ” Querying multisig with address: {}", multisig_ss58); + crate::log_verbose!("๐Ÿ” Address bytes: {}", hex::encode(multisig_bytes)); + + // Get latest block hash explicitly + let latest_block_hash = quantus_client.get_latest_block().await?; + crate::log_verbose!("๐Ÿ“ฆ Latest block hash: {:?}", latest_block_hash); + + let storage_query = + quantus_subxt::api::storage().multisig().multisigs(multisig_address.clone()); + + let storage_at = quantus_client.client().storage().at(latest_block_hash); + + let multisig_data = storage_at.fetch(&storage_query).await?; + + crate::log_verbose!( + "๐Ÿ” Fetch result: {}", + if multisig_data.is_some() { "Found" } else { "Not found" } + ); + + match multisig_data { + Some(data) => { + // Query balance for multisig address + let balance_query = + quantus_subxt::api::storage().system().account(multisig_address.clone()); + let account_info = storage_at.fetch(&balance_query).await?; + let (balance, reserved, frozen) = account_info + .map(|info| (info.data.free, info.data.reserved, info.data.frozen)) + .unwrap_or((0, 0, 0)); + + // Convert creator to SS58 + let creator_bytes: &[u8; 32] = data.creator.as_ref(); + let creator_sp = SpAccountId32::from(*creator_bytes); + let creator_ss58 = creator_sp.to_ss58check(); + + log_print!("๐Ÿ“‹ {} Information:", "MULTISIG".bright_green().bold()); + log_print!(" Address: {}", multisig_ss58.bright_cyan()); + log_print!(" Creator: {} (receives deposit back)", creator_ss58.bright_cyan()); + if reserved == 0 && frozen == 0 { + log_print!(" Balance: {}", format_balance(balance).bright_green().bold()); + } else { + log_print!(" Balance: {} (free)", format_balance(balance).bright_green().bold()); + if reserved > 0 { + log_print!( + " {} (reserved)", + format_balance(reserved).bright_yellow() + ); + } + if frozen > 0 { + log_print!(" {} (frozen)", format_balance(frozen).bright_yellow()); + } + } + log_print!(" Threshold: {}", data.threshold.to_string().bright_yellow()); + log_print!(" Signers ({}):", data.signers.0.len().to_string().bright_yellow()); + for (i, signer) in data.signers.0.iter().enumerate() { + // Convert subxt AccountId32 to SS58 + let signer_bytes: &[u8; 32] = signer.as_ref(); + let signer_sp = SpAccountId32::from(*signer_bytes); + log_print!(" {}. {}", i + 1, signer_sp.to_ss58check().bright_cyan()); + } + log_print!(" Proposal Nonce: {}", data.proposal_nonce); + log_print!( + " Deposit: {} (returned to creator on dissolve)", + format_balance(data.deposit) + ); + log_print!( + " Active Proposals: {}", + data.active_proposals.to_string().bright_yellow() + ); + + // Show active proposals summary if any exist + if data.active_proposals > 0 { + log_print!(""); + log_print!("๐Ÿ“ {} Active Proposals:", "PROPOSALS".bright_magenta().bold()); + let proposals_query = quantus_subxt::api::storage() + .multisig() + .proposals_iter1(multisig_address.clone()); + let mut proposals_stream = storage_at.iter(proposals_query).await?; + while let Some(Ok(kv)) = proposals_stream.next().await { + let proposal = kv.value; + // Extract proposal ID from key_bytes (last 4 bytes = u32 LE) + let proposal_id = if kv.key_bytes.len() >= 4 { + let id_bytes = &kv.key_bytes[kv.key_bytes.len() - 4..]; + u32::from_le_bytes([id_bytes[0], id_bytes[1], id_bytes[2], id_bytes[3]]) + } else { + 0 + }; + let status = match proposal.status { + quantus_subxt::api::runtime_types::pallet_multisig::ProposalStatus::Active => + "Active".bright_green(), + quantus_subxt::api::runtime_types::pallet_multisig::ProposalStatus::Approved => + "Approved (ready to execute)".bright_yellow(), + quantus_subxt::api::runtime_types::pallet_multisig::ProposalStatus::Executed => + "Executed".bright_blue(), + quantus_subxt::api::runtime_types::pallet_multisig::ProposalStatus::Cancelled => + "Cancelled".bright_red(), + }; + // Decode call name from the call field + let call_name = if proposal.call.0.len() >= 2 { + let pallet_idx = proposal.call.0[0]; + let call_idx = proposal.call.0[1]; + let metadata = quantus_client.client().metadata(); + if let Some(pallet) = metadata.pallet_by_index(pallet_idx) { + if let Some(variant) = pallet.call_variant_by_index(call_idx) { + format!("{}::{}", pallet.name(), variant.name) + } else { + format!("{}::call[{}]", pallet.name(), call_idx) + } + } else { + format!("pallet[{}]::call[{}]", pallet_idx, call_idx) + } + } else { + format!("({}B encoded)", proposal.call.0.len()) + }; + let proposer_bytes: &[u8; 32] = proposal.proposer.as_ref(); + let proposer_sp = SpAccountId32::from(*proposer_bytes); + log_print!( + " #{}: {} | {} | Approvals: {} | Proposer: {}", + proposal_id, + call_name.bright_white(), + status, + proposal.approvals.0.len(), + proposer_sp.to_ss58check().dimmed() + ); + } + } + + // Check for dissolution progress + let dissolve_query = quantus_subxt::api::storage() + .multisig() + .dissolve_approvals(multisig_address.clone()); + if let Some(dissolve_approvals) = storage_at.fetch(&dissolve_query).await? { + log_print!(""); + log_print!("๐Ÿ—‘๏ธ {} Dissolution in progress:", "DISSOLVE".bright_red().bold()); + log_print!( + " Progress: {}/{}", + dissolve_approvals.0.len().to_string().bright_yellow(), + data.threshold.to_string().bright_yellow() + ); + log_print!(" Approvals:"); + for (i, approver) in dissolve_approvals.0.iter().enumerate() { + let approver_bytes: &[u8; 32] = approver.as_ref(); + let approver_sp = SpAccountId32::from(*approver_bytes); + log_print!(" {}. {}", i + 1, approver_sp.to_ss58check().bright_cyan()); + } + + // Show pending approvals + let pending_signers: Vec<_> = + data.signers.0.iter().filter(|s| !dissolve_approvals.0.contains(s)).collect(); + + if !pending_signers.is_empty() { + log_print!(" Pending:"); + for (i, signer) in pending_signers.iter().enumerate() { + let signer_bytes: &[u8; 32] = signer.as_ref(); + let signer_sp = SpAccountId32::from(*signer_bytes); + log_print!(" {}. {}", i + 1, signer_sp.to_ss58check().dimmed()); + } + } + + log_print!(""); + log_print!(" โš ๏ธ {} When threshold is reached:", "WARNING".bright_red().bold()); + log_print!(" - Multisig will be dissolved IMMEDIATELY"); + log_print!( + " - Deposit will be RETURNED to creator: {}", + creator_ss58.bright_cyan() + ); + } else { + log_print!(""); + log_print!( + " ๐Ÿ’ก {} Deposit ({}) will be returned to creator on dissolve", + "INFO".bright_blue().bold(), + format_balance(data.deposit) + ); + } + }, + None => { + log_error!("โŒ Multisig not found at address: {}", multisig_ss58); + }, + } + + log_print!(""); + Ok(()) +} + +/// Decode call data into human-readable format +async fn decode_call_data( + quantus_client: &crate::chain::client::QuantusClient, + call_data: &[u8], +) -> crate::error::Result { + use codec::Decode; + + if call_data.len() < 2 { + return Ok(format!(" {} {} bytes (too short)", "Call Size:".dimmed(), call_data.len())); + } + + let pallet_index = call_data[0]; + let call_index = call_data[1]; + let args = &call_data[2..]; + + // Get metadata to find pallet and call names + let metadata = quantus_client.client().metadata(); + + // Try to find pallet by index + let pallet_name = metadata + .pallets() + .find(|p| p.index() == pallet_index) + .map(|p| p.name()) + .unwrap_or("Unknown"); + + // Try to decode based on known patterns + match (pallet_index, call_index) { + // Balances pallet transfers + // transfer_allow_death (0) or transfer_keep_alive (3) + (_, idx) if pallet_name == "Balances" && (idx == 0 || idx == 3) => { + let call_name = match idx { + 0 => "transfer_allow_death", + 3 => "transfer_keep_alive", + _ => unreachable!(), + }; + + if args.len() < 33 { + return Ok(format!( + " {} {}::{} (index {})\n {} {} bytes (too short)", + "Call:".dimmed(), + pallet_name.bright_cyan(), + call_name.bright_yellow(), + idx, + "Args:".dimmed(), + args.len() + )); + } + + // Decode MultiAddress::Id (first byte is variant, 0x00 = Id) + // Then 32 bytes for AccountId32 + let address_variant = args[0]; + if address_variant != 0 { + return Ok(format!( + " {} {}::{} (index {})\n {} {} bytes\n {} Unknown address variant: {}", + "Call:".dimmed(), + pallet_name.bright_cyan(), + call_name.bright_yellow(), + idx, + "Args:".dimmed(), + args.len(), + "Error:".dimmed(), + address_variant + )); + } + + let account_bytes: [u8; 32] = args[1..33].try_into().map_err(|_| { + crate::error::QuantusError::Generic("Failed to extract account bytes".to_string()) + })?; + let account_id = SpAccountId32::from(account_bytes); + let to_address = account_id.to_ss58check(); + + // Decode amount (Compact) + let mut cursor = &args[33..]; + let amount: u128 = match codec::Compact::::decode(&mut cursor) { + Ok(compact) => compact.0, + Err(_) => { + return Ok(format!( + " {} {}::{} (index {})\n {} {}\n {} Failed to decode amount", + "Call:".dimmed(), + pallet_name.bright_cyan(), + call_name.bright_yellow(), + idx, + "To:".dimmed(), + to_address.bright_cyan(), + "Error:".dimmed() + )); + }, + }; + + Ok(format!( + " {} {}::{}\n {} {}\n {} {}", + "Call:".dimmed(), + pallet_name.bright_cyan(), + call_name.bright_yellow(), + "To:".dimmed(), + to_address.bright_cyan(), + "Amount:".dimmed(), + format_balance(amount).bright_green() + )) + }, + // ReversibleTransfers::set_high_security + (_, idx) if pallet_name == "ReversibleTransfers" && idx == 0 => { + // set_high_security has: delay (enum), interceptor (AccountId32) + if args.is_empty() { + return Ok(format!( + " {} {}::set_high_security\n {} {} bytes (too short)", + "Call:".dimmed(), + pallet_name.bright_cyan(), + "Args:".dimmed(), + args.len() + )); + } + + // Decode delay (BlockNumberOrTimestamp enum) + let delay_variant = args[0]; + let delay_str: String; + let offset: usize; + + match delay_variant { + 0 => { + // BlockNumber(u32) + if args.len() < 5 { + return Ok(format!( + " {} {}::set_high_security\n {} Failed to decode delay (BlockNumber)", + "Call:".dimmed(), + pallet_name.bright_cyan(), + "Error:".dimmed() + )); + } + let blocks = u32::from_le_bytes([args[1], args[2], args[3], args[4]]); + delay_str = format!("{} blocks", blocks); + offset = 5; + }, + 1 => { + // Timestamp(u64) + if args.len() < 9 { + return Ok(format!( + " {} {}::set_high_security\n {} Failed to decode delay (Timestamp)", + "Call:".dimmed(), + pallet_name.bright_cyan(), + "Error:".dimmed() + )); + } + let millis = u64::from_le_bytes([ + args[1], args[2], args[3], args[4], args[5], args[6], args[7], args[8], + ]); + let seconds = millis / 1000; + delay_str = format!("{} seconds ({} ms)", seconds, millis); + offset = 9; + }, + _ => { + return Ok(format!( + " {} {}::set_high_security\n {} Unknown delay variant: {}", + "Call:".dimmed(), + pallet_name.bright_cyan(), + "Error:".dimmed(), + delay_variant + )); + }, + } + + // Decode interceptor (AccountId32) + if args.len() < offset + 32 { + return Ok(format!( + " {} {}::set_high_security\n {} {}\n {} Failed to decode interceptor", + "Call:".dimmed(), + pallet_name.bright_cyan(), + "Delay:".dimmed(), + delay_str.bright_yellow(), + "Error:".dimmed() + )); + } + + let interceptor_bytes: [u8; 32] = + args[offset..offset + 32].try_into().map_err(|_| { + crate::error::QuantusError::Generic( + "Failed to extract interceptor bytes".to_string(), + ) + })?; + let interceptor = SpAccountId32::from(interceptor_bytes); + let interceptor_ss58 = interceptor + .to_ss58check_with_version(sp_core::crypto::Ss58AddressFormat::custom(189)); + + Ok(format!( + " {} {}::set_high_security\n {} {}\n {} {}", + "Call:".dimmed(), + pallet_name.bright_cyan(), + "Delay:".dimmed(), + delay_str.bright_yellow(), + "Guardian:".dimmed(), + interceptor_ss58.bright_green() + )) + }, + _ => { + // Try to get call name from metadata + let call_name = metadata + .pallets() + .find(|p| p.index() == pallet_index) + .and_then(|p| { + p.call_variants().and_then(|calls| { + calls.iter().find(|v| v.index == call_index).map(|v| v.name.as_str()) + }) + }) + .unwrap_or("unknown"); + + Ok(format!( + " {} {}::{} (index {}:{})\n {} {} bytes\n {} {}", + "Call:".dimmed(), + pallet_name.bright_cyan(), + call_name.bright_yellow(), + pallet_index, + call_index, + "Args:".dimmed(), + args.len(), + "Raw:".dimmed(), + hex::encode(args).bright_green() + )) + }, + } +} + +/// Query proposal information +async fn handle_proposal_info( + multisig_address: String, + proposal_id: u32, + node_url: &str, +) -> crate::error::Result<()> { + log_print!("๐Ÿ” {} Querying proposal info...", "MULTISIG".bright_magenta().bold()); + log_print!(""); + + // Resolve multisig address + let multisig_ss58 = crate::cli::common::resolve_address(&multisig_address)?; + let (multisig_id, _) = + SpAccountId32::from_ss58check_with_version(&multisig_ss58).map_err(|e| { + crate::error::QuantusError::Generic(format!("Invalid multisig address: {:?}", e)) + })?; + let multisig_bytes: [u8; 32] = *multisig_id.as_ref(); + let multisig_address = subxt::ext::subxt_core::utils::AccountId32::from(multisig_bytes); + + // Connect to chain + let quantus_client = crate::chain::client::QuantusClient::new(node_url).await?; + + // Get latest block hash explicitly + let latest_block_hash = quantus_client.get_latest_block().await?; + + // Get current block number + let latest_block = quantus_client.client().blocks().at(latest_block_hash).await?; + let current_block_number = latest_block.number(); + + // Query storage by proposal ID + let storage_query = quantus_subxt::api::storage() + .multisig() + .proposals(multisig_address.clone(), proposal_id); + + let storage_at = quantus_client.client().storage().at(latest_block_hash); + let proposal_data = storage_at.fetch(&storage_query).await?; + + match proposal_data { + Some(data) => { + // Get multisig info for context + let multisig_query = + quantus_subxt::api::storage().multisig().multisigs(multisig_address.clone()); + let multisig_info = storage_at.fetch(&multisig_query).await?; + + log_print!("๐Ÿ“ {} Information:", "PROPOSAL".bright_green().bold()); + log_print!( + " Current Block: {}", + current_block_number.to_string().bright_white().bold() + ); + log_print!(" Multisig: {}", multisig_ss58.bright_cyan()); + + // Show threshold and approval progress + if let Some(ref ms_data) = multisig_info { + let progress = format!("{}/{}", data.approvals.0.len(), ms_data.threshold); + log_print!( + " Threshold: {} (progress: {})", + ms_data.threshold.to_string().bright_yellow(), + progress.bright_cyan() + ); + } + + log_print!(" Proposal ID: {}", proposal_id.to_string().bright_yellow()); + // Convert proposer to SS58 + let proposer_bytes: &[u8; 32] = data.proposer.as_ref(); + let proposer_sp = SpAccountId32::from(*proposer_bytes); + log_print!(" Proposer: {}", proposer_sp.to_ss58check().bright_cyan()); + + // Decode and display call data + log_print!(""); + match decode_call_data(&quantus_client, &data.call.0).await { + Ok(decoded) => { + log_print!("{}", decoded); + }, + Err(e) => { + log_print!(" Call Size: {} bytes", data.call.0.len()); + log_verbose!("Failed to decode call data: {:?}", e); + }, + } + log_print!(""); + + // Calculate blocks remaining until expiry + if data.expiry > current_block_number { + let blocks_remaining = data.expiry - current_block_number; + log_print!( + " Expiry: block {} ({} blocks remaining)", + data.expiry, + blocks_remaining.to_string().bright_green() + ); + } else { + log_print!(" Expiry: block {} ({})", data.expiry, "EXPIRED".bright_red().bold()); + } + log_print!(" Deposit: {} (locked)", format_balance(data.deposit)); + log_print!( + " Status: {}", + match data.status { + quantus_subxt::api::runtime_types::pallet_multisig::ProposalStatus::Active => + "Active".bright_green(), + quantus_subxt::api::runtime_types::pallet_multisig::ProposalStatus::Approved => + "Approved (ready to execute)".bright_yellow(), + quantus_subxt::api::runtime_types::pallet_multisig::ProposalStatus::Executed => + "Executed".bright_blue(), + quantus_subxt::api::runtime_types::pallet_multisig::ProposalStatus::Cancelled => + "Cancelled".bright_red(), + } + ); + log_print!(" Approvals ({}):", data.approvals.0.len().to_string().bright_yellow()); + for (i, approver) in data.approvals.0.iter().enumerate() { + // Convert approver to SS58 + let approver_bytes: &[u8; 32] = approver.as_ref(); + let approver_sp = SpAccountId32::from(*approver_bytes); + log_print!(" {}. {}", i + 1, approver_sp.to_ss58check().bright_cyan()); + } + + // Show which signers haven't approved yet + if let Some(ms_data) = multisig_info { + let pending_signers: Vec = ms_data + .signers + .0 + .iter() + .filter(|s| !data.approvals.0.contains(s)) + .map(|s| { + let bytes: &[u8; 32] = s.as_ref(); + let sp = SpAccountId32::from(*bytes); + sp.to_ss58check_with_version(sp_core::crypto::Ss58AddressFormat::custom( + 189, + )) + }) + .collect(); + + if !pending_signers.is_empty() { + log_print!(""); + log_print!( + " Pending Approvals ({}):", + pending_signers.len().to_string().bright_red() + ); + for (i, signer) in pending_signers.iter().enumerate() { + log_print!(" {}. {}", i + 1, signer.bright_red()); + } + } + } + }, + None => { + log_error!("โŒ Proposal not found"); + }, + } + + log_print!(""); + Ok(()) +} + +/// List all proposals for a multisig +async fn handle_list_proposals( + multisig_address: String, + node_url: &str, +) -> crate::error::Result<()> { + log_print!("๐Ÿ“‹ {} Listing proposals...", "MULTISIG".bright_magenta().bold()); + log_print!(""); + + // Resolve multisig address + let multisig_ss58 = crate::cli::common::resolve_address(&multisig_address)?; + let (multisig_id, _) = + SpAccountId32::from_ss58check_with_version(&multisig_ss58).map_err(|e| { + crate::error::QuantusError::Generic(format!("Invalid multisig address: {:?}", e)) + })?; + let multisig_bytes: [u8; 32] = *multisig_id.as_ref(); + let multisig_address = subxt::ext::subxt_core::utils::AccountId32::from(multisig_bytes); + + // Connect to chain + let quantus_client = crate::chain::client::QuantusClient::new(node_url).await?; + + // Get latest block hash explicitly + let latest_block_hash = quantus_client.get_latest_block().await?; + + // Query all proposals for this multisig using prefix iteration + let storage = quantus_client.client().storage().at(latest_block_hash); + + // Use iter_key_values to iterate over the double map + let address = quantus_subxt::api::storage().multisig().proposals_iter1(multisig_address); + let mut proposals = storage.iter(address).await?; + + let mut count = 0; + let mut active_count = 0; + let mut approved_count = 0; + let mut executed_count = 0; + let mut cancelled_count = 0; + + while let Some(result) = proposals.next().await { + match result { + Ok(kv) => { + count += 1; + + let status_str = match kv.value.status { + quantus_subxt::api::runtime_types::pallet_multisig::ProposalStatus::Active => { + active_count += 1; + "Active".bright_green() + }, + quantus_subxt::api::runtime_types::pallet_multisig::ProposalStatus::Approved => { + approved_count += 1; + "Approved (ready to execute)".bright_yellow() + }, + quantus_subxt::api::runtime_types::pallet_multisig::ProposalStatus::Executed => { + executed_count += 1; + "Executed".bright_blue() + }, + quantus_subxt::api::runtime_types::pallet_multisig::ProposalStatus::Cancelled => { + cancelled_count += 1; + "Cancelled".bright_red() + }, + }; + + // Extract proposal ID from key_bytes (u32, 4 bytes with Twox64Concat hasher) + // The key_bytes contains: + // [storage_prefix][Blake2_128Concat(multisig)][Twox64Concat(u32)] Twox64Concat + // encoding: [8-byte hash][4-byte value] We need the last 4 bytes as + // little-endian u32 + let key_bytes = kv.key_bytes; + if key_bytes.len() >= 4 { + let id_bytes = &key_bytes[key_bytes.len() - 4..]; + let proposal_id = + u32::from_le_bytes([id_bytes[0], id_bytes[1], id_bytes[2], id_bytes[3]]); + + log_print!("๐Ÿ“ Proposal #{}", count); + log_print!(" ID: {}", proposal_id.to_string().bright_yellow()); + + // Convert proposer to SS58 + let proposer_bytes: &[u8; 32] = kv.value.proposer.as_ref(); + let proposer_sp = SpAccountId32::from(*proposer_bytes); + log_print!(" Proposer: {}", proposer_sp.to_ss58check().bright_cyan()); + + // Decode and display call data (compact format for list) + match decode_call_data(&quantus_client, &kv.value.call.0).await { + Ok(decoded) => { + // Extract just the call info line for compact display + let lines: Vec<&str> = decoded.lines().collect(); + if !lines.is_empty() { + log_print!(" {}", lines[0].trim_start()); + } + }, + Err(_) => { + log_print!(" Call Size: {} bytes", kv.value.call.0.len()); + }, + } + + log_print!(" Status: {}", status_str); + log_print!(" Approvals: {}", kv.value.approvals.0.len()); + log_print!(" Expiry: block {}", kv.value.expiry); + log_print!(""); + } + }, + Err(e) => { + log_error!("Error reading proposal: {:?}", e); + }, + } + } + + if count == 0 { + log_print!(" No proposals found for this multisig"); + } else { + log_print!("๐Ÿ“Š {} Summary:", "PROPOSALS".bright_green().bold()); + log_print!(" Total: {}", count.to_string().bright_yellow()); + log_print!(" Active: {}", active_count.to_string().bright_green()); + log_print!(" Approved: {}", approved_count.to_string().bright_yellow()); + log_print!(" Executed: {}", executed_count.to_string().bright_blue()); + log_print!(" Cancelled: {}", cancelled_count.to_string().bright_red()); + } + + log_print!(""); + Ok(()) +} + +/// Build runtime call data from pallet, call name, and arguments +async fn build_runtime_call( + quantus_client: &crate::chain::client::QuantusClient, + pallet: &str, + call: &str, + args: Vec, +) -> crate::error::Result> { + // Validate pallet/call exists in metadata + let metadata = quantus_client.client().metadata(); + let pallet_metadata = metadata.pallet_by_name(pallet).ok_or_else(|| { + crate::error::QuantusError::Generic(format!("Pallet '{}' not found in metadata", pallet)) + })?; + + log_verbose!("โœ… Found pallet '{}' with index {}", pallet, pallet_metadata.index()); + + // Find the call in the pallet + let call_metadata = pallet_metadata.call_variant_by_name(call).ok_or_else(|| { + crate::error::QuantusError::Generic(format!( + "Call '{}' not found in pallet '{}'", + call, pallet + )) + })?; + + log_verbose!("โœ… Found call '{}' with index {}", call, call_metadata.index); + + // For now, we'll construct a basic call using the generic approach + // This is a simplified implementation - in production, you'd want to handle all argument types + use codec::Encode; + + let mut call_data = Vec::new(); + // Pallet index + call_data.push(pallet_metadata.index()); + // Call index + call_data.push(call_metadata.index); + + // Encode arguments based on call type + // This is a simplified version - in production you'd need proper argument encoding + match (pallet, call) { + ("Balances", "transfer_allow_death") | ("Balances", "transfer_keep_alive") => { + if args.len() != 2 { + return Err(crate::error::QuantusError::Generic( + "Balances transfer requires 2 arguments: [to_address, amount]".to_string(), + )); + } + + let to_address = args[0].as_str().ok_or_else(|| { + crate::error::QuantusError::Generic( + "First argument must be a string (to_address)".to_string(), + ) + })?; + + // Parse amount - can be either string or number in JSON + let amount: u128 = if let Some(amount_str) = args[1].as_str() { + // If it's a string, parse it + amount_str.parse().map_err(|_| { + crate::error::QuantusError::Generic( + "Second argument must be a valid number (amount)".to_string(), + ) + })? + } else if let Some(amount_num) = args[1].as_u64() { + // If it's a number, use it directly + amount_num as u128 + } else { + // Try as_i64 for negative numbers (though we'll reject them) + return Err(crate::error::QuantusError::Generic( + "Second argument must be a number (amount)".to_string(), + )); + }; + + // Convert to AccountId32 + let (to_account_id, _) = SpAccountId32::from_ss58check_with_version(to_address) + .map_err(|e| { + crate::error::QuantusError::Generic(format!("Invalid to_address: {:?}", e)) + })?; + + // Convert to subxt AccountId32 + let to_account_id_bytes: [u8; 32] = *to_account_id.as_ref(); + let to_account_id_subxt = + subxt::ext::subxt_core::utils::AccountId32::from(to_account_id_bytes); + + // Encode as MultiAddress::Id + let multi_address: subxt::ext::subxt_core::utils::MultiAddress< + subxt::ext::subxt_core::utils::AccountId32, + (), + > = subxt::ext::subxt_core::utils::MultiAddress::Id(to_account_id_subxt); + + multi_address.encode_to(&mut call_data); + // Amount must be Compact encoded for Balance type + codec::Compact(amount).encode_to(&mut call_data); + }, + ("System", "remark") | ("System", "remark_with_event") => { + // System::remark takes a Vec argument + if args.len() != 1 { + return Err(crate::error::QuantusError::Generic( + "System remark requires 1 argument: [hex_data]".to_string(), + )); + } + + let hex_data = args[0].as_str().ok_or_else(|| { + crate::error::QuantusError::Generic( + "Argument must be a hex string (e.g., \"0x48656c6c6f\")".to_string(), + ) + })?; + + // Remove 0x prefix if present + let hex_str = hex_data.trim_start_matches("0x"); + + // Decode hex to bytes + let data_bytes = hex::decode(hex_str).map_err(|e| { + crate::error::QuantusError::Generic(format!("Invalid hex data: {}", e)) + })?; + + // Encode as Vec (with length prefix) + data_bytes.encode_to(&mut call_data); + }, + _ => { + return Err(crate::error::QuantusError::Generic(format!( + "Building call data for {}.{} is not yet implemented. Use a simpler approach or add support.", + pallet, call + ))); + }, + } + + Ok(call_data) +} + +/// Format balance for display +fn format_balance(balance: u128) -> String { + let quan = balance / QUAN_DECIMALS; + let remainder = balance % QUAN_DECIMALS; + + if remainder == 0 { + format!("{} QUAN", quan) + } else { + // Show up to 12 decimal places, removing trailing zeros + let decimal_str = format!("{:012}", remainder).trim_end_matches('0').to_string(); + format!("{}.{} QUAN", quan, decimal_str) + } +} + +// ============================================================================ +// HIGH SECURITY HANDLERS +// ============================================================================ + +/// Check high-security status for a multisig +async fn handle_high_security_status( + multisig_address: String, + node_url: &str, +) -> crate::error::Result<()> { + log_print!("๐Ÿ” {} Checking High-Security status...", "MULTISIG".bright_magenta().bold()); + log_print!(""); + + // Resolve multisig address + let multisig_ss58 = crate::cli::common::resolve_address(&multisig_address)?; + let (multisig_id, _) = + SpAccountId32::from_ss58check_with_version(&multisig_ss58).map_err(|e| { + crate::error::QuantusError::Generic(format!("Invalid multisig address: {:?}", e)) + })?; + let multisig_bytes: [u8; 32] = *multisig_id.as_ref(); + let multisig_account_id = subxt::ext::subxt_core::utils::AccountId32::from(multisig_bytes); + + // Connect to chain + let quantus_client = crate::chain::client::QuantusClient::new(node_url).await?; + + // Query high-security status + let latest_block_hash = quantus_client.get_latest_block().await?; + let storage_at = quantus_client.client().storage().at(latest_block_hash); + + let storage_query = quantus_subxt::api::storage() + .reversible_transfers() + .high_security_accounts(multisig_account_id); + + let high_security_data = storage_at.fetch(&storage_query).await?; + + log_print!("๐Ÿ“‹ Multisig: {}", multisig_ss58.bright_cyan()); + log_print!(""); + + match high_security_data { + Some(data) => { + log_success!("โœ… High-Security: {}", "ENABLED".bright_green().bold()); + log_print!(""); + + // Convert interceptor to SS58 + let interceptor_bytes: &[u8; 32] = data.interceptor.as_ref(); + let interceptor_sp = SpAccountId32::from(*interceptor_bytes); + let interceptor_ss58 = interceptor_sp + .to_ss58check_with_version(sp_core::crypto::Ss58AddressFormat::custom(189)); + + log_print!("๐Ÿ›ก๏ธ Guardian/Interceptor: {}", interceptor_ss58.bright_green().bold()); + + // Format delay display + match data.delay { + quantus_subxt::api::runtime_types::qp_scheduler::BlockNumberOrTimestamp::BlockNumber( + blocks, + ) => { + log_print!("โฑ๏ธ Delay: {} blocks", blocks.to_string().bright_yellow()); + }, + quantus_subxt::api::runtime_types::qp_scheduler::BlockNumberOrTimestamp::Timestamp( + ms, + ) => { + let seconds = ms / 1000; + log_print!("โฑ๏ธ Delay: {} seconds", seconds.to_string().bright_yellow()); + }, + } + + log_print!(""); + log_print!( + "๐Ÿ’ก {} All transfers from this multisig will be delayed and reversible", + "INFO".bright_blue().bold() + ); + log_print!(" The guardian can intercept transactions during the delay period"); + log_print!(""); + log_print!( + "โš ๏ธ {} Guardian interception requires direct runtime call (not yet in CLI)", + "NOTE".bright_yellow().bold() + ); + log_print!(" Use: pallet_reversible_transfers::cancel(tx_id) as guardian account"); + }, + None => { + log_print!("โŒ High-Security: {}", "DISABLED".bright_red().bold()); + log_print!(""); + log_print!("๐Ÿ’ก This multisig does not have high-security enabled."); + log_print!(" Use 'quantus multisig high-security set' to enable it via a proposal."); + }, + } + + log_print!(""); + Ok(()) +} + +/// Enable high-security for a multisig (via proposal) +async fn handle_high_security_set( + multisig_address: String, + interceptor: String, + delay_blocks: Option, + delay_seconds: Option, + expiry: u32, + from: String, + password: Option, + password_file: Option, + node_url: &str, + execution_mode: ExecutionMode, +) -> crate::error::Result<()> { + log_print!( + "๐Ÿ›ก๏ธ {} Enabling High-Security (via proposal)...", + "MULTISIG".bright_magenta().bold() + ); + + // Validate delay parameters + if delay_blocks.is_none() && delay_seconds.is_none() { + log_error!("โŒ You must specify either --delay-blocks or --delay-seconds"); + return Err(crate::error::QuantusError::Generic("Missing delay parameter".to_string())); + } + + // Resolve multisig address + let multisig_ss58 = crate::cli::common::resolve_address(&multisig_address)?; + let (multisig_id, _) = + SpAccountId32::from_ss58check_with_version(&multisig_ss58).map_err(|e| { + crate::error::QuantusError::Generic(format!("Invalid multisig address: {:?}", e)) + })?; + let multisig_bytes: [u8; 32] = *multisig_id.as_ref(); + let multisig_account_id = subxt::ext::subxt_core::utils::AccountId32::from(multisig_bytes); + + // Resolve interceptor address + let interceptor_ss58 = crate::cli::common::resolve_address(&interceptor)?; + let (interceptor_id, _) = SpAccountId32::from_ss58check_with_version(&interceptor_ss58) + .map_err(|e| { + crate::error::QuantusError::Generic(format!("Invalid interceptor address: {:?}", e)) + })?; + let interceptor_bytes: [u8; 32] = *interceptor_id.as_ref(); + let interceptor_account_id = + subxt::ext::subxt_core::utils::AccountId32::from(interceptor_bytes); + + log_verbose!("Multisig: {}", multisig_ss58); + log_verbose!("Interceptor: {}", interceptor_ss58); + + // Connect to chain + let quantus_client = crate::chain::client::QuantusClient::new(node_url).await?; + + // Build the set_high_security call + use quantus_subxt::api::reversible_transfers::calls::types::set_high_security::Delay as HsDelay; + + let delay_value = if let Some(blocks) = delay_blocks { + HsDelay::BlockNumber(blocks) + } else if let Some(seconds) = delay_seconds { + HsDelay::Timestamp(seconds * 1000) // Convert seconds to milliseconds + } else { + return Err(crate::error::QuantusError::Generic("Missing delay parameter".to_string())); + }; + + log_verbose!("Delay: {:?}", delay_value); + + // Build the runtime call + let set_hs_call = quantus_subxt::api::tx() + .reversible_transfers() + .set_high_security(delay_value, interceptor_account_id); + + // Encode the call + use subxt::tx::Payload; + let call_data = + set_hs_call.encode_call_data(&quantus_client.client().metadata()).map_err(|e| { + crate::error::QuantusError::Generic(format!("Failed to encode call: {:?}", e)) + })?; + + log_verbose!("Call data size: {} bytes", call_data.len()); + + // Validate expiry is in the future (client-side check) + 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(); + + if expiry <= current_block_number { + log_error!( + "โŒ Expiry block {} is in the past (current block: {})", + expiry, + current_block_number + ); + log_print!(" Use a higher block number, e.g., --expiry {}", current_block_number + 1000); + return Err(crate::error::QuantusError::Generic("Expiry must be in the future".to_string())); + } + + // Validate proposer is a signer + let storage_at = quantus_client.client().storage().at(latest_block_hash); + let multisig_query = + quantus_subxt::api::storage().multisig().multisigs(multisig_account_id.clone()); + let multisig_data = storage_at.fetch(&multisig_query).await?.ok_or_else(|| { + crate::error::QuantusError::Generic(format!( + "Multisig not found at address: {}", + multisig_ss58 + )) + })?; + + // Resolve proposer address + let proposer_ss58 = crate::cli::common::resolve_address(&from)?; + let (proposer_id, _) = + SpAccountId32::from_ss58check_with_version(&proposer_ss58).map_err(|e| { + crate::error::QuantusError::Generic(format!("Invalid proposer address: {:?}", e)) + })?; + let proposer_bytes: [u8; 32] = *proposer_id.as_ref(); + let proposer_account_id = subxt::ext::subxt_core::utils::AccountId32::from(proposer_bytes); + + // Check if proposer is in signers list + if !multisig_data.signers.0.contains(&proposer_account_id) { + log_error!("โŒ Not authorized: {} is not a signer of this multisig", proposer_ss58); + return Err(crate::error::QuantusError::Generic( + "Only multisig signers can create proposals".to_string(), + )); + } + + // Check for expired proposals from this proposer (will be auto-cleaned by runtime) + let mut expired_count = 0; + let proposals_query = quantus_subxt::api::storage().multisig().proposals_iter(); + let mut proposals_stream = storage_at.iter(proposals_query).await?; + + while let Some(Ok(kv)) = proposals_stream.next().await { + let proposal = kv.value; + // Check if this proposal belongs to our proposer + if proposal.proposer == proposer_account_id { + // Check if expired + if proposal.expiry <= current_block_number { + expired_count += 1; + } + } + } + + if expired_count > 0 { + log_print!(""); + log_print!( + "๐Ÿงน {} Auto-cleanup: Runtime will remove your {} expired proposal(s)", + "INFO".bright_blue().bold(), + expired_count.to_string().bright_yellow() + ); + log_print!(" This happens automatically before creating the new proposal"); + log_print!(""); + } + + // Load keypair + let keypair = crate::wallet::load_keypair_from_wallet(&from, password, password_file)?; + + // Build propose transaction + let propose_tx = + quantus_subxt::api::tx() + .multisig() + .propose(multisig_account_id, call_data, expiry); + + // Always wait for transaction confirmation + let propose_execution_mode = ExecutionMode { wait_for_transaction: true, ..execution_mode }; + + // Submit transaction and wait for on-chain confirmation + crate::cli::common::submit_transaction( + &quantus_client, + &keypair, + propose_tx, + None, + propose_execution_mode, + ) + .await?; + + log_print!(""); + log_success!("โœ… High-Security proposal confirmed on-chain!"); + log_print!(""); + log_print!( + "๐Ÿ’ก {} Once this proposal reaches threshold, High-Security will be enabled", + "NEXT STEPS".bright_blue().bold() + ); + log_print!( + " - Other signers need to approve: quantus multisig approve --address {} --proposal-id --from ", + multisig_ss58.bright_cyan() + ); + log_print!(" - After threshold is reached, all transfers will be delayed and reversible"); + log_print!(""); + + Ok(()) +} diff --git a/src/cli/send.rs b/src/cli/send.rs index 55317c0..1fad9c9 100644 --- a/src/cli/send.rs +++ b/src/cli/send.rs @@ -7,8 +7,18 @@ use crate::{ use colored::Colorize; use sp_core::crypto::{AccountId32 as SpAccountId32, Ss58Codec}; -/// Get the `free` balance for the given account using on-chain storage. -pub async fn get_balance(quantus_client: &QuantusClient, account_address: &str) -> Result { +/// Account balance data +pub struct AccountBalanceData { + pub free: u128, + pub reserved: u128, + pub frozen: u128, +} + +/// Get full account balance data (free, reserved, frozen) from on-chain storage. +pub async fn get_account_data( + quantus_client: &QuantusClient, + account_address: &str, +) -> Result { use quantus_subxt::api; log_verbose!("๐Ÿ’ฐ Querying balance for account: {}", account_address.bright_green()); @@ -37,7 +47,17 @@ pub async fn get_balance(quantus_client: &QuantusClient, account_address: &str) crate::error::QuantusError::NetworkError(format!("Failed to fetch account info: {e:?}")) })?; - Ok(account_info.data.free) + Ok(AccountBalanceData { + free: account_info.data.free, + reserved: account_info.data.reserved, + frozen: account_info.data.frozen, + }) +} + +/// Get the `free` balance for the given account using on-chain storage. +pub async fn get_balance(quantus_client: &QuantusClient, account_address: &str) -> Result { + let data = get_account_data(quantus_client, account_address).await?; + Ok(data.free) } /// Get chain properties for formatting (uses system.rs ChainHead API) diff --git a/src/lib.rs b/src/lib.rs index 01a73ed..a265e05 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -30,6 +30,14 @@ pub use cli::send::{ batch_transfer, format_balance_with_symbol, get_balance, transfer, transfer_with_nonce, }; +// Re-export multisig functions for library usage +pub use cli::multisig::{ + approve_dissolve_multisig, approve_proposal, cancel_proposal, create_multisig, + get_multisig_info, get_proposal_info, list_proposals, parse_amount as parse_multisig_amount, + predict_multisig_address, propose_custom, propose_transfer, MultisigInfo, ProposalInfo, + ProposalStatus, +}; + /// Library version pub const VERSION: &str = env!("CARGO_PKG_VERSION");