Skip to content

feat(solana): add Squads v4 Multisig visualizer preset#240

Open
prasanna-anchorage wants to merge 6 commits into
mainfrom
add-squads-multisig-visualizer
Open

feat(solana): add Squads v4 Multisig visualizer preset#240
prasanna-anchorage wants to merge 6 commits into
mainfrom
add-squads-multisig-visualizer

Conversation

@prasanna-anchorage
Copy link
Copy Markdown
Contributor

Summary

  • Adds IDL-based visualizer for Squads v4 Multisig program (SQDS4ep65T869zMMBKyuUq6aD6EgTu8psMjkvj52pCf)
  • Decodes nested inner instructions inside vaultTransactionCreate by parsing the VaultTransactionMessage wire format and dispatching to the full visualizer framework
  • Fixes data_len field in VaultTransactionMessage::deserialize to read as u16 LE (matching the Squads v4 on-chain format)
  • Caches parsed IDL with LazyLock to avoid re-parsing 86KB JSON on every call

Test plan

  • cargo clippy -p visualsign-solana --all-targets -- -D warnings
  • cargo test -p visualsign-solana — 5 new squads tests (IDL loading, discriminators, error handling, vault transaction deserialization)
  • make -C src test — full workspace passes
  • Parse real Squads multisig transaction with parser_cli

🤖 Generated with Claude Code

Copilot AI review requested due to automatic review settings April 8, 2026 05:23
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 VaultTransactionMessage parsing + 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.

Comment on lines +111 to +118
// 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,
})
}
Copy link

Copilot AI Apr 8, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment on lines +409 to +416
.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)
})
Copy link

Copilot AI Apr 8, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread src/chain_parsers/visualsign-solana/src/presets/squads_multisig/mod.rs Outdated
prasanna-anchorage and others added 5 commits May 4, 2026 22:42
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>
inner_instructions: &[Instruction],
context: &VisualizerContext,
) -> Result<Vec<AnnotatedPayloadField>, VisualSignError> {
let visualizers: Vec<Box<dyn InstructionVisualizer>> = available_visualizers();
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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() {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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> {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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))
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants