From c26c43a7b6808664eeff54923d91ff64f61755ef Mon Sep 17 00:00:00 2001 From: Prasanna Gautam Date: Tue, 7 Apr 2026 22:58:20 +0000 Subject: [PATCH 1/7] feat: add Squads v4 Multisig visualizer for Solana Embed the Squads v4 Anchor IDL and decode instructions using parse_instruction_with_idl so transactions show named instructions (vaultTransactionCreate, proposalCreate, proposalApprove, etc.), named accounts, and decoded args instead of raw hex. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../visualsign-solana/src/presets/mod.rs | 1 + .../src/presets/squads_multisig/config.rs | 22 + .../src/presets/squads_multisig/mod.rs | 294 ++ .../squads_multisig_program.json | 3421 +++++++++++++++++ 4 files changed, 3738 insertions(+) create mode 100644 src/chain_parsers/visualsign-solana/src/presets/squads_multisig/config.rs create mode 100644 src/chain_parsers/visualsign-solana/src/presets/squads_multisig/mod.rs create mode 100644 src/chain_parsers/visualsign-solana/src/presets/squads_multisig/squads_multisig_program.json diff --git a/src/chain_parsers/visualsign-solana/src/presets/mod.rs b/src/chain_parsers/visualsign-solana/src/presets/mod.rs index 28bc00c6..d9d0f2da 100644 --- a/src/chain_parsers/visualsign-solana/src/presets/mod.rs +++ b/src/chain_parsers/visualsign-solana/src/presets/mod.rs @@ -17,6 +17,7 @@ pub mod meteora_dlmm; pub mod neutral_trade; pub mod onre_app; pub mod orca_whirlpool; +pub mod squads_multisig; pub mod stakepool; pub mod swig_wallet; pub mod system; diff --git a/src/chain_parsers/visualsign-solana/src/presets/squads_multisig/config.rs b/src/chain_parsers/visualsign-solana/src/presets/squads_multisig/config.rs new file mode 100644 index 00000000..5e67c10b --- /dev/null +++ b/src/chain_parsers/visualsign-solana/src/presets/squads_multisig/config.rs @@ -0,0 +1,22 @@ +use super::SQUADS_MULTISIG_PROGRAM_ID; +use crate::core::{SolanaIntegrationConfig, SolanaIntegrationConfigData}; +use std::collections::HashMap; + +pub struct SquadsMultisigConfig; + +impl SolanaIntegrationConfig for SquadsMultisigConfig { + fn new() -> Self { + Self + } + + fn data(&self) -> &SolanaIntegrationConfigData { + static DATA: std::sync::OnceLock = std::sync::OnceLock::new(); + DATA.get_or_init(|| { + let mut programs = HashMap::new(); + let mut instructions = HashMap::new(); + instructions.insert("*", vec!["*"]); + programs.insert(SQUADS_MULTISIG_PROGRAM_ID, instructions); + SolanaIntegrationConfigData { programs } + }) + } +} diff --git a/src/chain_parsers/visualsign-solana/src/presets/squads_multisig/mod.rs b/src/chain_parsers/visualsign-solana/src/presets/squads_multisig/mod.rs new file mode 100644 index 00000000..15e9efae --- /dev/null +++ b/src/chain_parsers/visualsign-solana/src/presets/squads_multisig/mod.rs @@ -0,0 +1,294 @@ +//! Squads v4 Multisig preset implementation for Solana + +mod config; + +use crate::core::{ + InstructionVisualizer, SolanaIntegrationConfig, VisualizerContext, VisualizerKind, +}; +use config::SquadsMultisigConfig; +use solana_parser::{ + Idl, SolanaParsedInstructionData, decode_idl_data, parse_instruction_with_idl, +}; +use std::collections::HashMap; +use visualsign::errors::VisualSignError; +use visualsign::field_builders::{create_raw_data_field, create_text_field}; +use visualsign::{ + AnnotatedPayloadField, SignablePayloadField, SignablePayloadFieldCommon, + SignablePayloadFieldListLayout, SignablePayloadFieldPreviewLayout, SignablePayloadFieldTextV2, +}; + +pub(crate) const SQUADS_MULTISIG_PROGRAM_ID: &str = "SQDS4ep65T869zMMBKyuUq6aD6EgTu8psMjkvj52pCf"; + +const SQUADS_IDL_JSON: &str = include_str!("squads_multisig_program.json"); + +static SQUADS_MULTISIG_CONFIG: SquadsMultisigConfig = SquadsMultisigConfig; + +pub struct SquadsMultisigVisualizer; + +impl InstructionVisualizer for SquadsMultisigVisualizer { + fn visualize_tx_commands( + &self, + context: &VisualizerContext, + ) -> Result { + let instruction = context + .current_instruction() + .ok_or_else(|| VisualSignError::MissingData("No instruction found".into()))?; + + let instruction_data_hex = hex::encode(&instruction.data); + let fallback_text = format!( + "Program ID: {}\nData: {instruction_data_hex}", + instruction.program_id, + ); + + let parsed = parse_squads_instruction(&instruction.data, &instruction.accounts); + + let (title, condensed_fields, expanded_fields) = match parsed { + Ok(parsed) => build_parsed_fields(&parsed, &instruction.program_id.to_string()), + Err(_) => build_fallback_fields(&instruction.program_id.to_string()), + }; + + let condensed = SignablePayloadFieldListLayout { + fields: condensed_fields, + }; + let expanded_with_raw = + append_raw_data(expanded_fields, &instruction.data, &instruction_data_hex); + let expanded = SignablePayloadFieldListLayout { + fields: expanded_with_raw, + }; + + let preview_layout = SignablePayloadFieldPreviewLayout { + title: Some(SignablePayloadFieldTextV2 { text: title }), + subtitle: Some(SignablePayloadFieldTextV2 { + text: String::new(), + }), + condensed: Some(condensed), + expanded: Some(expanded), + }; + + Ok(AnnotatedPayloadField { + static_annotation: None, + dynamic_annotation: None, + signable_payload_field: SignablePayloadField::PreviewLayout { + common: SignablePayloadFieldCommon { + label: format!("Instruction {}", context.instruction_index() + 1), + fallback_text, + }, + preview_layout, + }, + }) + } + + fn get_config(&self) -> Option<&dyn SolanaIntegrationConfig> { + Some(&SQUADS_MULTISIG_CONFIG) + } + + fn kind(&self) -> VisualizerKind { + VisualizerKind::Payments("SquadsMultisig") + } +} + +fn get_squads_idl() -> Option { + decode_idl_data(SQUADS_IDL_JSON).ok() +} + +fn parse_squads_instruction( + data: &[u8], + accounts: &[solana_sdk::instruction::AccountMeta], +) -> Result> { + if data.len() < 8 { + return Err("Invalid instruction data length".into()); + } + + let idl = get_squads_idl().ok_or("Squads Multisig IDL not available")?; + let parsed = parse_instruction_with_idl(data, SQUADS_MULTISIG_PROGRAM_ID, &idl)?; + + // Build named accounts by matching instruction accounts with IDL definitions + let named_accounts = build_named_accounts(data, &idl, accounts); + + Ok(SquadsParsedInstruction { + parsed, + named_accounts, + }) +} + +fn build_named_accounts( + data: &[u8], + idl: &Idl, + accounts: &[solana_sdk::instruction::AccountMeta], +) -> HashMap { + let mut named_accounts = HashMap::new(); + + let idl_instruction = idl.instructions.iter().find(|inst| { + inst.discriminator + .as_ref() + .is_some_and(|disc| data.len() >= disc.len() && data[..disc.len()] == *disc) + }); + + if let Some(idl_instruction) = idl_instruction { + for (index, account_meta) in accounts.iter().enumerate() { + if let Some(idl_account) = idl_instruction.accounts.get(index) { + named_accounts.insert(idl_account.name.clone(), account_meta.pubkey.to_string()); + } + } + } + + named_accounts +} + +struct SquadsParsedInstruction { + parsed: SolanaParsedInstructionData, + named_accounts: HashMap, +} + +fn build_parsed_fields( + instruction: &SquadsParsedInstruction, + program_id: &str, +) -> ( + String, + Vec, + Vec, +) { + let parsed = &instruction.parsed; + let title = format!("Squads Multisig: {}", parsed.instruction_name); + + let mut condensed_fields = vec![]; + let mut expanded_fields = vec![]; + + // Condensed: program name + instruction name + key args + if let Ok(f) = create_text_field("Program", "Squads Multisig") { + condensed_fields.push(f); + } + if let Ok(f) = create_text_field("Instruction", &parsed.instruction_name) { + condensed_fields.push(f); + } + for (key, value) in &parsed.program_call_args { + if let Ok(f) = create_text_field(key, &format_arg_value(value)) { + condensed_fields.push(f); + } + } + + // Expanded: full details + if let Ok(f) = create_text_field("Program ID", program_id) { + expanded_fields.push(f); + } + if let Ok(f) = create_text_field("Instruction", &parsed.instruction_name) { + expanded_fields.push(f); + } + if let Ok(f) = create_text_field("Discriminator", &parsed.discriminator) { + expanded_fields.push(f); + } + + // Named accounts + for (account_name, account_address) in &instruction.named_accounts { + if let Ok(f) = create_text_field(account_name, account_address) { + expanded_fields.push(f); + } + } + + // Args + for (key, value) in &parsed.program_call_args { + if let Ok(f) = create_text_field(key, &format_arg_value(value)) { + expanded_fields.push(f); + } + } + + (title, condensed_fields, expanded_fields) +} + +fn build_fallback_fields( + program_id: &str, +) -> ( + String, + Vec, + Vec, +) { + let title = "Squads Multisig: Unknown Instruction".to_string(); + + let mut condensed_fields = vec![]; + let mut expanded_fields = vec![]; + + if let Ok(f) = create_text_field("Program", "Squads Multisig") { + condensed_fields.push(f); + } + if let Ok(f) = create_text_field("Status", "Unknown instruction type") { + condensed_fields.push(f); + } + + if let Ok(f) = create_text_field("Program ID", program_id) { + expanded_fields.push(f); + } + if let Ok(f) = create_text_field("Status", "Unknown instruction type") { + expanded_fields.push(f); + } + + (title, condensed_fields, expanded_fields) +} + +fn append_raw_data( + mut fields: Vec, + data: &[u8], + hex_str: &str, +) -> Vec { + if let Ok(f) = create_raw_data_field(data, Some(hex_str.to_string())) { + fields.push(f); + } + fields +} + +fn format_arg_value(value: &serde_json::Value) -> String { + match value { + serde_json::Value::String(s) => s.clone(), + serde_json::Value::Number(n) => n.to_string(), + serde_json::Value::Bool(b) => b.to_string(), + serde_json::Value::Null => "null".to_string(), + other => other.to_string(), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_squads_idl_loads() { + let idl = get_squads_idl(); + assert!(idl.is_some(), "Squads IDL should load successfully"); + let idl = idl.unwrap(); + assert!(!idl.instructions.is_empty(), "IDL should have instructions"); + } + + #[test] + fn test_squads_idl_has_discriminators() { + let idl = get_squads_idl().unwrap(); + for instruction in &idl.instructions { + assert!( + instruction.discriminator.is_some(), + "Instruction '{}' should have a computed discriminator", + instruction.name + ); + let disc = instruction.discriminator.as_ref().unwrap(); + assert_eq!( + disc.len(), + 8, + "Discriminator for '{}' should be 8 bytes", + instruction.name + ); + } + } + + #[test] + fn test_unknown_discriminator_returns_error() { + let garbage_data = [0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09]; + let accounts = vec![]; + let result = parse_squads_instruction(&garbage_data, &accounts); + assert!(result.is_err(), "Unknown discriminator should return error"); + } + + #[test] + fn test_short_data_returns_error() { + let short_data = [0x01, 0x02, 0x03]; + let accounts = vec![]; + let result = parse_squads_instruction(&short_data, &accounts); + assert!(result.is_err(), "Short data should return error"); + } +} diff --git a/src/chain_parsers/visualsign-solana/src/presets/squads_multisig/squads_multisig_program.json b/src/chain_parsers/visualsign-solana/src/presets/squads_multisig/squads_multisig_program.json new file mode 100644 index 00000000..ae557d20 --- /dev/null +++ b/src/chain_parsers/visualsign-solana/src/presets/squads_multisig/squads_multisig_program.json @@ -0,0 +1,3421 @@ +{ + "version": "2.1.0", + "name": "squads_multisig_program", + "instructions": [ + { + "name": "programConfigInit", + "docs": [ + "Initialize the program config." + ], + "accounts": [ + { + "name": "programConfig", + "isMut": true, + "isSigner": false + }, + { + "name": "initializer", + "isMut": true, + "isSigner": true, + "docs": [ + "The hard-coded account that is used to initialize the program config once." + ] + }, + { + "name": "systemProgram", + "isMut": false, + "isSigner": false + } + ], + "args": [ + { + "name": "args", + "type": { + "defined": "ProgramConfigInitArgs" + } + } + ] + }, + { + "name": "programConfigSetAuthority", + "docs": [ + "Set the `authority` parameter of the program config." + ], + "accounts": [ + { + "name": "programConfig", + "isMut": true, + "isSigner": false + }, + { + "name": "authority", + "isMut": false, + "isSigner": true + } + ], + "args": [ + { + "name": "args", + "type": { + "defined": "ProgramConfigSetAuthorityArgs" + } + } + ] + }, + { + "name": "programConfigSetMultisigCreationFee", + "docs": [ + "Set the `multisig_creation_fee` parameter of the program config." + ], + "accounts": [ + { + "name": "programConfig", + "isMut": true, + "isSigner": false + }, + { + "name": "authority", + "isMut": false, + "isSigner": true + } + ], + "args": [ + { + "name": "args", + "type": { + "defined": "ProgramConfigSetMultisigCreationFeeArgs" + } + } + ] + }, + { + "name": "programConfigSetTreasury", + "docs": [ + "Set the `treasury` parameter of the program config." + ], + "accounts": [ + { + "name": "programConfig", + "isMut": true, + "isSigner": false + }, + { + "name": "authority", + "isMut": false, + "isSigner": true + } + ], + "args": [ + { + "name": "args", + "type": { + "defined": "ProgramConfigSetTreasuryArgs" + } + } + ] + }, + { + "name": "multisigCreate", + "docs": [ + "Create a multisig." + ], + "accounts": [ + { + "name": "null", + "isMut": false, + "isSigner": false + } + ], + "args": [] + }, + { + "name": "multisigCreateV2", + "docs": [ + "Create a multisig." + ], + "accounts": [ + { + "name": "programConfig", + "isMut": false, + "isSigner": false, + "docs": [ + "Global program config account." + ] + }, + { + "name": "treasury", + "isMut": true, + "isSigner": false, + "docs": [ + "The treasury where the creation fee is transferred to." + ] + }, + { + "name": "multisig", + "isMut": true, + "isSigner": false + }, + { + "name": "createKey", + "isMut": false, + "isSigner": true, + "docs": [ + "An ephemeral signer that is used as a seed for the Multisig PDA.", + "Must be a signer to prevent front-running attack by someone else but the original creator." + ] + }, + { + "name": "creator", + "isMut": true, + "isSigner": true, + "docs": [ + "The creator of the multisig." + ] + }, + { + "name": "systemProgram", + "isMut": false, + "isSigner": false + } + ], + "args": [ + { + "name": "args", + "type": { + "defined": "MultisigCreateArgsV2" + } + } + ] + }, + { + "name": "multisigAddMember", + "docs": [ + "Add a new member to the controlled multisig." + ], + "accounts": [ + { + "name": "multisig", + "isMut": true, + "isSigner": false + }, + { + "name": "configAuthority", + "isMut": false, + "isSigner": true, + "docs": [ + "Multisig `config_authority` that must authorize the configuration change." + ] + }, + { + "name": "rentPayer", + "isMut": true, + "isSigner": true, + "isOptional": true, + "docs": [ + "The account that will be charged or credited in case the multisig account needs to reallocate space,", + "for example when adding a new member or a spending limit.", + "This is usually the same as `config_authority`, but can be a different account if needed." + ] + }, + { + "name": "systemProgram", + "isMut": false, + "isSigner": false, + "isOptional": true, + "docs": [ + "We might need it in case reallocation is needed." + ] + } + ], + "args": [ + { + "name": "args", + "type": { + "defined": "MultisigAddMemberArgs" + } + } + ] + }, + { + "name": "multisigRemoveMember", + "docs": [ + "Remove a member/key from the controlled multisig." + ], + "accounts": [ + { + "name": "multisig", + "isMut": true, + "isSigner": false + }, + { + "name": "configAuthority", + "isMut": false, + "isSigner": true, + "docs": [ + "Multisig `config_authority` that must authorize the configuration change." + ] + }, + { + "name": "rentPayer", + "isMut": true, + "isSigner": true, + "isOptional": true, + "docs": [ + "The account that will be charged or credited in case the multisig account needs to reallocate space,", + "for example when adding a new member or a spending limit.", + "This is usually the same as `config_authority`, but can be a different account if needed." + ] + }, + { + "name": "systemProgram", + "isMut": false, + "isSigner": false, + "isOptional": true, + "docs": [ + "We might need it in case reallocation is needed." + ] + } + ], + "args": [ + { + "name": "args", + "type": { + "defined": "MultisigRemoveMemberArgs" + } + } + ] + }, + { + "name": "multisigSetTimeLock", + "docs": [ + "Set the `time_lock` config parameter for the controlled multisig." + ], + "accounts": [ + { + "name": "multisig", + "isMut": true, + "isSigner": false + }, + { + "name": "configAuthority", + "isMut": false, + "isSigner": true, + "docs": [ + "Multisig `config_authority` that must authorize the configuration change." + ] + }, + { + "name": "rentPayer", + "isMut": true, + "isSigner": true, + "isOptional": true, + "docs": [ + "The account that will be charged or credited in case the multisig account needs to reallocate space,", + "for example when adding a new member or a spending limit.", + "This is usually the same as `config_authority`, but can be a different account if needed." + ] + }, + { + "name": "systemProgram", + "isMut": false, + "isSigner": false, + "isOptional": true, + "docs": [ + "We might need it in case reallocation is needed." + ] + } + ], + "args": [ + { + "name": "args", + "type": { + "defined": "MultisigSetTimeLockArgs" + } + } + ] + }, + { + "name": "multisigChangeThreshold", + "docs": [ + "Set the `threshold` config parameter for the controlled multisig." + ], + "accounts": [ + { + "name": "multisig", + "isMut": true, + "isSigner": false + }, + { + "name": "configAuthority", + "isMut": false, + "isSigner": true, + "docs": [ + "Multisig `config_authority` that must authorize the configuration change." + ] + }, + { + "name": "rentPayer", + "isMut": true, + "isSigner": true, + "isOptional": true, + "docs": [ + "The account that will be charged or credited in case the multisig account needs to reallocate space,", + "for example when adding a new member or a spending limit.", + "This is usually the same as `config_authority`, but can be a different account if needed." + ] + }, + { + "name": "systemProgram", + "isMut": false, + "isSigner": false, + "isOptional": true, + "docs": [ + "We might need it in case reallocation is needed." + ] + } + ], + "args": [ + { + "name": "args", + "type": { + "defined": "MultisigChangeThresholdArgs" + } + } + ] + }, + { + "name": "multisigSetConfigAuthority", + "docs": [ + "Set the multisig `config_authority`." + ], + "accounts": [ + { + "name": "multisig", + "isMut": true, + "isSigner": false + }, + { + "name": "configAuthority", + "isMut": false, + "isSigner": true, + "docs": [ + "Multisig `config_authority` that must authorize the configuration change." + ] + }, + { + "name": "rentPayer", + "isMut": true, + "isSigner": true, + "isOptional": true, + "docs": [ + "The account that will be charged or credited in case the multisig account needs to reallocate space,", + "for example when adding a new member or a spending limit.", + "This is usually the same as `config_authority`, but can be a different account if needed." + ] + }, + { + "name": "systemProgram", + "isMut": false, + "isSigner": false, + "isOptional": true, + "docs": [ + "We might need it in case reallocation is needed." + ] + } + ], + "args": [ + { + "name": "args", + "type": { + "defined": "MultisigSetConfigAuthorityArgs" + } + } + ] + }, + { + "name": "multisigSetRentCollector", + "docs": [ + "Set the multisig `rent_collector`." + ], + "accounts": [ + { + "name": "multisig", + "isMut": true, + "isSigner": false + }, + { + "name": "configAuthority", + "isMut": false, + "isSigner": true, + "docs": [ + "Multisig `config_authority` that must authorize the configuration change." + ] + }, + { + "name": "rentPayer", + "isMut": true, + "isSigner": true, + "isOptional": true, + "docs": [ + "The account that will be charged or credited in case the multisig account needs to reallocate space,", + "for example when adding a new member or a spending limit.", + "This is usually the same as `config_authority`, but can be a different account if needed." + ] + }, + { + "name": "systemProgram", + "isMut": false, + "isSigner": false, + "isOptional": true, + "docs": [ + "We might need it in case reallocation is needed." + ] + } + ], + "args": [ + { + "name": "args", + "type": { + "defined": "MultisigSetRentCollectorArgs" + } + } + ] + }, + { + "name": "multisigAddSpendingLimit", + "docs": [ + "Create a new spending limit for the controlled multisig." + ], + "accounts": [ + { + "name": "multisig", + "isMut": false, + "isSigner": false + }, + { + "name": "configAuthority", + "isMut": false, + "isSigner": true, + "docs": [ + "Multisig `config_authority` that must authorize the configuration change." + ] + }, + { + "name": "spendingLimit", + "isMut": true, + "isSigner": false + }, + { + "name": "rentPayer", + "isMut": true, + "isSigner": true, + "docs": [ + "This is usually the same as `config_authority`, but can be a different account if needed." + ] + }, + { + "name": "systemProgram", + "isMut": false, + "isSigner": false + } + ], + "args": [ + { + "name": "args", + "type": { + "defined": "MultisigAddSpendingLimitArgs" + } + } + ] + }, + { + "name": "multisigRemoveSpendingLimit", + "docs": [ + "Remove the spending limit from the controlled multisig." + ], + "accounts": [ + { + "name": "multisig", + "isMut": false, + "isSigner": false + }, + { + "name": "configAuthority", + "isMut": false, + "isSigner": true, + "docs": [ + "Multisig `config_authority` that must authorize the configuration change." + ] + }, + { + "name": "spendingLimit", + "isMut": true, + "isSigner": false + }, + { + "name": "rentCollector", + "isMut": true, + "isSigner": false, + "docs": [ + "This is usually the same as `config_authority`, but can be a different account if needed." + ] + } + ], + "args": [ + { + "name": "args", + "type": { + "defined": "MultisigRemoveSpendingLimitArgs" + } + } + ] + }, + { + "name": "configTransactionCreate", + "docs": [ + "Create a new config transaction." + ], + "accounts": [ + { + "name": "multisig", + "isMut": true, + "isSigner": false + }, + { + "name": "transaction", + "isMut": true, + "isSigner": false + }, + { + "name": "creator", + "isMut": false, + "isSigner": true, + "docs": [ + "The member of the multisig that is creating the transaction." + ] + }, + { + "name": "rentPayer", + "isMut": true, + "isSigner": true, + "docs": [ + "The payer for the transaction account rent." + ] + }, + { + "name": "systemProgram", + "isMut": false, + "isSigner": false + } + ], + "args": [ + { + "name": "args", + "type": { + "defined": "ConfigTransactionCreateArgs" + } + } + ] + }, + { + "name": "configTransactionExecute", + "docs": [ + "Execute a config transaction.", + "The transaction must be `Approved`." + ], + "accounts": [ + { + "name": "multisig", + "isMut": true, + "isSigner": false, + "docs": [ + "The multisig account that owns the transaction." + ] + }, + { + "name": "member", + "isMut": false, + "isSigner": true, + "docs": [ + "One of the multisig members with `Execute` permission." + ] + }, + { + "name": "proposal", + "isMut": true, + "isSigner": false, + "docs": [ + "The proposal account associated with the transaction." + ] + }, + { + "name": "transaction", + "isMut": false, + "isSigner": false, + "docs": [ + "The transaction to execute." + ] + }, + { + "name": "rentPayer", + "isMut": true, + "isSigner": true, + "isOptional": true, + "docs": [ + "The account that will be charged/credited in case the config transaction causes space reallocation,", + "for example when adding a new member, adding or removing a spending limit.", + "This is usually the same as `member`, but can be a different account if needed." + ] + }, + { + "name": "systemProgram", + "isMut": false, + "isSigner": false, + "isOptional": true, + "docs": [ + "We might need it in case reallocation is needed." + ] + } + ], + "args": [] + }, + { + "name": "vaultTransactionCreate", + "docs": [ + "Create a new vault transaction." + ], + "accounts": [ + { + "name": "multisig", + "isMut": true, + "isSigner": false + }, + { + "name": "transaction", + "isMut": true, + "isSigner": false + }, + { + "name": "creator", + "isMut": false, + "isSigner": true, + "docs": [ + "The member of the multisig that is creating the transaction." + ] + }, + { + "name": "rentPayer", + "isMut": true, + "isSigner": true, + "docs": [ + "The payer for the transaction account rent." + ] + }, + { + "name": "systemProgram", + "isMut": false, + "isSigner": false + } + ], + "args": [ + { + "name": "args", + "type": { + "defined": "VaultTransactionCreateArgs" + } + } + ] + }, + { + "name": "transactionBufferCreate", + "docs": [ + "Create a transaction buffer account." + ], + "accounts": [ + { + "name": "multisig", + "isMut": false, + "isSigner": false + }, + { + "name": "transactionBuffer", + "isMut": true, + "isSigner": false + }, + { + "name": "creator", + "isMut": false, + "isSigner": true, + "docs": [ + "The member of the multisig that is creating the transaction." + ] + }, + { + "name": "rentPayer", + "isMut": true, + "isSigner": true, + "docs": [ + "The payer for the transaction account rent." + ] + }, + { + "name": "systemProgram", + "isMut": false, + "isSigner": false + } + ], + "args": [ + { + "name": "args", + "type": { + "defined": "TransactionBufferCreateArgs" + } + } + ] + }, + { + "name": "transactionBufferClose", + "docs": [ + "Close a transaction buffer account." + ], + "accounts": [ + { + "name": "multisig", + "isMut": false, + "isSigner": false + }, + { + "name": "transactionBuffer", + "isMut": true, + "isSigner": false + }, + { + "name": "creator", + "isMut": false, + "isSigner": true, + "docs": [ + "The member of the multisig that created the TransactionBuffer." + ] + } + ], + "args": [] + }, + { + "name": "transactionBufferExtend", + "docs": [ + "Extend a transaction buffer account." + ], + "accounts": [ + { + "name": "multisig", + "isMut": false, + "isSigner": false + }, + { + "name": "transactionBuffer", + "isMut": true, + "isSigner": false + }, + { + "name": "creator", + "isMut": false, + "isSigner": true, + "docs": [ + "The member of the multisig that created the TransactionBuffer." + ] + } + ], + "args": [ + { + "name": "args", + "type": { + "defined": "TransactionBufferExtendArgs" + } + } + ] + }, + { + "name": "vaultTransactionCreateFromBuffer", + "docs": [ + "Create a new vault transaction from a completed transaction buffer.", + "Finalized buffer hash must match `final_buffer_hash`" + ], + "accounts": [ + { + "name": "vaultTransactionCreate", + "accounts": [ + { + "name": "multisig", + "isMut": true, + "isSigner": false + }, + { + "name": "transaction", + "isMut": true, + "isSigner": false + }, + { + "name": "creator", + "isMut": false, + "isSigner": true, + "docs": [ + "The member of the multisig that is creating the transaction." + ] + }, + { + "name": "rentPayer", + "isMut": true, + "isSigner": true, + "docs": [ + "The payer for the transaction account rent." + ] + }, + { + "name": "systemProgram", + "isMut": false, + "isSigner": false + } + ] + }, + { + "name": "transactionBuffer", + "isMut": true, + "isSigner": false + }, + { + "name": "creator", + "isMut": true, + "isSigner": true + } + ], + "args": [ + { + "name": "args", + "type": { + "defined": "VaultTransactionCreateArgs" + } + } + ] + }, + { + "name": "vaultTransactionExecute", + "docs": [ + "Execute a vault transaction.", + "The transaction must be `Approved`." + ], + "accounts": [ + { + "name": "multisig", + "isMut": false, + "isSigner": false + }, + { + "name": "proposal", + "isMut": true, + "isSigner": false, + "docs": [ + "The proposal account associated with the transaction." + ] + }, + { + "name": "transaction", + "isMut": false, + "isSigner": false, + "docs": [ + "The transaction to execute." + ] + }, + { + "name": "member", + "isMut": false, + "isSigner": true + } + ], + "args": [] + }, + { + "name": "batchCreate", + "docs": [ + "Create a new batch." + ], + "accounts": [ + { + "name": "multisig", + "isMut": true, + "isSigner": false + }, + { + "name": "batch", + "isMut": true, + "isSigner": false + }, + { + "name": "creator", + "isMut": false, + "isSigner": true, + "docs": [ + "The member of the multisig that is creating the batch." + ] + }, + { + "name": "rentPayer", + "isMut": true, + "isSigner": true, + "docs": [ + "The payer for the batch account rent." + ] + }, + { + "name": "systemProgram", + "isMut": false, + "isSigner": false + } + ], + "args": [ + { + "name": "args", + "type": { + "defined": "BatchCreateArgs" + } + } + ] + }, + { + "name": "batchAddTransaction", + "docs": [ + "Add a transaction to the batch." + ], + "accounts": [ + { + "name": "multisig", + "isMut": false, + "isSigner": false, + "docs": [ + "Multisig account this batch belongs to." + ] + }, + { + "name": "proposal", + "isMut": false, + "isSigner": false, + "docs": [ + "The proposal account associated with the batch." + ] + }, + { + "name": "batch", + "isMut": true, + "isSigner": false + }, + { + "name": "transaction", + "isMut": true, + "isSigner": false, + "docs": [ + "`VaultBatchTransaction` account to initialize and add to the `batch`." + ] + }, + { + "name": "member", + "isMut": false, + "isSigner": true, + "docs": [ + "Member of the multisig." + ] + }, + { + "name": "rentPayer", + "isMut": true, + "isSigner": true, + "docs": [ + "The payer for the batch transaction account rent." + ] + }, + { + "name": "systemProgram", + "isMut": false, + "isSigner": false + } + ], + "args": [ + { + "name": "args", + "type": { + "defined": "BatchAddTransactionArgs" + } + } + ] + }, + { + "name": "batchExecuteTransaction", + "docs": [ + "Execute a transaction from the batch." + ], + "accounts": [ + { + "name": "multisig", + "isMut": false, + "isSigner": false, + "docs": [ + "Multisig account this batch belongs to." + ] + }, + { + "name": "member", + "isMut": false, + "isSigner": true, + "docs": [ + "Member of the multisig." + ] + }, + { + "name": "proposal", + "isMut": true, + "isSigner": false, + "docs": [ + "The proposal account associated with the batch.", + "If `transaction` is the last in the batch, the `proposal` status will be set to `Executed`." + ] + }, + { + "name": "batch", + "isMut": true, + "isSigner": false + }, + { + "name": "transaction", + "isMut": false, + "isSigner": false, + "docs": [ + "Batch transaction to execute." + ] + } + ], + "args": [] + }, + { + "name": "proposalCreate", + "docs": [ + "Create a new multisig proposal." + ], + "accounts": [ + { + "name": "multisig", + "isMut": false, + "isSigner": false + }, + { + "name": "proposal", + "isMut": true, + "isSigner": false + }, + { + "name": "creator", + "isMut": false, + "isSigner": true, + "docs": [ + "The member of the multisig that is creating the proposal." + ] + }, + { + "name": "rentPayer", + "isMut": true, + "isSigner": true, + "docs": [ + "The payer for the proposal account rent." + ] + }, + { + "name": "systemProgram", + "isMut": false, + "isSigner": false + } + ], + "args": [ + { + "name": "args", + "type": { + "defined": "ProposalCreateArgs" + } + } + ] + }, + { + "name": "proposalActivate", + "docs": [ + "Update status of a multisig proposal from `Draft` to `Active`." + ], + "accounts": [ + { + "name": "multisig", + "isMut": false, + "isSigner": false + }, + { + "name": "member", + "isMut": true, + "isSigner": true + }, + { + "name": "proposal", + "isMut": true, + "isSigner": false + } + ], + "args": [] + }, + { + "name": "proposalApprove", + "docs": [ + "Approve a multisig proposal on behalf of the `member`.", + "The proposal must be `Active`." + ], + "accounts": [ + { + "name": "multisig", + "isMut": false, + "isSigner": false + }, + { + "name": "member", + "isMut": true, + "isSigner": true + }, + { + "name": "proposal", + "isMut": true, + "isSigner": false + } + ], + "args": [ + { + "name": "args", + "type": { + "defined": "ProposalVoteArgs" + } + } + ] + }, + { + "name": "proposalReject", + "docs": [ + "Reject a multisig proposal on behalf of the `member`.", + "The proposal must be `Active`." + ], + "accounts": [ + { + "name": "multisig", + "isMut": false, + "isSigner": false + }, + { + "name": "member", + "isMut": true, + "isSigner": true + }, + { + "name": "proposal", + "isMut": true, + "isSigner": false + } + ], + "args": [ + { + "name": "args", + "type": { + "defined": "ProposalVoteArgs" + } + } + ] + }, + { + "name": "proposalCancel", + "docs": [ + "Cancel a multisig proposal on behalf of the `member`.", + "The proposal must be `Approved`." + ], + "accounts": [ + { + "name": "multisig", + "isMut": false, + "isSigner": false + }, + { + "name": "member", + "isMut": true, + "isSigner": true + }, + { + "name": "proposal", + "isMut": true, + "isSigner": false + } + ], + "args": [ + { + "name": "args", + "type": { + "defined": "ProposalVoteArgs" + } + } + ] + }, + { + "name": "proposalCancelV2", + "docs": [ + "Cancel a multisig proposal on behalf of the `member`.", + "The proposal must be `Approved`.", + "This was introduced to incorporate proper state update, as old multisig members", + "may have lingering votes, and the proposal size may need to be reallocated to", + "accommodate the new amount of cancel votes.", + "The previous implemenation still works if the proposal size is in line with the", + "threshold size." + ], + "accounts": [ + { + "name": "proposalVote", + "accounts": [ + { + "name": "multisig", + "isMut": false, + "isSigner": false + }, + { + "name": "member", + "isMut": true, + "isSigner": true + }, + { + "name": "proposal", + "isMut": true, + "isSigner": false + } + ] + }, + { + "name": "systemProgram", + "isMut": false, + "isSigner": false + } + ], + "args": [ + { + "name": "args", + "type": { + "defined": "ProposalVoteArgs" + } + } + ] + }, + { + "name": "spendingLimitUse", + "docs": [ + "Use a spending limit to transfer tokens from a multisig vault to a destination account." + ], + "accounts": [ + { + "name": "multisig", + "isMut": false, + "isSigner": false, + "docs": [ + "The multisig account the `spending_limit` is for." + ] + }, + { + "name": "member", + "isMut": false, + "isSigner": true + }, + { + "name": "spendingLimit", + "isMut": true, + "isSigner": false, + "docs": [ + "The SpendingLimit account to use." + ] + }, + { + "name": "vault", + "isMut": true, + "isSigner": false, + "docs": [ + "Multisig vault account to transfer tokens from." + ] + }, + { + "name": "destination", + "isMut": true, + "isSigner": false, + "docs": [ + "Destination account to transfer tokens to." + ] + }, + { + "name": "systemProgram", + "isMut": false, + "isSigner": false, + "isOptional": true, + "docs": [ + "In case `spending_limit.mint` is SOL." + ] + }, + { + "name": "mint", + "isMut": false, + "isSigner": false, + "isOptional": true, + "docs": [ + "The mint of the tokens to transfer in case `spending_limit.mint` is an SPL token." + ] + }, + { + "name": "vaultTokenAccount", + "isMut": true, + "isSigner": false, + "isOptional": true, + "docs": [ + "Multisig vault token account to transfer tokens from in case `spending_limit.mint` is an SPL token." + ] + }, + { + "name": "destinationTokenAccount", + "isMut": true, + "isSigner": false, + "isOptional": true, + "docs": [ + "Destination token account in case `spending_limit.mint` is an SPL token." + ] + }, + { + "name": "tokenProgram", + "isMut": false, + "isSigner": false, + "isOptional": true, + "docs": [ + "In case `spending_limit.mint` is an SPL token." + ] + } + ], + "args": [ + { + "name": "args", + "type": { + "defined": "SpendingLimitUseArgs" + } + } + ] + }, + { + "name": "configTransactionAccountsClose", + "docs": [ + "Closes a `ConfigTransaction` and the corresponding `Proposal`.", + "`transaction` can be closed if either:", + "- the `proposal` is in a terminal state: `Executed`, `Rejected`, or `Cancelled`.", + "- the `proposal` is stale." + ], + "accounts": [ + { + "name": "multisig", + "isMut": false, + "isSigner": false + }, + { + "name": "proposal", + "isMut": true, + "isSigner": false, + "docs": [ + "the logic within `config_transaction_accounts_close` does the rest of the checks." + ] + }, + { + "name": "transaction", + "isMut": true, + "isSigner": false, + "docs": [ + "ConfigTransaction corresponding to the `proposal`." + ] + }, + { + "name": "rentCollector", + "isMut": true, + "isSigner": false, + "docs": [ + "The rent collector." + ] + }, + { + "name": "systemProgram", + "isMut": false, + "isSigner": false + } + ], + "args": [] + }, + { + "name": "vaultTransactionAccountsClose", + "docs": [ + "Closes a `VaultTransaction` and the corresponding `Proposal`.", + "`transaction` can be closed if either:", + "- the `proposal` is in a terminal state: `Executed`, `Rejected`, or `Cancelled`.", + "- the `proposal` is stale and not `Approved`." + ], + "accounts": [ + { + "name": "multisig", + "isMut": false, + "isSigner": false + }, + { + "name": "proposal", + "isMut": true, + "isSigner": false, + "docs": [ + "the logic within `vault_transaction_accounts_close` does the rest of the checks." + ] + }, + { + "name": "transaction", + "isMut": true, + "isSigner": false, + "docs": [ + "VaultTransaction corresponding to the `proposal`." + ] + }, + { + "name": "rentCollector", + "isMut": true, + "isSigner": false, + "docs": [ + "The rent collector." + ] + }, + { + "name": "systemProgram", + "isMut": false, + "isSigner": false + } + ], + "args": [] + }, + { + "name": "vaultBatchTransactionAccountClose", + "docs": [ + "Closes a `VaultBatchTransaction` belonging to the `batch` and `proposal`.", + "`transaction` can be closed if either:", + "- it's marked as executed within the `batch`;", + "- the `proposal` is in a terminal state: `Executed`, `Rejected`, or `Cancelled`.", + "- the `proposal` is stale and not `Approved`." + ], + "accounts": [ + { + "name": "multisig", + "isMut": false, + "isSigner": false + }, + { + "name": "proposal", + "isMut": false, + "isSigner": false + }, + { + "name": "batch", + "isMut": true, + "isSigner": false, + "docs": [ + "`Batch` corresponding to the `proposal`." + ] + }, + { + "name": "transaction", + "isMut": true, + "isSigner": false, + "docs": [ + "`VaultBatchTransaction` account to close.", + "The transaction must be the current last one in the batch." + ] + }, + { + "name": "rentCollector", + "isMut": true, + "isSigner": false, + "docs": [ + "The rent collector." + ] + }, + { + "name": "systemProgram", + "isMut": false, + "isSigner": false + } + ], + "args": [] + }, + { + "name": "batchAccountsClose", + "docs": [ + "Closes Batch and the corresponding Proposal accounts for proposals in terminal states:", + "`Executed`, `Rejected`, or `Cancelled` or stale proposals that aren't `Approved`.", + "", + "This instruction is only allowed to be executed when all `VaultBatchTransaction` accounts", + "in the `batch` are already closed: `batch.size == 0`." + ], + "accounts": [ + { + "name": "multisig", + "isMut": false, + "isSigner": false + }, + { + "name": "proposal", + "isMut": true, + "isSigner": false, + "docs": [ + "the logic within `batch_accounts_close` does the rest of the checks." + ] + }, + { + "name": "batch", + "isMut": true, + "isSigner": false, + "docs": [ + "`Batch` corresponding to the `proposal`." + ] + }, + { + "name": "rentCollector", + "isMut": true, + "isSigner": false, + "docs": [ + "The rent collector." + ] + }, + { + "name": "systemProgram", + "isMut": false, + "isSigner": false + } + ], + "args": [] + } + ], + "accounts": [ + { + "name": "Batch", + "docs": [ + "Stores data required for serial execution of a batch of multisig vault transactions.", + "Vault transaction is a transaction that's executed on behalf of the multisig vault PDA", + "and wraps arbitrary Solana instructions, typically calling into other Solana programs.", + "The transactions themselves are stored in separate PDAs associated with the this account." + ], + "type": { + "kind": "struct", + "fields": [ + { + "name": "multisig", + "docs": [ + "The multisig this belongs to." + ], + "type": "publicKey" + }, + { + "name": "creator", + "docs": [ + "Member of the Multisig who submitted the batch." + ], + "type": "publicKey" + }, + { + "name": "index", + "docs": [ + "Index of this batch within the multisig transactions." + ], + "type": "u64" + }, + { + "name": "bump", + "docs": [ + "PDA bump." + ], + "type": "u8" + }, + { + "name": "vaultIndex", + "docs": [ + "Index of the vault this batch belongs to." + ], + "type": "u8" + }, + { + "name": "vaultBump", + "docs": [ + "Derivation bump of the vault PDA this batch belongs to." + ], + "type": "u8" + }, + { + "name": "size", + "docs": [ + "Number of transactions in the batch." + ], + "type": "u32" + }, + { + "name": "executedTransactionIndex", + "docs": [ + "Index of the last executed transaction within the batch.", + "0 means that no transactions have been executed yet." + ], + "type": "u32" + } + ] + } + }, + { + "name": "VaultBatchTransaction", + "docs": [ + "Stores data required for execution of one transaction from a batch." + ], + "type": { + "kind": "struct", + "fields": [ + { + "name": "bump", + "docs": [ + "PDA bump." + ], + "type": "u8" + }, + { + "name": "ephemeralSignerBumps", + "docs": [ + "Derivation bumps for additional signers.", + "Some transactions require multiple signers. Often these additional signers are \"ephemeral\" keypairs", + "that are generated on the client with a sole purpose of signing the transaction and be discarded immediately after.", + "When wrapping such transactions into multisig ones, we replace these \"ephemeral\" signing keypairs", + "with PDAs derived from the transaction's `transaction_index` and controlled by the Multisig Program;", + "during execution the program includes the seeds of these PDAs into the `invoke_signed` calls,", + "thus \"signing\" on behalf of these PDAs." + ], + "type": "bytes" + }, + { + "name": "message", + "docs": [ + "data required for executing the transaction." + ], + "type": { + "defined": "VaultTransactionMessage" + } + } + ] + } + }, + { + "name": "ConfigTransaction", + "docs": [ + "Stores data required for execution of a multisig configuration transaction.", + "Config transaction can perform a predefined set of actions on the Multisig PDA, such as adding/removing members,", + "changing the threshold, etc." + ], + "type": { + "kind": "struct", + "fields": [ + { + "name": "multisig", + "docs": [ + "The multisig this belongs to." + ], + "type": "publicKey" + }, + { + "name": "creator", + "docs": [ + "Member of the Multisig who submitted the transaction." + ], + "type": "publicKey" + }, + { + "name": "index", + "docs": [ + "Index of this transaction within the multisig." + ], + "type": "u64" + }, + { + "name": "bump", + "docs": [ + "bump for the transaction seeds." + ], + "type": "u8" + }, + { + "name": "actions", + "docs": [ + "Action to be performed on the multisig." + ], + "type": { + "vec": { + "defined": "ConfigAction" + } + } + } + ] + } + }, + { + "name": "Multisig", + "type": { + "kind": "struct", + "fields": [ + { + "name": "createKey", + "docs": [ + "Key that is used to seed the multisig PDA." + ], + "type": "publicKey" + }, + { + "name": "configAuthority", + "docs": [ + "The authority that can change the multisig config.", + "This is a very important parameter as this authority can change the members and threshold.", + "", + "The convention is to set this to `Pubkey::default()`.", + "In this case, the multisig becomes autonomous, so every config change goes through", + "the normal process of voting by the members.", + "", + "However, if this parameter is set to any other key, all the config changes for this multisig", + "will need to be signed by the `config_authority`. We call such a multisig a \"controlled multisig\"." + ], + "type": "publicKey" + }, + { + "name": "threshold", + "docs": [ + "Threshold for signatures." + ], + "type": "u16" + }, + { + "name": "timeLock", + "docs": [ + "How many seconds must pass between transaction voting settlement and execution." + ], + "type": "u32" + }, + { + "name": "transactionIndex", + "docs": [ + "Last transaction index. 0 means no transactions have been created." + ], + "type": "u64" + }, + { + "name": "staleTransactionIndex", + "docs": [ + "Last stale transaction index. All transactions up until this index are stale.", + "This index is updated when multisig config (members/threshold/time_lock) changes." + ], + "type": "u64" + }, + { + "name": "rentCollector", + "docs": [ + "The address where the rent for the accounts related to executed, rejected, or cancelled", + "transactions can be reclaimed. If set to `None`, the rent reclamation feature is turned off." + ], + "type": { + "option": "publicKey" + } + }, + { + "name": "bump", + "docs": [ + "Bump for the multisig PDA seed." + ], + "type": "u8" + }, + { + "name": "members", + "docs": [ + "Members of the multisig." + ], + "type": { + "vec": { + "defined": "Member" + } + } + } + ] + } + }, + { + "name": "ProgramConfig", + "docs": [ + "Global program configuration account." + ], + "type": { + "kind": "struct", + "fields": [ + { + "name": "authority", + "docs": [ + "The authority which can update the config." + ], + "type": "publicKey" + }, + { + "name": "multisigCreationFee", + "docs": [ + "The lamports amount charged for creating a new multisig account.", + "This fee is sent to the `treasury` account." + ], + "type": "u64" + }, + { + "name": "treasury", + "docs": [ + "The treasury account to send charged fees to." + ], + "type": "publicKey" + }, + { + "name": "reserved", + "docs": [ + "Reserved for future use." + ], + "type": { + "array": [ + "u8", + 64 + ] + } + } + ] + } + }, + { + "name": "Proposal", + "docs": [ + "Stores the data required for tracking the status of a multisig proposal.", + "Each `Proposal` has a 1:1 association with a transaction account, e.g. a `VaultTransaction` or a `ConfigTransaction`;", + "the latter can be executed only after the `Proposal` has been approved and its time lock is released." + ], + "type": { + "kind": "struct", + "fields": [ + { + "name": "multisig", + "docs": [ + "The multisig this belongs to." + ], + "type": "publicKey" + }, + { + "name": "transactionIndex", + "docs": [ + "Index of the multisig transaction this proposal is associated with." + ], + "type": "u64" + }, + { + "name": "status", + "docs": [ + "The status of the transaction." + ], + "type": { + "defined": "ProposalStatus" + } + }, + { + "name": "bump", + "docs": [ + "PDA bump." + ], + "type": "u8" + }, + { + "name": "approved", + "docs": [ + "Keys that have approved/signed." + ], + "type": { + "vec": "publicKey" + } + }, + { + "name": "rejected", + "docs": [ + "Keys that have rejected." + ], + "type": { + "vec": "publicKey" + } + }, + { + "name": "cancelled", + "docs": [ + "Keys that have cancelled (Approved only)." + ], + "type": { + "vec": "publicKey" + } + } + ] + } + }, + { + "name": "SpendingLimit", + "type": { + "kind": "struct", + "fields": [ + { + "name": "multisig", + "docs": [ + "The multisig this belongs to." + ], + "type": "publicKey" + }, + { + "name": "createKey", + "docs": [ + "Key that is used to seed the SpendingLimit PDA." + ], + "type": "publicKey" + }, + { + "name": "vaultIndex", + "docs": [ + "The index of the vault that the spending limit is for." + ], + "type": "u8" + }, + { + "name": "mint", + "docs": [ + "The token mint the spending limit is for.", + "Pubkey::default() means SOL.", + "use NATIVE_MINT for Wrapped SOL." + ], + "type": "publicKey" + }, + { + "name": "amount", + "docs": [ + "The amount of tokens that can be spent in a period.", + "This amount is in decimals of the mint,", + "so 1 SOL would be `1_000_000_000` and 1 USDC would be `1_000_000`." + ], + "type": "u64" + }, + { + "name": "period", + "docs": [ + "The reset period of the spending limit.", + "When it passes, the remaining amount is reset, unless it's `Period::OneTime`." + ], + "type": { + "defined": "Period" + } + }, + { + "name": "remainingAmount", + "docs": [ + "The remaining amount of tokens that can be spent in the current period.", + "When reaches 0, the spending limit cannot be used anymore until the period reset." + ], + "type": "u64" + }, + { + "name": "lastReset", + "docs": [ + "Unix timestamp marking the last time the spending limit was reset (or created)." + ], + "type": "i64" + }, + { + "name": "bump", + "docs": [ + "PDA bump." + ], + "type": "u8" + }, + { + "name": "members", + "docs": [ + "Members of the multisig that can use the spending limit.", + "In case a member is removed from the multisig, the spending limit will remain existent", + "(until explicitly deleted), but the removed member will not be able to use it anymore." + ], + "type": { + "vec": "publicKey" + } + }, + { + "name": "destinations", + "docs": [ + "The destination addresses the spending limit is allowed to sent funds to.", + "If empty, funds can be sent to any address." + ], + "type": { + "vec": "publicKey" + } + } + ] + } + }, + { + "name": "TransactionBuffer", + "type": { + "kind": "struct", + "fields": [ + { + "name": "multisig", + "docs": [ + "The multisig this belongs to." + ], + "type": "publicKey" + }, + { + "name": "creator", + "docs": [ + "Member of the Multisig who created the TransactionBuffer." + ], + "type": "publicKey" + }, + { + "name": "bufferIndex", + "docs": [ + "Index to seed address derivation" + ], + "type": "u8" + }, + { + "name": "vaultIndex", + "docs": [ + "Vault index of the transaction this buffer belongs to." + ], + "type": "u8" + }, + { + "name": "finalBufferHash", + "docs": [ + "Hash of the final assembled transaction message." + ], + "type": { + "array": [ + "u8", + 32 + ] + } + }, + { + "name": "finalBufferSize", + "docs": [ + "The size of the final assembled transaction message." + ], + "type": "u16" + }, + { + "name": "buffer", + "docs": [ + "The buffer of the transaction message." + ], + "type": "bytes" + } + ] + } + }, + { + "name": "VaultTransaction", + "docs": [ + "Stores data required for tracking the voting and execution status of a vault transaction.", + "Vault transaction is a transaction that's executed on behalf of the multisig vault PDA", + "and wraps arbitrary Solana instructions, typically calling into other Solana programs." + ], + "type": { + "kind": "struct", + "fields": [ + { + "name": "multisig", + "docs": [ + "The multisig this belongs to." + ], + "type": "publicKey" + }, + { + "name": "creator", + "docs": [ + "Member of the Multisig who submitted the transaction." + ], + "type": "publicKey" + }, + { + "name": "index", + "docs": [ + "Index of this transaction within the multisig." + ], + "type": "u64" + }, + { + "name": "bump", + "docs": [ + "bump for the transaction seeds." + ], + "type": "u8" + }, + { + "name": "vaultIndex", + "docs": [ + "Index of the vault this transaction belongs to." + ], + "type": "u8" + }, + { + "name": "vaultBump", + "docs": [ + "Derivation bump of the vault PDA this transaction belongs to." + ], + "type": "u8" + }, + { + "name": "ephemeralSignerBumps", + "docs": [ + "Derivation bumps for additional signers.", + "Some transactions require multiple signers. Often these additional signers are \"ephemeral\" keypairs", + "that are generated on the client with a sole purpose of signing the transaction and be discarded immediately after.", + "When wrapping such transactions into multisig ones, we replace these \"ephemeral\" signing keypairs", + "with PDAs derived from the MultisigTransaction's `transaction_index` and controlled by the Multisig Program;", + "during execution the program includes the seeds of these PDAs into the `invoke_signed` calls,", + "thus \"signing\" on behalf of these PDAs." + ], + "type": "bytes" + }, + { + "name": "message", + "docs": [ + "data required for executing the transaction." + ], + "type": { + "defined": "VaultTransactionMessage" + } + } + ] + } + } + ], + "types": [ + { + "name": "BatchAddTransactionArgs", + "type": { + "kind": "struct", + "fields": [ + { + "name": "ephemeralSigners", + "docs": [ + "Number of ephemeral signing PDAs required by the transaction." + ], + "type": "u8" + }, + { + "name": "transactionMessage", + "type": "bytes" + } + ] + } + }, + { + "name": "BatchCreateArgs", + "type": { + "kind": "struct", + "fields": [ + { + "name": "vaultIndex", + "docs": [ + "Index of the vault this transaction belongs to." + ], + "type": "u8" + }, + { + "name": "memo", + "type": { + "option": "string" + } + } + ] + } + }, + { + "name": "ConfigTransactionCreateArgs", + "type": { + "kind": "struct", + "fields": [ + { + "name": "actions", + "type": { + "vec": { + "defined": "ConfigAction" + } + } + }, + { + "name": "memo", + "type": { + "option": "string" + } + } + ] + } + }, + { + "name": "MultisigAddSpendingLimitArgs", + "type": { + "kind": "struct", + "fields": [ + { + "name": "createKey", + "docs": [ + "Key that is used to seed the SpendingLimit PDA." + ], + "type": "publicKey" + }, + { + "name": "vaultIndex", + "docs": [ + "The index of the vault that the spending limit is for." + ], + "type": "u8" + }, + { + "name": "mint", + "docs": [ + "The token mint the spending limit is for." + ], + "type": "publicKey" + }, + { + "name": "amount", + "docs": [ + "The amount of tokens that can be spent in a period.", + "This amount is in decimals of the mint,", + "so 1 SOL would be `1_000_000_000` and 1 USDC would be `1_000_000`." + ], + "type": "u64" + }, + { + "name": "period", + "docs": [ + "The reset period of the spending limit.", + "When it passes, the remaining amount is reset, unless it's `Period::OneTime`." + ], + "type": { + "defined": "Period" + } + }, + { + "name": "members", + "docs": [ + "Members of the Spending Limit that can use it.", + "Don't have to be members of the multisig." + ], + "type": { + "vec": "publicKey" + } + }, + { + "name": "destinations", + "docs": [ + "The destination addresses the spending limit is allowed to sent funds to.", + "If empty, funds can be sent to any address." + ], + "type": { + "vec": "publicKey" + } + }, + { + "name": "memo", + "docs": [ + "Memo is used for indexing only." + ], + "type": { + "option": "string" + } + } + ] + } + }, + { + "name": "MultisigAddMemberArgs", + "type": { + "kind": "struct", + "fields": [ + { + "name": "newMember", + "type": { + "defined": "Member" + } + }, + { + "name": "memo", + "docs": [ + "Memo is used for indexing only." + ], + "type": { + "option": "string" + } + } + ] + } + }, + { + "name": "MultisigRemoveMemberArgs", + "type": { + "kind": "struct", + "fields": [ + { + "name": "oldMember", + "type": "publicKey" + }, + { + "name": "memo", + "docs": [ + "Memo is used for indexing only." + ], + "type": { + "option": "string" + } + } + ] + } + }, + { + "name": "MultisigChangeThresholdArgs", + "type": { + "kind": "struct", + "fields": [ + { + "name": "newThreshold", + "type": "u16" + }, + { + "name": "memo", + "docs": [ + "Memo is used for indexing only." + ], + "type": { + "option": "string" + } + } + ] + } + }, + { + "name": "MultisigSetTimeLockArgs", + "type": { + "kind": "struct", + "fields": [ + { + "name": "timeLock", + "type": "u32" + }, + { + "name": "memo", + "docs": [ + "Memo is used for indexing only." + ], + "type": { + "option": "string" + } + } + ] + } + }, + { + "name": "MultisigSetConfigAuthorityArgs", + "type": { + "kind": "struct", + "fields": [ + { + "name": "configAuthority", + "type": "publicKey" + }, + { + "name": "memo", + "docs": [ + "Memo is used for indexing only." + ], + "type": { + "option": "string" + } + } + ] + } + }, + { + "name": "MultisigSetRentCollectorArgs", + "type": { + "kind": "struct", + "fields": [ + { + "name": "rentCollector", + "type": { + "option": "publicKey" + } + }, + { + "name": "memo", + "docs": [ + "Memo is used for indexing only." + ], + "type": { + "option": "string" + } + } + ] + } + }, + { + "name": "MultisigCreateArgsV2", + "type": { + "kind": "struct", + "fields": [ + { + "name": "configAuthority", + "docs": [ + "The authority that can configure the multisig: add/remove members, change the threshold, etc.", + "Should be set to `None` for autonomous multisigs." + ], + "type": { + "option": "publicKey" + } + }, + { + "name": "threshold", + "docs": [ + "The number of signatures required to execute a transaction." + ], + "type": "u16" + }, + { + "name": "members", + "docs": [ + "The members of the multisig." + ], + "type": { + "vec": { + "defined": "Member" + } + } + }, + { + "name": "timeLock", + "docs": [ + "How many seconds must pass between transaction voting, settlement, and execution." + ], + "type": "u32" + }, + { + "name": "rentCollector", + "docs": [ + "The address where the rent for the accounts related to executed, rejected, or cancelled", + "transactions can be reclaimed. If set to `None`, the rent reclamation feature is turned off." + ], + "type": { + "option": "publicKey" + } + }, + { + "name": "memo", + "docs": [ + "Memo is used for indexing only." + ], + "type": { + "option": "string" + } + } + ] + } + }, + { + "name": "MultisigRemoveSpendingLimitArgs", + "type": { + "kind": "struct", + "fields": [ + { + "name": "memo", + "docs": [ + "Memo is used for indexing only." + ], + "type": { + "option": "string" + } + } + ] + } + }, + { + "name": "ProgramConfigInitArgs", + "type": { + "kind": "struct", + "fields": [ + { + "name": "authority", + "docs": [ + "The authority that can configure the program config: change the treasury, etc." + ], + "type": "publicKey" + }, + { + "name": "multisigCreationFee", + "docs": [ + "The fee that is charged for creating a new multisig." + ], + "type": "u64" + }, + { + "name": "treasury", + "docs": [ + "The treasury where the creation fee is transferred to." + ], + "type": "publicKey" + } + ] + } + }, + { + "name": "ProgramConfigSetAuthorityArgs", + "type": { + "kind": "struct", + "fields": [ + { + "name": "newAuthority", + "type": "publicKey" + } + ] + } + }, + { + "name": "ProgramConfigSetMultisigCreationFeeArgs", + "type": { + "kind": "struct", + "fields": [ + { + "name": "newMultisigCreationFee", + "type": "u64" + } + ] + } + }, + { + "name": "ProgramConfigSetTreasuryArgs", + "type": { + "kind": "struct", + "fields": [ + { + "name": "newTreasury", + "type": "publicKey" + } + ] + } + }, + { + "name": "ProposalCreateArgs", + "type": { + "kind": "struct", + "fields": [ + { + "name": "transactionIndex", + "docs": [ + "Index of the multisig transaction this proposal is associated with." + ], + "type": "u64" + }, + { + "name": "draft", + "docs": [ + "Whether the proposal should be initialized with status `Draft`." + ], + "type": "bool" + } + ] + } + }, + { + "name": "ProposalVoteArgs", + "type": { + "kind": "struct", + "fields": [ + { + "name": "memo", + "type": { + "option": "string" + } + } + ] + } + }, + { + "name": "SpendingLimitUseArgs", + "type": { + "kind": "struct", + "fields": [ + { + "name": "amount", + "docs": [ + "Amount of tokens to transfer." + ], + "type": "u64" + }, + { + "name": "decimals", + "docs": [ + "Decimals of the token mint. Used for double-checking against incorrect order of magnitude of `amount`." + ], + "type": "u8" + }, + { + "name": "memo", + "docs": [ + "Memo used for indexing." + ], + "type": { + "option": "string" + } + } + ] + } + }, + { + "name": "TransactionBufferCreateArgs", + "type": { + "kind": "struct", + "fields": [ + { + "name": "bufferIndex", + "docs": [ + "Index of the buffer account to seed the account derivation" + ], + "type": "u8" + }, + { + "name": "vaultIndex", + "docs": [ + "Index of the vault this transaction belongs to." + ], + "type": "u8" + }, + { + "name": "finalBufferHash", + "docs": [ + "Hash of the final assembled transaction message." + ], + "type": { + "array": [ + "u8", + 32 + ] + } + }, + { + "name": "finalBufferSize", + "docs": [ + "Final size of the buffer." + ], + "type": "u16" + }, + { + "name": "buffer", + "docs": [ + "Initial slice of the buffer." + ], + "type": "bytes" + } + ] + } + }, + { + "name": "TransactionBufferExtendArgs", + "type": { + "kind": "struct", + "fields": [ + { + "name": "buffer", + "type": "bytes" + } + ] + } + }, + { + "name": "VaultTransactionCreateArgs", + "type": { + "kind": "struct", + "fields": [ + { + "name": "vaultIndex", + "docs": [ + "Index of the vault this transaction belongs to." + ], + "type": "u8" + }, + { + "name": "ephemeralSigners", + "docs": [ + "Number of ephemeral signing PDAs required by the transaction." + ], + "type": "u8" + }, + { + "name": "transactionMessage", + "type": "bytes" + }, + { + "name": "memo", + "type": { + "option": "string" + } + } + ] + } + }, + { + "name": "Member", + "type": { + "kind": "struct", + "fields": [ + { + "name": "key", + "type": "publicKey" + }, + { + "name": "permissions", + "type": { + "defined": "Permissions" + } + } + ] + } + }, + { + "name": "Permissions", + "docs": [ + "Bitmask for permissions." + ], + "type": { + "kind": "struct", + "fields": [ + { + "name": "mask", + "type": "u8" + } + ] + } + }, + { + "name": "VaultTransactionMessage", + "type": { + "kind": "struct", + "fields": [ + { + "name": "numSigners", + "docs": [ + "The number of signer pubkeys in the account_keys vec." + ], + "type": "u8" + }, + { + "name": "numWritableSigners", + "docs": [ + "The number of writable signer pubkeys in the account_keys vec." + ], + "type": "u8" + }, + { + "name": "numWritableNonSigners", + "docs": [ + "The number of writable non-signer pubkeys in the account_keys vec." + ], + "type": "u8" + }, + { + "name": "accountKeys", + "docs": [ + "Unique account pubkeys (including program IDs) required for execution of the tx.", + "The signer pubkeys appear at the beginning of the vec, with writable pubkeys first, and read-only pubkeys following.", + "The non-signer pubkeys follow with writable pubkeys first and read-only ones following.", + "Program IDs are also stored at the end of the vec along with other non-signer non-writable pubkeys:", + "", + "```plaintext", + "[pubkey1, pubkey2, pubkey3, pubkey4, pubkey5, pubkey6, pubkey7, pubkey8]", + "|---writable---| |---readonly---| |---writable---| |---readonly---|", + "|------------signers-------------| |----------non-singers-----------|", + "```" + ], + "type": { + "vec": "publicKey" + } + }, + { + "name": "instructions", + "docs": [ + "List of instructions making up the tx." + ], + "type": { + "vec": { + "defined": "MultisigCompiledInstruction" + } + } + }, + { + "name": "addressTableLookups", + "docs": [ + "List of address table lookups used to load additional accounts", + "for this transaction." + ], + "type": { + "vec": { + "defined": "MultisigMessageAddressTableLookup" + } + } + } + ] + } + }, + { + "name": "MultisigCompiledInstruction", + "docs": [ + "Concise serialization schema for instructions that make up a transaction.", + "Closely mimics the Solana transaction wire format." + ], + "type": { + "kind": "struct", + "fields": [ + { + "name": "programIdIndex", + "type": "u8" + }, + { + "name": "accountIndexes", + "docs": [ + "Indices into the tx's `account_keys` list indicating which accounts to pass to the instruction." + ], + "type": "bytes" + }, + { + "name": "data", + "docs": [ + "Instruction data." + ], + "type": "bytes" + } + ] + } + }, + { + "name": "MultisigMessageAddressTableLookup", + "docs": [ + "Address table lookups describe an on-chain address lookup table to use", + "for loading more readonly and writable accounts into a transaction." + ], + "type": { + "kind": "struct", + "fields": [ + { + "name": "accountKey", + "docs": [ + "Address lookup table account key." + ], + "type": "publicKey" + }, + { + "name": "writableIndexes", + "docs": [ + "List of indexes used to load writable accounts." + ], + "type": "bytes" + }, + { + "name": "readonlyIndexes", + "docs": [ + "List of indexes used to load readonly accounts." + ], + "type": "bytes" + } + ] + } + }, + { + "name": "Vote", + "type": { + "kind": "enum", + "variants": [ + { + "name": "Approve" + }, + { + "name": "Reject" + }, + { + "name": "Cancel" + } + ] + } + }, + { + "name": "ConfigAction", + "type": { + "kind": "enum", + "variants": [ + { + "name": "AddMember", + "fields": [ + { + "name": "newMember", + "type": { + "defined": "Member" + } + } + ] + }, + { + "name": "RemoveMember", + "fields": [ + { + "name": "oldMember", + "type": "publicKey" + } + ] + }, + { + "name": "ChangeThreshold", + "fields": [ + { + "name": "newThreshold", + "type": "u16" + } + ] + }, + { + "name": "SetTimeLock", + "fields": [ + { + "name": "newTimeLock", + "type": "u32" + } + ] + }, + { + "name": "AddSpendingLimit", + "fields": [ + { + "name": "createKey", + "docs": [ + "Key that is used to seed the SpendingLimit PDA." + ], + "type": "publicKey" + }, + { + "name": "vaultIndex", + "docs": [ + "The index of the vault that the spending limit is for." + ], + "type": "u8" + }, + { + "name": "mint", + "docs": [ + "The token mint the spending limit is for." + ], + "type": "publicKey" + }, + { + "name": "amount", + "docs": [ + "The amount of tokens that can be spent in a period.", + "This amount is in decimals of the mint,", + "so 1 SOL would be `1_000_000_000` and 1 USDC would be `1_000_000`." + ], + "type": "u64" + }, + { + "name": "period", + "docs": [ + "The reset period of the spending limit.", + "When it passes, the remaining amount is reset, unless it's `Period::OneTime`." + ], + "type": { + "defined": "Period" + } + }, + { + "name": "members", + "docs": [ + "Members of the multisig that can use the spending limit.", + "In case a member is removed from the multisig, the spending limit will remain existent", + "(until explicitly deleted), but the removed member will not be able to use it anymore." + ], + "type": { + "vec": "publicKey" + } + }, + { + "name": "destinations", + "docs": [ + "The destination addresses the spending limit is allowed to sent funds to.", + "If empty, funds can be sent to any address." + ], + "type": { + "vec": "publicKey" + } + } + ] + }, + { + "name": "RemoveSpendingLimit", + "fields": [ + { + "name": "spendingLimit", + "type": "publicKey" + } + ] + }, + { + "name": "SetRentCollector", + "fields": [ + { + "name": "newRentCollector", + "type": { + "option": "publicKey" + } + } + ] + } + ] + } + }, + { + "name": "ProposalStatus", + "docs": [ + "The status of a proposal.", + "Each variant wraps a timestamp of when the status was set." + ], + "type": { + "kind": "enum", + "variants": [ + { + "name": "Draft", + "fields": [ + { + "name": "timestamp", + "type": "i64" + } + ] + }, + { + "name": "Active", + "fields": [ + { + "name": "timestamp", + "type": "i64" + } + ] + }, + { + "name": "Rejected", + "fields": [ + { + "name": "timestamp", + "type": "i64" + } + ] + }, + { + "name": "Approved", + "fields": [ + { + "name": "timestamp", + "type": "i64" + } + ] + }, + { + "name": "Executing" + }, + { + "name": "Executed", + "fields": [ + { + "name": "timestamp", + "type": "i64" + } + ] + }, + { + "name": "Cancelled", + "fields": [ + { + "name": "timestamp", + "type": "i64" + } + ] + } + ] + } + }, + { + "name": "Period", + "docs": [ + "The reset period of the spending limit." + ], + "type": { + "kind": "enum", + "variants": [ + { + "name": "OneTime" + }, + { + "name": "Day" + }, + { + "name": "Week" + }, + { + "name": "Month" + } + ] + } + } + ], + "errors": [ + { + "code": 6000, + "name": "DuplicateMember", + "msg": "Found multiple members with the same pubkey" + }, + { + "code": 6001, + "name": "EmptyMembers", + "msg": "Members array is empty" + }, + { + "code": 6002, + "name": "TooManyMembers", + "msg": "Too many members, can be up to 65535" + }, + { + "code": 6003, + "name": "InvalidThreshold", + "msg": "Invalid threshold, must be between 1 and number of members with Vote permission" + }, + { + "code": 6004, + "name": "Unauthorized", + "msg": "Attempted to perform an unauthorized action" + }, + { + "code": 6005, + "name": "NotAMember", + "msg": "Provided pubkey is not a member of multisig" + }, + { + "code": 6006, + "name": "InvalidTransactionMessage", + "msg": "TransactionMessage is malformed." + }, + { + "code": 6007, + "name": "StaleProposal", + "msg": "Proposal is stale" + }, + { + "code": 6008, + "name": "InvalidProposalStatus", + "msg": "Invalid proposal status" + }, + { + "code": 6009, + "name": "InvalidTransactionIndex", + "msg": "Invalid transaction index" + }, + { + "code": 6010, + "name": "AlreadyApproved", + "msg": "Member already approved the transaction" + }, + { + "code": 6011, + "name": "AlreadyRejected", + "msg": "Member already rejected the transaction" + }, + { + "code": 6012, + "name": "AlreadyCancelled", + "msg": "Member already cancelled the transaction" + }, + { + "code": 6013, + "name": "InvalidNumberOfAccounts", + "msg": "Wrong number of accounts provided" + }, + { + "code": 6014, + "name": "InvalidAccount", + "msg": "Invalid account provided" + }, + { + "code": 6015, + "name": "RemoveLastMember", + "msg": "Cannot remove last member" + }, + { + "code": 6016, + "name": "NoVoters", + "msg": "Members don't include any voters" + }, + { + "code": 6017, + "name": "NoProposers", + "msg": "Members don't include any proposers" + }, + { + "code": 6018, + "name": "NoExecutors", + "msg": "Members don't include any executors" + }, + { + "code": 6019, + "name": "InvalidStaleTransactionIndex", + "msg": "`stale_transaction_index` must be <= `transaction_index`" + }, + { + "code": 6020, + "name": "NotSupportedForControlled", + "msg": "Instruction not supported for controlled multisig" + }, + { + "code": 6021, + "name": "TimeLockNotReleased", + "msg": "Proposal time lock has not been released" + }, + { + "code": 6022, + "name": "NoActions", + "msg": "Config transaction must have at least one action" + }, + { + "code": 6023, + "name": "MissingAccount", + "msg": "Missing account" + }, + { + "code": 6024, + "name": "InvalidMint", + "msg": "Invalid mint" + }, + { + "code": 6025, + "name": "InvalidDestination", + "msg": "Invalid destination" + }, + { + "code": 6026, + "name": "SpendingLimitExceeded", + "msg": "Spending limit exceeded" + }, + { + "code": 6027, + "name": "DecimalsMismatch", + "msg": "Decimals don't match the mint" + }, + { + "code": 6028, + "name": "UnknownPermission", + "msg": "Member has unknown permission" + }, + { + "code": 6029, + "name": "ProtectedAccount", + "msg": "Account is protected, it cannot be passed into a CPI as writable" + }, + { + "code": 6030, + "name": "TimeLockExceedsMaxAllowed", + "msg": "Time lock exceeds the maximum allowed (90 days)" + }, + { + "code": 6031, + "name": "IllegalAccountOwner", + "msg": "Account is not owned by Multisig program" + }, + { + "code": 6032, + "name": "RentReclamationDisabled", + "msg": "Rent reclamation is disabled for this multisig" + }, + { + "code": 6033, + "name": "InvalidRentCollector", + "msg": "Invalid rent collector address" + }, + { + "code": 6034, + "name": "ProposalForAnotherMultisig", + "msg": "Proposal is for another multisig" + }, + { + "code": 6035, + "name": "TransactionForAnotherMultisig", + "msg": "Transaction is for another multisig" + }, + { + "code": 6036, + "name": "TransactionNotMatchingProposal", + "msg": "Transaction doesn't match proposal" + }, + { + "code": 6037, + "name": "TransactionNotLastInBatch", + "msg": "Transaction is not last in batch" + }, + { + "code": 6038, + "name": "BatchNotEmpty", + "msg": "Batch is not empty" + }, + { + "code": 6039, + "name": "SpendingLimitInvalidAmount", + "msg": "Invalid SpendingLimit amount" + }, + { + "code": 6040, + "name": "InvalidInstructionArgs", + "msg": "Invalid Instruction Arguments" + }, + { + "code": 6041, + "name": "FinalBufferHashMismatch", + "msg": "Final message buffer hash doesnt match the expected hash" + }, + { + "code": 6042, + "name": "FinalBufferSizeExceeded", + "msg": "Final buffer size cannot exceed 4000 bytes" + }, + { + "code": 6043, + "name": "FinalBufferSizeMismatch", + "msg": "Final buffer size mismatch" + }, + { + "code": 6044, + "name": "MultisigCreateDeprecated", + "msg": "multisig_create has been deprecated. Use multisig_create_v2 instead." + } + ], + "metadata": { + "address": "SQDS4ep65T869zMMBKyuUq6aD6EgTu8psMjkvj52pCf", + "origin": "anchor", + "binaryVersion": "0.29.0", + "libVersion": "=0.29.0" + } +} \ No newline at end of file From be3b45f05bd17f82c2c68964ecd1e173c645132f Mon Sep 17 00:00:00 2001 From: Prasanna Gautam Date: Wed, 8 Apr 2026 02:05:56 +0000 Subject: [PATCH 2/7] feat: decode nested vaultTransactionCreate inner instructions Deserialize the VaultTransactionMessage from the transactionMessage bytes field using Solana's compact wire format, reconstruct full Instructions, and pass them through the existing visualizer framework so inner program calls (System, Drift, Token, etc.) are displayed with their decoded names and accounts instead of raw hex. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../src/presets/squads_multisig/mod.rs | 309 +++++++++++++++++- 1 file changed, 302 insertions(+), 7 deletions(-) diff --git a/src/chain_parsers/visualsign-solana/src/presets/squads_multisig/mod.rs b/src/chain_parsers/visualsign-solana/src/presets/squads_multisig/mod.rs index 15e9efae..95058fc1 100644 --- a/src/chain_parsers/visualsign-solana/src/presets/squads_multisig/mod.rs +++ b/src/chain_parsers/visualsign-solana/src/presets/squads_multisig/mod.rs @@ -4,11 +4,16 @@ mod config; use crate::core::{ InstructionVisualizer, SolanaIntegrationConfig, VisualizerContext, VisualizerKind, + available_visualizers, visualize_with_any, }; +use crate::idl::IdlRegistry; use config::SquadsMultisigConfig; +use solana_parser::solana::structs::SolanaAccount; use solana_parser::{ Idl, SolanaParsedInstructionData, decode_idl_data, parse_instruction_with_idl, }; +use solana_sdk::instruction::{AccountMeta, Instruction}; +use solana_sdk::pubkey::Pubkey; use std::collections::HashMap; use visualsign::errors::VisualSignError; use visualsign::field_builders::{create_raw_data_field, create_text_field}; @@ -23,6 +28,85 @@ const SQUADS_IDL_JSON: &str = include_str!("squads_multisig_program.json"); static SQUADS_MULTISIG_CONFIG: SquadsMultisigConfig = SquadsMultisigConfig; +// -- VaultTransactionMessage uses Solana's compact wire format (u8 lengths), not borsh -- + +struct VaultTransactionMessage { + account_keys: Vec, + instructions: Vec, +} + +struct MultisigCompiledInstruction { + program_id_index: u8, + account_indexes: Vec, + data: Vec, +} + +impl VaultTransactionMessage { + /// Parse from Squads' compact wire format (mimics Solana Message serialization). + /// Format: 3×u8 header, u8 account_keys count + pubkeys, + /// u8 instructions count + compiled instructions, + /// u8 address_table_lookups count + lookups + fn deserialize(data: &[u8]) -> Result> { + let mut pos = 0; + + let read_u8 = |pos: &mut usize| -> Result> { + if *pos >= data.len() { + return Err("unexpected end of data".into()); + } + let val = data[*pos]; + *pos += 1; + Ok(val) + }; + + let read_bytes = + |pos: &mut usize, len: usize| -> Result<&[u8], Box> { + if *pos + len > data.len() { + return Err("unexpected end of data".into()); + } + let slice = &data[*pos..*pos + len]; + *pos += len; + Ok(slice) + }; + + // Header: 3 u8s (numSigners, numWritableSigners, numWritableNonSigners) + let _num_signers = read_u8(&mut pos)?; + let _num_writable_signers = read_u8(&mut pos)?; + let _num_writable_non_signers = read_u8(&mut pos)?; + + // Account keys: u8 count + N × 32-byte pubkeys + let num_keys = read_u8(&mut pos)? as usize; + let mut account_keys = Vec::with_capacity(num_keys); + for _ in 0..num_keys { + let key_bytes = read_bytes(&mut pos, 32)?; + account_keys.push(Pubkey::new_from_array(key_bytes.try_into()?)); + } + + // Instructions: u8 count + N × compiled instructions + let num_instructions = read_u8(&mut pos)? as usize; + let mut instructions = Vec::with_capacity(num_instructions); + for _ in 0..num_instructions { + let program_id_index = read_u8(&mut pos)?; + let num_account_indexes = read_u8(&mut pos)? as usize; + let account_indexes = read_bytes(&mut pos, num_account_indexes)?.to_vec(); + let data_len = read_u8(&mut pos)? as usize; + let instruction_data = read_bytes(&mut pos, data_len)?.to_vec(); + instructions.push(MultisigCompiledInstruction { + program_id_index, + account_indexes, + data: instruction_data, + }); + } + + // Address table lookups: u8 count (we skip parsing the contents) + // They're not needed for instruction reconstruction without ALT resolution + + Ok(Self { + account_keys, + instructions, + }) + } +} + pub struct SquadsMultisigVisualizer; impl InstructionVisualizer for SquadsMultisigVisualizer { @@ -43,7 +127,9 @@ impl InstructionVisualizer for SquadsMultisigVisualizer { let parsed = parse_squads_instruction(&instruction.data, &instruction.accounts); let (title, condensed_fields, expanded_fields) = match parsed { - Ok(parsed) => build_parsed_fields(&parsed, &instruction.program_id.to_string()), + Ok(parsed) => { + build_parsed_fields(&parsed, &instruction.program_id.to_string(), context) + } Err(_) => build_fallback_fields(&instruction.program_id.to_string()), }; @@ -93,7 +179,7 @@ fn get_squads_idl() -> Option { fn parse_squads_instruction( data: &[u8], - accounts: &[solana_sdk::instruction::AccountMeta], + accounts: &[AccountMeta], ) -> Result> { if data.len() < 8 { return Err("Invalid instruction data length".into()); @@ -102,7 +188,6 @@ fn parse_squads_instruction( let idl = get_squads_idl().ok_or("Squads Multisig IDL not available")?; let parsed = parse_instruction_with_idl(data, SQUADS_MULTISIG_PROGRAM_ID, &idl)?; - // Build named accounts by matching instruction accounts with IDL definitions let named_accounts = build_named_accounts(data, &idl, accounts); Ok(SquadsParsedInstruction { @@ -114,7 +199,7 @@ fn parse_squads_instruction( fn build_named_accounts( data: &[u8], idl: &Idl, - accounts: &[solana_sdk::instruction::AccountMeta], + accounts: &[AccountMeta], ) -> HashMap { let mut named_accounts = HashMap::new(); @@ -143,12 +228,191 @@ struct SquadsParsedInstruction { fn build_parsed_fields( instruction: &SquadsParsedInstruction, program_id: &str, + context: &VisualizerContext, ) -> ( String, Vec, Vec, ) { let parsed = &instruction.parsed; + + // Special case: decode nested transaction message for vaultTransactionCreate + if parsed.instruction_name == "vaultTransactionCreate" { + if let Some(fields) = try_build_vault_transaction_fields( + parsed, + &instruction.named_accounts, + program_id, + context, + ) { + return fields; + } + } + + build_generic_fields(parsed, &instruction.named_accounts, program_id) +} + +/// Try to decode the nested transaction message inside vaultTransactionCreate. +/// Returns None if decoding fails, falling through to generic display. +fn try_build_vault_transaction_fields( + parsed: &SolanaParsedInstructionData, + named_accounts: &HashMap, + program_id: &str, + context: &VisualizerContext, +) -> Option<( + String, + Vec, + Vec, +)> { + // Extract transactionMessage hex from the nested args struct + let args_value = parsed.program_call_args.get("args")?; + let tx_msg_hex = args_value.get("transactionMessage")?.as_str()?; + let tx_msg_bytes = hex::decode(tx_msg_hex).ok()?; + let vault_msg = VaultTransactionMessage::deserialize(&tx_msg_bytes).ok()?; + + // Reconstruct full Instructions from the compiled instructions + let inner_instructions = reconstruct_instructions(&vault_msg); + + // Visualize inner instructions using the full visualizer framework + let inner_fields = visualize_inner_instructions(&inner_instructions, context); + + let vault_index = args_value + .get("vaultIndex") + .and_then(|v| v.as_u64()) + .unwrap_or(0); + let memo = args_value + .get("memo") + .and_then(|v| v.as_str()) + .unwrap_or("None"); + + let inner_count = inner_instructions.len(); + let title = format!("Squads Multisig: Vault Transaction ({inner_count} inner instruction(s))"); + + // Condensed: program, instruction, vault index, inner instruction count + let mut condensed_fields = vec![]; + if let Ok(f) = create_text_field("Program", "Squads Multisig") { + condensed_fields.push(f); + } + if let Ok(f) = create_text_field("Instruction", "vaultTransactionCreate") { + condensed_fields.push(f); + } + if let Ok(f) = create_text_field("Vault Index", &vault_index.to_string()) { + condensed_fields.push(f); + } + if let Ok(f) = create_text_field( + "Inner Instructions", + &format!("{inner_count} instruction(s)"), + ) { + condensed_fields.push(f); + } + + // Expanded: full details + decoded inner instructions + let mut expanded_fields = vec![]; + if let Ok(f) = create_text_field("Program ID", program_id) { + expanded_fields.push(f); + } + if let Ok(f) = create_text_field("Instruction", "vaultTransactionCreate") { + expanded_fields.push(f); + } + if let Ok(f) = create_text_field("Discriminator", &parsed.discriminator) { + expanded_fields.push(f); + } + if let Ok(f) = create_text_field("Vault Index", &vault_index.to_string()) { + expanded_fields.push(f); + } + if let Ok(f) = create_text_field("Memo", memo) { + expanded_fields.push(f); + } + + // Named accounts from the outer instruction + for (account_name, account_address) in named_accounts { + if let Ok(f) = create_text_field(account_name, account_address) { + expanded_fields.push(f); + } + } + + // Decoded inner instructions + expanded_fields.extend(inner_fields); + + Some((title, condensed_fields, expanded_fields)) +} + +/// Reconstruct Instruction objects from VaultTransactionMessage compiled instructions. +/// Same pattern as core/instructions.rs:39-66. +fn reconstruct_instructions(vault_msg: &VaultTransactionMessage) -> Vec { + let account_keys = &vault_msg.account_keys; + + vault_msg + .instructions + .iter() + .filter_map(|ci| { + let program_id_idx = ci.program_id_index as usize; + if program_id_idx >= account_keys.len() { + return None; + } + + let accounts: Vec = ci + .account_indexes + .iter() + .filter_map(|&i| { + let idx = i as usize; + if idx < account_keys.len() { + Some(AccountMeta::new_readonly(account_keys[idx], false)) + } else { + None + } + }) + .collect(); + + Some(Instruction { + program_id: account_keys[program_id_idx], + accounts, + data: ci.data.clone(), + }) + }) + .collect() +} + +/// Visualize reconstructed inner instructions using the full visualizer framework. +fn visualize_inner_instructions( + inner_instructions: &[Instruction], + context: &VisualizerContext, +) -> Vec { + let visualizers: Vec> = available_visualizers(); + let visualizers_refs: Vec<&dyn InstructionVisualizer> = + visualizers.iter().map(|v| v.as_ref()).collect(); + + let idl_registry = IdlRegistry::new(); + let sender = SolanaAccount { + account_key: context.sender().account_key.clone(), + signer: false, + writable: false, + }; + + let instructions_vec: Vec = inner_instructions.to_vec(); + + instructions_vec + .iter() + .enumerate() + .filter_map(|(idx, _)| { + let inner_context = + VisualizerContext::new(&sender, idx, &instructions_vec, &idl_registry); + + visualize_with_any(&visualizers_refs, &inner_context) + .and_then(|result| result.ok()) + .map(|viz_result| viz_result.field) + }) + .collect() +} + +fn build_generic_fields( + parsed: &SolanaParsedInstructionData, + named_accounts: &HashMap, + program_id: &str, +) -> ( + String, + Vec, + Vec, +) { let title = format!("Squads Multisig: {}", parsed.instruction_name); let mut condensed_fields = vec![]; @@ -178,14 +442,12 @@ fn build_parsed_fields( expanded_fields.push(f); } - // Named accounts - for (account_name, account_address) in &instruction.named_accounts { + for (account_name, account_address) in named_accounts { if let Ok(f) = create_text_field(account_name, account_address) { expanded_fields.push(f); } } - // Args for (key, value) in &parsed.program_call_args { if let Ok(f) = create_text_field(key, &format_arg_value(value)) { expanded_fields.push(f); @@ -291,4 +553,37 @@ mod tests { let result = parse_squads_instruction(&short_data, &accounts); assert!(result.is_err(), "Short data should return error"); } + + #[test] + fn test_vault_transaction_message_deserialization() { + // The transactionMessage hex from the sample transaction + let tx_msg_hex = "01010103904fc8953dcfc9f3b5179893ee12fc9c445cad889a957d61cfb8dbcc172f6a4f4a3eef4b03c82a71599ea07a16ee4bcf6dce31357d8460b2ac1bd4c3a9860c9d0954dbbe9ec960c98a7a293fe21336966fe180d151ae4b8179561f89854a53f601020200012800a1b028d53cb8b3e4ef5e27e0961546aad46749acc092e03c1a8c7c1187e887cc245db9cf2bca9a9900"; + let tx_msg_bytes = hex::decode(tx_msg_hex).unwrap(); + let vault_msg = VaultTransactionMessage::deserialize(&tx_msg_bytes).unwrap(); + + assert_eq!( + vault_msg.account_keys.len(), + 3, + "Should have 3 account keys" + ); + assert_eq!( + vault_msg.instructions.len(), + 1, + "Should have 1 inner instruction" + ); + + let inner = &vault_msg.instructions[0]; + assert!( + (inner.program_id_index as usize) < vault_msg.account_keys.len(), + "program_id_index should be valid" + ); + + let instructions = reconstruct_instructions(&vault_msg); + assert_eq!(instructions.len(), 1, "Should reconstruct 1 instruction"); + // The inner instruction's program_id should be one of the account keys + assert!( + vault_msg.account_keys.contains(&instructions[0].program_id), + "Inner instruction program_id should be in account_keys" + ); + } } From e5b33c11f462cfb05a680773764b875d7e07188c Mon Sep 17 00:00:00 2001 From: Prasanna Gautam Date: Wed, 8 Apr 2026 05:11:05 +0000 Subject: [PATCH 3/7] fix(solana): fix VaultTransactionMessage data_len parsing and cache IDL Fix instruction data_len field in VaultTransactionMessage::deserialize to read as u16 LE instead of u8, matching the Squads v4 on-chain wire format. The previous u8 read consumed one too few bytes, shifting instruction data by one byte and causing inner instruction discriminator mismatches (e.g. Drift updateAdmin showed as "Unknown Instruction"). Also cache the parsed Squads IDL with LazyLock to avoid re-parsing 86KB JSON on every instruction. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../src/presets/squads_multisig/mod.rs | 25 ++++++++++++++----- 1 file changed, 19 insertions(+), 6 deletions(-) diff --git a/src/chain_parsers/visualsign-solana/src/presets/squads_multisig/mod.rs b/src/chain_parsers/visualsign-solana/src/presets/squads_multisig/mod.rs index 95058fc1..5a3cd47a 100644 --- a/src/chain_parsers/visualsign-solana/src/presets/squads_multisig/mod.rs +++ b/src/chain_parsers/visualsign-solana/src/presets/squads_multisig/mod.rs @@ -45,7 +45,9 @@ impl VaultTransactionMessage { /// Parse from Squads' compact wire format (mimics Solana Message serialization). /// Format: 3×u8 header, u8 account_keys count + pubkeys, /// u8 instructions count + compiled instructions, - /// u8 address_table_lookups count + lookups + /// u8 address_table_lookups count + lookups. + /// Within compiled instructions, data length is encoded as u16 LE + /// (matching the Squads v4 on-chain format). fn deserialize(data: &[u8]) -> Result> { let mut pos = 0; @@ -58,6 +60,15 @@ impl VaultTransactionMessage { Ok(val) }; + let read_u16_le = |pos: &mut usize| -> Result> { + if *pos + 2 > data.len() { + return Err("unexpected end of data".into()); + } + let val = u16::from_le_bytes([data[*pos], data[*pos + 1]]); + *pos += 2; + Ok(val) + }; + let read_bytes = |pos: &mut usize, len: usize| -> Result<&[u8], Box> { if *pos + len > data.len() { @@ -88,7 +99,7 @@ impl VaultTransactionMessage { let program_id_index = read_u8(&mut pos)?; let num_account_indexes = read_u8(&mut pos)? as usize; let account_indexes = read_bytes(&mut pos, num_account_indexes)?.to_vec(); - let data_len = read_u8(&mut pos)? as usize; + let data_len = read_u16_le(&mut pos)? as usize; let instruction_data = read_bytes(&mut pos, data_len)?.to_vec(); instructions.push(MultisigCompiledInstruction { program_id_index, @@ -173,8 +184,10 @@ impl InstructionVisualizer for SquadsMultisigVisualizer { } } -fn get_squads_idl() -> Option { - decode_idl_data(SQUADS_IDL_JSON).ok() +fn get_squads_idl() -> Option<&'static Idl> { + static IDL: std::sync::LazyLock> = + std::sync::LazyLock::new(|| decode_idl_data(SQUADS_IDL_JSON).ok()); + IDL.as_ref() } fn parse_squads_instruction( @@ -186,9 +199,9 @@ fn parse_squads_instruction( } let idl = get_squads_idl().ok_or("Squads Multisig IDL not available")?; - let parsed = parse_instruction_with_idl(data, SQUADS_MULTISIG_PROGRAM_ID, &idl)?; + let parsed = parse_instruction_with_idl(data, SQUADS_MULTISIG_PROGRAM_ID, idl)?; - let named_accounts = build_named_accounts(data, &idl, accounts); + let named_accounts = build_named_accounts(data, idl, accounts); Ok(SquadsParsedInstruction { parsed, From 058e02558df09a4f65df7cbf5f36a557ac492456 Mon Sep 17 00:00:00 2001 From: Prasanna Gautam Date: Mon, 4 May 2026 22:37:36 +0000 Subject: [PATCH 4/7] fix(solana/squads): address Copilot review comments - VaultTransactionMessage::deserialize now consumes the address-table-lookups trailer (count + 32-byte pubkey + writable/readonly index arrays per lookup) and rejects messages with unconsumed trailing bytes. Previously messages containing ALTs (or any padding) would silently parse as Ok. - visualize_inner_instructions returns Result and explicitly falls back to UnknownProgramVisualizer when the chosen visualizer fails or no visualizer matches. Previously per-instruction errors were swallowed via result.ok() and the fields were dropped from the output. - Replace the if-let-Ok-discard pattern on create_text_field / create_raw_data_field with `?` propagation throughout the build_* helpers (matching stakepool/mod.rs). All affected helpers now return Result; introduces a SquadsPreviewFields type alias to keep clippy::type_complexity happy. cargo test (-p visualsign-solana, all 64 tests), cargo clippy (workspace, -D warnings), cargo fmt --check all pass. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../src/presets/squads_multisig/mod.rs | 257 +++++++++--------- 1 file changed, 123 insertions(+), 134 deletions(-) diff --git a/src/chain_parsers/visualsign-solana/src/presets/squads_multisig/mod.rs b/src/chain_parsers/visualsign-solana/src/presets/squads_multisig/mod.rs index 5a3cd47a..2b0d9611 100644 --- a/src/chain_parsers/visualsign-solana/src/presets/squads_multisig/mod.rs +++ b/src/chain_parsers/visualsign-solana/src/presets/squads_multisig/mod.rs @@ -108,8 +108,26 @@ impl VaultTransactionMessage { }); } - // Address table lookups: u8 count (we skip parsing the contents) - // They're not needed for instruction reconstruction without ALT resolution + // Address table lookups: u8 count + N × { 32-byte pubkey, u8 count + writable + // indexes, u8 count + readonly indexes }. We don't need the lookup contents to + // reconstruct top-level instructions, but we still consume them so a malformed + // (truncated or padded) message is rejected rather than silently accepted. + let num_lookups = read_u8(&mut pos)? as usize; + for _ in 0..num_lookups { + let _account_key = read_bytes(&mut pos, 32)?; + let num_writable = read_u8(&mut pos)? as usize; + let _writable_indexes = read_bytes(&mut pos, num_writable)?; + let num_readonly = read_u8(&mut pos)? as usize; + let _readonly_indexes = read_bytes(&mut pos, num_readonly)?; + } + + if pos != data.len() { + return Err(format!( + "trailing bytes after VaultTransactionMessage: pos={pos}, len={}", + data.len() + ) + .into()); + } Ok(Self { account_keys, @@ -139,16 +157,16 @@ impl InstructionVisualizer for SquadsMultisigVisualizer { let (title, condensed_fields, expanded_fields) = match parsed { Ok(parsed) => { - build_parsed_fields(&parsed, &instruction.program_id.to_string(), context) + build_parsed_fields(&parsed, &instruction.program_id.to_string(), context)? } - Err(_) => build_fallback_fields(&instruction.program_id.to_string()), + Err(_) => build_fallback_fields(&instruction.program_id.to_string())?, }; let condensed = SignablePayloadFieldListLayout { fields: condensed_fields, }; let expanded_with_raw = - append_raw_data(expanded_fields, &instruction.data, &instruction_data_hex); + append_raw_data(expanded_fields, &instruction.data, &instruction_data_hex)?; let expanded = SignablePayloadFieldListLayout { fields: expanded_with_raw, }; @@ -238,15 +256,18 @@ struct SquadsParsedInstruction { named_accounts: HashMap, } +/// `(title, condensed_fields, expanded_fields)` returned by the various `build_*` helpers. +type SquadsPreviewFields = ( + String, + Vec, + Vec, +); + fn build_parsed_fields( instruction: &SquadsParsedInstruction, program_id: &str, context: &VisualizerContext, -) -> ( - String, - Vec, - Vec, -) { +) -> Result { let parsed = &instruction.parsed; // Special case: decode nested transaction message for vaultTransactionCreate @@ -256,8 +277,8 @@ fn build_parsed_fields( &instruction.named_accounts, program_id, context, - ) { - return fields; + )? { + return Ok(fields); } } @@ -265,28 +286,41 @@ fn build_parsed_fields( } /// Try to decode the nested transaction message inside vaultTransactionCreate. -/// Returns None if decoding fails, falling through to generic display. +/// +/// Returns `Ok(None)` when the embedded transaction message is missing or unparseable +/// (callers fall through to the generic display). Returns `Err` only when field-builder +/// or downstream visualization errors occur — those propagate up so the caller can decide +/// whether to surface them. fn try_build_vault_transaction_fields( parsed: &SolanaParsedInstructionData, named_accounts: &HashMap, program_id: &str, context: &VisualizerContext, -) -> Option<( - String, - Vec, - Vec, -)> { - // Extract transactionMessage hex from the nested args struct - let args_value = parsed.program_call_args.get("args")?; - let tx_msg_hex = args_value.get("transactionMessage")?.as_str()?; - let tx_msg_bytes = hex::decode(tx_msg_hex).ok()?; - let vault_msg = VaultTransactionMessage::deserialize(&tx_msg_bytes).ok()?; +) -> Result, VisualSignError> { + // Extract transactionMessage hex from the nested args struct. + // Missing/malformed embedded data is "graceful degradation" — the outer caller falls + // back to the generic display, NOT an error. + let Some(args_value) = parsed.program_call_args.get("args") else { + return Ok(None); + }; + let Some(tx_msg_hex) = args_value + .get("transactionMessage") + .and_then(|v| v.as_str()) + else { + return Ok(None); + }; + let Ok(tx_msg_bytes) = hex::decode(tx_msg_hex) else { + return Ok(None); + }; + let Ok(vault_msg) = VaultTransactionMessage::deserialize(&tx_msg_bytes) else { + return Ok(None); + }; // Reconstruct full Instructions from the compiled instructions let inner_instructions = reconstruct_instructions(&vault_msg); // Visualize inner instructions using the full visualizer framework - let inner_fields = visualize_inner_instructions(&inner_instructions, context); + let inner_fields = visualize_inner_instructions(&inner_instructions, context)?; let vault_index = args_value .get("vaultIndex") @@ -301,52 +335,34 @@ fn try_build_vault_transaction_fields( let title = format!("Squads Multisig: Vault Transaction ({inner_count} inner instruction(s))"); // Condensed: program, instruction, vault index, inner instruction count - let mut condensed_fields = vec![]; - if let Ok(f) = create_text_field("Program", "Squads Multisig") { - condensed_fields.push(f); - } - if let Ok(f) = create_text_field("Instruction", "vaultTransactionCreate") { - condensed_fields.push(f); - } - if let Ok(f) = create_text_field("Vault Index", &vault_index.to_string()) { - condensed_fields.push(f); - } - if let Ok(f) = create_text_field( - "Inner Instructions", - &format!("{inner_count} instruction(s)"), - ) { - condensed_fields.push(f); - } + let condensed_fields = vec![ + create_text_field("Program", "Squads Multisig")?, + create_text_field("Instruction", "vaultTransactionCreate")?, + create_text_field("Vault Index", &vault_index.to_string())?, + create_text_field( + "Inner Instructions", + &format!("{inner_count} instruction(s)"), + )?, + ]; // Expanded: full details + decoded inner instructions - let mut expanded_fields = vec![]; - if let Ok(f) = create_text_field("Program ID", program_id) { - expanded_fields.push(f); - } - if let Ok(f) = create_text_field("Instruction", "vaultTransactionCreate") { - expanded_fields.push(f); - } - if let Ok(f) = create_text_field("Discriminator", &parsed.discriminator) { - expanded_fields.push(f); - } - if let Ok(f) = create_text_field("Vault Index", &vault_index.to_string()) { - expanded_fields.push(f); - } - if let Ok(f) = create_text_field("Memo", memo) { - expanded_fields.push(f); - } + let mut expanded_fields = vec![ + create_text_field("Program ID", program_id)?, + create_text_field("Instruction", "vaultTransactionCreate")?, + create_text_field("Discriminator", &parsed.discriminator)?, + create_text_field("Vault Index", &vault_index.to_string())?, + create_text_field("Memo", memo)?, + ]; // Named accounts from the outer instruction for (account_name, account_address) in named_accounts { - if let Ok(f) = create_text_field(account_name, account_address) { - expanded_fields.push(f); - } + expanded_fields.push(create_text_field(account_name, account_address)?); } // Decoded inner instructions expanded_fields.extend(inner_fields); - Some((title, condensed_fields, expanded_fields)) + Ok(Some((title, condensed_fields, expanded_fields))) } /// Reconstruct Instruction objects from VaultTransactionMessage compiled instructions. @@ -386,10 +402,16 @@ fn reconstruct_instructions(vault_msg: &VaultTransactionMessage) -> Vec Vec { +) -> Result, VisualSignError> { let visualizers: Vec> = available_visualizers(); let visualizers_refs: Vec<&dyn InstructionVisualizer> = visualizers.iter().map(|v| v.as_ref()).collect(); @@ -402,112 +424,79 @@ fn visualize_inner_instructions( }; let instructions_vec: Vec = inner_instructions.to_vec(); + let mut fields = Vec::with_capacity(instructions_vec.len()); - instructions_vec - .iter() - .enumerate() - .filter_map(|(idx, _)| { - let inner_context = - VisualizerContext::new(&sender, idx, &instructions_vec, &idl_registry); - - visualize_with_any(&visualizers_refs, &inner_context) - .and_then(|result| result.ok()) - .map(|viz_result| viz_result.field) - }) - .collect() + for (idx, _) in instructions_vec.iter().enumerate() { + let inner_context = VisualizerContext::new(&sender, idx, &instructions_vec, &idl_registry); + + let field = match visualize_with_any(&visualizers_refs, &inner_context) { + Some(Ok(result)) => result.field, + Some(Err(_)) | None => crate::presets::unknown_program::UnknownProgramVisualizer + .visualize_tx_commands(&inner_context)?, + }; + fields.push(field); + } + + Ok(fields) } fn build_generic_fields( parsed: &SolanaParsedInstructionData, named_accounts: &HashMap, program_id: &str, -) -> ( - String, - Vec, - Vec, -) { +) -> Result { let title = format!("Squads Multisig: {}", parsed.instruction_name); - let mut condensed_fields = vec![]; - let mut expanded_fields = vec![]; - // Condensed: program name + instruction name + key args - if let Ok(f) = create_text_field("Program", "Squads Multisig") { - condensed_fields.push(f); - } - if let Ok(f) = create_text_field("Instruction", &parsed.instruction_name) { - condensed_fields.push(f); - } + let mut condensed_fields = vec![ + create_text_field("Program", "Squads Multisig")?, + create_text_field("Instruction", &parsed.instruction_name)?, + ]; for (key, value) in &parsed.program_call_args { - if let Ok(f) = create_text_field(key, &format_arg_value(value)) { - condensed_fields.push(f); - } + condensed_fields.push(create_text_field(key, &format_arg_value(value))?); } // Expanded: full details - if let Ok(f) = create_text_field("Program ID", program_id) { - expanded_fields.push(f); - } - if let Ok(f) = create_text_field("Instruction", &parsed.instruction_name) { - expanded_fields.push(f); - } - if let Ok(f) = create_text_field("Discriminator", &parsed.discriminator) { - expanded_fields.push(f); - } + let mut expanded_fields = vec![ + create_text_field("Program ID", program_id)?, + create_text_field("Instruction", &parsed.instruction_name)?, + create_text_field("Discriminator", &parsed.discriminator)?, + ]; for (account_name, account_address) in named_accounts { - if let Ok(f) = create_text_field(account_name, account_address) { - expanded_fields.push(f); - } + expanded_fields.push(create_text_field(account_name, account_address)?); } for (key, value) in &parsed.program_call_args { - if let Ok(f) = create_text_field(key, &format_arg_value(value)) { - expanded_fields.push(f); - } + expanded_fields.push(create_text_field(key, &format_arg_value(value))?); } - (title, condensed_fields, expanded_fields) + Ok((title, condensed_fields, expanded_fields)) } -fn build_fallback_fields( - program_id: &str, -) -> ( - String, - Vec, - Vec, -) { +fn build_fallback_fields(program_id: &str) -> Result { let title = "Squads Multisig: Unknown Instruction".to_string(); - let mut condensed_fields = vec![]; - let mut expanded_fields = vec![]; + let condensed_fields = vec![ + create_text_field("Program", "Squads Multisig")?, + create_text_field("Status", "Unknown instruction type")?, + ]; - if let Ok(f) = create_text_field("Program", "Squads Multisig") { - condensed_fields.push(f); - } - if let Ok(f) = create_text_field("Status", "Unknown instruction type") { - condensed_fields.push(f); - } + let expanded_fields = vec![ + create_text_field("Program ID", program_id)?, + create_text_field("Status", "Unknown instruction type")?, + ]; - if let Ok(f) = create_text_field("Program ID", program_id) { - expanded_fields.push(f); - } - if let Ok(f) = create_text_field("Status", "Unknown instruction type") { - expanded_fields.push(f); - } - - (title, condensed_fields, expanded_fields) + Ok((title, condensed_fields, expanded_fields)) } fn append_raw_data( mut fields: Vec, data: &[u8], hex_str: &str, -) -> Vec { - if let Ok(f) = create_raw_data_field(data, Some(hex_str.to_string())) { - fields.push(f); - } - fields +) -> Result, VisualSignError> { + fields.push(create_raw_data_field(data, Some(hex_str.to_string()))?); + Ok(fields) } fn format_arg_value(value: &serde_json::Value) -> String { From aae91ab25c0ef290e7e3ed2c919f9d8df6d2e6f7 Mon Sep 17 00:00:00 2001 From: Prasanna Gautam Date: Mon, 4 May 2026 22:47:43 +0000 Subject: [PATCH 5/7] test(squads): use Result + ? in tests instead of unwrap MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Workspace lints (#231, #245) deny `unwrap_used`, `expect_used`, and `panic` — these landed on main after this branch was created. Rather than dodging them with `#[allow(...)]` on the test module (which other presets did and is the lazy fix), convert each test to return `Result<(), Box>` and use `?` / `ok_or` to surface failures. Tests now fail with a meaningful error message via the test runner's normal Result reporting instead of a panic, which is also better debugging UX. cargo clippy --workspace --all-targets -- -D warnings: clean. cargo test -p visualsign-solana: 5/5 squads tests pass. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../src/presets/squads_multisig/mod.rs | 45 ++++++++++++------- 1 file changed, 28 insertions(+), 17 deletions(-) diff --git a/src/chain_parsers/visualsign-solana/src/presets/squads_multisig/mod.rs b/src/chain_parsers/visualsign-solana/src/presets/squads_multisig/mod.rs index 2b0d9611..f62bb9bb 100644 --- a/src/chain_parsers/visualsign-solana/src/presets/squads_multisig/mod.rs +++ b/src/chain_parsers/visualsign-solana/src/presets/squads_multisig/mod.rs @@ -513,24 +513,27 @@ fn format_arg_value(value: &serde_json::Value) -> String { mod tests { use super::*; + /// Test-helper error type. Boxed dyn-error so `?` accepts both `hex::FromHexError` and + /// the `Box` returned by `VaultTransactionMessage::deserialize`. + type TestResult = Result<(), Box>; + #[test] - fn test_squads_idl_loads() { - let idl = get_squads_idl(); - assert!(idl.is_some(), "Squads IDL should load successfully"); - let idl = idl.unwrap(); + fn test_squads_idl_loads() -> TestResult { + let idl = get_squads_idl().ok_or("Squads IDL should load successfully")?; assert!(!idl.instructions.is_empty(), "IDL should have instructions"); + Ok(()) } #[test] - fn test_squads_idl_has_discriminators() { - let idl = get_squads_idl().unwrap(); + fn test_squads_idl_has_discriminators() -> TestResult { + let idl = get_squads_idl().ok_or("Squads IDL should load successfully")?; for instruction in &idl.instructions { - assert!( - instruction.discriminator.is_some(), - "Instruction '{}' should have a computed discriminator", - instruction.name - ); - let disc = instruction.discriminator.as_ref().unwrap(); + let disc = instruction.discriminator.as_ref().ok_or_else(|| { + format!( + "Instruction '{}' should have a computed discriminator", + instruction.name + ) + })?; assert_eq!( disc.len(), 8, @@ -538,6 +541,7 @@ mod tests { instruction.name ); } + Ok(()) } #[test] @@ -557,11 +561,11 @@ mod tests { } #[test] - fn test_vault_transaction_message_deserialization() { + fn test_vault_transaction_message_deserialization() -> TestResult { // The transactionMessage hex from the sample transaction let tx_msg_hex = "01010103904fc8953dcfc9f3b5179893ee12fc9c445cad889a957d61cfb8dbcc172f6a4f4a3eef4b03c82a71599ea07a16ee4bcf6dce31357d8460b2ac1bd4c3a9860c9d0954dbbe9ec960c98a7a293fe21336966fe180d151ae4b8179561f89854a53f601020200012800a1b028d53cb8b3e4ef5e27e0961546aad46749acc092e03c1a8c7c1187e887cc245db9cf2bca9a9900"; - let tx_msg_bytes = hex::decode(tx_msg_hex).unwrap(); - let vault_msg = VaultTransactionMessage::deserialize(&tx_msg_bytes).unwrap(); + let tx_msg_bytes = hex::decode(tx_msg_hex)?; + let vault_msg = VaultTransactionMessage::deserialize(&tx_msg_bytes)?; assert_eq!( vault_msg.account_keys.len(), @@ -574,7 +578,10 @@ mod tests { "Should have 1 inner instruction" ); - let inner = &vault_msg.instructions[0]; + let inner = vault_msg + .instructions + .first() + .ok_or("expected at least one instruction")?; assert!( (inner.program_id_index as usize) < vault_msg.account_keys.len(), "program_id_index should be valid" @@ -582,10 +589,14 @@ mod tests { let instructions = reconstruct_instructions(&vault_msg); assert_eq!(instructions.len(), 1, "Should reconstruct 1 instruction"); + let first_inner = instructions + .first() + .ok_or("expected at least one reconstructed instruction")?; // The inner instruction's program_id should be one of the account keys assert!( - vault_msg.account_keys.contains(&instructions[0].program_id), + vault_msg.account_keys.contains(&first_inner.program_id), "Inner instruction program_id should be in account_keys" ); + Ok(()) } } From 7494d49743206217b3d7cd5a1ab363ef27bbd57e Mon Sep 17 00:00:00 2001 From: Prasanna Gautam Date: Fri, 8 May 2026 16:41:32 +0000 Subject: [PATCH 6/7] fix(solana): bound nested-instruction recursion + address Squads review feedback Adds a generalized depth cap to VisualizerContext (MAX_CALL_DEPTH=4, matching Solana's runtime CPI cap) so any preset that recursively visualizes inner instructions is protected from unbounded recursion / stack-overflow DoS. Threads `for_nested_call` through both squads_multisig and swig_wallet inner visualization paths; presets emit an explicit truncation field when the cap is reached rather than recursing further. Also addresses pepe-anchor review feedback on Squads: - HashMap -> BTreeMap for `named_accounts` (deterministic field ordering) - reconstruct_instructions returns Result; out-of-range indices (likely ALT- resolved keys we cannot dereference) cause a fall-back to generic display instead of a silently truncated account list - inner AccountMeta is_signer / is_writable derived from MessageHeader counts rather than every account being marked readonly - inner sender is now the vault PDA derived from `multisig` + vault_index, not the outer fee-payer - vault_index requires explicit u64-as-u8 parse; missing field falls back to generic display instead of silently defaulting to 0 Co-Authored-By: Claude Opus 4.7 (1M context) --- .../visualsign-solana/src/core/mod.rs | 81 +++- .../src/presets/squads_multisig/mod.rs | 408 ++++++++++++++++-- .../src/presets/swig_wallet/mod.rs | 89 +++- 3 files changed, 518 insertions(+), 60 deletions(-) diff --git a/src/chain_parsers/visualsign-solana/src/core/mod.rs b/src/chain_parsers/visualsign-solana/src/core/mod.rs index ee81fc37..adab7cd7 100644 --- a/src/chain_parsers/visualsign-solana/src/core/mod.rs +++ b/src/chain_parsers/visualsign-solana/src/core/mod.rs @@ -28,6 +28,15 @@ pub enum VisualizerKind { Payments(&'static str), } +/// Maximum nesting depth for visualizing inner instructions. +/// +/// Solana's runtime CPI cap is 4, so any visualization that hits this depth is +/// either malformed or adversarial input. The cap prevents stack-overflow DoS +/// through nested-instruction encodings (e.g., a `vaultTransactionCreate` +/// containing another `vaultTransactionCreate`, or a swig instruction wrapping +/// another swig instruction). +pub const MAX_CALL_DEPTH: u8 = 4; + /// Context for visualizing a Solana instruction. /// /// Holds all necessary information to visualize a specific command @@ -43,10 +52,12 @@ pub struct VisualizerContext<'a> { instructions: &'a Vec, /// IDL registry for parsing unknown programs with Anchor IDLs idl_registry: &'a crate::idl::IdlRegistry, + /// Depth of nested inner-instruction visualization (0 for top-level). + call_depth: u8, } impl<'a> VisualizerContext<'a> { - /// Creates a new `VisualizerContext`. + /// Creates a new top-level `VisualizerContext` with `call_depth = 0`. pub fn new( sender: &'a SolanaAccount, instruction_index: usize, @@ -58,7 +69,34 @@ impl<'a> VisualizerContext<'a> { instruction_index, instructions, idl_registry, + call_depth: 0, + } + } + + /// Creates a child context for visualizing a nested inner instruction. + /// + /// Returns `None` when incrementing would exceed [`MAX_CALL_DEPTH`]. Callers + /// should treat `None` as a signal to emit a "max depth exceeded" fallback + /// rather than recursing further. + pub fn for_nested_call<'b>( + &self, + sender: &'b SolanaAccount, + instruction_index: usize, + instructions: &'b Vec, + ) -> Option> + where + 'a: 'b, + { + if self.call_depth >= MAX_CALL_DEPTH { + return None; } + Some(VisualizerContext { + sender, + instruction_index, + instructions, + idl_registry: self.idl_registry, + call_depth: self.call_depth + 1, + }) } /// Returns a reference to the IDL registry @@ -85,6 +123,11 @@ impl<'a> VisualizerContext<'a> { pub fn current_instruction(&self) -> Option<&Instruction> { self.instructions.get(self.instruction_index) } + + /// Returns the nesting depth of this context (0 at the top level). + pub fn call_depth(&self) -> u8 { + self.call_depth + } } pub struct SolanaIntegrationConfigData { @@ -181,3 +224,39 @@ pub fn visualize_with_any( ) }) } + +#[cfg(test)] +#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] +mod tests { + use super::*; + use crate::idl::IdlRegistry; + + #[test] + fn for_nested_call_caps_at_max_call_depth() { + let sender = SolanaAccount { + account_key: "11111111111111111111111111111111".to_string(), + signer: false, + writable: false, + }; + let instructions: Vec = vec![]; + let registry = IdlRegistry::new(); + let root = VisualizerContext::new(&sender, 0, &instructions, ®istry); + assert_eq!(root.call_depth(), 0); + + let mut current = root; + for expected_depth in 1..=u32::from(MAX_CALL_DEPTH) { + let next = current + .for_nested_call(&sender, 0, &instructions) + .unwrap_or_else(|| { + panic!("for_nested_call refused before reaching cap at depth {expected_depth}") + }); + assert_eq!(u32::from(next.call_depth()), expected_depth); + current = next; + } + + assert!( + current.for_nested_call(&sender, 0, &instructions).is_none(), + "for_nested_call must return None once MAX_CALL_DEPTH is reached" + ); + } +} diff --git a/src/chain_parsers/visualsign-solana/src/presets/squads_multisig/mod.rs b/src/chain_parsers/visualsign-solana/src/presets/squads_multisig/mod.rs index f62bb9bb..8e48654d 100644 --- a/src/chain_parsers/visualsign-solana/src/presets/squads_multisig/mod.rs +++ b/src/chain_parsers/visualsign-solana/src/presets/squads_multisig/mod.rs @@ -6,7 +6,6 @@ use crate::core::{ InstructionVisualizer, SolanaIntegrationConfig, VisualizerContext, VisualizerKind, available_visualizers, visualize_with_any, }; -use crate::idl::IdlRegistry; use config::SquadsMultisigConfig; use solana_parser::solana::structs::SolanaAccount; use solana_parser::{ @@ -14,7 +13,8 @@ use solana_parser::{ }; use solana_sdk::instruction::{AccountMeta, Instruction}; use solana_sdk::pubkey::Pubkey; -use std::collections::HashMap; +use std::collections::BTreeMap; +use std::str::FromStr; use visualsign::errors::VisualSignError; use visualsign::field_builders::{create_raw_data_field, create_text_field}; use visualsign::{ @@ -31,6 +31,14 @@ static SQUADS_MULTISIG_CONFIG: SquadsMultisigConfig = SquadsMultisigConfig; // -- VaultTransactionMessage uses Solana's compact wire format (u8 lengths), not borsh -- struct VaultTransactionMessage { + /// Total number of signer accounts (the first `num_signers` entries of `account_keys`). + num_signers: u8, + /// Number of writable signer accounts (the first `num_writable_signers` + /// entries of `account_keys`). + num_writable_signers: u8, + /// Number of writable non-signer accounts (immediately following the signer + /// block in `account_keys`). + num_writable_non_signers: u8, account_keys: Vec, instructions: Vec, } @@ -80,9 +88,9 @@ impl VaultTransactionMessage { }; // Header: 3 u8s (numSigners, numWritableSigners, numWritableNonSigners) - let _num_signers = read_u8(&mut pos)?; - let _num_writable_signers = read_u8(&mut pos)?; - let _num_writable_non_signers = read_u8(&mut pos)?; + let num_signers = read_u8(&mut pos)?; + let num_writable_signers = read_u8(&mut pos)?; + let num_writable_non_signers = read_u8(&mut pos)?; // Account keys: u8 count + N × 32-byte pubkeys let num_keys = read_u8(&mut pos)? as usize; @@ -130,6 +138,9 @@ impl VaultTransactionMessage { } Ok(Self { + num_signers, + num_writable_signers, + num_writable_non_signers, account_keys, instructions, }) @@ -231,8 +242,8 @@ fn build_named_accounts( data: &[u8], idl: &Idl, accounts: &[AccountMeta], -) -> HashMap { - let mut named_accounts = HashMap::new(); +) -> BTreeMap { + let mut named_accounts = BTreeMap::new(); let idl_instruction = idl.instructions.iter().find(|inst| { inst.discriminator @@ -253,7 +264,7 @@ fn build_named_accounts( struct SquadsParsedInstruction { parsed: SolanaParsedInstructionData, - named_accounts: HashMap, + named_accounts: BTreeMap, } /// `(title, condensed_fields, expanded_fields)` returned by the various `build_*` helpers. @@ -287,13 +298,16 @@ fn build_parsed_fields( /// Try to decode the nested transaction message inside vaultTransactionCreate. /// -/// Returns `Ok(None)` when the embedded transaction message is missing or unparseable -/// (callers fall through to the generic display). Returns `Err` only when field-builder -/// or downstream visualization errors occur — those propagate up so the caller can decide -/// whether to surface them. +/// Returns `Ok(None)` when the embedded transaction message is missing, unparseable, +/// references accounts via an Address Lookup Table, or otherwise fails to satisfy the +/// invariants required for safe nested visualization (callers fall through to the +/// generic display, where the user can still see the raw decoded args). +/// +/// Returns `Err` only when field-builder or downstream visualization errors occur — +/// those propagate up so the caller can decide whether to surface them. fn try_build_vault_transaction_fields( parsed: &SolanaParsedInstructionData, - named_accounts: &HashMap, + named_accounts: &BTreeMap, program_id: &str, context: &VisualizerContext, ) -> Result, VisualSignError> { @@ -316,16 +330,32 @@ fn try_build_vault_transaction_fields( return Ok(None); }; - // Reconstruct full Instructions from the compiled instructions - let inner_instructions = reconstruct_instructions(&vault_msg); + // Vault index must be present and fit a u8 (Squads supports vault indices 0..=255). + // Don't default a missing or out-of-range value to 0 — vault 0 is real, so a silent + // default is indistinguishable from an explicit selection. + let Some(vault_index_u64) = args_value.get("vaultIndex").and_then(|v| v.as_u64()) else { + return Ok(None); + }; + let Ok(vault_index) = u8::try_from(vault_index_u64) else { + return Ok(None); + }; - // Visualize inner instructions using the full visualizer framework - let inner_fields = visualize_inner_instructions(&inner_instructions, context)?; + // Reconstruct full Instructions from the compiled instructions. An out-of-range + // index means an ALT-resolved key the parser doesn't have; fall back to generic + // display rather than rendering a truncated account list. + let Ok(inner_instructions) = reconstruct_instructions(&vault_msg) else { + return Ok(None); + }; + + // Derive the vault PDA so downstream visualizers that compare `context.sender()` + // attribute the inner instruction's signer to the vault, not the outer fee-payer. + let inner_sender_account = vault_pda_account(named_accounts, vault_index); + + // Visualize inner instructions using the full visualizer framework, threading + // `for_nested_call` so we cap at MAX_CALL_DEPTH. + let inner_fields = + visualize_inner_instructions(&inner_instructions, context, inner_sender_account.as_ref())?; - let vault_index = args_value - .get("vaultIndex") - .and_then(|v| v.as_u64()) - .unwrap_or(0); let memo = args_value .get("memo") .and_then(|v| v.as_str()) @@ -365,34 +395,92 @@ fn try_build_vault_transaction_fields( Ok(Some((title, condensed_fields, expanded_fields))) } +/// Derive the Squads v4 vault PDA from the multisig pubkey + vault index. +/// +/// Seeds: `[b"multisig", multisig.as_ref(), b"vault", &[vault_index]]`, +/// program = `SQUADS_MULTISIG_PROGRAM_ID`. +/// +/// Returns `None` when `multisig` isn't present in `named_accounts`, isn't a parseable +/// pubkey, or when the program-id constant fails to parse (the last is unreachable in +/// practice but we don't panic). +fn vault_pda_account( + named_accounts: &BTreeMap, + vault_index: u8, +) -> Option { + let multisig_str = named_accounts.get("multisig")?; + let multisig = Pubkey::from_str(multisig_str).ok()?; + let program_id = Pubkey::from_str(SQUADS_MULTISIG_PROGRAM_ID).ok()?; + let (vault_pda, _bump) = Pubkey::find_program_address( + &[b"multisig", multisig.as_ref(), b"vault", &[vault_index]], + &program_id, + ); + Some(SolanaAccount { + account_key: vault_pda.to_string(), + signer: true, + writable: false, + }) +} + /// Reconstruct Instruction objects from VaultTransactionMessage compiled instructions. -/// Same pattern as core/instructions.rs:39-66. -fn reconstruct_instructions(vault_msg: &VaultTransactionMessage) -> Vec { +/// +/// Returns `Err` if any program-id index or account index references an out-of-range +/// position in `account_keys`. The latter is a real signal that the transaction would +/// resolve missing keys via an Address Lookup Table at execution time; we don't have +/// the ALT contents here, so we refuse to reconstruct rather than silently dropping +/// accounts (which can hide a transfer destination from the user). +/// +/// `is_signer` / `is_writable` are derived from the header counts per Solana's +/// `MessageHeader` convention: +/// - `[0, num_writable_signers)` are signer + writable +/// - `[num_writable_signers, num_signers)` are signer + readonly +/// - `[num_signers, num_signers + num_writable_non_signers)` are non-signer + writable +/// - everything else is non-signer + readonly +fn reconstruct_instructions( + vault_msg: &VaultTransactionMessage, +) -> Result, &'static str> { let account_keys = &vault_msg.account_keys; + let num_keys = account_keys.len(); + let num_signers = vault_msg.num_signers as usize; + let num_writable_signers = vault_msg.num_writable_signers as usize; + let num_writable_non_signers = vault_msg.num_writable_non_signers as usize; + + let account_meta_for_index = |idx: usize| -> Option { + let pubkey = *account_keys.get(idx)?; + let is_signer = idx < num_signers; + let is_writable = if is_signer { + idx < num_writable_signers + } else { + let non_signer_idx = idx - num_signers; + non_signer_idx < num_writable_non_signers + }; + Some(if is_writable { + AccountMeta::new(pubkey, is_signer) + } else { + AccountMeta::new_readonly(pubkey, is_signer) + }) + }; vault_msg .instructions .iter() - .filter_map(|ci| { + .map(|ci| { let program_id_idx = ci.program_id_index as usize; - if program_id_idx >= account_keys.len() { - return None; + if program_id_idx >= num_keys { + return Err("program_id index out of range (likely an ALT-resolved key)"); } let accounts: Vec = ci .account_indexes .iter() - .filter_map(|&i| { - let idx = i as usize; - if idx < account_keys.len() { - Some(AccountMeta::new_readonly(account_keys[idx], false)) - } else { - None - } + .map(|&i| { + account_meta_for_index(i as usize).ok_or( + "inner-instruction account index out of range \ + (likely an ALT-resolved key)", + ) }) - .collect(); + .collect::>()?; - Some(Instruction { + Ok(Instruction { program_id: account_keys[program_id_idx], accounts, data: ci.data.clone(), @@ -408,26 +496,50 @@ fn reconstruct_instructions(vault_msg: &VaultTransactionMessage) -> Vec, ) -> Result, VisualSignError> { + let owned_sender = inner_sender_override + .cloned() + .unwrap_or_else(|| SolanaAccount { + account_key: context.sender().account_key.clone(), + signer: false, + writable: false, + }); + let instructions_vec: Vec = inner_instructions.to_vec(); + + let Some(_probe) = context.for_nested_call(&owned_sender, 0, &instructions_vec) else { + return Ok(vec![create_text_field( + "Inner Instructions", + "", + )?]); + }; + let visualizers: Vec> = available_visualizers(); let visualizers_refs: Vec<&dyn InstructionVisualizer> = visualizers.iter().map(|v| v.as_ref()).collect(); - let idl_registry = IdlRegistry::new(); - let sender = SolanaAccount { - account_key: context.sender().account_key.clone(), - signer: false, - writable: false, - }; - - let instructions_vec: Vec = inner_instructions.to_vec(); let mut fields = Vec::with_capacity(instructions_vec.len()); for (idx, _) in instructions_vec.iter().enumerate() { - let inner_context = VisualizerContext::new(&sender, idx, &instructions_vec, &idl_registry); + // Safe to unwrap-equivalent: depth was already checked above (the probe Some-arm + // means depth + 1 <= MAX_CALL_DEPTH). We re-call so the index advances per inner. + let Some(inner_context) = context.for_nested_call(&owned_sender, idx, &instructions_vec) + else { + // Should be unreachable given the probe above, but keep the safety net. + fields.push(create_text_field( + "Inner Instructions", + "", + )?); + break; + }; let field = match visualize_with_any(&visualizers_refs, &inner_context) { Some(Ok(result)) => result.field, @@ -442,7 +554,7 @@ fn visualize_inner_instructions( fn build_generic_fields( parsed: &SolanaParsedInstructionData, - named_accounts: &HashMap, + named_accounts: &BTreeMap, program_id: &str, ) -> Result { let title = format!("Squads Multisig: {}", parsed.instruction_name); @@ -510,6 +622,7 @@ fn format_arg_value(value: &serde_json::Value) -> String { } #[cfg(test)] +#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] mod tests { use super::*; @@ -587,7 +700,7 @@ mod tests { "program_id_index should be valid" ); - let instructions = reconstruct_instructions(&vault_msg); + let instructions = reconstruct_instructions(&vault_msg)?; assert_eq!(instructions.len(), 1, "Should reconstruct 1 instruction"); let first_inner = instructions .first() @@ -599,4 +712,207 @@ mod tests { ); Ok(()) } + + fn make_pubkey(seed: u8) -> Pubkey { + Pubkey::new_from_array([seed; 32]) + } + + fn make_vault_msg( + num_signers: u8, + num_writable_signers: u8, + num_writable_non_signers: u8, + account_keys: Vec, + compiled: Vec, + ) -> VaultTransactionMessage { + VaultTransactionMessage { + num_signers, + num_writable_signers, + num_writable_non_signers, + account_keys, + instructions: compiled, + } + } + + #[test] + fn test_reconstruct_instructions_out_of_range_returns_err() { + // Three keys, but the inner instruction references account index 99 (ALT-resolved + // at execution time). We must refuse rather than silently dropping the account. + let vault_msg = make_vault_msg( + 1, + 1, + 0, + vec![make_pubkey(1), make_pubkey(2), make_pubkey(3)], + vec![MultisigCompiledInstruction { + program_id_index: 2, + account_indexes: vec![0, 99], + data: vec![], + }], + ); + assert!( + reconstruct_instructions(&vault_msg).is_err(), + "out-of-range account index must be rejected" + ); + } + + #[test] + fn test_reconstruct_instructions_writable_flags_match_header() { + // Account layout per Solana MessageHeader convention: + // indices [0, num_writable_signers) -> signer + writable + // indices [num_writable_signers, num_signers) -> signer + readonly + // indices [num_signers, num_signers + num_writable_non_signers) -> writable non-signer + // indices >= -> readonly non-signer + // num_signers=2, num_writable_signers=1, num_writable_non_signers=1 means: + // key[0] writable signer, key[1] readonly signer, key[2] writable non-signer, + // key[3] readonly non-signer (program), key[4] readonly non-signer. + let vault_msg = make_vault_msg( + 2, + 1, + 1, + vec![ + make_pubkey(0), + make_pubkey(1), + make_pubkey(2), + make_pubkey(3), + make_pubkey(4), + ], + vec![MultisigCompiledInstruction { + program_id_index: 3, + account_indexes: vec![0, 1, 2, 4], + data: vec![], + }], + ); + let reconstructed = + reconstruct_instructions(&vault_msg).expect("reconstruction should succeed"); + let metas = &reconstructed[0].accounts; + assert_eq!(metas.len(), 4); + // key[0]: writable signer + assert!(metas[0].is_writable && metas[0].is_signer); + // key[1]: readonly signer + assert!(!metas[1].is_writable && metas[1].is_signer); + // key[2]: writable non-signer + assert!(metas[2].is_writable && !metas[2].is_signer); + // key[4]: readonly non-signer + assert!(!metas[3].is_writable && !metas[3].is_signer); + } + + #[test] + fn test_build_named_accounts_returns_btreemap_for_deterministic_order() -> TestResult { + // BTreeMap orders by key; verify two calls yield the same iteration sequence. + let idl = get_squads_idl().ok_or("Squads IDL should load successfully")?; + let create_disc = idl + .instructions + .iter() + .find(|i| i.name == "vaultTransactionCreate") + .and_then(|i| i.discriminator.as_ref()) + .ok_or("vaultTransactionCreate must have a discriminator")? + .clone(); + // Build minimal "data" carrying the discriminator and arbitrary AccountMeta list. + let data = create_disc; + let metas: Vec = (1u8..=8) + .map(|i| AccountMeta::new_readonly(make_pubkey(i), false)) + .collect(); + let first = build_named_accounts(&data, idl, &metas); + let second = build_named_accounts(&data, idl, &metas); + let first_keys: Vec<&String> = first.keys().collect(); + let second_keys: Vec<&String> = second.keys().collect(); + assert_eq!(first_keys, second_keys, "BTreeMap iteration must be stable"); + Ok(()) + } + + #[test] + fn test_vault_pda_account_derives_pda() { + let multisig = make_pubkey(7); + let mut named = BTreeMap::new(); + named.insert("multisig".to_string(), multisig.to_string()); + let acct = vault_pda_account(&named, 0).expect("vault PDA should derive"); + // PDA must differ from the multisig and be a valid pubkey string. + assert_ne!(acct.account_key, multisig.to_string()); + Pubkey::from_str(&acct.account_key).expect("vault PDA should round-trip"); + assert!(acct.signer, "inner sender should be marked as signer"); + } + + #[test] + fn test_vault_pda_account_missing_multisig_returns_none() { + let named: BTreeMap = BTreeMap::new(); + assert!(vault_pda_account(&named, 0).is_none()); + } + + #[test] + fn test_visualize_inner_instructions_truncates_when_at_max_depth() -> TestResult { + // Build a context that is already at MAX_CALL_DEPTH so for_nested_call refuses + // and visualize_inner_instructions emits the explicit truncation field. + use crate::core::MAX_CALL_DEPTH; + let sender = SolanaAccount { + account_key: make_pubkey(9).to_string(), + signer: false, + writable: false, + }; + let outer_instructions: Vec = vec![]; + let registry = crate::idl::IdlRegistry::new(); + let mut current = VisualizerContext::new(&sender, 0, &outer_instructions, ®istry); + for _ in 0..MAX_CALL_DEPTH { + current = current + .for_nested_call(&sender, 0, &outer_instructions) + .ok_or("for_nested_call should succeed under cap")?; + } + // current.call_depth() == MAX_CALL_DEPTH; the next call from inside + // visualize_inner_instructions returns None and we emit the truncation message. + let inner_instructions = vec![Instruction { + program_id: make_pubkey(1), + accounts: vec![], + data: vec![], + }]; + let fields = visualize_inner_instructions(&inner_instructions, ¤t, None)?; + assert_eq!(fields.len(), 1, "truncation must emit a single field"); + let serialized = serde_json::to_string(&fields[0])?; + assert!( + serialized.contains("maximum nested-instruction visualization depth reached"), + "field must contain the truncation marker: {serialized}" + ); + Ok(()) + } + + #[test] + fn test_try_build_vault_transaction_fields_missing_vault_index_returns_none() -> TestResult { + // Construct a minimal SolanaParsedInstructionData that has args but no vaultIndex. + // The args.transactionMessage points at our existing fixture so the message itself + // parses fine; only vaultIndex is missing. + let tx_msg_hex = "01010103904fc8953dcfc9f3b5179893ee12fc9c445cad889a957d61cfb8dbcc172f6a4f4a3eef4b03c82a71599ea07a16ee4bcf6dce31357d8460b2ac1bd4c3a9860c9d0954dbbe9ec960c98a7a293fe21336966fe180d151ae4b8179561f89854a53f601020200012800a1b028d53cb8b3e4ef5e27e0961546aad46749acc092e03c1a8c7c1187e887cc245db9cf2bca9a9900"; + let mut args_inner = serde_json::Map::new(); + args_inner.insert( + "transactionMessage".to_string(), + serde_json::Value::String(tx_msg_hex.to_string()), + ); + // Note: deliberately no "vaultIndex" key. + let mut args_outer = serde_json::Map::new(); + args_outer.insert("args".to_string(), serde_json::Value::Object(args_inner)); + let parsed = SolanaParsedInstructionData { + instruction_name: "vaultTransactionCreate".to_string(), + discriminator: "00".to_string(), + named_accounts: std::collections::HashMap::new(), + program_call_args: args_outer, + idl_source: solana_parser::IdlSource::Custom, + idl_hash: String::new(), + }; + let named: BTreeMap = BTreeMap::new(); + let sender = SolanaAccount { + account_key: make_pubkey(0).to_string(), + signer: false, + writable: false, + }; + let instructions: Vec = vec![]; + let registry = crate::idl::IdlRegistry::new(); + let context = VisualizerContext::new(&sender, 0, &instructions, ®istry); + let result = try_build_vault_transaction_fields( + &parsed, + &named, + SQUADS_MULTISIG_PROGRAM_ID, + &context, + )?; + assert!( + result.is_none(), + "missing vaultIndex must signal fall-back to generic display" + ); + Ok(()) + } } diff --git a/src/chain_parsers/visualsign-solana/src/presets/swig_wallet/mod.rs b/src/chain_parsers/visualsign-solana/src/presets/swig_wallet/mod.rs index e543044b..7c23b339 100644 --- a/src/chain_parsers/visualsign-solana/src/presets/swig_wallet/mod.rs +++ b/src/chain_parsers/visualsign-solana/src/presets/swig_wallet/mod.rs @@ -42,7 +42,7 @@ impl InstructionVisualizer for SwigWalletVisualizer { // Convert 0-based index to 1-based instruction number for user-facing labels // (e.g., "Instruction 1" instead of "Instruction 0") let instruction_number = context.instruction_index() + 1; - let decoded = parse_swig_instruction(&instruction.data, &instruction.accounts) + let decoded = parse_swig_instruction(&instruction.data, &instruction.accounts, context) .map_err(|err| VisualSignError::DecodeError(err.to_string()))?; let summary = decoded.summary(); @@ -359,6 +359,7 @@ impl std::error::Error for SwigParseError {} fn parse_swig_instruction( data: &[u8], accounts: &[AccountMeta], + parent: &VisualizerContext, ) -> Result { if data.len() < 2 { return Err(SwigParseError::DataTooShort("missing discriminator")); @@ -382,18 +383,18 @@ fn parse_swig_instruction( SwigInstructionKind::RemoveAuthorityV1 => parse_remove_authority_v1(data), SwigInstructionKind::UpdateAuthorityV1 => parse_update_authority_v1(data), SwigInstructionKind::SignV1 => { - let sign = parse_sign_instruction(data, 8, accounts)?; + let sign = parse_sign_instruction(data, 8, accounts, parent)?; Ok(SwigInstructionDecoded::SignV1(sign)) } SwigInstructionKind::SignV2 => { - let sign = parse_sign_instruction(data, 8, accounts)?; + let sign = parse_sign_instruction(data, 8, accounts, parent)?; Ok(SwigInstructionDecoded::SignV2(sign)) } SwigInstructionKind::CreateSessionV1 => parse_create_session_v1(data), SwigInstructionKind::CreateSubAccountV1 => parse_create_sub_account_v1(data), SwigInstructionKind::WithdrawFromSubAccountV1 => parse_withdraw_from_sub_account(data), SwigInstructionKind::SubAccountSignV1 => { - let sign = parse_sign_instruction(data, 16, accounts)?; + let sign = parse_sign_instruction(data, 16, accounts, parent)?; Ok(SwigInstructionDecoded::SubAccountSignV1(sign)) } SwigInstructionKind::ToggleSubAccountV1 => parse_toggle_sub_account(data), @@ -546,6 +547,7 @@ fn parse_sign_instruction( data: &[u8], header_len: usize, accounts: &[AccountMeta], + parent: &VisualizerContext, ) -> Result { if data.len() < header_len { return Err(SwigParseError::DataTooShort("sign header")); @@ -562,7 +564,7 @@ fn parse_sign_instruction( let payload_end = payload_start + instruction_payload_len; let instruction_payload = &data[payload_start..payload_end]; let authority_payload = data[payload_end..].to_vec(); - let inner_instructions = decode_compact_instructions(instruction_payload, accounts)?; + let inner_instructions = decode_compact_instructions(instruction_payload, accounts, parent)?; Ok(SignInstructionDecoded { role_id, @@ -679,6 +681,7 @@ fn parse_transfer_assets(data: &[u8]) -> Result Result, SwigParseError> { if payload.is_empty() { return Ok(Vec::new()); @@ -740,6 +743,7 @@ fn decode_compact_instructions( &program_meta.display, &data_slice, &inner_accounts, + parent, ); instructions.push(DecodedInnerInstruction { program_id: program_meta.pubkey, @@ -783,6 +787,7 @@ fn describe_inner_instruction( program_display: &str, data: &[u8], accounts: &[InnerAccountMeta], + parent: &VisualizerContext, ) -> String { let fallback = || { let byte_len = data.len(); @@ -794,8 +799,13 @@ fn describe_inner_instruction( }; if let Some(instruction) = build_inner_instruction(program_id, accounts, data) { - if let Some(summary) = visualize_inner_instruction(instruction) { - return summary; + match visualize_inner_instruction(instruction, parent) { + InnerInstructionSummary::Visualized(summary) => return summary, + InnerInstructionSummary::DepthExceeded => { + return "" + .to_string(); + } + InnerInstructionSummary::NotVisualized => {} } } @@ -841,11 +851,20 @@ fn build_inner_instruction( }) } -fn visualize_inner_instruction(instruction: Instruction) -> Option { - let visualizers: Vec> = available_visualizers(); - let visualizer_refs: Vec<&dyn InstructionVisualizer> = - visualizers.iter().map(|viz| viz.as_ref()).collect(); +/// Outcome of attempting to visualize a swig inner instruction with the full visualizer +/// framework. The variants are distinct so the caller can render an explicit +/// "max depth exceeded" string instead of a generic byte-count fallback. +#[derive(Debug)] +enum InnerInstructionSummary { + Visualized(String), + DepthExceeded, + NotVisualized, +} +fn visualize_inner_instruction( + instruction: Instruction, + parent: &VisualizerContext, +) -> InnerInstructionSummary { let sender = SolanaAccount { account_key: instruction .accounts @@ -856,8 +875,17 @@ fn visualize_inner_instruction(instruction: Instruction) -> Option { writable: false, }; let instructions = vec![instruction]; - let idl_registry = crate::idl::IdlRegistry::new(); - let context = VisualizerContext::new(&sender, 0, &instructions, &idl_registry); + + // for_nested_call increments depth and short-circuits when MAX_CALL_DEPTH is reached, + // protecting against unbounded recursion via swig instructions whose inner compact + // payloads themselves target swig. + let Some(context) = parent.for_nested_call(&sender, 0, &instructions) else { + return InnerInstructionSummary::DepthExceeded; + }; + + let visualizers: Vec> = available_visualizers(); + let visualizer_refs: Vec<&dyn InstructionVisualizer> = + visualizers.iter().map(|viz| viz.as_ref()).collect(); visualize_with_any(&visualizer_refs, &context) .and_then(|result| result.ok()) @@ -865,6 +893,8 @@ fn visualize_inner_instruction(instruction: Instruction) -> Option { VisualizerKind::Payments("UnknownProgram") => None, _ => summarize_visualized_field(&viz_result.field), }) + .map(InnerInstructionSummary::Visualized) + .unwrap_or(InnerInstructionSummary::NotVisualized) } fn summarize_visualized_field(field: &AnnotatedPayloadField) -> Option { @@ -2618,4 +2648,37 @@ mod tests { label == "Updated Actions (hex)" && *value == hex::encode(&action_bytes) })); } + + #[test] + fn test_visualize_inner_instruction_returns_depth_exceeded_at_cap() { + use crate::core::{MAX_CALL_DEPTH, VisualizerContext}; + use solana_parser::solana::structs::SolanaAccount; + use solana_sdk::instruction::Instruction; + + let sender = SolanaAccount { + account_key: Pubkey::new_from_array([9; 32]).to_string(), + signer: false, + writable: false, + }; + let outer_instructions: Vec = vec![]; + let registry = crate::idl::IdlRegistry::new(); + let mut current = VisualizerContext::new(&sender, 0, &outer_instructions, ®istry); + for _ in 0..MAX_CALL_DEPTH { + current = current + .for_nested_call(&sender, 0, &outer_instructions) + .expect("for_nested_call should succeed under cap"); + } + + let inner = Instruction { + program_id: Pubkey::new_from_array([1; 32]), + accounts: vec![], + data: vec![], + }; + match super::visualize_inner_instruction(inner, ¤t) { + super::InnerInstructionSummary::DepthExceeded => {} + other => panic!( + "expected DepthExceeded once parent context is at MAX_CALL_DEPTH, got {other:?}" + ), + } + } } From f4df83378cd4033db1651293abf5d6f2864399dd Mon Sep 17 00:00:00 2001 From: Prasanna Gautam Date: Fri, 15 May 2026 21:19:52 +0000 Subject: [PATCH 7/7] fix(solana/squads): validate VaultTransactionMessage header counts; clarify MAX_CALL_DEPTH doc MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses two Copilot follow-up comments on #240. * **Header-count validation** (mod.rs:446). `reconstruct_instructions` derives signer/writable flags from `num_signers`, `num_writable_signers`, and `num_writable_non_signers`. Without validation, malformed input could mis-label accounts as signers or writable (e.g. `num_writable_signers > num_signers`, or `num_signers > account_keys.len()`). Now we refuse with a `Result::Err` if the three header invariants are violated, falling back to the generic display upstream rather than emitting a misleading inner-instruction view. Test: `test_reconstruct_instructions_rejects_inconsistent_header` covers all three failure modes. * **`MAX_CALL_DEPTH` doc/behavior wording** (core/mod.rs:37). The doc comment said "any visualization that hits this depth is malformed", but `for_nested_call` only returns `None` when nesting beyond it. Reworded to say contexts may reach `call_depth == MAX_CALL_DEPTH`, but any attempt to *exceed* it returns `None`. Logic unchanged — the cap continues to match Solana's runtime CPI limit of 4. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../visualsign-solana/src/core/mod.rs | 11 +++--- .../src/presets/squads_multisig/mod.rs | 35 +++++++++++++++++++ 2 files changed, 41 insertions(+), 5 deletions(-) diff --git a/src/chain_parsers/visualsign-solana/src/core/mod.rs b/src/chain_parsers/visualsign-solana/src/core/mod.rs index adab7cd7..d42ff3c1 100644 --- a/src/chain_parsers/visualsign-solana/src/core/mod.rs +++ b/src/chain_parsers/visualsign-solana/src/core/mod.rs @@ -30,11 +30,12 @@ pub enum VisualizerKind { /// Maximum nesting depth for visualizing inner instructions. /// -/// Solana's runtime CPI cap is 4, so any visualization that hits this depth is -/// either malformed or adversarial input. The cap prevents stack-overflow DoS -/// through nested-instruction encodings (e.g., a `vaultTransactionCreate` -/// containing another `vaultTransactionCreate`, or a swig instruction wrapping -/// another swig instruction). +/// Matches Solana's runtime CPI cap of 4: contexts may reach `call_depth == +/// MAX_CALL_DEPTH`, but any attempt to nest beyond it (i.e. exceed this depth) +/// returns `None` from [`VisualizerContext::for_nested_call`]. The cap +/// prevents stack-overflow DoS through nested-instruction encodings (e.g., +/// a `vaultTransactionCreate` containing another `vaultTransactionCreate`, +/// or a swig instruction wrapping another swig instruction). pub const MAX_CALL_DEPTH: u8 = 4; /// Context for visualizing a Solana instruction. diff --git a/src/chain_parsers/visualsign-solana/src/presets/squads_multisig/mod.rs b/src/chain_parsers/visualsign-solana/src/presets/squads_multisig/mod.rs index 8e48654d..95d93eb1 100644 --- a/src/chain_parsers/visualsign-solana/src/presets/squads_multisig/mod.rs +++ b/src/chain_parsers/visualsign-solana/src/presets/squads_multisig/mod.rs @@ -444,6 +444,20 @@ fn reconstruct_instructions( let num_writable_signers = vault_msg.num_writable_signers as usize; let num_writable_non_signers = vault_msg.num_writable_non_signers as usize; + // Validate the header invariants up-front: a malformed input that claims + // more writable signers than signers, more signers than keys, or more + // writable non-signers than non-signer slots would silently mis-label + // accounts as signers/writable downstream. Refuse instead. + if num_writable_signers > num_signers { + return Err("num_writable_signers > num_signers"); + } + if num_signers > num_keys { + return Err("num_signers > account_keys.len()"); + } + if num_writable_non_signers > num_keys - num_signers { + return Err("num_writable_non_signers > non-signer slots"); + } + let account_meta_for_index = |idx: usize| -> Option { let pubkey = *account_keys.get(idx)?; let is_signer = idx < num_signers; @@ -754,6 +768,27 @@ mod tests { ); } + #[test] + fn test_reconstruct_instructions_rejects_inconsistent_header() { + // num_writable_signers > num_signers — would mis-label non-signers as + // writable. Refuse. + let too_many_writable_signers = + make_vault_msg(1, 2, 0, vec![make_pubkey(0), make_pubkey(1)], vec![]); + assert!(reconstruct_instructions(&too_many_writable_signers).is_err()); + + // num_signers > account_keys.len() — would treat keys past the end + // as signers via out-of-bounds index assumptions. Refuse. + let signers_exceed_keys = + make_vault_msg(3, 0, 0, vec![make_pubkey(0), make_pubkey(1)], vec![]); + assert!(reconstruct_instructions(&signers_exceed_keys).is_err()); + + // num_writable_non_signers > non-signer slot count — would mark + // entries past the end as writable. Refuse. + let too_many_writable_non_signers = + make_vault_msg(1, 0, 5, vec![make_pubkey(0), make_pubkey(1)], vec![]); + assert!(reconstruct_instructions(&too_many_writable_non_signers).is_err()); + } + #[test] fn test_reconstruct_instructions_writable_flags_match_header() { // Account layout per Solana MessageHeader convention: