feat(solana): add Squads v4 Multisig visualizer preset#240
feat(solana): add Squads v4 Multisig visualizer preset#240prasanna-anchorage wants to merge 6 commits into
Conversation
There was a problem hiding this comment.
Pull request overview
Adds a new Solana preset to visualize Squads v4 Multisig (SQDS4ep65T869zMMBKyuUq6aD6EgTu8psMjkvj52pCf) instructions using an embedded Anchor IDL, including special handling to decode and visualize nested inner instructions from vaultTransactionCreate.
Changes:
- Introduces Squads v4 Multisig preset visualizer with IDL-backed parsing and a fallback layout.
- Implements
VaultTransactionMessageparsing + inner-instruction reconstruction/dispatch into the existing visualizer framework. - Adds Squads preset config wiring and embeds the Squads v4 IDL JSON.
Reviewed changes
Copilot reviewed 3 out of 4 changed files in this pull request and generated 3 comments.
| File | Description |
|---|---|
src/chain_parsers/visualsign-solana/src/presets/squads_multisig/squads_multisig_program.json |
Adds embedded Squads v4 Anchor IDL used for instruction decoding. |
src/chain_parsers/visualsign-solana/src/presets/squads_multisig/mod.rs |
Implements the Squads visualizer, nested vault message parsing, and tests. |
src/chain_parsers/visualsign-solana/src/presets/squads_multisig/config.rs |
Adds integration config for matching Squads program ID. |
src/chain_parsers/visualsign-solana/src/presets/mod.rs |
Exposes the new squads_multisig preset module. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| // Address table lookups: u8 count (we skip parsing the contents) | ||
| // They're not needed for instruction reconstruction without ALT resolution | ||
|
|
||
| Ok(Self { | ||
| account_keys, | ||
| instructions, | ||
| }) | ||
| } |
There was a problem hiding this comment.
VaultTransactionMessage::deserialize stops after reading the compiled instructions and does not consume/validate the trailing address_table_lookups section (or even the lookup count). This means messages containing ALTs (or any trailing bytes) will still return Ok, and later reconstruction may silently omit out-of-range account indices, potentially producing a misleading inner-instruction visualization. Consider reading the lookup count and either (a) parsing/skipping the lookups, or (b) returning an error when lookups are present / when trailing bytes remain so the caller can fall back to the generic display.
There was a problem hiding this comment.
Resolved in dc52b29 (predates this thread). The current code parses the address-table-lookups section in full and then asserts pos == data.len(), so trailing or padding bytes are now rejected by VaultTransactionMessage::deserialize.
| .filter_map(|(idx, _)| { | ||
| let inner_context = | ||
| VisualizerContext::new(&sender, idx, &instructions_vec, &idl_registry); | ||
|
|
||
| visualize_with_any(&visualizers_refs, &inner_context) | ||
| .and_then(|result| result.ok()) | ||
| .map(|viz_result| viz_result.field) | ||
| }) |
There was a problem hiding this comment.
visualize_inner_instructions drops visualization errors via result.ok() and simply omits the field when a visualizer returns Err. Because visualize_with_any selects the first can_handle visualizer, a failure there also prevents falling back to unknown_program, leading to missing inner-instruction output with no indication to the user. Consider propagating the error (so the outer instruction can fall back), or explicitly converting errors into a visible error/fallback field for that inner instruction.
There was a problem hiding this comment.
Resolved in d28dd18. visualize_inner_instructions no longer drops errors via result.ok(); Some(Err(_)) and None both fall back to UnknownProgramVisualizer so every inner instruction produces some output.
Embed the Squads v4 Anchor IDL and decode instructions using parse_instruction_with_idl so transactions show named instructions (vaultTransactionCreate, proposalCreate, proposalApprove, etc.), named accounts, and decoded args instead of raw hex. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Deserialize the VaultTransactionMessage from the transactionMessage bytes field using Solana's compact wire format, reconstruct full Instructions, and pass them through the existing visualizer framework so inner program calls (System, Drift, Token, etc.) are displayed with their decoded names and accounts instead of raw hex. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Fix instruction data_len field in VaultTransactionMessage::deserialize to read as u16 LE instead of u8, matching the Squads v4 on-chain wire format. The previous u8 read consumed one too few bytes, shifting instruction data by one byte and causing inner instruction discriminator mismatches (e.g. Drift updateAdmin showed as "Unknown Instruction"). Also cache the parsed Squads IDL with LazyLock to avoid re-parsing 86KB JSON on every instruction. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- VaultTransactionMessage::deserialize now consumes the address-table-lookups trailer (count + 32-byte pubkey + writable/readonly index arrays per lookup) and rejects messages with unconsumed trailing bytes. Previously messages containing ALTs (or any padding) would silently parse as Ok. - visualize_inner_instructions returns Result and explicitly falls back to UnknownProgramVisualizer when the chosen visualizer fails or no visualizer matches. Previously per-instruction errors were swallowed via result.ok() and the fields were dropped from the output. - Replace the if-let-Ok-discard pattern on create_text_field / create_raw_data_field with `?` propagation throughout the build_* helpers (matching stakepool/mod.rs). All affected helpers now return Result<SquadsPreviewFields, VisualSignError>; introduces a SquadsPreviewFields type alias to keep clippy::type_complexity happy. cargo test (-p visualsign-solana, all 64 tests), cargo clippy (workspace, -D warnings), cargo fmt --check all pass. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Workspace lints (#231, #245) deny `unwrap_used`, `expect_used`, and `panic` — these landed on main after this branch was created. Rather than dodging them with `#[allow(...)]` on the test module (which other presets did and is the lazy fix), convert each test to return `Result<(), Box<dyn std::error::Error>>` and use `?` / `ok_or` to surface failures. Tests now fail with a meaningful error message via the test runner's normal Result reporting instead of a panic, which is also better debugging UX. cargo clippy --workspace --all-targets -- -D warnings: clean. cargo test -p visualsign-solana: 5/5 squads tests pass. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
25ce1b4 to
52aa22c
Compare
| inner_instructions: &[Instruction], | ||
| context: &VisualizerContext, | ||
| ) -> Result<Vec<AnnotatedPayloadField>, VisualSignError> { | ||
| let visualizers: Vec<Box<dyn InstructionVisualizer>> = available_visualizers(); |
There was a problem hiding this comment.
CRITICAL: unbounded recursion / DoS
available_visualizers() includes SquadsMultisigVisualizer itself (auto-registered by build.rs). A vaultTransactionCreate whose embedded transactionMessage references program SQDS4ep… with another vaultTransactionCreate recurses without bound → stack overflow. VisualizerContext has no depth field.
Fix: add a depth: u8 to VisualizerContext, increment on entry to visualize_inner_instructions, bail at a small cap (e.g. 4).
Authored by Claude on behalf of @pepe-anchor.
There was a problem hiding this comment.
Fixed in 876ddf1. Added a call_depth field + MAX_CALL_DEPTH=4 constant to VisualizerContext (matching Solana's runtime CPI cap). Inner visualization in both squads_multisig and swig_wallet now goes through context.for_nested_call(...); once the cap is reached the visualizer emits an explicit "max depth exceeded" field instead of recursing. Generalized to swig_wallet because the same recursion shape exists there per the related security finding.
| .iter() | ||
| .filter_map(|&i| { | ||
| let idx = i as usize; | ||
| if idx < account_keys.len() { |
There was a problem hiding this comment.
HIGH: ALT-delivered accounts silently dropped
When account_indexes references an index ≥ account_keys.len() (account delivered via an address table lookup), the entry is silently dropped by filter_map. The inner instruction renders with a truncated account list — a transfer destination could be invisible to the user.
Fix: detect any out-of-range index and fall back to generic display with a warning banner.
Authored by Claude on behalf of @pepe-anchor.
There was a problem hiding this comment.
Fixed in 876ddf1. reconstruct_instructions now returns Result; any out-of-range program-id or account index causes try_build_vault_transaction_fields to return Ok(None), falling back to the generic display instead of silently dropping accounts. Test: test_reconstruct_instructions_out_of_range_returns_err.
| data: &[u8], | ||
| idl: &Idl, | ||
| accounts: &[AccountMeta], | ||
| ) -> HashMap<String, String> { |
There was a problem hiding this comment.
HIGH: HashMap instead of BTreeMap — non-deterministic field order
Iterating HashMap<String, String> to build Vec<AnnotatedPayloadField> produces non-deterministic field ordering across runs. dflow_aggregator (the reference IDL preset) uses BTreeMap here.
Fix: change return type and SquadsParsedInstruction.named_accounts to BTreeMap<String, String>.
Authored by Claude on behalf of @pepe-anchor.
There was a problem hiding this comment.
Fixed in 876ddf1. build_named_accounts and SquadsParsedInstruction.named_accounts are now BTreeMap<String, String>, matching dflow_aggregator. Test: test_build_named_accounts_returns_btreemap_for_deterministic_order calls the builder twice and asserts identical iteration order.
| .filter_map(|&i| { | ||
| let idx = i as usize; | ||
| if idx < account_keys.len() { | ||
| Some(AccountMeta::new_readonly(account_keys[idx], false)) |
There was a problem hiding this comment.
MEDIUM: all inner accounts marked new_readonly
The header fields _num_writable_signers / _num_writable_non_signers are parsed but discarded — every reconstructed account gets is_writable=false, is_signer=false. Downstream visualizers that branch on writability will produce incorrect output.
Authored by Claude on behalf of @pepe-anchor.
There was a problem hiding this comment.
Fixed in 876ddf1. Header counts (num_signers, num_writable_signers, num_writable_non_signers) are now retained on VaultTransactionMessage. reconstruct_instructions derives per-account is_signer / is_writable from those counts using the standard Solana MessageHeader ordering convention. Test: test_reconstruct_instructions_writable_flags_match_header.
| visualizers.iter().map(|v| v.as_ref()).collect(); | ||
|
|
||
| let idl_registry = IdlRegistry::new(); | ||
| let sender = SolanaAccount { |
There was a problem hiding this comment.
MEDIUM: inner sender is the outer fee-payer, not the vault PDA
At execution time the signer of inner instructions is the vault PDA (multisig + vaultIndex), not the fee-payer creating the proposal. Visualizers that compare context.sender() to identify ownership will misattribute the source.
Authored by Claude on behalf of @pepe-anchor.
There was a problem hiding this comment.
Fixed in 876ddf1. Inner-instruction visualization now derives the vault PDA (Pubkey::find_program_address(&[b"multisig", multisig.as_ref(), b"vault", &[vault_index]], SQUADS_MULTISIG_PROGRAM_ID)) and uses it as the nested context's sender. Falls back to the generic display if multisig is missing from named_accounts or the program-id constant fails to parse. Test: test_vault_pda_account_derives_pda.
| // Visualize inner instructions using the full visualizer framework | ||
| let inner_fields = visualize_inner_instructions(&inner_instructions, context)?; | ||
|
|
||
| let vault_index = args_value |
There was a problem hiding this comment.
MEDIUM: vault_index silently defaults to 0 when missing
unwrap_or(0) on a security-relevant field: vault 0 is valid, so a parse failure is indistinguishable from an explicit index 0.
Authored by Claude on behalf of @pepe-anchor.
There was a problem hiding this comment.
Fixed in 876ddf1. vaultIndex must now be present and fit a u8 (Squads only supports vault indices 0..=255); missing or out-of-range values cause try_build_vault_transaction_fields to return Ok(None), falling back to the generic display so the user can still see the raw decoded args. Test: test_try_build_vault_transaction_fields_missing_vault_index_returns_none.
…ew feedback Adds a generalized depth cap to VisualizerContext (MAX_CALL_DEPTH=4, matching Solana's runtime CPI cap) so any preset that recursively visualizes inner instructions is protected from unbounded recursion / stack-overflow DoS. Threads `for_nested_call` through both squads_multisig and swig_wallet inner visualization paths; presets emit an explicit truncation field when the cap is reached rather than recursing further. Also addresses pepe-anchor review feedback on Squads: - HashMap -> BTreeMap for `named_accounts` (deterministic field ordering) - reconstruct_instructions returns Result; out-of-range indices (likely ALT- resolved keys we cannot dereference) cause a fall-back to generic display instead of a silently truncated account list - inner AccountMeta is_signer / is_writable derived from MessageHeader counts rather than every account being marked readonly - inner sender is now the vault PDA derived from `multisig` + vault_index, not the outer fee-payer - vault_index requires explicit u64-as-u8 parse; missing field falls back to generic display instead of silently defaulting to 0 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Summary
SQDS4ep65T869zMMBKyuUq6aD6EgTu8psMjkvj52pCf)vaultTransactionCreateby parsing theVaultTransactionMessagewire format and dispatching to the full visualizer frameworkdata_lenfield inVaultTransactionMessage::deserializeto read as u16 LE (matching the Squads v4 on-chain format)LazyLockto avoid re-parsing 86KB JSON on every callTest plan
cargo clippy -p visualsign-solana --all-targets -- -D warningscargo test -p visualsign-solana— 5 new squads tests (IDL loading, discriminators, error handling, vault transaction deserialization)make -C src test— full workspace passesparser_cli🤖 Generated with Claude Code