diff --git a/src/chain_parsers/visualsign-solana/src/core/mod.rs b/src/chain_parsers/visualsign-solana/src/core/mod.rs index ee81fc37..d42ff3c1 100644 --- a/src/chain_parsers/visualsign-solana/src/core/mod.rs +++ b/src/chain_parsers/visualsign-solana/src/core/mod.rs @@ -28,6 +28,16 @@ pub enum VisualizerKind { Payments(&'static str), } +/// Maximum nesting depth for visualizing inner instructions. +/// +/// 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. /// /// Holds all necessary information to visualize a specific command @@ -43,10 +53,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 +70,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 +124,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 +225,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/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..95d93eb1 --- /dev/null +++ b/src/chain_parsers/visualsign-solana/src/presets/squads_multisig/mod.rs @@ -0,0 +1,953 @@ +//! Squads v4 Multisig preset implementation for Solana + +mod config; + +use crate::core::{ + InstructionVisualizer, SolanaIntegrationConfig, VisualizerContext, VisualizerKind, + available_visualizers, visualize_with_any, +}; +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::BTreeMap; +use std::str::FromStr; +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; + +// -- 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, +} + +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. + /// 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; + + 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_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() { + 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_u16_le(&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 + 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 { + num_signers, + num_writable_signers, + num_writable_non_signers, + account_keys, + instructions, + }) + } +} + +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(), context)? + } + 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<&'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( + data: &[u8], + accounts: &[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)?; + + let named_accounts = build_named_accounts(data, idl, accounts); + + Ok(SquadsParsedInstruction { + parsed, + named_accounts, + }) +} + +fn build_named_accounts( + data: &[u8], + idl: &Idl, + accounts: &[AccountMeta], +) -> BTreeMap { + let mut named_accounts = BTreeMap::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: BTreeMap, +} + +/// `(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, +) -> Result { + 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 Ok(fields); + } + } + + build_generic_fields(parsed, &instruction.named_accounts, program_id) +} + +/// Try to decode the nested transaction message inside vaultTransactionCreate. +/// +/// 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: &BTreeMap, + program_id: &str, + context: &VisualizerContext, +) -> 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); + }; + + // 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); + }; + + // 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 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 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![ + 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 { + expanded_fields.push(create_text_field(account_name, account_address)?); + } + + // Decoded inner instructions + expanded_fields.extend(inner_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. +/// +/// 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; + + // 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; + 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() + .map(|ci| { + let program_id_idx = ci.program_id_index as usize; + 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() + .map(|&i| { + account_meta_for_index(i as usize).ok_or( + "inner-instruction account index out of range \ + (likely an ALT-resolved key)", + ) + }) + .collect::>()?; + + Ok(Instruction { + program_id: account_keys[program_id_idx], + accounts, + data: ci.data.clone(), + }) + }) + .collect() +} + +/// Visualize reconstructed inner instructions using the full visualizer framework. +/// +/// `visualize_with_any` selects the first visualizer whose `can_handle` returns true and +/// invokes it. If that visualizer's `visualize_tx_commands` then fails — or no visualizer +/// matches — we explicitly fall back to `UnknownProgramVisualizer` (a catch-all). This +/// guarantees every inner instruction produces *some* output: a failure in a specific +/// program's visualizer never silently drops the field. +/// +/// Recursion is bounded by `VisualizerContext::for_nested_call`; once `MAX_CALL_DEPTH` +/// is reached, we return a single explicit "max depth exceeded" field rather than +/// recursing further. This protects against unbounded-recursion DoS via cyclically +/// nested `vaultTransactionCreate` payloads. +fn visualize_inner_instructions( + inner_instructions: &[Instruction], + context: &VisualizerContext, + inner_sender_override: Option<&SolanaAccount>, +) -> 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 mut fields = Vec::with_capacity(instructions_vec.len()); + + for (idx, _) in instructions_vec.iter().enumerate() { + // 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, + 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: &BTreeMap, + program_id: &str, +) -> Result { + let title = format!("Squads Multisig: {}", parsed.instruction_name); + + // Condensed: program name + instruction name + key args + 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 { + condensed_fields.push(create_text_field(key, &format_arg_value(value))?); + } + + // Expanded: full details + 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 { + expanded_fields.push(create_text_field(account_name, account_address)?); + } + + for (key, value) in &parsed.program_call_args { + expanded_fields.push(create_text_field(key, &format_arg_value(value))?); + } + + Ok((title, condensed_fields, expanded_fields)) +} + +fn build_fallback_fields(program_id: &str) -> Result { + let title = "Squads Multisig: Unknown Instruction".to_string(); + + let condensed_fields = vec![ + create_text_field("Program", "Squads Multisig")?, + create_text_field("Status", "Unknown instruction type")?, + ]; + + let expanded_fields = vec![ + create_text_field("Program ID", program_id)?, + create_text_field("Status", "Unknown instruction type")?, + ]; + + Ok((title, condensed_fields, expanded_fields)) +} + +fn append_raw_data( + mut fields: Vec, + data: &[u8], + hex_str: &str, +) -> 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 { + 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)] +#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] +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() -> 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() -> TestResult { + let idl = get_squads_idl().ok_or("Squads IDL should load successfully")?; + for instruction in &idl.instructions { + let disc = instruction.discriminator.as_ref().ok_or_else(|| { + format!( + "Instruction '{}' should have a computed discriminator", + instruction.name + ) + })?; + assert_eq!( + disc.len(), + 8, + "Discriminator for '{}' should be 8 bytes", + instruction.name + ); + } + Ok(()) + } + + #[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"); + } + + #[test] + 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)?; + let vault_msg = VaultTransactionMessage::deserialize(&tx_msg_bytes)?; + + 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 + .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" + ); + + 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(&first_inner.program_id), + "Inner instruction program_id should be in account_keys" + ); + 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_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: + // 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/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 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:?}" + ), + } + } }