diff --git a/Cargo.lock b/Cargo.lock index 8b5ab0d..5218fe1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,6 +2,16 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "Inflector" +version = "0.11.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe438c63458706e03479442743baae6c88256498e6431708f6dfc520a26515d3" +dependencies = [ + "lazy_static", + "regex", +] + [[package]] name = "addr2line" version = "0.24.2" @@ -4618,6 +4628,45 @@ dependencies = [ "solana-sysvar", ] +[[package]] +name = "solana-account-decoder" +version = "2.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc13737697fe2ab4475bcae71525e37abd2b357a12dc68fc3e0938dd1a0dcbfd" +dependencies = [ + "Inflector", + "base64 0.22.1", + "bincode", + "bs58", + "bv", + "lazy_static", + "serde", + "serde_derive", + "serde_json", + "solana-account", + "solana-account-decoder-client-types", + "solana-clock", + "solana-config-program", + "solana-epoch-schedule", + "solana-fee-calculator", + "solana-instruction", + "solana-nonce", + "solana-program", + "solana-program-pack", + "solana-pubkey", + "solana-rent", + "solana-sdk-ids", + "solana-slot-hashes", + "solana-slot-history", + "solana-sysvar", + "spl-token 7.0.0", + "spl-token-2022 7.0.0", + "spl-token-group-interface 0.5.0", + "spl-token-metadata-interface 0.6.0", + "thiserror 2.0.12", + "zstd", +] + [[package]] name = "solana-account-decoder-client-types" version = "2.2.7" @@ -7256,6 +7305,47 @@ dependencies = [ "solana-signature", ] +[[package]] +name = "solana-transaction-status" +version = "2.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3f43457f2a9bfe6e625af7e37c6c46de152f20f9cc9657f8b26321da36826ea" +dependencies = [ + "Inflector", + "agave-reserved-account-keys", + "base64 0.22.1", + "bincode", + "borsh 1.5.7", + "bs58", + "lazy_static", + "log", + "serde", + "serde_derive", + "serde_json", + "solana-account-decoder", + "solana-clock", + "solana-hash", + "solana-instruction", + "solana-loader-v2-interface", + "solana-message", + "solana-program", + "solana-pubkey", + "solana-reward-info", + "solana-sdk-ids", + "solana-signature", + "solana-system-interface", + "solana-transaction", + "solana-transaction-error", + "solana-transaction-status-client-types", + "spl-associated-token-account", + "spl-memo", + "spl-token 7.0.0", + "spl-token-2022 7.0.0", + "spl-token-group-interface 0.5.0", + "spl-token-metadata-interface 0.6.0", + "thiserror 2.0.12", +] + [[package]] name = "solana-transaction-status-client-types" version = "2.2.7" @@ -7856,6 +7946,34 @@ dependencies = [ "thiserror 1.0.69", ] +[[package]] +name = "spl-token-2022" +version = "7.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9048b26b0df0290f929ff91317c83db28b3ef99af2b3493dd35baa146774924c" +dependencies = [ + "arrayref", + "bytemuck", + "num-derive", + "num-traits", + "num_enum", + "solana-program", + "solana-security-txt", + "solana-zk-sdk", + "spl-elgamal-registry 0.1.1", + "spl-memo", + "spl-pod", + "spl-token 7.0.0", + "spl-token-confidential-transfer-ciphertext-arithmetic 0.2.1", + "spl-token-confidential-transfer-proof-extraction 0.2.1", + "spl-token-confidential-transfer-proof-generation 0.3.0", + "spl-token-group-interface 0.5.0", + "spl-token-metadata-interface 0.6.0", + "spl-transfer-hook-interface 0.9.0", + "spl-type-length-value 0.7.0", + "thiserror 2.0.12", +] + [[package]] name = "spl-token-2022" version = "9.0.0" @@ -7999,6 +8117,17 @@ dependencies = [ "thiserror 1.0.69", ] +[[package]] +name = "spl-token-confidential-transfer-proof-generation" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e3597628b0d2fe94e7900fd17cdb4cfbb31ee35c66f82809d27d86e44b2848b" +dependencies = [ + "curve25519-dalek 4.1.3", + "solana-zk-sdk", + "thiserror 2.0.12", +] + [[package]] name = "spl-token-confidential-transfer-proof-generation" version = "0.4.0" @@ -8176,6 +8305,20 @@ dependencies = [ "thiserror 2.0.12", ] +[[package]] +name = "squads-sdk" +version = "0.1.0" +dependencies = [ + "base64 0.22.1", + "bincode", + "sha2 0.10.9", + "solana-client", + "solana-sdk", + "solana-transaction-status", + "thiserror 2.0.12", + "tokio", +] + [[package]] name = "stable_deref_trait" version = "1.2.0" diff --git a/Cargo.toml b/Cargo.toml index c5eac17..aca9a62 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,5 +1,5 @@ [workspace] -members = ["server-example", "webhook-api", "programs/*", "order-engine-sdk"] +members = ["server-example", "webhook-api", "programs/*", "order-engine-sdk", "squads-sdk"] resolver = "2" [workspace.package] @@ -29,6 +29,7 @@ reqwest = { version = "0.12", features = [ "blocking", ] } solana-client = "~2" +solana-transaction-status = "~2" solana-program = "~2" solana-program-test = "~2" solana-rpc-client = "~2" @@ -38,6 +39,7 @@ spl-token-2022 = { version = "9.0.0" } serde = { version = "^1.0", features = ["derive"] } serde_json = "^1.0" +sha2 = "0.10" strum = { version = "0.26.3", features = ["derive"] } test-case = "3.3.1" tokio = "1.43.0" diff --git a/squads-sdk/Cargo.toml b/squads-sdk/Cargo.toml new file mode 100644 index 0000000..e70c132 --- /dev/null +++ b/squads-sdk/Cargo.toml @@ -0,0 +1,16 @@ +[package] +name = "squads-sdk" +version = "0.1.0" +edition = { workspace = true } + +[dependencies] +base64 = { workspace = true } +bincode = { workspace = true } +sha2 = { workspace = true } +solana-sdk = { workspace = true } +thiserror = { workspace = true } + +[dev-dependencies] +solana-client = { workspace = true } +solana-transaction-status = { workspace = true } +tokio = { workspace = true, features = ["rt-multi-thread", "macros"] } diff --git a/squads-sdk/README.md b/squads-sdk/README.md new file mode 100644 index 0000000..f698d68 --- /dev/null +++ b/squads-sdk/README.md @@ -0,0 +1,30 @@ +# squads-sdk + +Rust SDK for wrapping and unwrapping Solana transactions in the Squads V5 multisig format (`executeTransactionSyncV2`). + +## Features + +- **Wrap** — take swap instructions and wrap them into a Squads multisig transaction, with optional ALT support +- **Unwrap** — recover inner instructions from a wrapped transaction (with or without ALTs) +- **Settings parsing** — parse on-chain Squads V5 settings accounts (members, threshold, etc.) +- **PDA derivation** — derive settings and vault PDAs +- **Preflight validation** — check CPI account limits and estimated tx size before wrapping +- **Detection** — identify whether a transaction is Squads-wrapped + +## Error variants + +| Variant | When | +|---------|------| +| `InvalidConfig` | Empty members, zero threshold, threshold > members | +| `CpiAccountLimitExceeded` | Inner instructions + Squads overhead > 64 accounts | +| `TransactionSizeExceeded` | Wrapped tx exceeds size limit | +| `UnrecognizedDiscriminator` | Transaction doesn't contain a Squads V2 instruction | +| `InvalidBase64` / `InvalidTransaction` | Malformed input | +| `InvalidSettingsData` | Settings account data is corrupted or truncated | + +## Limitations + +- Only supports `executeTransactionSyncV2` (not V1) +- Unwrap requires the caller to provide resolved account keys for ALT transactions (no RPC) +- Wrap produces unsigned transactions — caller must collect member signatures +- Squads CPI is limited to 64 accounts diff --git a/squads-sdk/examples/wrap_and_unwrap.rs b/squads-sdk/examples/wrap_and_unwrap.rs new file mode 100644 index 0000000..ba10df3 --- /dev/null +++ b/squads-sdk/examples/wrap_and_unwrap.rs @@ -0,0 +1,177 @@ +//! Example: wrap a swap instruction through a Squads multisig, then unwrap a +//! confirmed transaction from mainnet to inspect its inner instructions. +//! +//! Run with: +//! cargo run --example wrap_and_unwrap +//! +//! Requires mainnet RPC access (uses a real confirmed transaction). + +use solana_client::nonblocking::rpc_client::RpcClient; +use solana_client::rpc_config::RpcTransactionConfig; +use solana_sdk::{ + commitment_config::CommitmentConfig, + instruction::{AccountMeta, Instruction}, + pubkey, + pubkey::Pubkey, + signature::Signature, +}; +use solana_transaction_status::UiTransactionEncoding; +use std::str::FromStr; + +use squads_sdk::{ + build_squads_wrapped_transaction, unwrap_transaction, unwrap_transaction_with_account_keys, + SquadsWrapConfig, +}; + +#[tokio::main] +async fn main() { + // ── Part 1: Wrap ──────────────────────────────────────────────────── + // + // Take a swap instruction and wrap it so it executes through a Squads + // multisig vault via executeTransactionSyncV2. + + let rpc = RpcClient::new("https://api.mainnet-beta.solana.com".to_string()); + + // In production these come from your Squads multisig account on-chain. + let settings_pda = pubkey!("8f1s1b4Y3CVP9vA8QFf8m6v3oc7Q5Q8m2Un9u9A34M2T"); + let vault_pda = pubkey!("3q8J3wTVpd6fHiFcPfebP8Fd6hQfKd8QxJ5zhhWgE4n9"); + let member_a = pubkey!("Dk9EdQJk3JxR5aVdS3tDqQnBk7LfMoT1n7Vm5R4n4fq4"); + let member_b = pubkey!("4C58H5fm5P5k2p4A6HRo25ykoPS2atdx2myTaYF9E1f3"); + + let config = SquadsWrapConfig { + settings_pda, + vault_pda, + members: vec![member_a, member_b], + threshold: 2, + }; + + // Whatever swap/transfer the vault needs to execute. + let token_program = pubkey!("TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA"); + let swap_program = pubkey!("9xQeWvG816bUx9EPf2st4qGSe6P6xj6Yy7D6A6M6y8d"); + let user_ata = pubkey!("GDrB6xfg2s7zNBi8W6vX4NQAz3gU8GdU4cf9jXhVJzjP"); + + let swap_ix = Instruction { + program_id: swap_program, + accounts: vec![ + AccountMeta::new(vault_pda, true), // vault is the "signer" — Squads CPI signs for it + AccountMeta::new(user_ata, false), + AccountMeta::new_readonly(token_program, false), + ], + data: vec![0xDE, 0xAD, 0xBE, 0xEF], + }; + + // Get a recent blockhash from RPC (needed for the transaction message). + let recent_blockhash = rpc + .get_latest_blockhash_with_commitment(CommitmentConfig::confirmed()) + .await + .expect("failed to get blockhash") + .0; + + // Wrap it. The result is a VersionedTransaction with null signatures — + // members sign it before submitting. + let wrapped_tx = build_squads_wrapped_transaction( + &[swap_ix], + &config, + recent_blockhash, + 400_000, // compute unit limit + 500_000, // compute unit price (micro-lamports) + ) + .expect("failed to wrap transaction"); + + println!("=== Wrap ==="); + println!( + " signatures: {} (null until members sign)", + wrapped_tx.signatures.len() + ); + + // You can unwrap your own wrapped tx without ALTs (no lookup tables used). + let unwrapped = unwrap_transaction(&wrapped_tx).expect("failed to unwrap"); + println!( + " round-trip inner instructions: {}", + unwrapped.instructions.len() + ); + println!(" inner program: {}", unwrapped.instructions[0].program_id); + println!(" inner data: {:?}", unwrapped.instructions[0].data); + + // ── Part 2: Unwrap a real mainnet transaction (with ALTs) ─────────── + // + // Real Squads transactions usually use Address Lookup Tables. To unwrap + // them you need the full resolved account key list from RPC. + + // A real Squads-wrapped SOL→USDC swap on mainnet. + let tx_sig = Signature::from_str( + "5kdWZuVbbfY7vPFH78ueRczphwbo7c6RiywaMcwgcAZyr5ZM29jSHudZFx9VMNRLZkKCqQZWPEzd1uaPWVyyM855", + ) + .unwrap(); + + println!("\n=== Unwrap mainnet tx ==="); + println!(" signature: {tx_sig}"); + + // Fetch the confirmed transaction. maxSupportedTransactionVersion=0 + // is required for V0 (versioned) transactions. + let tx_response = rpc + .get_transaction_with_config( + &tx_sig, + RpcTransactionConfig { + encoding: Some(UiTransactionEncoding::Base64), + commitment: Some(CommitmentConfig::confirmed()), + max_supported_transaction_version: Some(0), + }, + ) + .await + .expect("failed to fetch transaction"); + + // The RPC response gives us the full resolved account key list: + // static keys ++ ALT writable keys ++ ALT readonly keys + // This is what unwrap_transaction_with_account_keys needs. + let ui_tx = tx_response + .transaction + .transaction + .decode() + .expect("failed to decode transaction"); + + let meta = tx_response + .transaction + .meta + .expect("transaction has no meta"); + + // Build the full account key list: static keys + loaded ALT addresses + let mut account_keys: Vec = ui_tx.message.static_account_keys().to_vec(); + if let solana_transaction_status::option_serializer::OptionSerializer::Some(loaded) = + meta.loaded_addresses + { + for addr in &loaded.writable { + account_keys.push(Pubkey::from_str(addr).expect("invalid writable ALT pubkey")); + } + for addr in &loaded.readonly { + account_keys.push(Pubkey::from_str(addr).expect("invalid readonly ALT pubkey")); + } + } + + println!( + " account keys: {} static + {} from ALTs = {} total", + ui_tx.message.static_account_keys().len(), + account_keys.len() - ui_tx.message.static_account_keys().len(), + account_keys.len(), + ); + + // Now unwrap with the full key list. + let unwrapped = + unwrap_transaction_with_account_keys(&ui_tx, &account_keys).expect("failed to unwrap"); + + println!(" settings_pda: {}", unwrapped.settings_pda); + println!(" members: {:?}", unwrapped.members); + println!(" num_signers: {}", unwrapped.num_signers); + println!(" compute_unit_limit: {}", unwrapped.compute_unit_limit); + println!(" compute_unit_price: {}", unwrapped.compute_unit_price); + println!(" inner instructions:"); + for (i, ix) in unwrapped.instructions.iter().enumerate() { + println!( + " ix[{}]: program={}, accounts={}, data_len={}", + i, + ix.program_id, + ix.accounts.len(), + ix.data.len(), + ); + } +} diff --git a/squads-sdk/src/accounts.rs b/squads-sdk/src/accounts.rs new file mode 100644 index 0000000..0a632e4 --- /dev/null +++ b/squads-sdk/src/accounts.rs @@ -0,0 +1,101 @@ +use std::collections::HashMap; + +use solana_sdk::{ + instruction::{AccountMeta, Instruction}, + pubkey::Pubkey, +}; + +use crate::error::{Result, SquadsSdkError}; + +#[derive(Clone, Copy, Debug)] +struct KeyMetaFlags { + is_signer: bool, + is_writable: bool, + first_seen: usize, +} + +pub fn compile_remaining_accounts( + swap_instructions: &[Instruction], + vault_pda: &Pubkey, +) -> Result> { + let mut map: HashMap = HashMap::new(); + let mut seen_counter: usize = 0; + + for ix in swap_instructions { + map.entry(ix.program_id).or_insert_with(|| { + let flags = KeyMetaFlags { + is_signer: false, + is_writable: false, + first_seen: seen_counter, + }; + seen_counter += 1; + flags + }); + + for key in &ix.accounts { + let entry = map.entry(key.pubkey).or_insert_with(|| { + let flags = KeyMetaFlags { + is_signer: false, + is_writable: false, + first_seen: seen_counter, + }; + seen_counter += 1; + flags + }); + entry.is_signer = entry.is_signer || key.is_signer; + entry.is_writable = entry.is_writable || key.is_writable; + } + } + + let mut writable_signers = Vec::new(); + let mut readonly_signers = Vec::new(); + let mut writable_non_signers = Vec::new(); + let mut readonly_non_signers = Vec::new(); + + let mut metas = map.into_iter().collect::>(); + metas.sort_by_key(|(_, flags)| flags.first_seen); + + for (pubkey, mut flags) in metas { + if pubkey == *vault_pda { + flags.is_signer = false; + } + + let account_meta = match (flags.is_signer, flags.is_writable) { + (true, true) => AccountMeta::new(pubkey, true), + (true, false) => AccountMeta::new_readonly(pubkey, true), + (false, true) => AccountMeta::new(pubkey, false), + (false, false) => AccountMeta::new_readonly(pubkey, false), + }; + + match (account_meta.is_signer, account_meta.is_writable) { + (true, true) => writable_signers.push(account_meta), + (true, false) => readonly_signers.push(account_meta), + (false, true) => writable_non_signers.push(account_meta), + (false, false) => readonly_non_signers.push(account_meta), + } + } + + if writable_signers.len() + + readonly_signers.len() + + writable_non_signers.len() + + readonly_non_signers.len() + > usize::from(u8::MAX) + { + return Err(SquadsSdkError::ParseError( + "too many remaining accounts".into(), + )); + } + + let mut remaining_accounts = Vec::with_capacity( + writable_signers.len() + + readonly_signers.len() + + writable_non_signers.len() + + readonly_non_signers.len(), + ); + remaining_accounts.extend(writable_signers); + remaining_accounts.extend(readonly_signers); + remaining_accounts.extend(writable_non_signers); + remaining_accounts.extend(readonly_non_signers); + + Ok(remaining_accounts) +} diff --git a/squads-sdk/src/config.rs b/squads-sdk/src/config.rs new file mode 100644 index 0000000..c51374b --- /dev/null +++ b/squads-sdk/src/config.rs @@ -0,0 +1,101 @@ +use solana_sdk::pubkey::Pubkey; + +use crate::error::{Result, SquadsSdkError}; +use crate::pda::derive_vault_pda; +use crate::settings::SquadsSettings; + +#[derive(Clone, Debug)] +pub struct SquadsWrapConfig { + pub settings_pda: Pubkey, + pub vault_pda: Pubkey, + pub members: Vec, + pub threshold: u8, +} + +impl SquadsWrapConfig { + pub fn validate(&self) -> Result<()> { + if self.members.is_empty() { + return Err(SquadsSdkError::InvalidConfig( + "members cannot be empty".into(), + )); + } + if self.threshold == 0 { + return Err(SquadsSdkError::InvalidConfig( + "threshold must be greater than zero".into(), + )); + } + if usize::from(self.threshold) > self.members.len() { + return Err(SquadsSdkError::InvalidConfig( + "threshold cannot be greater than members length".into(), + )); + } + Ok(()) + } + + /// Build a [`SquadsWrapConfig`] from parsed on-chain settings. + /// + /// `signer_pubkeys` is the ordered list of members that will sign this + /// transaction. The first entry becomes the fee payer. Every pubkey must + /// be a member of the multisig and the count must meet the threshold. + pub fn from_settings( + settings: &SquadsSettings, + settings_pda: Pubkey, + vault_index: u8, + signer_pubkeys: &[Pubkey], + ) -> Result { + if signer_pubkeys.is_empty() { + return Err(SquadsSdkError::InvalidConfig( + "signer_pubkeys cannot be empty".into(), + )); + } + if (signer_pubkeys.len() as u16) < settings.threshold { + return Err(SquadsSdkError::InvalidConfig(format!( + "need at least {} signers (threshold), got {}", + settings.threshold, + signer_pubkeys.len() + ))); + } + for signer in signer_pubkeys { + if !settings.members.iter().any(|m| m.pubkey == *signer) { + return Err(SquadsSdkError::InvalidConfig(format!( + "signer {} is not a member of the multisig", + signer + ))); + } + } + + let (vault_pda, _) = derive_vault_pda(&settings_pda, vault_index); + + let config = Self { + settings_pda, + vault_pda, + members: signer_pubkeys.to_vec(), + threshold: settings.threshold as u8, + }; + config.validate()?; + Ok(config) + } +} + +/// Configurable options for transaction wrapping. +#[derive(Clone, Debug)] +pub struct WrapOptions { + /// Multiplier applied to the original compute-unit limit. + /// Squads CPI adds significant overhead; without simulation we use a + /// conservative multiplier. Default: `2`. + pub cu_multiplier: u32, + /// Absolute cap for the compute-unit limit. Default: `1_400_000`. + pub cu_cap: u32, + /// Maximum serialized transaction size in bytes. Default: `1232` (Solana limit). + pub tx_size_limit: usize, +} + +impl Default for WrapOptions { + fn default() -> Self { + Self { + cu_multiplier: 2, + cu_cap: 1_400_000, + tx_size_limit: 1232, + } + } +} diff --git a/squads-sdk/src/error.rs b/squads-sdk/src/error.rs new file mode 100644 index 0000000..2057f44 --- /dev/null +++ b/squads-sdk/src/error.rs @@ -0,0 +1,49 @@ +use thiserror::Error; + +#[derive(Debug, Error)] +pub enum SquadsSdkError { + #[error("invalid config: {0}")] + InvalidConfig(String), + + #[error("inner instruction accounts ({inner}) plus Squads overhead ({overhead}) = {total} exceeds 64-account CPI limit")] + CpiAccountLimitExceeded { + inner: usize, + overhead: usize, + total: usize, + }, + + #[error("wrapped transaction size ({size} bytes) exceeds {limit} byte limit; route has too many accounts for Squads wrapping without ALT support")] + TransactionSizeExceeded { size: usize, limit: usize }, + + #[error("invalid base64: {0}")] + InvalidBase64(String), + + #[error("invalid transaction: {0}")] + InvalidTransaction(String), + + #[error("unrecognized squads instruction discriminator")] + UnrecognizedDiscriminator, + + #[error("invalid settings account data: {0}")] + InvalidSettingsData(String), + + #[error("parse error: {0}")] + ParseError(String), + + #[error("solana error: {0}")] + SolanaError(String), +} + +impl From for SquadsSdkError { + fn from(e: solana_sdk::signer::SignerError) -> Self { + SquadsSdkError::SolanaError(e.to_string()) + } +} + +impl From for SquadsSdkError { + fn from(e: solana_sdk::message::CompileError) -> Self { + SquadsSdkError::SolanaError(e.to_string()) + } +} + +pub type Result = std::result::Result; diff --git a/squads-sdk/src/lib.rs b/squads-sdk/src/lib.rs new file mode 100644 index 0000000..d01e730 --- /dev/null +++ b/squads-sdk/src/lib.rs @@ -0,0 +1,136 @@ +mod accounts; +pub mod config; +pub mod error; +pub mod pda; +pub mod serialize; +pub mod settings; +pub mod transaction; +pub mod unwrap; +pub mod wrap; + +use sha2::{Digest, Sha256}; +use solana_sdk::message::VersionedMessage; +use solana_sdk::pubkey::Pubkey; +use solana_sdk::transaction::VersionedTransaction; + +pub const SQUADS_PROGRAM_ID: Pubkey = + solana_sdk::pubkey!("SMRTzfY6DfH5ik3TKiyLFfXexV8uSG3d2UksSCYdunG"); + +pub const EXECUTE_TX_SYNC_V2_DISCRIMINATOR: [u8; 8] = [90, 81, 187, 81, 39, 70, 128, 78]; + +/// Compute an Anchor-style 8-byte discriminator from a Sighash string +/// (e.g. `b"global:execute_transaction_sync_v2"`). +pub fn get_discriminator_bytes(sighash: &[u8]) -> [u8; 8] { + let mut hasher = Sha256::new(); + hasher.update(sighash); + let hash = hasher.finalize(); + let mut disc = [0u8; 8]; + disc.copy_from_slice(&hash[..8]); + disc +} + +// Re-exports for ergonomic use +pub use config::{SquadsWrapConfig, WrapOptions}; +pub use error::SquadsSdkError; +pub use pda::{derive_settings_pda, derive_vault_pda}; +pub use settings::{parse_squads_settings, MemberPermissions, SquadsMember, SquadsSettings}; +pub use unwrap::{ + unwrap_message, unwrap_message_with_account_keys, unwrap_transaction, + unwrap_transaction_base64, unwrap_transaction_base64_with_account_keys, + unwrap_transaction_with_account_keys, UnwrappedTransaction, +}; +pub use wrap::{ + build_squads_wrapped_transaction, build_squads_wrapped_transaction_with_alts, can_wrap, + wrap_quote_transaction_base64, wrap_transaction_base64, wrap_transaction_base64_with_alts, +}; + +/// Check if a [`VersionedMessage`] contains a Squads +/// `executeTransactionSyncV2` instruction without performing a full unwrap. +pub fn is_squads_message(message: &VersionedMessage) -> bool { + let account_keys = message.static_account_keys(); + let instructions = transaction::compiled_instructions(message); + instructions.iter().any(|compiled| { + let program_index = usize::from(compiled.program_id_index); + program_index < account_keys.len() + && account_keys[program_index] == SQUADS_PROGRAM_ID + && compiled.data.len() >= 8 + && compiled.data[..8] == EXECUTE_TX_SYNC_V2_DISCRIMINATOR + }) +} + +/// Check if a [`VersionedTransaction`] contains a Squads +/// `executeTransactionSyncV2` instruction without performing a full unwrap. +pub fn is_squads_transaction(tx: &VersionedTransaction) -> bool { + is_squads_message(&tx.message) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn v2_discriminator_matches_sha256() { + let computed = get_discriminator_bytes(b"global:execute_transaction_sync_v2"); + assert_eq!(computed, EXECUTE_TX_SYNC_V2_DISCRIMINATOR); + } + + #[test] + fn is_squads_transaction_detects_wrapped_tx() { + use solana_sdk::{ + hash::Hash, instruction::AccountMeta, instruction::Instruction, pubkey::Pubkey, + }; + + let settings = Pubkey::new_unique(); + let vault = Pubkey::new_unique(); + let member = Pubkey::new_unique(); + let swap_program = Pubkey::new_unique(); + + let swap_ix = Instruction { + program_id: swap_program, + accounts: vec![AccountMeta::new(vault, true)], + data: vec![1, 2, 3], + }; + + let tx = build_squads_wrapped_transaction( + &[swap_ix], + &SquadsWrapConfig { + settings_pda: settings, + vault_pda: vault, + members: vec![member], + threshold: 1, + }, + Hash::new_unique(), + 400_000, + 500_000, + ) + .unwrap(); + + assert!(is_squads_transaction(&tx)); + } + + #[test] + fn is_squads_transaction_rejects_plain_tx() { + use solana_sdk::{ + compute_budget::ComputeBudgetInstruction, + hash::Hash, + message::{self, VersionedMessage}, + pubkey::Pubkey, + signature::NullSigner, + transaction::VersionedTransaction, + }; + + let payer = Pubkey::new_unique(); + let message = message::v0::Message::try_compile( + &payer, + &[ComputeBudgetInstruction::set_compute_unit_limit(400_000)], + &[], + Hash::new_unique(), + ) + .unwrap(); + + let signer = NullSigner::new(&payer); + let tx = VersionedTransaction::try_new(VersionedMessage::V0(message), &[&signer]).unwrap(); + + assert!(!is_squads_transaction(&tx)); + } +} diff --git a/squads-sdk/src/pda.rs b/squads-sdk/src/pda.rs new file mode 100644 index 0000000..1c51ee2 --- /dev/null +++ b/squads-sdk/src/pda.rs @@ -0,0 +1,81 @@ +use solana_sdk::pubkey::Pubkey; + +use crate::SQUADS_PROGRAM_ID; + +/// Derive the settings PDA (smart account) from the create key. +/// +/// Seeds: `["smart_account", create_key]` +pub fn derive_settings_pda(create_key: &Pubkey) -> (Pubkey, u8) { + Pubkey::find_program_address(&[b"smart_account", create_key.as_ref()], &SQUADS_PROGRAM_ID) +} + +/// Derive the vault PDA from the settings PDA and vault index. +/// +/// Seeds: `["smart_account", settings_pda, "smart_account", vault_index]` +pub fn derive_vault_pda(settings_pda: &Pubkey, vault_index: u8) -> (Pubkey, u8) { + Pubkey::find_program_address( + &[ + b"smart_account", + settings_pda.as_ref(), + b"smart_account", + &[vault_index], + ], + &SQUADS_PROGRAM_ID, + ) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn derive_settings_pda_is_deterministic() { + let create_key = Pubkey::new_unique(); + let (pda1, bump1) = derive_settings_pda(&create_key); + let (pda2, bump2) = derive_settings_pda(&create_key); + assert_eq!(pda1, pda2); + assert_eq!(bump1, bump2); + } + + #[test] + fn derive_vault_pda_is_deterministic() { + let settings = Pubkey::new_unique(); + let (pda1, bump1) = derive_vault_pda(&settings, 0); + let (pda2, bump2) = derive_vault_pda(&settings, 0); + assert_eq!(pda1, pda2); + assert_eq!(bump1, bump2); + } + + #[test] + fn different_vault_indices_produce_different_addresses() { + let settings = Pubkey::new_unique(); + let (vault0, _) = derive_vault_pda(&settings, 0); + let (vault1, _) = derive_vault_pda(&settings, 1); + let (vault2, _) = derive_vault_pda(&settings, 2); + assert_ne!(vault0, vault1); + assert_ne!(vault1, vault2); + assert_ne!(vault0, vault2); + } + + #[test] + fn different_create_keys_produce_different_settings() { + let key_a = Pubkey::new_unique(); + let key_b = Pubkey::new_unique(); + let (settings_a, _) = derive_settings_pda(&key_a); + let (settings_b, _) = derive_settings_pda(&key_b); + assert_ne!(settings_a, settings_b); + } + + #[test] + fn derive_vault_pda_matches_on_chain_program() { + use solana_sdk::pubkey; + // Known settings PDA → vault PDA pair from mainnet + let settings_pda = pubkey!("8QJmMPTmRJSLsGxjAXYDquEtWjVaKvBK8HVvV4Mcn1gB"); + let expected_vault = pubkey!("HviMBVH4L84zW7xKL8oSPcDbXrjLVyRkCiYUjcVCVACE"); + let (vault, _) = derive_vault_pda(&settings_pda, 0); + assert_eq!( + vault, expected_vault, + "vault PDA derivation must match the on-chain Squads program" + ); + } +} diff --git a/squads-sdk/src/serialize.rs b/squads-sdk/src/serialize.rs new file mode 100644 index 0000000..f07c4d5 --- /dev/null +++ b/squads-sdk/src/serialize.rs @@ -0,0 +1,144 @@ +use std::collections::HashMap; + +use solana_sdk::{ + instruction::{AccountMeta, Instruction}, + pubkey::Pubkey, +}; + +use crate::error::{Result, SquadsSdkError}; + +pub fn serialize_swap_instructions( + swap_instructions: &[Instruction], + remaining_accounts: &[AccountMeta], +) -> Result> { + if swap_instructions.len() > usize::from(u8::MAX) { + return Err(SquadsSdkError::ParseError( + "too many swap instructions".into(), + )); + } + + let mut indexes: HashMap = HashMap::new(); + for (idx, meta) in remaining_accounts.iter().enumerate() { + indexes.insert(meta.pubkey, idx); + } + + let estimated_size: usize = 1 + swap_instructions + .iter() + .map(|ix| 1 + 1 + ix.accounts.len() + 2 + ix.data.len()) + .sum::(); + let mut out = Vec::with_capacity(estimated_size); + out.push(swap_instructions.len() as u8); + + for ix in swap_instructions { + let program_idx = indexes.get(&ix.program_id).ok_or_else(|| { + SquadsSdkError::ParseError("program id not found in remaining accounts".into()) + })?; + if *program_idx > usize::from(u8::MAX) { + return Err(SquadsSdkError::ParseError("program index overflow".into())); + } + + if ix.accounts.len() > usize::from(u8::MAX) { + return Err(SquadsSdkError::ParseError( + "too many account indexes in instruction".into(), + )); + } + if ix.data.len() > usize::from(u16::MAX) { + return Err(SquadsSdkError::ParseError( + "instruction data too large".into(), + )); + } + + out.push(*program_idx as u8); + out.push(ix.accounts.len() as u8); + + for key in &ix.accounts { + let key_idx = indexes.get(&key.pubkey).ok_or_else(|| { + SquadsSdkError::ParseError("account not found in remaining accounts".into()) + })?; + if *key_idx > usize::from(u8::MAX) { + return Err(SquadsSdkError::ParseError("account index overflow".into())); + } + out.push(*key_idx as u8); + } + + let len = ix.data.len() as u16; + out.extend_from_slice(&len.to_le_bytes()); + out.extend_from_slice(&ix.data); + } + + Ok(out) +} + +/// A parsed inner instruction from the serialized binary format. +#[derive(Debug, Clone)] +pub struct InnerInstruction { + pub program_id_index: u8, + pub account_indices: Vec, + pub data: Vec, +} + +/// Deserialize the binary payload produced by [`serialize_swap_instructions`] +/// back into its component parts. +pub fn deserialize_inner_instructions(data: &[u8]) -> Result> { + if data.is_empty() { + return Err(SquadsSdkError::ParseError( + "empty serialized instructions".into(), + )); + } + + let mut pos = 0; + let num_instructions = data[pos] as usize; + pos += 1; + + let mut instructions = Vec::with_capacity(num_instructions); + + for _ in 0..num_instructions { + if pos >= data.len() { + return Err(SquadsSdkError::ParseError( + "unexpected end of instruction data".into(), + )); + } + let program_id_index = data[pos]; + pos += 1; + + if pos >= data.len() { + return Err(SquadsSdkError::ParseError( + "unexpected end of instruction data".into(), + )); + } + let num_accounts = data[pos] as usize; + pos += 1; + + if pos + num_accounts > data.len() { + return Err(SquadsSdkError::ParseError( + "unexpected end of instruction data".into(), + )); + } + let account_indices = data[pos..pos + num_accounts].to_vec(); + pos += num_accounts; + + if pos + 2 > data.len() { + return Err(SquadsSdkError::ParseError( + "unexpected end of instruction data".into(), + )); + } + let data_len = u16::from_le_bytes([data[pos], data[pos + 1]]) as usize; + pos += 2; + + if pos + data_len > data.len() { + return Err(SquadsSdkError::ParseError( + "unexpected end of instruction data".into(), + )); + } + let ix_data = data[pos..pos + data_len].to_vec(); + pos += data_len; + + instructions.push(InnerInstruction { + program_id_index, + account_indices, + data: ix_data, + }); + } + + Ok(instructions) +} diff --git a/squads-sdk/src/settings.rs b/squads-sdk/src/settings.rs new file mode 100644 index 0000000..a54258a --- /dev/null +++ b/squads-sdk/src/settings.rs @@ -0,0 +1,335 @@ +use solana_sdk::pubkey::Pubkey; + +use crate::error::{Result, SquadsSdkError}; + +/// Bitmask for a Squads member's permissions. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct MemberPermissions(pub u8); + +impl MemberPermissions { + pub const INITIATE: u8 = 1 << 0; + pub const VOTE: u8 = 1 << 1; + pub const EXECUTE: u8 = 1 << 2; + + pub fn can_initiate(self) -> bool { + self.0 & Self::INITIATE != 0 + } + + pub fn can_vote(self) -> bool { + self.0 & Self::VOTE != 0 + } + + pub fn can_execute(self) -> bool { + self.0 & Self::EXECUTE != 0 + } +} + +/// A parsed member from a Squads V5 settings account. +#[derive(Debug, Clone)] +pub struct SquadsMember { + pub pubkey: Pubkey, + pub permissions: MemberPermissions, +} + +/// Parsed Squads V5 settings account (SmartAccount). +#[derive(Debug, Clone)] +pub struct SquadsSettings { + pub multisig: Pubkey, + pub settings_index: u64, + pub stale_transaction_index: u64, + pub threshold: u16, + pub time_lock: u32, + pub transaction_index: u64, + pub archival_authority: Option, + pub archivable_after: u64, + pub bump: u8, + pub members: Vec, +} + +/// Expected Anchor discriminator for `Settings` (Squads V5). +fn settings_discriminator() -> [u8; 8] { + crate::get_discriminator_bytes(b"account:Settings") +} + +fn read_u8(data: &[u8], offset: &mut usize) -> Result { + if *offset >= data.len() { + return Err(SquadsSdkError::InvalidSettingsData( + "unexpected end of data".into(), + )); + } + let val = data[*offset]; + *offset += 1; + Ok(val) +} + +fn read_u16_le(data: &[u8], offset: &mut usize) -> Result { + if *offset + 2 > data.len() { + return Err(SquadsSdkError::InvalidSettingsData( + "unexpected end of data".into(), + )); + } + let val = u16::from_le_bytes([data[*offset], data[*offset + 1]]); + *offset += 2; + Ok(val) +} + +fn read_u32_le(data: &[u8], offset: &mut usize) -> Result { + if *offset + 4 > data.len() { + return Err(SquadsSdkError::InvalidSettingsData( + "unexpected end of data".into(), + )); + } + let val = u32::from_le_bytes(data[*offset..*offset + 4].try_into().unwrap()); + *offset += 4; + Ok(val) +} + +fn read_u64_le(data: &[u8], offset: &mut usize) -> Result { + if *offset + 8 > data.len() { + return Err(SquadsSdkError::InvalidSettingsData( + "unexpected end of data".into(), + )); + } + let val = u64::from_le_bytes(data[*offset..*offset + 8].try_into().unwrap()); + *offset += 8; + Ok(val) +} + +fn read_pubkey(data: &[u8], offset: &mut usize) -> Result { + if *offset + 32 > data.len() { + return Err(SquadsSdkError::InvalidSettingsData( + "unexpected end of data".into(), + )); + } + let key = Pubkey::new_from_array(data[*offset..*offset + 32].try_into().unwrap()); + *offset += 32; + Ok(key) +} + +/// Parse raw Squads V5 settings account data into [`SquadsSettings`]. +/// +/// The caller is responsible for fetching the account via RPC. +/// This function validates the Anchor discriminator and extracts all fields. +pub fn parse_squads_settings(data: &[u8]) -> Result { + if data.len() < 78 { + return Err(SquadsSdkError::InvalidSettingsData(format!( + "account data too short: {} bytes, need at least 78", + data.len() + ))); + } + + // Validate discriminator + let expected_disc = settings_discriminator(); + if data[0..8] != expected_disc { + return Err(SquadsSdkError::InvalidSettingsData(format!( + "wrong discriminator: expected {:?}, got {:?}", + expected_disc, + &data[0..8] + ))); + } + + let mut offset = 8; + + let multisig = read_pubkey(data, &mut offset)?; + let settings_index = read_u64_le(data, &mut offset)?; + let stale_transaction_index = read_u64_le(data, &mut offset)?; + let threshold = read_u16_le(data, &mut offset)?; + let time_lock = read_u32_le(data, &mut offset)?; + let transaction_index = read_u64_le(data, &mut offset)?; + + // Skip 8 bytes padding + if offset + 8 > data.len() { + return Err(SquadsSdkError::InvalidSettingsData( + "unexpected end of data at padding".into(), + )); + } + offset += 8; + + // archival_authority: COption + let archival_tag = read_u8(data, &mut offset)?; + let archival_authority = if archival_tag == 1 { + Some(read_pubkey(data, &mut offset)?) + } else { + None + }; + + let archivable_after = read_u64_le(data, &mut offset)?; + let bump = read_u8(data, &mut offset)?; + + // signers: Vec + let signers_len = read_u32_le(data, &mut offset)? as usize; + let mut members = Vec::with_capacity(signers_len); + + for _ in 0..signers_len { + let pubkey = read_pubkey(data, &mut offset)?; + let permissions = MemberPermissions(read_u8(data, &mut offset)?); + members.push(SquadsMember { + pubkey, + permissions, + }); + } + + Ok(SquadsSettings { + multisig, + settings_index, + stale_transaction_index, + threshold, + time_lock, + transaction_index, + archival_authority, + archivable_after, + bump, + members, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Build a synthetic V5 settings account buffer. + fn build_settings_buffer( + multisig: &Pubkey, + threshold: u16, + time_lock: u32, + archival_authority: Option<&Pubkey>, + bump: u8, + members: &[(Pubkey, u8)], + ) -> Vec { + let mut buf = Vec::new(); + + // Discriminator + buf.extend_from_slice(&settings_discriminator()); + // multisig + buf.extend_from_slice(multisig.as_ref()); + // settings_index + buf.extend_from_slice(&0u64.to_le_bytes()); + // stale_transaction_index + buf.extend_from_slice(&0u64.to_le_bytes()); + // threshold + buf.extend_from_slice(&threshold.to_le_bytes()); + // time_lock + buf.extend_from_slice(&time_lock.to_le_bytes()); + // transaction_index + buf.extend_from_slice(&42u64.to_le_bytes()); + // padding + buf.extend_from_slice(&[0u8; 8]); + // archival_authority + match archival_authority { + Some(key) => { + buf.push(1); + buf.extend_from_slice(key.as_ref()); + } + None => { + buf.push(0); + } + } + // archivable_after + buf.extend_from_slice(&0u64.to_le_bytes()); + // bump + buf.push(bump); + // members vec + buf.extend_from_slice(&(members.len() as u32).to_le_bytes()); + for (key, perms) in members { + buf.extend_from_slice(key.as_ref()); + buf.push(*perms); + } + + buf + } + + #[test] + fn parses_settings_without_archival_authority() { + let multisig = Pubkey::new_unique(); + let member_a = Pubkey::new_unique(); + let member_b = Pubkey::new_unique(); + + let data = build_settings_buffer( + &multisig, + 2, + 0, + None, + 254, + &[(member_a, 0x07), (member_b, 0x07)], + ); + + let settings = parse_squads_settings(&data).unwrap(); + assert_eq!(settings.multisig, multisig); + assert_eq!(settings.threshold, 2); + assert_eq!(settings.time_lock, 0); + assert_eq!(settings.transaction_index, 42); + assert!(settings.archival_authority.is_none()); + assert_eq!(settings.bump, 254); + assert_eq!(settings.members.len(), 2); + assert_eq!(settings.members[0].pubkey, member_a); + assert_eq!(settings.members[0].permissions.0, 0x07); + assert!(settings.members[0].permissions.can_initiate()); + assert!(settings.members[0].permissions.can_vote()); + assert!(settings.members[0].permissions.can_execute()); + assert_eq!(settings.members[1].pubkey, member_b); + } + + #[test] + fn parses_settings_with_archival_authority() { + let multisig = Pubkey::new_unique(); + let archival = Pubkey::new_unique(); + let member = Pubkey::new_unique(); + + let data = + build_settings_buffer(&multisig, 1, 3600, Some(&archival), 253, &[(member, 0x03)]); + + let settings = parse_squads_settings(&data).unwrap(); + assert_eq!(settings.threshold, 1); + assert_eq!(settings.time_lock, 3600); + assert_eq!(settings.archival_authority, Some(archival)); + assert_eq!(settings.bump, 253); + assert_eq!(settings.members.len(), 1); + assert!(settings.members[0].permissions.can_initiate()); + assert!(settings.members[0].permissions.can_vote()); + assert!(!settings.members[0].permissions.can_execute()); + } + + #[test] + fn rejects_wrong_discriminator() { + let mut data = build_settings_buffer( + &Pubkey::new_unique(), + 1, + 0, + None, + 255, + &[(Pubkey::new_unique(), 0x07)], + ); + // Corrupt discriminator + data[0] = 0xFF; + + let err = parse_squads_settings(&data).unwrap_err(); + assert!(err.to_string().contains("wrong discriminator")); + } + + #[test] + fn rejects_truncated_data() { + // Too short for minimum header + let err = parse_squads_settings(&[0u8; 10]).unwrap_err(); + assert!(err.to_string().contains("too short")); + + // Valid discriminator, passes length check, but truncated at signers vec + let mut data = build_settings_buffer( + &Pubkey::new_unique(), + 1, + 0, + None, + 255, + &[(Pubkey::new_unique(), 0x07)], + ); + // Chop off the last member bytes so the member parsing fails + data.truncate(data.len() - 10); + let err = parse_squads_settings(&data).unwrap_err(); + assert!(err.to_string().contains("unexpected end of data")); + } + + #[test] + fn rejects_data_too_short() { + let err = parse_squads_settings(&[0u8; 4]).unwrap_err(); + assert!(err.to_string().contains("too short")); + } +} diff --git a/squads-sdk/src/transaction.rs b/squads-sdk/src/transaction.rs new file mode 100644 index 0000000..c2d2c6b --- /dev/null +++ b/squads-sdk/src/transaction.rs @@ -0,0 +1,123 @@ +use solana_sdk::{ + compute_budget, + instruction::{AccountMeta, CompiledInstruction, Instruction}, + message::VersionedMessage, + transaction::VersionedTransaction, +}; + +use base64::{prelude::BASE64_STANDARD, Engine}; + +use crate::error::{Result, SquadsSdkError}; + +/// Decode a base64-encoded Solana transaction. +pub fn decode_transaction_base64(b64: &str) -> Result { + let tx_bytes = BASE64_STANDARD + .decode(b64) + .map_err(|e| SquadsSdkError::InvalidBase64(e.to_string()))?; + bincode::deserialize(&tx_bytes).map_err(|e| SquadsSdkError::InvalidTransaction(e.to_string())) +} + +/// Get the compiled instructions from a [`VersionedMessage`], regardless of variant. +pub fn compiled_instructions(message: &VersionedMessage) -> &[CompiledInstruction] { + match message { + VersionedMessage::V0(v0) => &v0.instructions, + VersionedMessage::Legacy(legacy) => &legacy.instructions, + } +} + +fn is_signer(message: &VersionedMessage, account_index: usize) -> bool { + let header = message.header(); + account_index < usize::from(header.num_required_signatures) +} + +fn is_writable(message: &VersionedMessage, account_index: usize, account_keys_len: usize) -> bool { + let header = message.header(); + let num_required = usize::from(header.num_required_signatures); + let num_readonly_signed = usize::from(header.num_readonly_signed_accounts); + let num_readonly_unsigned = usize::from(header.num_readonly_unsigned_accounts); + + if account_index < num_required { + account_index < (num_required.saturating_sub(num_readonly_signed)) + } else { + account_index < (account_keys_len.saturating_sub(num_readonly_unsigned)) + } +} + +pub fn decompile_instruction( + message: &VersionedMessage, + compiled: &CompiledInstruction, +) -> Result { + let account_keys = message.static_account_keys(); + let program_index = usize::from(compiled.program_id_index); + if program_index >= account_keys.len() { + return Err(SquadsSdkError::ParseError( + "program id index out of range".into(), + )); + } + + let mut metas = Vec::with_capacity(compiled.accounts.len()); + for idx in &compiled.accounts { + let index = usize::from(*idx); + if index >= account_keys.len() { + return Err(SquadsSdkError::ParseError( + "account index out of range".into(), + )); + } + let pubkey = account_keys[index]; + let writable = is_writable(message, index, account_keys.len()); + let signer = is_signer(message, index); + let meta = if writable { + AccountMeta::new(pubkey, signer) + } else { + AccountMeta::new_readonly(pubkey, signer) + }; + metas.push(meta); + } + + Ok(Instruction { + program_id: account_keys[program_index], + accounts: metas, + data: compiled.data.clone(), + }) +} + +pub fn extract_compute_budget_params(message: &VersionedMessage) -> Result<(u32, u64)> { + let instructions = compiled_instructions(message); + + let account_keys = message.static_account_keys(); + let mut cu_limit: Option = None; + let mut cu_price: Option = None; + + for compiled in instructions { + // Skip instructions whose program_id resolves through ALTs (not in static keys). + // Compute budget instructions always use static account keys. + let program_index = usize::from(compiled.program_id_index); + if program_index >= account_keys.len() { + continue; + } + if account_keys[program_index] != compute_budget::id() { + continue; + } + if compiled.data.is_empty() { + continue; + } + + match compiled.data[0] { + // ComputeBudgetInstruction::SetComputeUnitLimit + 2 if compiled.data.len() >= 5 => { + let mut bytes = [0u8; 4]; + bytes.copy_from_slice(&compiled.data[1..5]); + cu_limit = Some(u32::from_le_bytes(bytes)); + } + // ComputeBudgetInstruction::SetComputeUnitPrice + 3 if compiled.data.len() >= 9 => { + let mut bytes = [0u8; 8]; + bytes.copy_from_slice(&compiled.data[1..9]); + cu_price = Some(u64::from_le_bytes(bytes)); + } + _ => {} + } + } + + Ok((cu_limit.unwrap_or(400_000), cu_price.unwrap_or(500_000))) +} diff --git a/squads-sdk/src/unwrap.rs b/squads-sdk/src/unwrap.rs new file mode 100644 index 0000000..2dafc9f --- /dev/null +++ b/squads-sdk/src/unwrap.rs @@ -0,0 +1,503 @@ +use solana_sdk::{ + instruction::{AccountMeta, Instruction}, + message::VersionedMessage, + pubkey::Pubkey, + transaction::VersionedTransaction, +}; + +use crate::{ + error::{Result, SquadsSdkError}, + serialize::deserialize_inner_instructions, + transaction::{ + compiled_instructions, decode_transaction_base64, extract_compute_budget_params, + }, + EXECUTE_TX_SYNC_V2_DISCRIMINATOR, SQUADS_PROGRAM_ID, +}; + +/// The result of unwrapping a Squads-wrapped transaction. +#[derive(Debug, Clone)] +pub struct UnwrappedTransaction { + /// The inner swap instructions that were wrapped. + pub instructions: Vec, + /// The settings PDA used in the Squads instruction. + pub settings_pda: Pubkey, + /// The member pubkeys that were signers. + pub members: Vec, + /// Number of signers encoded in the Squads instruction. + pub num_signers: u8, + /// Compute unit limit from the outer transaction's compute budget. + pub compute_unit_limit: u32, + /// Compute unit price from the outer transaction's compute budget. + pub compute_unit_price: u64, +} + +/// Unwrap a Squads V2 wrapped [`VersionedTransaction`] using the full resolved +/// account key list. +/// +/// `account_keys` must be the complete ordered list of account pubkeys for the +/// transaction — static keys followed by ALT-resolved writable keys then +/// ALT-resolved readonly keys. This is the same order returned by RPC methods +/// like `getTransaction` in the `accountKeys` field. +/// +/// For transactions **without** address lookup tables, you can use +/// [`unwrap_transaction`] instead, which derives the keys from the message. +pub fn unwrap_transaction_with_account_keys( + tx: &VersionedTransaction, + account_keys: &[Pubkey], +) -> Result { + unwrap_message_with_account_keys(&tx.message, account_keys) +} + +/// Unwrap a Squads V2 wrapped [`VersionedMessage`] using the full resolved +/// account key list. +/// +/// Behaves the same as [`unwrap_transaction_with_account_keys`] but operates +/// directly on a [`VersionedMessage`] — useful when you have the message but +/// not the full transaction. +pub fn unwrap_message_with_account_keys( + message: &VersionedMessage, + account_keys: &[Pubkey], +) -> Result { + let instructions = compiled_instructions(message); + + // Find the Squads execute instruction + let squads_compiled = instructions + .iter() + .find(|compiled| { + let program_index = usize::from(compiled.program_id_index); + program_index < account_keys.len() + && account_keys[program_index] == SQUADS_PROGRAM_ID + && compiled.data.len() >= 8 + && compiled.data[..8] == EXECUTE_TX_SYNC_V2_DISCRIMINATOR + }) + .ok_or(SquadsSdkError::UnrecognizedDiscriminator)?; + + // Parse the Squads instruction data: + // [disc:8][accountIndex:1][numSigners:1][padding:1][len:4][serialized_instructions...] + let data = &squads_compiled.data; + if data.len() < 15 { + return Err(SquadsSdkError::ParseError( + "squads instruction data too short".into(), + )); + } + + let num_signers = data[9]; + let payload_len = u32::from_le_bytes([data[11], data[12], data[13], data[14]]) as usize; + + if data.len() < 15 + payload_len { + return Err(SquadsSdkError::ParseError( + "squads instruction payload truncated".into(), + )); + } + + let payload = &data[15..15 + payload_len]; + + // The Squads instruction accounts list (from the compiled instruction): + // [settingsPda, SQUADS_PROGRAM_ID, ...members(numSigners reversed), ...remaining_accounts] + let squads_account_indices = &squads_compiled.accounts; + let num_signers_usize = usize::from(num_signers); + + // We need at least: settingsPda + SQUADS_PROGRAM_ID + numSigners members + let overhead = 2 + num_signers_usize; + if squads_account_indices.len() < overhead { + return Err(SquadsSdkError::ParseError( + "not enough accounts in squads instruction".into(), + )); + } + + let settings_pda_index = usize::from(squads_account_indices[0]); + if settings_pda_index >= account_keys.len() { + return Err(SquadsSdkError::ParseError( + "settings PDA index out of range".into(), + )); + } + let settings_pda = account_keys[settings_pda_index]; + + // Members are at indices 2..2+numSigners (reversed from how they were prepended) + let mut members = Vec::with_capacity(num_signers_usize); + for i in 0..num_signers_usize { + let idx = usize::from(squads_account_indices[2 + i]); + if idx >= account_keys.len() { + return Err(SquadsSdkError::ParseError( + "member account index out of range".into(), + )); + } + members.push(account_keys[idx]); + } + // The members were prepended in forward order with insert(0), so they appear reversed. + // Reverse them back to original order. + members.reverse(); + + // remaining_accounts start after the overhead + let remaining_start = overhead; + let remaining_account_indices = &squads_account_indices[remaining_start..]; + + // Resolve remaining account pubkeys from the full account key list. + // We derive writable status from the remaining_accounts ordering convention: + // writable_signers, readonly_signers, writable_non_signers, readonly_non_signers. + // However, since the inner serialized instructions reference these by index, we + // just need the pubkeys. Writable status is embedded in the remaining_accounts + // ordering which the Squads program uses at runtime. + let remaining_pubkeys: Vec = remaining_account_indices + .iter() + .map(|&idx| { + let index = usize::from(idx); + if index >= account_keys.len() { + return Err(SquadsSdkError::ParseError(format!( + "remaining account index {} out of range (total keys: {})", + index, + account_keys.len() + ))); + } + Ok(account_keys[index]) + }) + .collect::>>()?; + + // Deserialize the inner instructions + let inner_instructions = deserialize_inner_instructions(payload)?; + + let num_inner = inner_instructions.len(); + let mut reconstructed = Vec::with_capacity(num_inner); + for inner in inner_instructions { + let program_idx = usize::from(inner.program_id_index); + if program_idx >= remaining_pubkeys.len() { + return Err(SquadsSdkError::ParseError( + "inner instruction program index out of range".into(), + )); + } + let program_id = remaining_pubkeys[program_idx]; + + let mut metas = Vec::with_capacity(inner.account_indices.len()); + for acct_idx in &inner.account_indices { + let idx = usize::from(*acct_idx); + if idx >= remaining_pubkeys.len() { + return Err(SquadsSdkError::ParseError( + "inner instruction account index out of range".into(), + )); + } + // We can't perfectly recover signer/writable flags for ALT-resolved + // accounts without the original instruction metadata. Use writable=false, + // signer=false as safe defaults — callers who need exact flags should + // compare against the original instruction set. + metas.push(AccountMeta::new_readonly(remaining_pubkeys[idx], false)); + } + + reconstructed.push(Instruction { + program_id, + accounts: metas, + data: inner.data, // move, no clone + }); + } + + let (compute_unit_limit, compute_unit_price) = extract_compute_budget_params(message)?; + + Ok(UnwrappedTransaction { + instructions: reconstructed, + settings_pda, + members, + num_signers, + compute_unit_limit, + compute_unit_price, + }) +} + +/// Unwrap a Squads V2 wrapped [`VersionedTransaction`] to recover the inner instructions. +/// +/// This works for Legacy messages and V0 messages **without** address lookup tables. +/// If the transaction uses ALTs, use [`unwrap_transaction_with_account_keys`] instead, +/// passing the full resolved account key list. +pub fn unwrap_transaction(tx: &VersionedTransaction) -> Result { + unwrap_message_with_account_keys(&tx.message, tx.message.static_account_keys()) +} + +/// Unwrap a Squads V2 wrapped [`VersionedMessage`] to recover the inner instructions. +pub fn unwrap_message(message: &VersionedMessage) -> Result { + unwrap_message_with_account_keys(message, message.static_account_keys()) +} + +/// Convenience wrapper that decodes a base64 transaction, then unwraps it. +/// +/// Only works for transactions without address lookup tables. For ALT transactions, +/// decode manually and use [`unwrap_transaction_with_account_keys`]. +pub fn unwrap_transaction_base64(tx_b64: &str) -> Result { + let tx = decode_transaction_base64(tx_b64)?; + unwrap_transaction(&tx) +} + +/// Convenience wrapper that decodes a base64 transaction and unwraps it using the +/// provided full account key list (required for transactions with ALTs). +pub fn unwrap_transaction_base64_with_account_keys( + tx_b64: &str, + account_keys: &[Pubkey], +) -> Result { + let tx = decode_transaction_base64(tx_b64)?; + unwrap_transaction_with_account_keys(&tx, account_keys) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{config::SquadsWrapConfig, wrap::build_squads_wrapped_transaction}; + use base64::{prelude::BASE64_STANDARD, Engine}; + use solana_sdk::{hash::Hash, pubkey}; + + fn test_pubkeys() -> (Pubkey, Pubkey, Pubkey, Pubkey, Pubkey, Pubkey, Pubkey) { + ( + pubkey!("8f1s1b4Y3CVP9vA8QFf8m6v3oc7Q5Q8m2Un9u9A34M2T"), // settings + pubkey!("3q8J3wTVpd6fHiFcPfebP8Fd6hQfKd8QxJ5zhhWgE4n9"), // vault + pubkey!("Dk9EdQJk3JxR5aVdS3tDqQnBk7LfMoT1n7Vm5R4n4fq4"), // member_a + pubkey!("4C58H5fm5P5k2p4A6HRo25ykoPS2atdx2myTaYF9E1f3"), // member_b + pubkey!("9xQeWvG816bUx9EPf2st4qGSe6P6xj6Yy7D6A6M6y8d"), // swap_program + pubkey!("TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA"), // token_program + pubkey!("GDrB6xfg2s7zNBi8W6vX4NQAz3gU8GdU4cf9jXhVJzjP"), // user_ata + ) + } + + #[test] + fn round_trip_wrap_then_unwrap() { + let (settings, vault, member_a, member_b, swap_program, token_program, user_ata) = + test_pubkeys(); + + let original_instructions = vec![ + Instruction { + program_id: token_program, + accounts: vec![ + AccountMeta::new(user_ata, false), + AccountMeta::new(vault, true), + ], + data: vec![0xAA], + }, + Instruction { + program_id: swap_program, + accounts: vec![ + AccountMeta::new(vault, true), + AccountMeta::new(user_ata, false), + AccountMeta::new_readonly(token_program, false), + ], + data: vec![0xBB, 0xCC], + }, + ]; + + let config = SquadsWrapConfig { + settings_pda: settings, + vault_pda: vault, + members: vec![member_a, member_b], + threshold: 2, + }; + + let wrapped_tx = build_squads_wrapped_transaction( + &original_instructions, + &config, + Hash::new_unique(), + 400_000, + 500_000, + ) + .expect("wrap"); + + let unwrapped = unwrap_transaction(&wrapped_tx).expect("unwrap"); + + // Verify we got the right number of instructions back + assert_eq!(unwrapped.instructions.len(), original_instructions.len()); + + // Verify instruction data matches + for (original, recovered) in original_instructions.iter().zip(&unwrapped.instructions) { + assert_eq!(original.program_id, recovered.program_id); + assert_eq!(original.data, recovered.data); + assert_eq!(original.accounts.len(), recovered.accounts.len()); + + // Verify account pubkeys match + for (orig_meta, rec_meta) in original.accounts.iter().zip(&recovered.accounts) { + assert_eq!(orig_meta.pubkey, rec_meta.pubkey); + } + } + + // Verify metadata + assert_eq!(unwrapped.settings_pda, settings); + assert_eq!(unwrapped.num_signers, 2); + assert_eq!(unwrapped.members.len(), 2); + assert!(unwrapped.members.contains(&member_a)); + assert!(unwrapped.members.contains(&member_b)); + assert_eq!(unwrapped.compute_unit_limit, 400_000); + assert_eq!(unwrapped.compute_unit_price, 500_000); + } + + #[test] + fn unwrap_rejects_non_squads_transaction() { + use solana_sdk::{ + compute_budget::ComputeBudgetInstruction, + message::{self, VersionedMessage}, + signature::NullSigner, + }; + + // Build a regular (non-Squads) transaction + let payer = Pubkey::new_unique(); + let message = message::v0::Message::try_compile( + &payer, + &[ComputeBudgetInstruction::set_compute_unit_limit(400_000)], + &[], + Hash::new_unique(), + ) + .unwrap(); + + let signer = NullSigner::new(&payer); + let tx = VersionedTransaction::try_new(VersionedMessage::V0(message), &[&signer]).unwrap(); + + let result = unwrap_transaction(&tx); + assert!(result.is_err()); + match result.unwrap_err() { + SquadsSdkError::UnrecognizedDiscriminator => {} + other => panic!("expected UnrecognizedDiscriminator, got: {other}"), + } + } + + #[test] + fn round_trip_single_instruction() { + let (settings, vault, member_a, member_b, swap_program, _token_program, user_ata) = + test_pubkeys(); + + let original = vec![Instruction { + program_id: swap_program, + accounts: vec![ + AccountMeta::new(vault, true), + AccountMeta::new(user_ata, false), + ], + data: vec![1, 2, 3, 4, 5], + }]; + + let config = SquadsWrapConfig { + settings_pda: settings, + vault_pda: vault, + members: vec![member_a, member_b], + threshold: 2, + }; + + let wrapped = build_squads_wrapped_transaction( + &original, + &config, + Hash::new_unique(), + 200_000, + 100_000, + ) + .expect("wrap"); + + let unwrapped = unwrap_transaction(&wrapped).expect("unwrap"); + + assert_eq!(unwrapped.instructions.len(), 1); + assert_eq!(unwrapped.instructions[0].program_id, swap_program); + assert_eq!(unwrapped.instructions[0].data, vec![1, 2, 3, 4, 5]); + assert_eq!(unwrapped.instructions[0].accounts.len(), 2); + assert_eq!(unwrapped.instructions[0].accounts[0].pubkey, vault); + assert_eq!(unwrapped.instructions[0].accounts[1].pubkey, user_ata); + } + + /// Unwrap a real mainnet Squads V2 transaction (uses ALTs). + /// Tx: 5kdWZuVbbfY7...M855 + #[test] + fn unwrap_real_mainnet_squads_transaction() { + let tx_b64 = "Au2xQSHfJn+2wj2UXyVDKsh+AizyR1opKXXRmd5gIcHkEv+ods81D9/geHVaC3MuCUTXkRSeQa0oH6hj6tzr/gioy3L9eLVkKmNjxC76ExU0U/6F4boCGYbyP/3tfYHanOcGH1bWrzQotaoicmDnhGwO+CE1ycZKXxBNvxc6BsAEgAIBAwoJY8tYG0EIet8mKXQ1rq6w6WjL8PxN+YrGD0onnYB1YYCZS02zq8/l0MYlibCOwhZ7dps7kBBWnv17+5U0nSqpYfgkJBjyRmM2coxL6RzurZMdqXTFJHvNbssVIcoQcFdt+kDxUHqJnLt1p0uNFHPhdkDXx3X5S13oybOGbeiZWIJYwHQeHxkIA+r5TTIZgVN9hLL6NJ2nrAKehqGss5LXi48B75t4fM5trXbsdFwBzuw6JluEHxgKZRA10BzJ6of7fYamLwIJw6+YEL8kgnCbpH6XZgyM/EPi5Hy1vnfDHwMGRm/lIRcy/+ytunLDm+e8jOW7xfcSayxDmzpAAAAABHnVW/IxwG7udMVuzmgVB/2xst6j9I5RArHNola8E48Gfpx62lotJoPJMYMsMojaWpFLHPMIFmFC+wEBb/QcQaqtnWWvxywtL+/NG9a967NTCOH4pqgLh8BenA1Gd8m2AwcABQIJ9gMABwAJAz5rCwAAAAAACR8DCQEABgIKCwQMBQ0OEQ8QEggXGBMVFBocGxkdHx4WmQFaUbtRJ0aATgACAIoAAAAEDAIAAQwAAgAAAHC0twAAAAAADQUBAA4PDAkAk/F7ZPSErnb/DSoQAAECAwQOEQ8PEg0CExAFAgYHCA4UFRYXDw8YEBkJBgMKCwoLCw8XDRovANGYU5N8/tjpD4CWmAAAAAAAwrkMAAAAAAAyAAIAAAACAAAAeQAQJwABaAEQJwECDwMBAAABAAkDKb+VBypPvwTfcXWT5zi8zCEnKBXCRxqA2fn1VbDUhCUCJzgHExIAKAEXFD3ff3ub180agM8AY10XjaJ4m10aRhU7S2DRTJaD+zJtA9nb3AXa3sTY3eMUE2FSxyhNElhHUd7cMvgUJj7rkOQzjJyWkW4i2WDHAyIdIAIjHw=="; + + // Full resolved account keys from RPC getTransaction response. + // 10 static + 8 writable ALT + 14 readonly ALT = 32 total. + let account_keys: Vec = vec![ + pubkey!("devpNoNn6FCTp1S2gxUFaGa9vSagrWdkUoBVyVZ7ai4"), // 0 - signer 1 + pubkey!("9ezm3kzUXTLYXyfh5SAK4KP5dHKeQN7C1pc14rzNZA48"), // 1 - signer 2 + pubkey!("7bS1ESnzxiCYbygf4ForuGUjUGU7uPvqTYoVz1szg6UW"), // 2 + pubkey!("8QJmMPTmRJSLsGxjAXYDquEtWjVaKvBK8HVvV4Mcn1gB"), // 3 + pubkey!("9mpVcDHc8CrMxdR1Lmu5A5GE3nqdfgzorZiie2LJdomQ"), // 4 + pubkey!("APn9WAoAX6hqnkGsJyLdiYtAZxzY8Ywk1hULjVaQSauc"), // 5 + pubkey!("HviMBVH4L84zW7xKL8oSPcDbXrjLVyRkCiYUjcVCVACE"), // 6 - vault + pubkey!("ComputeBudget111111111111111111111111111111"), // 7 + pubkey!("JUP6LkbZbjS1jKKwapdHNy74zcZ3tLUZoi5QNyVTaV4"), // 8 + pubkey!("SMRTzfY6DfH5ik3TKiyLFfXexV8uSG3d2UksSCYdunG"), // 9 - Squads + pubkey!("2oL6my4QDDCfpgJZX1bZV1NgbmuNptKdgcE8wJm6efgk"), // 10 + pubkey!("EpdaePzdqRkMtdZJquVPUWgyoJ5YEEpYALki6dv9VBrt"), // 11 + pubkey!("AeanNmmxpMEcSv3a3rKaRcrPjXDwpEiG37syPRyu3VJ2"), // 12 + pubkey!("AWKLq38dBA6JEP1bLBUrqcki3zePKNDiLX8SocMLSFMj"), // 13 + pubkey!("Fgcod1MMhVeuMYG9zrJcnucf54U32bcEj4t8v9eiG4HJ"), // 14 + pubkey!("2AusztjRJ2dcShL8xSUv2FrTqWbLe28UszzHptwrqh2e"), // 15 + pubkey!("F2KCaXcp7AoQtxTDvNEDCyMyWjSCAMWNzcyN9dsPfPs5"), // 16 + pubkey!("FnH8uVCgE8iGz4KQSEpQWdpLxzEENB2n2XHYUhUw13wc"), // 17 + pubkey!("11111111111111111111111111111111"), // 18 + pubkey!("7iWnBRRhBCiNXXPhqiGzvvBkKrvFSWqqmxRyu9VyYBxE"), // 19 + pubkey!("D8cy77BBepLMngZx6ZukaTff5hCt1HrWyKk3Hnd9oitf"), // 20 + pubkey!("EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v"), // 21 + pubkey!("jitodontfront11111111111JustUseJupiterU1tra"), // 22 + pubkey!("So11111111111111111111111111111111111111112"), // 23 + pubkey!("TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA"), // 24 + pubkey!("BNrK9LpEn65QA4TyBLVSMdngW3XHj3xLfFPwGdCBv8wV"), // 25 + pubkey!("goonuddtQRrWqqn5nFyczVKaie28f3kDkHWkHtURSLE"), // 26 + pubkey!("HagefcrC63EBesXX9yFHWVscSwqu26LQtTB7RxyVSThj"), // 27 + pubkey!("JuprjznTrTSp2UFa3ZBUFgwdAmtZCq4MQCwysN55USD"), // 28 + pubkey!("Sysvar1nstructions1111111111111111111111111"), // 29 + pubkey!("2naph4yYn9nF8yddV2aTjwnGuMLbUcgVX8M6B4ckezPE"), // 30 + pubkey!("ALPHAQmeA7bjrVuccPsYPiCvsi428SNwte66Srvs4pHA"), // 31 + ]; + + let tx_bytes = BASE64_STANDARD.decode(tx_b64).expect("decode base64"); + let tx: VersionedTransaction = bincode::deserialize(&tx_bytes).expect("deserialize tx"); + + let unwrapped = unwrap_transaction_with_account_keys(&tx, &account_keys) + .expect("should unwrap real mainnet tx"); + + // Known signers + let signer_a = pubkey!("devpNoNn6FCTp1S2gxUFaGa9vSagrWdkUoBVyVZ7ai4"); + let signer_b = pubkey!("9ezm3kzUXTLYXyfh5SAK4KP5dHKeQN7C1pc14rzNZA48"); + + // Vault (taker) + let vault = pubkey!("HviMBVH4L84zW7xKL8oSPcDbXrjLVyRkCiYUjcVCVACE"); + + // Verify members recovered correctly + assert_eq!(unwrapped.num_signers, 2); + assert_eq!(unwrapped.members.len(), 2); + assert!( + unwrapped.members[0] == signer_a, + "should contain signer devpNoNn..." + ); + assert!( + unwrapped.members[1] == signer_b, + "should contain signer 9ezm3k..." + ); + + // Verify we got inner swap instructions (not empty) + assert!( + !unwrapped.instructions.is_empty(), + "should have inner instructions" + ); + + // The inner instructions should reference the vault as an account + let vault_referenced = unwrapped + .instructions + .iter() + .any(|ix| ix.accounts.iter().any(|meta| meta.pubkey == vault)); + assert!( + vault_referenced, + "inner instructions should reference the vault" + ); + + // Squads program ID should NOT appear as an inner instruction program_id + let squads_as_inner = unwrapped + .instructions + .iter() + .any(|ix| ix.program_id == crate::SQUADS_PROGRAM_ID); + assert!( + !squads_as_inner, + "squads program should not be an inner instruction" + ); + + // Compute budget should have been extracted from outer tx + assert!( + unwrapped.compute_unit_limit > 0, + "should have non-zero CU limit" + ); + assert!( + unwrapped.compute_unit_price > 0, + "should have non-zero CU price" + ); + + // Settings PDA — index 3 in the Squads instruction accounts + assert_eq!( + unwrapped.settings_pda, + pubkey!("8QJmMPTmRJSLsGxjAXYDquEtWjVaKvBK8HVvV4Mcn1gB") + ); + } +} diff --git a/squads-sdk/src/wrap.rs b/squads-sdk/src/wrap.rs new file mode 100644 index 0000000..d38b1ac --- /dev/null +++ b/squads-sdk/src/wrap.rs @@ -0,0 +1,600 @@ +use base64::{prelude::BASE64_STANDARD, Engine}; +use solana_sdk::{ + address_lookup_table::AddressLookupTableAccount, + compute_budget, + compute_budget::ComputeBudgetInstruction, + hash::Hash, + instruction::{AccountMeta, Instruction}, + message::{self, VersionedMessage}, + pubkey::Pubkey, + signature::NullSigner, + transaction::VersionedTransaction, +}; + +use crate::{ + accounts::compile_remaining_accounts, + config::{SquadsWrapConfig, WrapOptions}, + error::{Result, SquadsSdkError}, + serialize::serialize_swap_instructions, + transaction::{ + compiled_instructions, decode_transaction_base64, decompile_instruction, + extract_compute_budget_params, + }, + EXECUTE_TX_SYNC_V2_DISCRIMINATOR, SQUADS_PROGRAM_ID, +}; + +pub fn build_squads_wrapped_transaction( + swap_instructions: &[Instruction], + config: &SquadsWrapConfig, + recent_blockhash: Hash, + compute_unit_limit: u32, + compute_unit_price: u64, +) -> Result { + build_squads_wrapped_transaction_with_alts( + swap_instructions, + config, + recent_blockhash, + compute_unit_limit, + compute_unit_price, + &[], + ) +} + +/// Build a Squads-wrapped transaction with Address Lookup Table support. +/// +/// ALTs are used in the outer `VersionedMessage` only — they compress the +/// static account keys in the serialized message. The inner serialized +/// instructions reference accounts by index into `remaining_accounts`, +/// which is unaffected by ALTs. +pub fn build_squads_wrapped_transaction_with_alts( + swap_instructions: &[Instruction], + config: &SquadsWrapConfig, + recent_blockhash: Hash, + compute_unit_limit: u32, + compute_unit_price: u64, + address_lookup_tables: &[AddressLookupTableAccount], +) -> Result { + config.validate()?; + + let mut remaining_accounts = compile_remaining_accounts(swap_instructions, &config.vault_pda)?; + + // Squads CPI invoke_signed limit: 64 account_infos total. + // Total = remaining_accounts + 2 (settingsPda, SQUADS_PROGRAM_ID) + members. + let total_accounts = remaining_accounts.len() + 2 + config.members.len(); + if total_accounts > 64 { + return Err(SquadsSdkError::CpiAccountLimitExceeded { + inner: remaining_accounts.len(), + overhead: 2 + config.members.len(), + total: total_accounts, + }); + } + + let serialized = serialize_swap_instructions(swap_instructions, &remaining_accounts)?; + + // Collect signers from the swap instructions that are neither members nor the + // vault PDA (whose flag was already stripped). These accounts — typically the + // maker — must be signers on the outer transaction so their `is_signer` flag + // propagates through CPI when the Squads program executes the inner instructions. + let other_signer_pubkeys: Vec = remaining_accounts + .iter() + .filter(|meta| meta.is_signer && !config.members.contains(&meta.pubkey)) + .map(|meta| meta.pubkey) + .collect(); + + // Prepend members as readonly signers. Forward iteration with insert(0) reverses + // them, matching ultra-api's unshift behavior. The Squads program strips numSigners + // entries from the front at runtime. + for member in config.members.iter() { + remaining_accounts.insert(0, AccountMeta::new_readonly(*member, true)); + } + + if serialized.len() > usize::try_from(u32::MAX).unwrap_or(usize::MAX) { + return Err(SquadsSdkError::ParseError( + "serialized instruction payload too large".into(), + )); + } + + let mut data = Vec::with_capacity(8 + 1 + 1 + 1 + 4 + serialized.len()); + data.extend_from_slice(&EXECUTE_TX_SYNC_V2_DISCRIMINATOR); + data.push(0u8); // accountIndex + data.push(config.members.len() as u8); // numSigners — must match prepended member count + data.push(0u8); // padding + data.extend_from_slice(&(serialized.len() as u32).to_le_bytes()); + data.extend_from_slice(&serialized); + + let mut keys = Vec::with_capacity(2 + remaining_accounts.len()); + keys.push(AccountMeta::new(config.settings_pda, false)); + keys.push(AccountMeta::new_readonly(SQUADS_PROGRAM_ID, false)); + keys.extend(remaining_accounts); + + let execute_ix = Instruction { + program_id: SQUADS_PROGRAM_ID, + accounts: keys, + data, + }; + + let message = message::v0::Message::try_compile( + &config.members[0], + &[ + ComputeBudgetInstruction::set_compute_unit_limit(compute_unit_limit), + ComputeBudgetInstruction::set_compute_unit_price(compute_unit_price), + execute_ix, + ], + address_lookup_tables, + recent_blockhash, + )?; + + let other_signers: Vec = other_signer_pubkeys.iter().map(NullSigner::new).collect(); + + let member_signers: Vec = config.members.iter().map(NullSigner::new).collect(); + + let mut signer_refs: Vec<&NullSigner> = member_signers.iter().collect(); + signer_refs.extend(other_signers.iter()); + + Ok(VersionedTransaction::try_new( + VersionedMessage::V0(message), + &signer_refs, + )?) +} + +pub fn wrap_transaction_base64( + quote_tx_b64: &str, + config: &SquadsWrapConfig, + options: &WrapOptions, +) -> Result<(String, String)> { + wrap_transaction_base64_with_alts(quote_tx_b64, config, options, &[]) +} + +/// Wrap a base64-encoded transaction with ALT support. +/// +/// Decompiles the input transaction, strips compute budget instructions, +/// wraps the remaining instructions inside Squads `executeTransactionSyncV2`, +/// and compresses the outer message using the provided ALTs. +pub fn wrap_transaction_base64_with_alts( + quote_tx_b64: &str, + config: &SquadsWrapConfig, + options: &WrapOptions, + address_lookup_tables: &[AddressLookupTableAccount], +) -> Result<(String, String)> { + let tx = decode_transaction_base64(quote_tx_b64)?; + let (cu_limit, cu_price) = extract_compute_budget_params(&tx.message)?; + let instructions = compiled_instructions(&tx.message); + + let mut swap_instructions = Vec::new(); + for compiled in instructions { + let ix = decompile_instruction(&tx.message, compiled)?; + if ix.program_id != compute_budget::id() { + swap_instructions.push(ix); + } + } + + let squads_cu_limit = cu_limit + .saturating_mul(options.cu_multiplier) + .min(options.cu_cap); + + let wrapped = build_squads_wrapped_transaction_with_alts( + &swap_instructions, + config, + *tx.message.recent_blockhash(), + squads_cu_limit, + cu_price, + address_lookup_tables, + )?; + + let wrapped_message_b64 = BASE64_STANDARD.encode(wrapped.message.serialize()); + let wrapped_tx_bytes = bincode::serialize(&wrapped) + .map_err(|e| SquadsSdkError::InvalidTransaction(format!("failed to serialize tx: {e}")))?; + + if wrapped_tx_bytes.len() > options.tx_size_limit { + return Err(SquadsSdkError::TransactionSizeExceeded { + size: wrapped_tx_bytes.len(), + limit: options.tx_size_limit, + }); + } + + let wrapped_tx_b64 = BASE64_STANDARD.encode(&wrapped_tx_bytes); + + Ok((wrapped_tx_b64, wrapped_message_b64)) +} + +/// Convenience wrapper that uses default [`WrapOptions`]. +/// +/// Returns `(wrapped_tx_base64, wrapped_message_base64)`. +pub fn wrap_quote_transaction_base64( + quote_tx_b64: &str, + config: &SquadsWrapConfig, +) -> Result<(String, String)> { + wrap_transaction_base64(quote_tx_b64, config, &WrapOptions::default()) +} + +/// Preflight check: can the given instructions be wrapped into a Squads +/// transaction without exceeding constraints? +/// +/// Validates config, CPI account limit (<=64), and estimates whether the +/// serialized transaction would fit within `options.tx_size_limit`. +/// Returns `Ok(())` if wrappable, or a specific error explaining why not. +pub fn can_wrap( + swap_instructions: &[Instruction], + config: &SquadsWrapConfig, + options: &WrapOptions, +) -> Result<()> { + config.validate()?; + + let remaining_accounts = compile_remaining_accounts(swap_instructions, &config.vault_pda)?; + + let total_accounts = remaining_accounts.len() + 2 + config.members.len(); + if total_accounts > 64 { + return Err(SquadsSdkError::CpiAccountLimitExceeded { + inner: remaining_accounts.len(), + overhead: 2 + config.members.len(), + total: total_accounts, + }); + } + + let serialized = serialize_swap_instructions(swap_instructions, &remaining_accounts)?; + + // Conservative size estimate (no ALT compression): + // signatures: 64 bytes * num_members + // message: header(3) + blockhash(32) + compact_array overhead(~3) + // + account_keys: 32 * total unique keys + // + instructions: compute budget (~20 bytes) + squads ix (15 + payload) + let num_keys = total_accounts + 2; // +2 for compute budget program + squads program (may overlap) + let estimated_size = 64 * config.members.len() // signatures + + 3 + 32 + 3 // message header + blockhash + compact arrays + + 32 * num_keys // account keys (no ALT compression) + + 20 // compute budget instructions + + 15 + serialized.len(); // squads execute ix + + if estimated_size > options.tx_size_limit { + return Err(SquadsSdkError::TransactionSizeExceeded { + size: estimated_size, + limit: options.tx_size_limit, + }); + } + + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use solana_sdk::{ + hash::Hash, instruction::AccountMeta, instruction::Instruction, pubkey, pubkey::Pubkey, + }; + + use crate::accounts::compile_remaining_accounts; + + fn test_pubkeys() -> ( + Pubkey, + Pubkey, + Pubkey, + Pubkey, + Pubkey, + Pubkey, + Pubkey, + Pubkey, + ) { + ( + pubkey!("8f1s1b4Y3CVP9vA8QFf8m6v3oc7Q5Q8m2Un9u9A34M2T"), // settings + pubkey!("3q8J3wTVpd6fHiFcPfebP8Fd6hQfKd8QxJ5zhhWgE4n9"), // vault + pubkey!("Dk9EdQJk3JxR5aVdS3tDqQnBk7LfMoT1n7Vm5R4n4fq4"), // member_a + pubkey!("4C58H5fm5P5k2p4A6HRo25ykoPS2atdx2myTaYF9E1f3"), // member_b + pubkey!("HviMBVH4L84zW7xKL8oSPcDbXrjLVyRkCiYUjcVCVACE"), // member_c + pubkey!("9xQeWvG816bUx9EPf2st4qGSe6P6xj6Yy7D6A6M6y8d"), // swap_program + pubkey!("TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA"), // token_program + pubkey!("GDrB6xfg2s7zNBi8W6vX4NQAz3gU8GdU4cf9jXhVJzjP"), // user_ata + ) + } + + fn simple_swap_ix( + vault: Pubkey, + user_ata: Pubkey, + token_program: Pubkey, + swap_program: Pubkey, + ) -> Instruction { + Instruction { + program_id: swap_program, + accounts: vec![ + AccountMeta::new(vault, true), + AccountMeta::new(user_ata, false), + AccountMeta::new_readonly(token_program, false), + ], + data: vec![1, 2, 3], + } + } + + #[test] + fn wraps_swap_instructions_with_members_as_signers() { + let (settings, vault, member_a, member_b, _, swap_program, token_program, user_ata) = + test_pubkeys(); + + let swap_ix = simple_swap_ix(vault, user_ata, token_program, swap_program); + + let tx = build_squads_wrapped_transaction( + &[swap_ix], + &SquadsWrapConfig { + settings_pda: settings, + vault_pda: vault, + members: vec![member_a, member_b], + threshold: 2, + }, + Hash::new_unique(), + 400_000, + 500_000, + ) + .expect("build squads tx"); + + assert_eq!(tx.signatures.len(), 2); + match tx.message { + VersionedMessage::V0(message) => { + assert_eq!(message.account_keys[0], member_a); + assert!(message.account_keys.contains(&member_b)); + assert_eq!(message.instructions.len(), 3); + } + _ => panic!("expected v0 message"), + } + } + + #[test] + fn two_of_three_multisig_uses_members_len_not_threshold() { + let (settings, vault, member_a, member_b, member_c, swap_program, token_program, user_ata) = + test_pubkeys(); + + let swap_ix = simple_swap_ix(vault, user_ata, token_program, swap_program); + + let tx = build_squads_wrapped_transaction( + &[swap_ix], + &SquadsWrapConfig { + settings_pda: settings, + vault_pda: vault, + members: vec![member_a, member_b, member_c], + threshold: 2, // 2-of-3 + }, + Hash::new_unique(), + 400_000, + 500_000, + ) + .expect("build squads tx"); + + // All 3 members must be signers, not just threshold count + assert_eq!(tx.signatures.len(), 3); + + match &tx.message { + VersionedMessage::V0(message) => { + // Fee payer should be member_a + assert_eq!(message.account_keys[0], member_a); + assert!(message.account_keys.contains(&member_b)); + assert!(message.account_keys.contains(&member_c)); + assert_eq!(message.instructions.len(), 3); + + // Verify numSigners in the execute instruction data = 3 (members.len), not 2 (threshold) + let execute_ix = &message.instructions[2]; // CU limit, CU price, execute + // data layout: [disc:8][accountIndex:1][numSigners:1][padding:1][len:4][...] + let num_signers = execute_ix.data[9]; // offset 9 = numSigners + assert_eq!( + num_signers, 3, + "numSigners should be members.len() (3), not threshold (2)" + ); + } + _ => panic!("expected v0 message"), + } + } + + #[test] + fn member_prepend_order_matches_ultra_api() { + let (_settings, vault, member_a, member_b, member_c, swap_program, token_program, user_ata) = + test_pubkeys(); + + let swap_ix = simple_swap_ix(vault, user_ata, token_program, swap_program); + + let mut remaining = compile_remaining_accounts(&[swap_ix], &vault).unwrap(); + + // Before members: remaining has swap accounts only + let accounts_before_members = remaining.len(); + + // Replicate the member prepend from build_squads_wrapped_transaction + for member in [member_a, member_b, member_c].iter() { + remaining.insert(0, AccountMeta::new_readonly(*member, true)); + } + + // Members should be reversed at the front (matching ultra-api unshift behavior) + assert_eq!( + remaining[0].pubkey, member_c, + "first should be last member (reversed)" + ); + assert_eq!( + remaining[1].pubkey, member_b, + "second should be middle member" + ); + assert_eq!( + remaining[2].pubkey, member_a, + "third should be first member" + ); + + // Original accounts follow + assert_eq!(remaining.len(), accounts_before_members + 3); + } + + #[test] + fn rejects_exceeding_64_account_cpi_limit() { + let (settings, vault, member_a, member_b, _, _, _, _) = test_pubkeys(); + + // Create an instruction with many unique accounts to exceed the 64-account limit + let mut accounts = Vec::new(); + for i in 0..62u8 { + let mut bytes = [0u8; 32]; + bytes[0] = i; + bytes[1] = 1; // avoid system program collision + accounts.push(AccountMeta::new_readonly( + Pubkey::new_from_array(bytes), + false, + )); + } + let big_ix = Instruction { + program_id: Pubkey::new_unique(), + accounts, + data: vec![0], + }; + + let result = build_squads_wrapped_transaction( + &[big_ix], + &SquadsWrapConfig { + settings_pda: settings, + vault_pda: vault, + members: vec![member_a, member_b], + threshold: 2, + }, + Hash::new_unique(), + 400_000, + 500_000, + ); + + assert!( + result.is_err(), + "should reject when accounts exceed 64-account CPI limit" + ); + let err = result.unwrap_err().to_string(); + assert!( + err.contains("64-account CPI limit"), + "error message should mention CPI limit, got: {}", + err + ); + } + + #[test] + fn multi_instruction_wrapping_serializes_correctly() { + let (settings, vault, member_a, member_b, _, swap_program, token_program, user_ata) = + test_pubkeys(); + + let ix1 = Instruction { + program_id: token_program, + accounts: vec![ + AccountMeta::new(user_ata, false), + AccountMeta::new(vault, true), + ], + data: vec![0xAA], + }; + let ix2 = Instruction { + program_id: swap_program, + accounts: vec![ + AccountMeta::new(vault, true), + AccountMeta::new(user_ata, false), + AccountMeta::new_readonly(token_program, false), + ], + data: vec![0xBB, 0xCC], + }; + + let tx = build_squads_wrapped_transaction( + &[ix1, ix2], + &SquadsWrapConfig { + settings_pda: settings, + vault_pda: vault, + members: vec![member_a, member_b], + threshold: 2, + }, + Hash::new_unique(), + 400_000, + 500_000, + ) + .expect("build squads tx with multiple instructions"); + + match &tx.message { + VersionedMessage::V0(message) => { + assert_eq!(message.instructions.len(), 3); // CU limit + CU price + execute + let execute_ix = &message.instructions[2]; + // Verify instruction count in serialized data + // data: [disc:8][accountIndex:1][numSigners:1][padding:1][len:4][numIx:1][...] + let ix_payload_offset = 8 + 1 + 1 + 1 + 4; // 15 + assert_eq!( + execute_ix.data[ix_payload_offset], 2, + "should contain 2 serialized inner instructions" + ); + } + _ => panic!("expected v0 message"), + } + } + + #[test] + fn validate_rejects_empty_members() { + let config = SquadsWrapConfig { + settings_pda: Pubkey::new_unique(), + vault_pda: Pubkey::new_unique(), + members: vec![], + threshold: 1, + }; + assert!(config.validate().is_err()); + } + + #[test] + fn validate_rejects_threshold_exceeding_members() { + let config = SquadsWrapConfig { + settings_pda: Pubkey::new_unique(), + vault_pda: Pubkey::new_unique(), + members: vec![Pubkey::new_unique()], + threshold: 2, + }; + assert!(config.validate().is_err()); + } + + #[test] + fn validate_rejects_zero_threshold() { + let config = SquadsWrapConfig { + settings_pda: Pubkey::new_unique(), + vault_pda: Pubkey::new_unique(), + members: vec![Pubkey::new_unique()], + threshold: 0, + }; + assert!(config.validate().is_err()); + } + + #[test] + fn non_member_signer_included_in_outer_transaction() { + let (settings, vault, member_a, member_b, _, swap_program, token_program, user_ata) = + test_pubkeys(); + + // The maker is a signer on the inner instruction (like the order-engine + // Fill instruction) but is NOT a squads member. + let maker = pubkey!("BfvJHsm36WTTbMXFqBUfKZJGDqnSrvGnRWAT4WHQFcVP"); + + let swap_ix = Instruction { + program_id: swap_program, + accounts: vec![ + AccountMeta::new(vault, true), // taker (vault PDA, signer) + AccountMeta::new(maker, true), // maker (non-member signer) + AccountMeta::new(user_ata, false), + AccountMeta::new_readonly(token_program, false), + ], + data: vec![1, 2, 3], + }; + + let tx = build_squads_wrapped_transaction( + &[swap_ix], + &SquadsWrapConfig { + settings_pda: settings, + vault_pda: vault, + members: vec![member_a, member_b], + threshold: 2, + }, + Hash::new_unique(), + 400_000, + 500_000, + ) + .expect("build should succeed when swap instructions have non-member signers"); + + // 2 members + 1 maker = 3 required signatures + assert_eq!(tx.signatures.len(), 3); + match &tx.message { + VersionedMessage::V0(message) => { + let signer_keys = + &message.account_keys[..message.header.num_required_signatures as usize]; + assert!( + signer_keys.contains(&maker), + "maker must be in the signers section of the message" + ); + assert!(signer_keys.contains(&member_a)); + assert!(signer_keys.contains(&member_b)); + } + _ => panic!("expected v0 message"), + } + } +}