diff --git a/Anchor.toml b/Anchor.toml index e29658e6..e3ae6f21 100644 --- a/Anchor.toml +++ b/Anchor.toml @@ -1,6 +1,6 @@ [toolchain] anchor_version = "0.29.0" # `anchor-cli` version to use -solana_version = "1.17.0" # Solana version to use +solana_version = "1.18.16" # Solana version to use [features] seeds = false diff --git a/Cargo.lock b/Cargo.lock index 3aa15976..ee960a8b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4164,7 +4164,7 @@ dependencies = [ [[package]] name = "squads-multisig-program" -version = "2.0.0" +version = "2.1.0" dependencies = [ "anchor-lang", "anchor-spl", diff --git a/package.json b/package.json index 97c06d20..e3ae5e79 100644 --- a/package.json +++ b/package.json @@ -1,29 +1,30 @@ { - "private": true, - "workspaces": [ - "sdk/*" - ], - "scripts": { - "build": "turbo run build", - "test": "turbo run build && anchor test -- --features=testing && echo \"\n⚠️ Don't forget to recompile the .so file before deployment\n\"", - "pretest": "mkdir -p target/deploy && cp ./test-program-keypair.json ./target/deploy/squads_multisig_program-keypair.json", - "ts": "turbo run ts && yarn tsc --noEmit" - }, - "devDependencies": { - "@solana/spl-token": "*", - "@solana/spl-memo": "^0.2.3", - "@types/bn.js": "5.1.0", - "@types/mocha": "10.0.1", - "@types/node-fetch": "2.6.2", - "mocha": "10.2.0", - "prettier": "2.6.2", - "ts-node": "10.9.1", - "turbo": "1.6.3", - "typescript": "*" - }, - "resolutions": { - "@solana/web3.js": "1.70.3", - "@solana/spl-token": "0.3.6", - "typescript": "4.9.4" - } + "private": true, + "workspaces": [ + "sdk/*" + ], + "scripts": { + "build": "turbo run build", + "test:detached": "turbo run build && anchor test --detach -- --features=testing && echo \"\n⚠️ Don't forget to recompile the .so file before deployment\n\"", + "test": "turbo run build && anchor test -- --features=testing && echo \"\n⚠️ Don't forget to recompile the .so file before deployment\n\"", + "pretest": "mkdir -p target/deploy && cp ./test-program-keypair.json ./target/deploy/squads_multisig_program-keypair.json", + "ts": "turbo run ts && yarn tsc --noEmit" + }, + "devDependencies": { + "@solana/spl-token": "*", + "@solana/spl-memo": "^0.2.3", + "@types/bn.js": "5.1.0", + "@types/mocha": "10.0.1", + "@types/node-fetch": "2.6.2", + "mocha": "10.2.0", + "prettier": "2.6.2", + "ts-node": "10.9.1", + "turbo": "1.6.3", + "typescript": "*" + }, + "resolutions": { + "@solana/web3.js": "1.70.3", + "@solana/spl-token": "0.3.6", + "typescript": "4.9.4" + } } diff --git a/programs/squads_multisig_program/Cargo.toml b/programs/squads_multisig_program/Cargo.toml index aa83782d..ac6ef1c4 100644 --- a/programs/squads_multisig_program/Cargo.toml +++ b/programs/squads_multisig_program/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "squads-multisig-program" -version = "2.0.0" +version = "2.1.0" description = "Squads Multisig Program V4" edition = "2021" license-file = "../../LICENSE" @@ -10,12 +10,13 @@ crate-type = ["cdylib", "lib"] name = "squads_multisig_program" [features] +default = ["custom-heap"] +custom-heap = [] no-entrypoint = [] no-idl = [] no-log-ix-name = [] cpi = ["no-entrypoint"] testing = [] -default = [] [dependencies] anchor-lang = { version = "=0.29.0", features = ["allow-missing-optionals"] } diff --git a/programs/squads_multisig_program/src/allocator.rs b/programs/squads_multisig_program/src/allocator.rs new file mode 100644 index 00000000..61c71288 --- /dev/null +++ b/programs/squads_multisig_program/src/allocator.rs @@ -0,0 +1,133 @@ +/* +Optimizing Bump Heap Allocation + +Objective: Increase available heap memory while maintaining flexibility in program invocation. + +1. Initial State: Default 32 KiB Heap + +Memory Layout: +0x300000000 0x300008000 + | | + v v + [--------------------] + ^ ^ + | | + VM Lower VM Upper + Boundary Boundary + +Default Allocator (Allocates Backwards / Top Down) (Default 32 KiB): +0x300000000 0x300008000 + | | + [--------------------] + ^ + | + Allocation starts here (SAFE) + +2. Naive Approach: Increase HEAP_LENGTH to 8 * 32 KiB + Default Allocator + +Memory Layout with Increased HEAP_LENGTH: +0x300000000 0x300008000 0x300040000 + | | | + v v v + [--------------------|------------------------------------|] + ^ ^ ^ + | | | + VM Lower VM Upper Allocation starts here + Boundary Boundary (ACCESS VIOLATION!) + +Issue: Access violation occurs without requestHeapFrame, requiring it for every transaction. + +3. Optimized Solution: Forward Allocation with Flexible Heap Usage + +Memory Layout (Same as Naive Approach): +0x300000000 0x300008000 0x300040000 + | | | + v v v + [--------------------|------------------------------------|] + ^ ^ ^ + | | | + VM Lower VM Upper Allocator & VM + Boundary Boundary Heap Limit + +Forward Allocator Behavior: + +a) Without requestHeapFrame: +0x300000000 0x300008000 + | | + [--------------------] + ^ ^ + | | + VM Lower VM Upper + Boundary Boundary + Allocation + starts here (SAFE) + +b) With requestHeapFrame: +0x300000000 0x300008000 0x300040000 + | | | + [--------------------|------------------------------------|] + ^ ^ ^ + | | | + VM Lower | VM Upper + Boundary Boundary + Allocation Allocation continues Maximum allocation + starts here with requestHeapFrame with requestHeapFrame +(SAFE) + +Key Advantages: +1. Compatibility: Functions without requestHeapFrame for allocations ≤32 KiB. +2. Extensibility: Supports larger allocations when requestHeapFrame is invoked. +3. Efficiency: Eliminates mandatory requestHeapFrame calls for all transactions. + +Conclusion: +The forward allocation strategy offers a robust solution, providing both backward +compatibility for smaller heap requirements and the flexibility to utilize extended +heap space when necessary. + +The following allocator is a copy of the bump allocator found in +solana_program::entrypoint and +https://github.com/solana-labs/solana-program-library/blob/master/examples/rust/custom-heap/src/entrypoint.rs + +but with changes to its HEAP_LENGTH and its +starting allocation address. +*/ + +use solana_program::entrypoint::HEAP_START_ADDRESS; +use std::{alloc::Layout, mem::size_of, ptr::null_mut}; + +/// Length of the memory region used for program heap. +pub const HEAP_LENGTH: usize = 8 * 32 * 1024; + +struct BumpAllocator; + +unsafe impl std::alloc::GlobalAlloc for BumpAllocator { + #[inline] + unsafe fn alloc(&self, layout: Layout) -> *mut u8 { + const POS_PTR: *mut usize = HEAP_START_ADDRESS as *mut usize; + const TOP_ADDRESS: usize = HEAP_START_ADDRESS as usize + HEAP_LENGTH; + const BOTTOM_ADDRESS: usize = HEAP_START_ADDRESS as usize + size_of::<*mut u8>(); + let mut pos = *POS_PTR; + if pos == 0 { + // First time, set starting position to bottom address + pos = BOTTOM_ADDRESS; + } + // Align the position upwards + pos = (pos + layout.align() - 1) & !(layout.align() - 1); + let next_pos = pos.saturating_add(layout.size()); + if next_pos > TOP_ADDRESS { + return null_mut(); + } + *POS_PTR = next_pos; + pos as *mut u8 + } + + #[inline] + unsafe fn dealloc(&self, _: *mut u8, _: Layout) { + // I'm a bump allocator, I don't free + } +} + +// Only use the allocator if we're not in a no-entrypoint context +#[cfg(not(feature = "no-entrypoint"))] +#[global_allocator] +static A: BumpAllocator = BumpAllocator; diff --git a/programs/squads_multisig_program/src/errors.rs b/programs/squads_multisig_program/src/errors.rs index 01a74a94..5384443c 100644 --- a/programs/squads_multisig_program/src/errors.rs +++ b/programs/squads_multisig_program/src/errors.rs @@ -82,4 +82,14 @@ pub enum MultisigError { BatchNotEmpty, #[msg("Invalid SpendingLimit amount")] SpendingLimitInvalidAmount, + #[msg("Invalid Instruction Arguments")] + InvalidInstructionArgs, + #[msg("Final message buffer hash doesnt match the expected hash")] + FinalBufferHashMismatch, + #[msg("Final buffer size cannot exceed 4000 bytes")] + FinalBufferSizeExceeded, + #[msg("Final buffer size mismatch")] + FinalBufferSizeMismatch, + #[msg("multisig_create has been deprecated. Use multisig_create_v2 instead.")] + MultisigCreateDeprecated, } diff --git a/programs/squads_multisig_program/src/instructions/batch_execute_transaction.rs b/programs/squads_multisig_program/src/instructions/batch_execute_transaction.rs index 6d46ebd9..e0666dfa 100644 --- a/programs/squads_multisig_program/src/instructions/batch_execute_transaction.rs +++ b/programs/squads_multisig_program/src/instructions/batch_execute_transaction.rs @@ -108,7 +108,12 @@ impl BatchExecuteTransaction<'_> { let multisig = &mut ctx.accounts.multisig; let proposal = &mut ctx.accounts.proposal; let batch = &mut ctx.accounts.batch; - let transaction = &mut ctx.accounts.transaction; + + // NOTE: After `take()` is called, the VaultTransaction is reduced to + // its default empty value, which means it should no longer be referenced or + // used after this point to avoid faulty behavior. + // Instead only make use of the returned `transaction` value. + let transaction = ctx.accounts.transaction.take(); let multisig_key = multisig.key(); let batch_key = batch.key(); @@ -121,7 +126,7 @@ impl BatchExecuteTransaction<'_> { &[batch.vault_bump], ]; - let transaction_message = &transaction.message; + let transaction_message = transaction.message; let num_lookups = transaction_message.address_table_lookups.len(); let message_account_infos = ctx @@ -149,11 +154,13 @@ impl BatchExecuteTransaction<'_> { let protected_accounts = &[proposal.key(), batch_key]; // Execute the transaction message instructions one-by-one. + // NOTE: `execute_message()` calls `self.to_instructions_and_accounts()` + // which in turn calls `take()` on + // `self.message.instructions`, therefore after this point no more + // references or usages of `self.message` should be made to avoid + // faulty behavior. executable_message.execute_message( - &vault_seeds - .iter() - .map(|seed| seed.to_vec()) - .collect::>>(), + vault_seeds, &ephemeral_signer_seeds, protected_accounts, )?; diff --git a/programs/squads_multisig_program/src/instructions/config_transaction_execute.rs b/programs/squads_multisig_program/src/instructions/config_transaction_execute.rs index 6b9fb287..06851e1c 100644 --- a/programs/squads_multisig_program/src/instructions/config_transaction_execute.rs +++ b/programs/squads_multisig_program/src/instructions/config_transaction_execute.rs @@ -143,14 +143,6 @@ impl<'info> ConfigTransactionExecute<'info> { members, destinations, } => { - // SpendingLimit members must all be members of the multisig. - for sl_member in members.iter() { - require!( - multisig.is_member(*sl_member).is_some(), - MultisigError::NotAMember - ); - } - let (spending_limit_key, spending_limit_bump) = Pubkey::find_program_address( &[ SEED_PREFIX, diff --git a/programs/squads_multisig_program/src/instructions/mod.rs b/programs/squads_multisig_program/src/instructions/mod.rs index d64dd9c1..82e17d81 100644 --- a/programs/squads_multisig_program/src/instructions/mod.rs +++ b/programs/squads_multisig_program/src/instructions/mod.rs @@ -7,14 +7,18 @@ pub use multisig_add_spending_limit::*; pub use multisig_config::*; pub use multisig_create::*; pub use multisig_remove_spending_limit::*; -pub use program_config_init::*; pub use program_config::*; +pub use program_config_init::*; pub use proposal_activate::*; pub use proposal_create::*; pub use proposal_vote::*; pub use spending_limit_use::*; pub use transaction_accounts_close::*; +pub use transaction_buffer_close::*; +pub use transaction_buffer_create::*; +pub use transaction_buffer_extend::*; pub use vault_transaction_create::*; +pub use vault_transaction_create_from_buffer::*; pub use vault_transaction_execute::*; mod batch_add_transaction; @@ -26,12 +30,16 @@ mod multisig_add_spending_limit; mod multisig_config; mod multisig_create; mod multisig_remove_spending_limit; -mod program_config_init; mod program_config; +mod program_config_init; mod proposal_activate; mod proposal_create; mod proposal_vote; mod spending_limit_use; mod transaction_accounts_close; +mod transaction_buffer_close; +mod transaction_buffer_create; +mod transaction_buffer_extend; mod vault_transaction_create; +mod vault_transaction_create_from_buffer; mod vault_transaction_execute; diff --git a/programs/squads_multisig_program/src/instructions/multisig_add_spending_limit.rs b/programs/squads_multisig_program/src/instructions/multisig_add_spending_limit.rs index 1a0f34d4..944288b1 100644 --- a/programs/squads_multisig_program/src/instructions/multisig_add_spending_limit.rs +++ b/programs/squads_multisig_program/src/instructions/multisig_add_spending_limit.rs @@ -18,9 +18,8 @@ pub struct MultisigAddSpendingLimitArgs { /// The reset period of the spending limit. /// When it passes, the remaining amount is reset, unless it's `Period::OneTime`. pub period: Period, - /// Members of the multisig that can use the spending limit. - /// In case a member is removed from the multisig, the spending limit will remain existent - /// (until explicitly deleted), but the removed member will not be able to use it anymore. + /// Members of the Spending Limit that can use it. + /// Don't have to be members of the multisig. pub members: Vec, /// The destination addresses the spending limit is allowed to sent funds to. /// If empty, funds can be sent to any address. @@ -73,14 +72,6 @@ impl MultisigAddSpendingLimit<'_> { // `spending_limit` is partially checked via its seeds. - // SpendingLimit members must all be members of the multisig. - for sl_member in self.spending_limit.members.iter() { - require!( - self.multisig.is_member(*sl_member).is_some(), - MultisigError::NotAMember - ); - } - Ok(()) } @@ -94,6 +85,10 @@ impl MultisigAddSpendingLimit<'_> { ) -> Result<()> { let spending_limit = &mut ctx.accounts.spending_limit; + // Make sure there are no duplicate keys in this direct invocation by sorting so the invariant will catch + let mut sorted_members = args.members; + sorted_members.sort(); + spending_limit.multisig = ctx.accounts.multisig.key(); spending_limit.create_key = args.create_key; spending_limit.vault_index = args.vault_index; @@ -103,7 +98,7 @@ impl MultisigAddSpendingLimit<'_> { spending_limit.remaining_amount = args.amount; spending_limit.last_reset = Clock::get()?.unix_timestamp; spending_limit.bump = ctx.bumps.spending_limit; - spending_limit.members = args.members; + spending_limit.members = sorted_members; spending_limit.destinations = args.destinations; spending_limit.invariant()?; diff --git a/programs/squads_multisig_program/src/instructions/multisig_config.rs b/programs/squads_multisig_program/src/instructions/multisig_config.rs index 00d534b2..45516c4a 100644 --- a/programs/squads_multisig_program/src/instructions/multisig_config.rs +++ b/programs/squads_multisig_program/src/instructions/multisig_config.rs @@ -88,6 +88,12 @@ impl MultisigConfig<'_> { let multisig = &mut ctx.accounts.multisig; + // Make sure that the new member is not already in the multisig. + require!( + multisig.is_member(new_member.key).is_none(), + MultisigError::DuplicateMember + ); + multisig.add_member(new_member); // Make sure the multisig account can fit the newly set rent_collector. diff --git a/programs/squads_multisig_program/src/instructions/multisig_create.rs b/programs/squads_multisig_program/src/instructions/multisig_create.rs index fd690db3..e4b058ec 100644 --- a/programs/squads_multisig_program/src/instructions/multisig_create.rs +++ b/programs/squads_multisig_program/src/instructions/multisig_create.rs @@ -6,80 +6,11 @@ use solana_program::native_token::LAMPORTS_PER_SOL; use crate::errors::MultisigError; use crate::state::*; -#[derive(AnchorSerialize, AnchorDeserialize)] -pub struct MultisigCreateArgs { - /// The authority that can configure the multisig: add/remove members, change the threshold, etc. - /// Should be set to `None` for autonomous multisigs. - pub config_authority: Option, - /// The number of signatures required to execute a transaction. - pub threshold: u16, - /// The members of the multisig. - pub members: Vec, - /// How many seconds must pass between transaction voting, settlement, and execution. - pub time_lock: u32, - /// Memo is used for indexing only. - pub memo: Option, -} - -#[deprecated( - since = "0.4.0", - note = "This instruction is deprecated and will be removed soon. Please use `multisig_create_v2` to ensure future compatibility." -)] +// Dummy Account context for multisigCreate, since Anchor doesn't allow empty instructions. #[derive(Accounts)] -#[instruction(args: MultisigCreateArgs)] -pub struct MultisigCreate<'info> { - #[account( - init, - payer = creator, - space = Multisig::size(args.members.len()), - seeds = [SEED_PREFIX, SEED_MULTISIG, create_key.key().as_ref()], - bump - )] - pub multisig: Account<'info, Multisig>, - - /// An ephemeral signer that is used as a seed for the Multisig PDA. - /// Must be a signer to prevent front-running attack by someone else but the original creator. - pub create_key: Signer<'info>, - - /// The creator of the multisig. - #[account(mut)] - pub creator: Signer<'info>, - - pub system_program: Program<'info, System>, -} - -#[allow(deprecated)] -impl MultisigCreate<'_> { - fn validate(&self) -> Result<()> { - Ok(()) - } - - /// Creates a multisig. - #[allow(deprecated)] - #[access_control(ctx.accounts.validate())] - pub fn multisig_create(ctx: Context, args: MultisigCreateArgs) -> Result<()> { - msg!("WARNING: This instruction is deprecated and will be removed soon. Please use `multisig_create_v2` to ensure future compatibility."); - - // Sort the members by pubkey. - let mut members = args.members; - members.sort_by_key(|m| m.key); - - // Initialize the multisig. - let multisig = &mut ctx.accounts.multisig; - multisig.config_authority = args.config_authority.unwrap_or_default(); - multisig.threshold = args.threshold; - multisig.time_lock = args.time_lock; - multisig.transaction_index = 0; - multisig.stale_transaction_index = 0; - multisig.create_key = ctx.accounts.create_key.key(); - multisig.bump = ctx.bumps.multisig; - multisig.members = members; - multisig.rent_collector = None; - - multisig.invariant()?; - - Ok(()) - } +pub struct Deprecated<'info> { + ///CHECK: Dummy Account + pub null: AccountInfo<'info>, } #[derive(AnchorSerialize, AnchorDeserialize)] diff --git a/programs/squads_multisig_program/src/instructions/proposal_vote.rs b/programs/squads_multisig_program/src/instructions/proposal_vote.rs index 87075dd8..a00dec3b 100644 --- a/programs/squads_multisig_program/src/instructions/proposal_vote.rs +++ b/programs/squads_multisig_program/src/instructions/proposal_vote.rs @@ -33,6 +33,14 @@ pub struct ProposalVote<'info> { pub proposal: Account<'info, Proposal>, } +#[derive(Accounts)] +pub struct ProposalCancelV2<'info> { + // The context needed for the ProposalVote instruction + pub proposal_vote: ProposalVote<'info>, + + pub system_program: Program<'info, System>, +} + impl ProposalVote<'_> { fn validate(&self, vote: Vote) -> Result<()> { let Self { @@ -113,12 +121,46 @@ impl ProposalVote<'_> { let proposal = &mut ctx.accounts.proposal; let member = &mut ctx.accounts.member; + proposal + .cancelled + .retain(|k| multisig.is_member(*k).is_some()); + proposal.cancel(member.key(), usize::from(multisig.threshold))?; Ok(()) } } +impl<'info> ProposalCancelV2<'info> { + + /// Cancel a multisig proposal on behalf of the `member`. + /// The proposal must be `Approved`. + pub fn proposal_cancel_v2(ctx: Context<'_, '_, 'info, 'info, Self>, _args: ProposalVoteArgs) -> Result<()> { + // Readonly accounts + let multisig = &ctx.accounts.proposal_vote.multisig.clone(); + + // Account infos necessary for reallocation + let proposal_account_info = &ctx.accounts.proposal_vote.proposal.to_account_info(); + let member_account_info = &ctx.accounts.proposal_vote.member.to_account_info(); + let system_program_account_info = &ctx.accounts.system_program.to_account_info(); + + // Create context for cancel instruction + let cancel_context = Context::new(ctx.program_id, &mut ctx.accounts.proposal_vote, ctx.remaining_accounts, ctx.bumps.proposal_vote); + + // Call cancel instruction + ProposalVote::proposal_cancel(cancel_context, _args)?; + + // Reallocate the proposal size if needed + Proposal::realloc_if_needed( + proposal_account_info.clone(), + multisig.members.len(), + Some(member_account_info.clone()), + Some(system_program_account_info.clone()), + )?; + Ok(()) + } +} + pub enum Vote { Approve, Reject, diff --git a/programs/squads_multisig_program/src/instructions/spending_limit_use.rs b/programs/squads_multisig_program/src/instructions/spending_limit_use.rs index b357605a..0d929220 100644 --- a/programs/squads_multisig_program/src/instructions/spending_limit_use.rs +++ b/programs/squads_multisig_program/src/instructions/spending_limit_use.rs @@ -97,11 +97,6 @@ impl SpendingLimitUse<'_> { } = self; // member - require!( - multisig.is_member(member.key()).is_some(), - MultisigError::NotAMember - ); - // We don't check member's permissions here but we check if the spending_limit is for the member. require!( spending_limit.members.contains(&member.key()), MultisigError::Unauthorized diff --git a/programs/squads_multisig_program/src/instructions/transaction_accounts_close.rs b/programs/squads_multisig_program/src/instructions/transaction_accounts_close.rs index c7cadefc..08806845 100644 --- a/programs/squads_multisig_program/src/instructions/transaction_accounts_close.rs +++ b/programs/squads_multisig_program/src/instructions/transaction_accounts_close.rs @@ -13,6 +13,7 @@ use anchor_lang::prelude::*; use crate::errors::*; use crate::state::*; +use crate::utils; #[derive(Accounts)] pub struct ConfigTransactionAccountsClose<'info> { @@ -23,18 +24,25 @@ pub struct ConfigTransactionAccountsClose<'info> { )] pub multisig: Account<'info, Multisig>, + /// CHECK: `seeds` and `bump` verify that the account is the canonical Proposal, + /// the logic within `config_transaction_accounts_close` does the rest of the checks. #[account( mut, - has_one = multisig @ MultisigError::ProposalForAnotherMultisig, - close = rent_collector + seeds = [ + SEED_PREFIX, + multisig.key().as_ref(), + SEED_TRANSACTION, + &transaction.index.to_le_bytes(), + SEED_PROPOSAL, + ], + bump, )] - pub proposal: Account<'info, Proposal>, + pub proposal: AccountInfo<'info>, /// ConfigTransaction corresponding to the `proposal`. #[account( mut, has_one = multisig @ MultisigError::TransactionForAnotherMultisig, - constraint = transaction.index == proposal.transaction_index @ MultisigError::TransactionNotMatchingProposal, close = rent_collector )] pub transaction: Account<'info, ConfigTransaction>, @@ -51,47 +59,63 @@ pub struct ConfigTransactionAccountsClose<'info> { } impl ConfigTransactionAccountsClose<'_> { - fn validate(&self) -> Result<()> { - let Self { - multisig, proposal, .. - } = self; - - let is_stale = proposal.transaction_index <= multisig.stale_transaction_index; + /// Closes a `ConfigTransaction` and the corresponding `Proposal`. + /// `transaction` can be closed if either: + /// - the `proposal` is in a terminal state: `Executed`, `Rejected`, or `Cancelled`. + /// - the `proposal` is stale. + pub fn config_transaction_accounts_close(ctx: Context) -> Result<()> { + let multisig = &ctx.accounts.multisig; + let transaction = &ctx.accounts.transaction; + let proposal = &mut ctx.accounts.proposal; + let rent_collector = &ctx.accounts.rent_collector; + + let is_stale = transaction.index <= multisig.stale_transaction_index; + + let proposal_account = if proposal.data.borrow().is_empty() { + None + } else { + Some(Proposal::try_deserialize( + &mut &**proposal.data.borrow_mut(), + )?) + }; - // Has to be either stale or in a terminal state. #[allow(deprecated)] - let can_close = match proposal.status { - // Draft proposals can only be closed if stale, - // so they can't be activated anymore. - ProposalStatus::Draft { .. } => is_stale, - // Active proposals can only be closed if stale, - // so they can't be voted on anymore. - ProposalStatus::Active { .. } => is_stale, - // Approved proposals for ConfigTransactions can be closed if stale, - // because they cannot be executed anymore. - ProposalStatus::Approved { .. } => is_stale, - // Rejected proposals can be closed. - ProposalStatus::Rejected { .. } => true, - // Executed proposals can be closed. - ProposalStatus::Executed { .. } => true, - // Cancelled proposals can be closed. - ProposalStatus::Cancelled { .. } => true, - // Should never really be in this state. - ProposalStatus::Executing => false, + let can_close = if let Some(proposal_account) = &proposal_account { + match proposal_account.status { + // Draft proposals can only be closed if stale, + // so they can't be activated anymore. + ProposalStatus::Draft { .. } => is_stale, + // Active proposals can only be closed if stale, + // so they can't be voted on anymore. + ProposalStatus::Active { .. } => is_stale, + // Approved proposals for ConfigTransactions can be closed if stale, + // because they cannot be executed anymore. + ProposalStatus::Approved { .. } => is_stale, + // Rejected proposals can be closed. + ProposalStatus::Rejected { .. } => true, + // Executed proposals can be closed. + ProposalStatus::Executed { .. } => true, + // Cancelled proposals can be closed. + ProposalStatus::Cancelled { .. } => true, + // Should never really be in this state. + ProposalStatus::Executing => false, + } + } else { + // If no Proposal account exists then the ConfigTransaction can only be closed if stale + is_stale }; require!(can_close, MultisigError::InvalidProposalStatus); - Ok(()) - } + // Close the `proposal` account if exists. + if proposal_account.is_some() { + utils::close( + ctx.accounts.proposal.to_account_info(), + rent_collector.to_account_info(), + )?; + } - /// Closes a `ConfigTransaction` and the corresponding `Proposal`. - /// `transaction` can be closed if either: - /// - the `proposal` is in a terminal state: `Executed`, `Rejected`, or `Cancelled`. - /// - the `proposal` is stale. - #[access_control(_ctx.accounts.validate())] - pub fn config_transaction_accounts_close(_ctx: Context) -> Result<()> { - // Anchor will close the accounts for us. + // Anchor will close the `transaction` account for us. Ok(()) } } @@ -105,18 +129,25 @@ pub struct VaultTransactionAccountsClose<'info> { )] pub multisig: Account<'info, Multisig>, + /// CHECK: `seeds` and `bump` verify that the account is the canonical Proposal, + /// the logic within `vault_transaction_accounts_close` does the rest of the checks. #[account( mut, - has_one = multisig @ MultisigError::ProposalForAnotherMultisig, - close = rent_collector + seeds = [ + SEED_PREFIX, + multisig.key().as_ref(), + SEED_TRANSACTION, + &transaction.index.to_le_bytes(), + SEED_PROPOSAL, + ], + bump, )] - pub proposal: Account<'info, Proposal>, + pub proposal: AccountInfo<'info>, /// VaultTransaction corresponding to the `proposal`. #[account( mut, has_one = multisig @ MultisigError::TransactionForAnotherMultisig, - constraint = transaction.index == proposal.transaction_index @ MultisigError::TransactionNotMatchingProposal, close = rent_collector )] pub transaction: Account<'info, VaultTransaction>, @@ -133,46 +164,65 @@ pub struct VaultTransactionAccountsClose<'info> { } impl VaultTransactionAccountsClose<'_> { - fn validate(&self) -> Result<()> { - let Self { - multisig, proposal, .. - } = self; - - let is_stale = proposal.transaction_index <= multisig.stale_transaction_index; + /// Closes a `VaultTransaction` and the corresponding `Proposal`. + /// `transaction` can be closed if either: + /// - the `proposal` is in a terminal state: `Executed`, `Rejected`, or `Cancelled`. + /// - the `proposal` is stale and not `Approved`. + pub fn vault_transaction_accounts_close( + ctx: Context, + ) -> Result<()> { + let multisig = &ctx.accounts.multisig; + let transaction = &ctx.accounts.transaction; + let proposal = &mut ctx.accounts.proposal; + let rent_collector = &ctx.accounts.rent_collector; + + let is_stale = transaction.index <= multisig.stale_transaction_index; + + let proposal_account = if proposal.data.borrow().is_empty() { + None + } else { + Some(Proposal::try_deserialize( + &mut &**proposal.data.borrow_mut(), + )?) + }; #[allow(deprecated)] - let can_close = match proposal.status { - // Draft proposals can only be closed if stale, - // so they can't be activated anymore. - ProposalStatus::Draft { .. } => is_stale, - // Active proposals can only be closed if stale, - // so they can't be voted on anymore. - ProposalStatus::Active { .. } => is_stale, - // Approved proposals for VaultTransactions cannot be closed even if stale, - // because they still can be executed. - ProposalStatus::Approved { .. } => false, - // Rejected proposals can be closed. - ProposalStatus::Rejected { .. } => true, - // Executed proposals can be closed. - ProposalStatus::Executed { .. } => true, - // Cancelled proposals can be closed. - ProposalStatus::Cancelled { .. } => true, - // Should never really be in this state. - ProposalStatus::Executing => false, + let can_close = if let Some(proposal_account) = &proposal_account { + match proposal_account.status { + // Draft proposals can only be closed if stale, + // so they can't be activated anymore. + ProposalStatus::Draft { .. } => is_stale, + // Active proposals can only be closed if stale, + // so they can't be voted on anymore. + ProposalStatus::Active { .. } => is_stale, + // Approved proposals for VaultTransactions cannot be closed even if stale, + // because they still can be executed. + ProposalStatus::Approved { .. } => false, + // Rejected proposals can be closed. + ProposalStatus::Rejected { .. } => true, + // Executed proposals can be closed. + ProposalStatus::Executed { .. } => true, + // Cancelled proposals can be closed. + ProposalStatus::Cancelled { .. } => true, + // Should never really be in this state. + ProposalStatus::Executing => false, + } + } else { + // If no Proposal account exists then the VaultTransaction can only be closed if stale + is_stale }; require!(can_close, MultisigError::InvalidProposalStatus); - Ok(()) - } + // Close the `proposal` account if exists. + if proposal_account.is_some() { + utils::close( + ctx.accounts.proposal.to_account_info(), + rent_collector.to_account_info(), + )?; + } - /// Closes a `VaultTransaction` and the corresponding `Proposal`. - /// `transaction` can be closed if either: - /// - the `proposal` is in a terminal state: `Executed`, `Rejected`, or `Cancelled`. - /// - the `proposal` is stale and not `Approved`. - #[access_control(_ctx.accounts.validate())] - pub fn vault_transaction_accounts_close(_ctx: Context) -> Result<()> { - // Anchor will close the accounts for us. + // Anchor will close the `transaction` account for us. Ok(()) } } @@ -313,18 +363,26 @@ pub struct BatchAccountsClose<'info> { )] pub multisig: Account<'info, Multisig>, + // pub proposal: Account<'info, Proposal>, + /// CHECK: `seeds` and `bump` verify that the account is the canonical Proposal, + /// the logic within `batch_accounts_close` does the rest of the checks. #[account( mut, - has_one = multisig @ MultisigError::ProposalForAnotherMultisig, - close = rent_collector + seeds = [ + SEED_PREFIX, + multisig.key().as_ref(), + SEED_TRANSACTION, + &batch.index.to_le_bytes(), + SEED_PROPOSAL, + ], + bump, )] - pub proposal: Account<'info, Proposal>, + pub proposal: AccountInfo<'info>, /// `Batch` corresponding to the `proposal`. #[account( mut, has_one = multisig @ MultisigError::TransactionForAnotherMultisig, - constraint = batch.index == proposal.transaction_index @ MultisigError::TransactionNotMatchingProposal, close = rent_collector )] pub batch: Account<'info, Batch>, @@ -341,35 +399,51 @@ pub struct BatchAccountsClose<'info> { } impl BatchAccountsClose<'_> { - fn validate(&self) -> Result<()> { - let Self { - multisig, - proposal, - batch, - .. - } = self; - - let is_stale = proposal.transaction_index <= multisig.stale_transaction_index; + /// Closes Batch and the corresponding Proposal accounts for proposals in terminal states: + /// `Executed`, `Rejected`, or `Cancelled` or stale proposals that aren't `Approved`. + /// + /// This instruction is only allowed to be executed when all `VaultBatchTransaction` accounts + /// in the `batch` are already closed: `batch.size == 0`. + pub fn batch_accounts_close(ctx: Context) -> Result<()> { + let multisig = &ctx.accounts.multisig; + let batch = &ctx.accounts.batch; + let proposal = &mut ctx.accounts.proposal; + let rent_collector = &ctx.accounts.rent_collector; + + let is_stale = batch.index <= multisig.stale_transaction_index; + + let proposal_account = if proposal.data.borrow().is_empty() { + None + } else { + Some(Proposal::try_deserialize( + &mut &**proposal.data.borrow_mut(), + )?) + }; #[allow(deprecated)] - let can_close = match proposal.status { - // Draft proposals can only be closed if stale, - // so they can't be activated anymore. - ProposalStatus::Draft { .. } => is_stale, - // Active proposals can only be closed if stale, - // so they can't be voted on anymore. - ProposalStatus::Active { .. } => is_stale, - // Approved proposals for `Batch`s cannot be closed even if stale, - // because they still can be executed. - ProposalStatus::Approved { .. } => false, - // Rejected proposals can be closed. - ProposalStatus::Rejected { .. } => true, - // Executed proposals can be closed. - ProposalStatus::Executed { .. } => true, - // Cancelled proposals can be closed. - ProposalStatus::Cancelled { .. } => true, - // Should never really be in this state. - ProposalStatus::Executing => false, + let can_close = if let Some(proposal_account) = &proposal_account { + match proposal_account.status { + // Draft proposals can only be closed if stale, + // so they can't be activated anymore. + ProposalStatus::Draft { .. } => is_stale, + // Active proposals can only be closed if stale, + // so they can't be voted on anymore. + ProposalStatus::Active { .. } => is_stale, + // Approved proposals for `Batch`s cannot be closed even if stale, + // because they still can be executed. + ProposalStatus::Approved { .. } => false, + // Rejected proposals can be closed. + ProposalStatus::Rejected { .. } => true, + // Executed proposals can be closed. + ProposalStatus::Executed { .. } => true, + // Cancelled proposals can be closed. + ProposalStatus::Cancelled { .. } => true, + // Should never really be in this state. + ProposalStatus::Executing => false, + } + } else { + // If no Proposal account exists then the Batch can only be closed if stale + is_stale }; require!(can_close, MultisigError::InvalidProposalStatus); @@ -377,17 +451,15 @@ impl BatchAccountsClose<'_> { // Batch must be empty. require_eq!(batch.size, 0, MultisigError::BatchNotEmpty); - Ok(()) - } + // Close the `proposal` account if exists. + if proposal_account.is_some() { + utils::close( + ctx.accounts.proposal.to_account_info(), + rent_collector.to_account_info(), + )?; + } - /// Closes Batch and the corresponding Proposal accounts for proposals in terminal states: - /// `Executed`, `Rejected`, or `Cancelled` or stale proposals that aren't `Approved`. - /// - /// This instruction is only allowed to be executed when all `VaultBatchTransaction` accounts - /// in the `batch` are already closed: `batch.size == 0`. - #[access_control(_ctx.accounts.validate())] - pub fn batch_accounts_close(_ctx: Context) -> Result<()> { - // Anchor will close the accounts for us. + // Anchor will close the `batch` account for us. Ok(()) } } diff --git a/programs/squads_multisig_program/src/instructions/transaction_buffer_close.rs b/programs/squads_multisig_program/src/instructions/transaction_buffer_close.rs new file mode 100644 index 00000000..d38e3c9e --- /dev/null +++ b/programs/squads_multisig_program/src/instructions/transaction_buffer_close.rs @@ -0,0 +1,47 @@ +use anchor_lang::prelude::*; + +use crate::errors::*; +use crate::state::*; + +#[derive(Accounts)] +pub struct TransactionBufferClose<'info> { + #[account( + seeds = [SEED_PREFIX, SEED_MULTISIG, multisig.create_key.as_ref()], + bump = multisig.bump, + )] + pub multisig: Account<'info, Multisig>, + + #[account( + mut, + // Rent gets returned to the creator + close = creator, + // Only the creator can close the buffer + constraint = transaction_buffer.creator == creator.key() @ MultisigError::Unauthorized, + // Account can be closed anytime by the creator, regardless of the + // current multisig transaction index + seeds = [ + SEED_PREFIX, + multisig.key().as_ref(), + SEED_TRANSACTION_BUFFER, + creator.key().as_ref(), + &transaction_buffer.buffer_index.to_le_bytes() + ], + bump + )] + pub transaction_buffer: Account<'info, TransactionBuffer>, + + /// The member of the multisig that created the TransactionBuffer. + pub creator: Signer<'info>, +} + +impl TransactionBufferClose<'_> { + fn validate(&self) -> Result<()> { + Ok(()) + } + + /// Close a transaction buffer account. + #[access_control(ctx.accounts.validate())] + pub fn transaction_buffer_close(ctx: Context) -> Result<()> { + Ok(()) + } +} diff --git a/programs/squads_multisig_program/src/instructions/transaction_buffer_create.rs b/programs/squads_multisig_program/src/instructions/transaction_buffer_create.rs new file mode 100644 index 00000000..6593fe3a --- /dev/null +++ b/programs/squads_multisig_program/src/instructions/transaction_buffer_create.rs @@ -0,0 +1,110 @@ +use anchor_lang::prelude::*; + +use crate::errors::*; +use crate::state::MAX_BUFFER_SIZE; +use crate::state::*; + +#[derive(AnchorSerialize, AnchorDeserialize)] +pub struct TransactionBufferCreateArgs { + /// Index of the buffer account to seed the account derivation + pub buffer_index: u8, + /// Index of the vault this transaction belongs to. + pub vault_index: u8, + /// Hash of the final assembled transaction message. + pub final_buffer_hash: [u8; 32], + /// Final size of the buffer. + pub final_buffer_size: u16, + /// Initial slice of the buffer. + pub buffer: Vec, +} + +#[derive(Accounts)] +#[instruction(args: TransactionBufferCreateArgs)] +pub struct TransactionBufferCreate<'info> { + #[account( + seeds = [SEED_PREFIX, SEED_MULTISIG, multisig.create_key.as_ref()], + bump = multisig.bump, + )] + pub multisig: Account<'info, Multisig>, + + #[account( + init, + payer = rent_payer, + space = TransactionBuffer::size(args.final_buffer_size)?, + seeds = [ + SEED_PREFIX, + multisig.key().as_ref(), + SEED_TRANSACTION_BUFFER, + creator.key().as_ref(), + &args.buffer_index.to_le_bytes(), + ], + bump + )] + pub transaction_buffer: Account<'info, TransactionBuffer>, + + /// The member of the multisig that is creating the transaction. + pub creator: Signer<'info>, + + /// The payer for the transaction account rent. + #[account(mut)] + pub rent_payer: Signer<'info>, + + pub system_program: Program<'info, System>, +} + +impl TransactionBufferCreate<'_> { + fn validate(&self, args: &TransactionBufferCreateArgs) -> Result<()> { + let Self { + multisig, creator, .. + } = self; + + // creator is a member in the multisig + require!( + multisig.is_member(creator.key()).is_some(), + MultisigError::NotAMember + ); + // creator has initiate permissions + require!( + multisig.member_has_permission(creator.key(), Permission::Initiate), + MultisigError::Unauthorized + ); + + // Final Buffer Size must not exceed 4000 bytes + require!( + args.final_buffer_size as usize <= MAX_BUFFER_SIZE, + MultisigError::FinalBufferSizeExceeded + ); + Ok(()) + } + + /// Create a new vault transaction. + #[access_control(ctx.accounts.validate(&args))] + pub fn transaction_buffer_create( + ctx: Context, + args: TransactionBufferCreateArgs, + ) -> Result<()> { + // Mutable Accounts + let transaction_buffer = &mut ctx.accounts.transaction_buffer; + + // Readonly Accounts + let multisig = &ctx.accounts.multisig; + let creator = &mut ctx.accounts.creator; + + // Get the buffer index. + let buffer_index = args.buffer_index; + + // Initialize the transaction fields. + transaction_buffer.multisig = multisig.key(); + transaction_buffer.creator = creator.key(); + transaction_buffer.vault_index = args.vault_index; + transaction_buffer.buffer_index = buffer_index; + transaction_buffer.final_buffer_hash = args.final_buffer_hash; + transaction_buffer.final_buffer_size = args.final_buffer_size; + transaction_buffer.buffer = args.buffer; + + // Invariant function on the transaction buffer + transaction_buffer.invariant()?; + + Ok(()) + } +} diff --git a/programs/squads_multisig_program/src/instructions/transaction_buffer_extend.rs b/programs/squads_multisig_program/src/instructions/transaction_buffer_extend.rs new file mode 100644 index 00000000..9276dac8 --- /dev/null +++ b/programs/squads_multisig_program/src/instructions/transaction_buffer_extend.rs @@ -0,0 +1,101 @@ +use anchor_lang::prelude::*; + +use crate::errors::*; +use crate::state::*; + +#[derive(AnchorSerialize, AnchorDeserialize)] +pub struct TransactionBufferExtendArgs { + // Buffer to extend the TransactionBuffer with. + pub buffer: Vec, +} + +#[derive(Accounts)] +#[instruction(args: TransactionBufferExtendArgs)] +pub struct TransactionBufferExtend<'info> { + #[account( + seeds = [SEED_PREFIX, SEED_MULTISIG, multisig.create_key.as_ref()], + bump = multisig.bump, + )] + pub multisig: Account<'info, Multisig>, + + #[account( + mut, + // Only the creator can extend the buffer + constraint = transaction_buffer.creator == creator.key() @ MultisigError::Unauthorized, + seeds = [ + SEED_PREFIX, + multisig.key().as_ref(), + SEED_TRANSACTION_BUFFER, + creator.key().as_ref(), + &transaction_buffer.buffer_index.to_le_bytes() + ], + bump + )] + pub transaction_buffer: Account<'info, TransactionBuffer>, + + /// The member of the multisig that created the TransactionBuffer. + pub creator: Signer<'info>, +} + +impl TransactionBufferExtend<'_> { + fn validate(&self, args: &TransactionBufferExtendArgs) -> Result<()> { + let Self { + multisig, + creator, + transaction_buffer, + .. + } = self; + + // creator is still a member in the multisig + require!( + multisig.is_member(creator.key()).is_some(), + MultisigError::NotAMember + ); + + // creator still has initiate permissions + require!( + multisig.member_has_permission(creator.key(), Permission::Initiate), + MultisigError::Unauthorized + ); + + // Extended Buffer size must not exceed final buffer size + // Calculate remaining space in the buffer + let current_buffer_size = transaction_buffer.buffer.len() as u16; + let remaining_space = transaction_buffer + .final_buffer_size + .checked_sub(current_buffer_size) + .unwrap(); + + // Check if the new data exceeds the remaining space + let new_data_size = args.buffer.len() as u16; + require!( + new_data_size <= remaining_space, + MultisigError::FinalBufferSizeExceeded + ); + + Ok(()) + } + + /// Create a new vault transaction. + #[access_control(ctx.accounts.validate(&args))] + pub fn transaction_buffer_extend( + ctx: Context, + args: TransactionBufferExtendArgs, + ) -> Result<()> { + // Mutable Accounts + let transaction_buffer = &mut ctx.accounts.transaction_buffer; + + // Required Data + let buffer_slice_extension = args.buffer; + + // Extend the Buffer inside the TransactionBuffer + transaction_buffer + .buffer + .extend_from_slice(&buffer_slice_extension); + + // Invariant function on the transaction buffer + transaction_buffer.invariant()?; + + Ok(()) + } +} diff --git a/programs/squads_multisig_program/src/instructions/vault_transaction_create.rs b/programs/squads_multisig_program/src/instructions/vault_transaction_create.rs index 2a12a58f..b7c8f22e 100644 --- a/programs/squads_multisig_program/src/instructions/vault_transaction_create.rs +++ b/programs/squads_multisig_program/src/instructions/vault_transaction_create.rs @@ -1,4 +1,5 @@ use anchor_lang::prelude::*; +use anchor_lang::system_program; use crate::errors::*; use crate::state::*; @@ -24,7 +25,7 @@ pub struct VaultTransactionCreate<'info> { )] pub multisig: Account<'info, Multisig>, - #[account( + #[account( init, payer = rent_payer, space = VaultTransaction::size(args.ephemeral_signers, &args.transaction_message)?, @@ -48,8 +49,8 @@ pub struct VaultTransactionCreate<'info> { pub system_program: Program<'info, System>, } -impl VaultTransactionCreate<'_> { - fn validate(&self) -> Result<()> { +impl<'info> VaultTransactionCreate<'info> { + pub fn validate(&self) -> Result<()> { let Self { multisig, creator, .. } = self; diff --git a/programs/squads_multisig_program/src/instructions/vault_transaction_create_from_buffer.rs b/programs/squads_multisig_program/src/instructions/vault_transaction_create_from_buffer.rs new file mode 100644 index 00000000..46e31b7d --- /dev/null +++ b/programs/squads_multisig_program/src/instructions/vault_transaction_create_from_buffer.rs @@ -0,0 +1,123 @@ +use crate::errors::*; +use crate::instructions::*; +use crate::state::*; +use anchor_lang::{prelude::*, system_program}; + +#[derive(Accounts)] +pub struct VaultTransactionCreateFromBuffer<'info> { + // The context needed for the VaultTransactionCreate instruction + pub vault_transaction_create: VaultTransactionCreate<'info>, + + #[account( + mut, + close = creator, + // Only the creator can turn the buffer into a transaction and reclaim + // the rent + constraint = transaction_buffer.creator == creator.key() @ MultisigError::Unauthorized, + seeds = [ + SEED_PREFIX, + vault_transaction_create.multisig.key().as_ref(), + SEED_TRANSACTION_BUFFER, + creator.key().as_ref(), + &transaction_buffer.buffer_index.to_le_bytes(), + ], + bump + )] + pub transaction_buffer: Box>, + + // Anchor doesn't allow us to use the creator inside of + // vault_transaction_create, so we just re-pass it here with the same constraint + #[account( + mut, + address = vault_transaction_create.creator.key(), + )] + pub creator: Signer<'info>, +} + +impl<'info> VaultTransactionCreateFromBuffer<'info> { + pub fn validate(&self, args: &VaultTransactionCreateArgs) -> Result<()> { + let transaction_buffer_account = &self.transaction_buffer; + + // Check that the transaction message is "empty" + require!( + args.transaction_message == vec![0, 0, 0, 0, 0, 0], + MultisigError::InvalidInstructionArgs + ); + + // Validate that the final hash matches the buffer + transaction_buffer_account.validate_hash()?; + + // Validate that the final size is correct + transaction_buffer_account.validate_size()?; + Ok(()) + } + /// Create a new vault transaction from a completed transaction buffer account. + #[access_control(ctx.accounts.validate(&args))] + pub fn vault_transaction_create_from_buffer( + ctx: Context<'_, '_, 'info, 'info, Self>, + args: VaultTransactionCreateArgs, + ) -> Result<()> { + // Account infos necessary for reallocation + let vault_transaction_account_info = &ctx + .accounts + .vault_transaction_create + .transaction + .to_account_info(); + let rent_payer_account_info = &ctx + .accounts + .vault_transaction_create + .rent_payer + .to_account_info(); + + let system_program = &ctx.accounts.vault_transaction_create.system_program.to_account_info(); + + // Read-only accounts + let transaction_buffer = &ctx.accounts.transaction_buffer; + + // Calculate the new required length of the vault transaction account, + // since it was initialized with an empty transaction message + let new_len = + VaultTransaction::size(args.ephemeral_signers, transaction_buffer.buffer.as_slice())?; + + // Calculate the rent exemption for new length + let rent_exempt_lamports = Rent::get().unwrap().minimum_balance(new_len).max(1); + + // Check the difference between the rent exemption and the current lamports + let top_up_lamports = + rent_exempt_lamports.saturating_sub(vault_transaction_account_info.lamports()); + + // System Transfer the remaining difference to the vault transaction account + let transfer_context = CpiContext::new( + system_program.to_account_info(), + system_program::Transfer { + from: rent_payer_account_info.clone(), + to: vault_transaction_account_info.clone(), + }, + ); + system_program::transfer(transfer_context, top_up_lamports)?; + + // Reallocate the vault transaction account to the new length of the + // actual transaction message + AccountInfo::realloc(&vault_transaction_account_info, new_len, true)?; + + // Create the args for the vault transaction create instruction + let create_args = VaultTransactionCreateArgs { + vault_index: args.vault_index, + ephemeral_signers: args.ephemeral_signers, + transaction_message: transaction_buffer.buffer.clone(), + memo: args.memo, + }; + // Create the context for the vault transaction create instruction + let context = Context::new( + ctx.program_id, + &mut ctx.accounts.vault_transaction_create, + ctx.remaining_accounts, + ctx.bumps.vault_transaction_create, + ); + + // Call the vault transaction create instruction + VaultTransactionCreate::vault_transaction_create(context, create_args)?; + + Ok(()) + } +} diff --git a/programs/squads_multisig_program/src/instructions/vault_transaction_execute.rs b/programs/squads_multisig_program/src/instructions/vault_transaction_execute.rs index edbb962c..a5ff077f 100644 --- a/programs/squads_multisig_program/src/instructions/vault_transaction_execute.rs +++ b/programs/squads_multisig_program/src/instructions/vault_transaction_execute.rs @@ -88,10 +88,15 @@ impl VaultTransactionExecute<'_> { pub fn vault_transaction_execute(ctx: Context) -> Result<()> { let multisig = &mut ctx.accounts.multisig; let proposal = &mut ctx.accounts.proposal; - let transaction = &mut ctx.accounts.transaction; + + // NOTE: After `take()` is called, the VaultTransaction is reduced to + // its default empty value, which means it should no longer be referenced or + // used after this point to avoid faulty behavior. + // Instead only make use of the returned `transaction` value. + let transaction = ctx.accounts.transaction.take(); let multisig_key = multisig.key(); - let transaction_key = transaction.key(); + let transaction_key = ctx.accounts.transaction.key(); let vault_seeds = &[ SEED_PREFIX, @@ -101,7 +106,7 @@ impl VaultTransactionExecute<'_> { &[transaction.vault_bump], ]; - let transaction_message = &transaction.message; + let transaction_message = transaction.message; let num_lookups = transaction_message.address_table_lookups.len(); let message_account_infos = ctx @@ -129,11 +134,13 @@ impl VaultTransactionExecute<'_> { let protected_accounts = &[proposal.key()]; // Execute the transaction message instructions one-by-one. + // NOTE: `execute_message()` calls `self.to_instructions_and_accounts()` + // which in turn calls `take()` on + // `self.message.instructions`, therefore after this point no more + // references or usages of `self.message` should be made to avoid + // faulty behavior. executable_message.execute_message( - &vault_seeds - .iter() - .map(|seed| seed.to_vec()) - .collect::>>(), + vault_seeds, &ephemeral_signer_seeds, protected_accounts, )?; diff --git a/programs/squads_multisig_program/src/lib.rs b/programs/squads_multisig_program/src/lib.rs index 130d24c6..2ae5b8d9 100644 --- a/programs/squads_multisig_program/src/lib.rs +++ b/programs/squads_multisig_program/src/lib.rs @@ -15,6 +15,7 @@ pub use instructions::*; pub use state::*; pub use utils::SmallVec; +pub mod allocator; pub mod errors; pub mod instructions; pub mod state; @@ -39,6 +40,8 @@ declare_id!("GyhGAqjokLwF9UXdQ2dR5Zwiup242j4mX4J1tSMKyAmD"); #[program] pub mod squads_multisig_program { + use errors::MultisigError; + use super::*; /// Initialize the program config. @@ -74,9 +77,9 @@ pub mod squads_multisig_program { } /// Create a multisig. - #[allow(deprecated)] - pub fn multisig_create(ctx: Context, args: MultisigCreateArgs) -> Result<()> { - MultisigCreate::multisig_create(ctx, args) + pub fn multisig_create(_ctx: Context) -> Result<()> { + msg!("multisig_create has been deprecated. Use multisig_create_v2 instead."); + Err(MultisigError::MultisigCreateDeprecated.into()) } /// Create a multisig. @@ -175,6 +178,36 @@ pub mod squads_multisig_program { VaultTransactionCreate::vault_transaction_create(ctx, args) } + /// Create a transaction buffer account. + pub fn transaction_buffer_create( + ctx: Context, + args: TransactionBufferCreateArgs, + ) -> Result<()> { + TransactionBufferCreate::transaction_buffer_create(ctx, args) + } + + /// Close a transaction buffer account. + pub fn transaction_buffer_close(ctx: Context) -> Result<()> { + TransactionBufferClose::transaction_buffer_close(ctx) + } + + /// Extend a transaction buffer account. + pub fn transaction_buffer_extend( + ctx: Context, + args: TransactionBufferExtendArgs, + ) -> Result<()> { + TransactionBufferExtend::transaction_buffer_extend(ctx, args) + } + + /// Create a new vault transaction from a completed transaction buffer. + /// Finalized buffer hash must match `final_buffer_hash` + pub fn vault_transaction_create_from_buffer<'info>( + ctx: Context<'_, '_, 'info, 'info, VaultTransactionCreateFromBuffer<'info>>, + args: VaultTransactionCreateArgs, + ) -> Result<()> { + VaultTransactionCreateFromBuffer::vault_transaction_create_from_buffer(ctx, args) + } + /// Execute a vault transaction. /// The transaction must be `Approved`. pub fn vault_transaction_execute(ctx: Context) -> Result<()> { @@ -227,6 +260,20 @@ pub mod squads_multisig_program { ProposalVote::proposal_cancel(ctx, args) } + /// Cancel a multisig proposal on behalf of the `member`. + /// The proposal must be `Approved`. + /// This was introduced to incorporate proper state update, as old multisig members + /// may have lingering votes, and the proposal size may need to be reallocated to + /// accommodate the new amount of cancel votes. + /// The previous implemenation still works if the proposal size is in line with the + /// threshold size. + pub fn proposal_cancel_v2<'info>( + ctx: Context<'_, '_, 'info, 'info, ProposalCancelV2<'info>>, + args: ProposalVoteArgs, + ) -> Result<()> { + ProposalCancelV2::proposal_cancel_v2(ctx, args) + } + /// Use a spending limit to transfer tokens from a multisig vault to a destination account. pub fn spending_limit_use( ctx: Context, diff --git a/programs/squads_multisig_program/src/state/batch.rs b/programs/squads_multisig_program/src/state/batch.rs index 24793deb..810cb370 100644 --- a/programs/squads_multisig_program/src/state/batch.rs +++ b/programs/squads_multisig_program/src/state/batch.rs @@ -40,6 +40,7 @@ impl Batch { /// Stores data required for execution of one transaction from a batch. #[account] +#[derive(Default)] pub struct VaultBatchTransaction { /// PDA bump. pub bump: u8, @@ -69,4 +70,10 @@ impl VaultBatchTransaction { message_size, // message ) } + + /// Reduces the VaultBatchTransaction to its default empty value and moves + /// ownership of the data to the caller/return value. + pub fn take(&mut self) -> VaultBatchTransaction { + core::mem::take(self) + } } diff --git a/programs/squads_multisig_program/src/state/mod.rs b/programs/squads_multisig_program/src/state/mod.rs index 85e19941..8eec0934 100644 --- a/programs/squads_multisig_program/src/state/mod.rs +++ b/programs/squads_multisig_program/src/state/mod.rs @@ -5,6 +5,7 @@ pub use program_config::*; pub use proposal::*; pub use seeds::*; pub use spending_limit::*; +pub use transaction_buffer::*; pub use vault_transaction::*; mod batch; @@ -14,4 +15,5 @@ mod program_config; mod proposal; mod seeds; mod spending_limit; +mod transaction_buffer; mod vault_transaction; diff --git a/programs/squads_multisig_program/src/state/proposal.rs b/programs/squads_multisig_program/src/state/proposal.rs index 18a0fcc0..de5010cc 100644 --- a/programs/squads_multisig_program/src/state/proposal.rs +++ b/programs/squads_multisig_program/src/state/proposal.rs @@ -2,6 +2,9 @@ use anchor_lang::prelude::*; use crate::errors::*; +use crate::id; + +use anchor_lang::system_program; /// Stores the data required for tracking the status of a multisig proposal. /// Each `Proposal` has a 1:1 association with a transaction account, e.g. a `VaultTransaction` or a `ConfigTransaction`; @@ -122,6 +125,62 @@ impl Proposal { fn remove_approval_vote(&mut self, index: usize) { self.approved.remove(index); } + + /// Check if the proposal account space needs to be reallocated to accommodate `cancelled` vec. + /// Proposal size is crated at creation, and thus may not accomodate enough space for all members to cancel if more are added or changed + /// Returns `true` if the account was reallocated. + pub fn realloc_if_needed<'a>( + proposal: AccountInfo<'a>, + members_length: usize, + rent_payer: Option>, + system_program: Option>, + ) -> Result { + // Sanity checks + require_keys_eq!(*proposal.owner, id(), MultisigError::IllegalAccountOwner); + + let current_account_size = proposal.data.borrow().len(); + let account_size_to_fit_members = Proposal::size(members_length); + + // Check if we need to reallocate space. + if current_account_size >= account_size_to_fit_members { + return Ok(false); + } + + // Reallocate more space. + AccountInfo::realloc(&proposal, account_size_to_fit_members, false)?; + + // If more lamports are needed, transfer them to the account. + let rent_exempt_lamports = Rent::get() + .unwrap() + .minimum_balance(account_size_to_fit_members) + .max(1); + let top_up_lamports = + rent_exempt_lamports.saturating_sub(proposal.to_account_info().lamports()); + + if top_up_lamports > 0 { + let system_program = system_program.ok_or(MultisigError::MissingAccount)?; + require_keys_eq!( + *system_program.key, + system_program::ID, + MultisigError::InvalidAccount + ); + + let rent_payer = rent_payer.ok_or(MultisigError::MissingAccount)?; + + system_program::transfer( + CpiContext::new( + system_program, + system_program::Transfer { + from: rent_payer, + to: proposal, + }, + ), + top_up_lamports, + )?; + } + + Ok(true) + } } /// The status of a proposal. diff --git a/programs/squads_multisig_program/src/state/seeds.rs b/programs/squads_multisig_program/src/state/seeds.rs index e44e4ad0..a4f6fb55 100644 --- a/programs/squads_multisig_program/src/state/seeds.rs +++ b/programs/squads_multisig_program/src/state/seeds.rs @@ -7,3 +7,4 @@ pub const SEED_BATCH_TRANSACTION: &[u8] = b"batch_transaction"; pub const SEED_VAULT: &[u8] = b"vault"; pub const SEED_EPHEMERAL_SIGNER: &[u8] = b"ephemeral_signer"; pub const SEED_SPENDING_LIMIT: &[u8] = b"spending_limit"; +pub const SEED_TRANSACTION_BUFFER: &[u8] = b"transaction_buffer"; diff --git a/programs/squads_multisig_program/src/state/spending_limit.rs b/programs/squads_multisig_program/src/state/spending_limit.rs index f68894a0..c7567115 100644 --- a/programs/squads_multisig_program/src/state/spending_limit.rs +++ b/programs/squads_multisig_program/src/state/spending_limit.rs @@ -66,7 +66,7 @@ impl SpendingLimit { } pub fn invariant(&self) -> Result<()> { - // Amount must be positive. + // Amount must be a non-zero value. require_neq!(self.amount, 0, MultisigError::SpendingLimitInvalidAmount); require!(!self.members.is_empty(), MultisigError::EmptyMembers); diff --git a/programs/squads_multisig_program/src/state/transaction_buffer.rs b/programs/squads_multisig_program/src/state/transaction_buffer.rs new file mode 100644 index 00000000..bd9a8553 --- /dev/null +++ b/programs/squads_multisig_program/src/state/transaction_buffer.rs @@ -0,0 +1,81 @@ +use anchor_lang::prelude::*; +use anchor_lang::solana_program::hash::hash; + +use crate::errors::MultisigError; + +// Maximum PDA allocation size in an inner ix is 10240 bytes. +// 10240 - account contents = 10128 bytes +pub const MAX_BUFFER_SIZE: usize = 10128 ; + +#[account] +#[derive(Default, Debug)] +pub struct TransactionBuffer { + /// The multisig this belongs to. + pub multisig: Pubkey, + /// Member of the Multisig who created the TransactionBuffer. + pub creator: Pubkey, + /// Index to seed address derivation + pub buffer_index: u8, + /// Vault index of the transaction this buffer belongs to. + pub vault_index: u8, + /// Hash of the final assembled transaction message. + pub final_buffer_hash: [u8; 32], + /// The size of the final assembled transaction message. + pub final_buffer_size: u16, + /// The buffer of the transaction message. + pub buffer: Vec, +} + +impl TransactionBuffer { + pub fn size(final_message_buffer_size: u16) -> Result { + // Make sure final size is not greater than MAX_BUFFER_SIZE bytes. + if (final_message_buffer_size as usize) > MAX_BUFFER_SIZE { + return err!(MultisigError::FinalBufferSizeExceeded); + } + Ok( + 8 + // anchor account discriminator + 32 + // multisig + 32 + // creator + 1 + // buffer_index + 1 + // vault_index + 32 + // transaction_message_hash + 2 + // final_buffer_size + 4 + // vec length bytes + final_message_buffer_size as usize, // buffer + ) + } + + pub fn validate_hash(&self) -> Result<()> { + let message_buffer_hash = hash(&self.buffer); + require!( + message_buffer_hash.to_bytes() == self.final_buffer_hash, + MultisigError::FinalBufferHashMismatch + ); + Ok(()) + } + pub fn validate_size(&self) -> Result<()> { + require_eq!( + self.buffer.len(), + self.final_buffer_size as usize, + MultisigError::FinalBufferSizeMismatch + ); + Ok(()) + } + + pub fn invariant(&self) -> Result<()> { + require!( + self.final_buffer_size as usize <= MAX_BUFFER_SIZE, + MultisigError::FinalBufferSizeExceeded + ); + require!( + self.buffer.len() <= MAX_BUFFER_SIZE, + MultisigError::FinalBufferSizeExceeded + ); + require!( + self.buffer.len() <= self.final_buffer_size as usize, + MultisigError::FinalBufferSizeMismatch + ); + + Ok(()) + } +} diff --git a/programs/squads_multisig_program/src/state/vault_transaction.rs b/programs/squads_multisig_program/src/state/vault_transaction.rs index 9f2a75a8..1e7cbac3 100644 --- a/programs/squads_multisig_program/src/state/vault_transaction.rs +++ b/programs/squads_multisig_program/src/state/vault_transaction.rs @@ -8,6 +8,7 @@ use crate::instructions::{CompiledInstruction, MessageAddressTableLookup, Transa /// Vault transaction is a transaction that's executed on behalf of the multisig vault PDA /// and wraps arbitrary Solana instructions, typically calling into other Solana programs. #[account] +#[derive(Default)] pub struct VaultTransaction { /// The multisig this belongs to. pub multisig: Pubkey, @@ -27,7 +28,7 @@ pub struct VaultTransaction { /// When wrapping such transactions into multisig ones, we replace these "ephemeral" signing keypairs /// with PDAs derived from the MultisigTransaction's `transaction_index` and controlled by the Multisig Program; /// during execution the program includes the seeds of these PDAs into the `invoke_signed` calls, - /// thus "signing" on behalf of these PDAs. + /// thus "signing" on behalf of these PDAs. pub ephemeral_signer_bumps: Vec, /// data required for executing the transaction. pub message: VaultTransactionMessage, @@ -44,16 +45,21 @@ impl VaultTransaction { 32 + // multisig 32 + // creator 8 + // index - 1 + // bump + 1 + // bump 1 + // vault_index 1 + // vault_bump (4 + usize::from(ephemeral_signers_length)) + // ephemeral_signers_bumps vec message_size, // message ) } + /// Reduces the VaultTransaction to its default empty value and moves + /// ownership of the data to the caller/return value. + pub fn take(&mut self) -> VaultTransaction { + core::mem::take(self) + } } -#[derive(AnchorSerialize, AnchorDeserialize, Clone)] +#[derive(AnchorSerialize, AnchorDeserialize, Clone, Default)] pub struct VaultTransactionMessage { /// The number of signer pubkeys in the account_keys vec. pub num_signers: u8, diff --git a/programs/squads_multisig_program/src/utils/executable_transaction_message.rs b/programs/squads_multisig_program/src/utils/executable_transaction_message.rs index cf590c6e..eb5ba797 100644 --- a/programs/squads_multisig_program/src/utils/executable_transaction_message.rs +++ b/programs/squads_multisig_program/src/utils/executable_transaction_message.rs @@ -13,7 +13,7 @@ use crate::state::*; /// Sanitized and validated combination of a `MsTransactionMessage` and `AccountInfo`s it references. pub struct ExecutableTransactionMessage<'a, 'info> { /// Message which loaded a collection of lookup table addresses. - message: &'a VaultTransactionMessage, + message: VaultTransactionMessage, /// Resolved `account_keys` of the message. static_accounts: Vec<&'a AccountInfo<'info>>, /// Concatenated vector of resolved `writable_indexes` from all address lookups. @@ -29,7 +29,7 @@ impl<'a, 'info> ExecutableTransactionMessage<'a, 'info> { /// `address_lookup_table_account_infos` - AccountInfo's that are expected to correspond to the lookup tables mentioned in `message.address_table_lookups`. /// `vault_pubkey` - The vault PDA that is expected to sign the message. pub fn new_validated( - message: &'a VaultTransactionMessage, + message: VaultTransactionMessage, message_account_infos: &'a [AccountInfo<'info>], address_lookup_table_account_infos: &'a [AccountInfo<'info>], vault_pubkey: &'a Pubkey, @@ -178,11 +178,28 @@ impl<'a, 'info> ExecutableTransactionMessage<'a, 'info> { /// * `ephemeral_signer_seeds` - Seeds for the ephemeral signer PDAs. /// * `protected_accounts` - Accounts that must not be passed as writable to the CPI calls to prevent potential reentrancy attacks. pub fn execute_message( - &self, - vault_seeds: &[Vec], + self, + vault_seeds: &[&[u8]], ephemeral_signer_seeds: &[Vec>], protected_accounts: &[Pubkey], ) -> Result<()> { + // First round of type conversion; from Vec>> to Vec>. + let ephemeral_signer_seeds = &ephemeral_signer_seeds + .iter() + .map(|seeds| seeds.iter().map(Vec::as_slice).collect::>()) + .collect::>>(); + // Second round of type conversion; from Vec> to Vec<&[&[u8]]>. + let mut signer_seeds = ephemeral_signer_seeds + .iter() + .map(Vec::as_slice) + .collect::>(); + // Add the vault seeds. + signer_seeds.push(&vault_seeds); + + // NOTE: `self.to_instructions_and_accounts()` calls `take()` on + // `self.message.instructions`, therefore after this point no more + // references or usages of `self.message` should be made to avoid + // faulty behavior. for (ix, account_infos) in self.to_instructions_and_accounts().iter() { // Make sure we don't pass protected accounts as writable to CPI calls. for account_meta in ix.accounts.iter().filter(|m| m.is_writable) { @@ -191,27 +208,8 @@ impl<'a, 'info> ExecutableTransactionMessage<'a, 'info> { MultisigError::ProtectedAccount ); } - - // Convert vault_seeds to Vec<&[u8]>. - let vault_seeds = vault_seeds.iter().map(Vec::as_slice).collect::>(); - - // First round of type conversion; from Vec>> to Vec>. - let ephemeral_signer_seeds = &ephemeral_signer_seeds - .iter() - .map(|seeds| seeds.iter().map(Vec::as_slice).collect::>()) - .collect::>>(); - // Second round of type conversion; from Vec> to Vec<&[&[u8]]>. - let mut signer_seeds = ephemeral_signer_seeds - .iter() - .map(Vec::as_slice) - .collect::>(); - - // Add the vault seeds. - signer_seeds.push(&vault_seeds); - - invoke_signed(ix, account_infos, &signer_seeds)?; + invoke_signed(&ix, &account_infos, &signer_seeds)?; } - Ok(()) } @@ -254,10 +252,10 @@ impl<'a, 'info> ExecutableTransactionMessage<'a, 'info> { index < self.loaded_writable_accounts.len() } - pub fn to_instructions_and_accounts(&self) -> Vec<(Instruction, Vec>)> { + pub fn to_instructions_and_accounts(mut self) -> Vec<(Instruction, Vec>)> { let mut executable_instructions = vec![]; - for ms_compiled_instruction in self.message.instructions.iter() { + for ms_compiled_instruction in core::mem::take(&mut self.message.instructions) { let ix_accounts: Vec<(AccountInfo<'info>, AccountMeta)> = ms_compiled_instruction .account_indexes .iter() @@ -289,7 +287,7 @@ impl<'a, 'info> ExecutableTransactionMessage<'a, 'info> { .iter() .map(|(_, account_meta)| account_meta.clone()) .collect(), - data: ms_compiled_instruction.data.clone(), + data: ms_compiled_instruction.data, }; let mut account_infos: Vec = ix_accounts diff --git a/programs/squads_multisig_program/src/utils/system.rs b/programs/squads_multisig_program/src/utils/system.rs index 374d5552..f39054e4 100644 --- a/programs/squads_multisig_program/src/utils/system.rs +++ b/programs/squads_multisig_program/src/utils/system.rs @@ -99,3 +99,17 @@ pub fn create_account<'a, 'info>( ) } } + +/// Closes an account by transferring all lamports to the `sol_destination`. +/// +/// Lifted from private `anchor_lang::common::close`: https://github.com/coral-xyz/anchor/blob/714d5248636493a3d1db1481f16052836ee59e94/lang/src/common.rs#L6 +pub fn close<'info>(info: AccountInfo<'info>, sol_destination: AccountInfo<'info>) -> Result<()> { + // Transfer tokens from the account to the sol_destination. + let dest_starting_lamports = sol_destination.lamports(); + **sol_destination.lamports.borrow_mut() = + dest_starting_lamports.checked_add(info.lamports()).unwrap(); + **info.lamports.borrow_mut() = 0; + + info.assign(&system_program::ID); + info.realloc(0, false).map_err(Into::into) +} diff --git a/sdk/multisig/idl/squads_multisig_program.json b/sdk/multisig/idl/squads_multisig_program.json index e6e1e167..ae557d20 100644 --- a/sdk/multisig/idl/squads_multisig_program.json +++ b/sdk/multisig/idl/squads_multisig_program.json @@ -1,5 +1,5 @@ { - "version": "2.0.0", + "version": "2.1.0", "name": "squads_multisig_program", "instructions": [ { @@ -121,41 +121,12 @@ ], "accounts": [ { - "name": "multisig", - "isMut": true, - "isSigner": false - }, - { - "name": "createKey", - "isMut": false, - "isSigner": true, - "docs": [ - "An ephemeral signer that is used as a seed for the Multisig PDA.", - "Must be a signer to prevent front-running attack by someone else but the original creator." - ] - }, - { - "name": "creator", - "isMut": true, - "isSigner": true, - "docs": [ - "The creator of the multisig." - ] - }, - { - "name": "systemProgram", + "name": "null", "isMut": false, "isSigner": false } ], - "args": [ - { - "name": "args", - "type": { - "defined": "MultisigCreateArgs" - } - } - ] + "args": [] }, { "name": "multisigCreateV2", @@ -755,6 +726,177 @@ } ] }, + { + "name": "transactionBufferCreate", + "docs": [ + "Create a transaction buffer account." + ], + "accounts": [ + { + "name": "multisig", + "isMut": false, + "isSigner": false + }, + { + "name": "transactionBuffer", + "isMut": true, + "isSigner": false + }, + { + "name": "creator", + "isMut": false, + "isSigner": true, + "docs": [ + "The member of the multisig that is creating the transaction." + ] + }, + { + "name": "rentPayer", + "isMut": true, + "isSigner": true, + "docs": [ + "The payer for the transaction account rent." + ] + }, + { + "name": "systemProgram", + "isMut": false, + "isSigner": false + } + ], + "args": [ + { + "name": "args", + "type": { + "defined": "TransactionBufferCreateArgs" + } + } + ] + }, + { + "name": "transactionBufferClose", + "docs": [ + "Close a transaction buffer account." + ], + "accounts": [ + { + "name": "multisig", + "isMut": false, + "isSigner": false + }, + { + "name": "transactionBuffer", + "isMut": true, + "isSigner": false + }, + { + "name": "creator", + "isMut": false, + "isSigner": true, + "docs": [ + "The member of the multisig that created the TransactionBuffer." + ] + } + ], + "args": [] + }, + { + "name": "transactionBufferExtend", + "docs": [ + "Extend a transaction buffer account." + ], + "accounts": [ + { + "name": "multisig", + "isMut": false, + "isSigner": false + }, + { + "name": "transactionBuffer", + "isMut": true, + "isSigner": false + }, + { + "name": "creator", + "isMut": false, + "isSigner": true, + "docs": [ + "The member of the multisig that created the TransactionBuffer." + ] + } + ], + "args": [ + { + "name": "args", + "type": { + "defined": "TransactionBufferExtendArgs" + } + } + ] + }, + { + "name": "vaultTransactionCreateFromBuffer", + "docs": [ + "Create a new vault transaction from a completed transaction buffer.", + "Finalized buffer hash must match `final_buffer_hash`" + ], + "accounts": [ + { + "name": "vaultTransactionCreate", + "accounts": [ + { + "name": "multisig", + "isMut": true, + "isSigner": false + }, + { + "name": "transaction", + "isMut": true, + "isSigner": false + }, + { + "name": "creator", + "isMut": false, + "isSigner": true, + "docs": [ + "The member of the multisig that is creating the transaction." + ] + }, + { + "name": "rentPayer", + "isMut": true, + "isSigner": true, + "docs": [ + "The payer for the transaction account rent." + ] + }, + { + "name": "systemProgram", + "isMut": false, + "isSigner": false + } + ] + }, + { + "name": "transactionBuffer", + "isMut": true, + "isSigner": false + }, + { + "name": "creator", + "isMut": true, + "isSigner": true + } + ], + "args": [ + { + "name": "args", + "type": { + "defined": "VaultTransactionCreateArgs" + } + } + ] + }, { "name": "vaultTransactionExecute", "docs": [ @@ -1118,6 +1260,53 @@ } ] }, + { + "name": "proposalCancelV2", + "docs": [ + "Cancel a multisig proposal on behalf of the `member`.", + "The proposal must be `Approved`.", + "This was introduced to incorporate proper state update, as old multisig members", + "may have lingering votes, and the proposal size may need to be reallocated to", + "accommodate the new amount of cancel votes.", + "The previous implemenation still works if the proposal size is in line with the", + "threshold size." + ], + "accounts": [ + { + "name": "proposalVote", + "accounts": [ + { + "name": "multisig", + "isMut": false, + "isSigner": false + }, + { + "name": "member", + "isMut": true, + "isSigner": true + }, + { + "name": "proposal", + "isMut": true, + "isSigner": false + } + ] + }, + { + "name": "systemProgram", + "isMut": false, + "isSigner": false + } + ], + "args": [ + { + "name": "args", + "type": { + "defined": "ProposalVoteArgs" + } + } + ] + }, { "name": "spendingLimitUse", "docs": [ @@ -1233,7 +1422,10 @@ { "name": "proposal", "isMut": true, - "isSigner": false + "isSigner": false, + "docs": [ + "the logic within `config_transaction_accounts_close` does the rest of the checks." + ] }, { "name": "transaction", @@ -1276,7 +1468,10 @@ { "name": "proposal", "isMut": true, - "isSigner": false + "isSigner": false, + "docs": [ + "the logic within `vault_transaction_accounts_close` does the rest of the checks." + ] }, { "name": "transaction", @@ -1373,7 +1568,10 @@ { "name": "proposal", "isMut": true, - "isSigner": false + "isSigner": false, + "docs": [ + "the logic within `batch_accounts_close` does the rest of the checks." + ] }, { "name": "batch", @@ -1866,6 +2064,68 @@ ] } }, + { + "name": "TransactionBuffer", + "type": { + "kind": "struct", + "fields": [ + { + "name": "multisig", + "docs": [ + "The multisig this belongs to." + ], + "type": "publicKey" + }, + { + "name": "creator", + "docs": [ + "Member of the Multisig who created the TransactionBuffer." + ], + "type": "publicKey" + }, + { + "name": "bufferIndex", + "docs": [ + "Index to seed address derivation" + ], + "type": "u8" + }, + { + "name": "vaultIndex", + "docs": [ + "Vault index of the transaction this buffer belongs to." + ], + "type": "u8" + }, + { + "name": "finalBufferHash", + "docs": [ + "Hash of the final assembled transaction message." + ], + "type": { + "array": [ + "u8", + 32 + ] + } + }, + { + "name": "finalBufferSize", + "docs": [ + "The size of the final assembled transaction message." + ], + "type": "u16" + }, + { + "name": "buffer", + "docs": [ + "The buffer of the transaction message." + ], + "type": "bytes" + } + ] + } + }, { "name": "VaultTransaction", "docs": [ @@ -2055,9 +2315,8 @@ { "name": "members", "docs": [ - "Members of the multisig that can use the spending limit.", - "In case a member is removed from the multisig, the spending limit will remain existent", - "(until explicitly deleted), but the removed member will not be able to use it anymore." + "Members of the Spending Limit that can use it.", + "Don't have to be members of the multisig." ], "type": { "vec": "publicKey" @@ -2215,58 +2474,6 @@ ] } }, - { - "name": "MultisigCreateArgs", - "type": { - "kind": "struct", - "fields": [ - { - "name": "configAuthority", - "docs": [ - "The authority that can configure the multisig: add/remove members, change the threshold, etc.", - "Should be set to `None` for autonomous multisigs." - ], - "type": { - "option": "publicKey" - } - }, - { - "name": "threshold", - "docs": [ - "The number of signatures required to execute a transaction." - ], - "type": "u16" - }, - { - "name": "members", - "docs": [ - "The members of the multisig." - ], - "type": { - "vec": { - "defined": "Member" - } - } - }, - { - "name": "timeLock", - "docs": [ - "How many seconds must pass between transaction voting, settlement, and execution." - ], - "type": "u32" - }, - { - "name": "memo", - "docs": [ - "Memo is used for indexing only." - ], - "type": { - "option": "string" - } - } - ] - } - }, { "name": "MultisigCreateArgsV2", "type": { @@ -2478,6 +2685,66 @@ ] } }, + { + "name": "TransactionBufferCreateArgs", + "type": { + "kind": "struct", + "fields": [ + { + "name": "bufferIndex", + "docs": [ + "Index of the buffer account to seed the account derivation" + ], + "type": "u8" + }, + { + "name": "vaultIndex", + "docs": [ + "Index of the vault this transaction belongs to." + ], + "type": "u8" + }, + { + "name": "finalBufferHash", + "docs": [ + "Hash of the final assembled transaction message." + ], + "type": { + "array": [ + "u8", + 32 + ] + } + }, + { + "name": "finalBufferSize", + "docs": [ + "Final size of the buffer." + ], + "type": "u16" + }, + { + "name": "buffer", + "docs": [ + "Initial slice of the buffer." + ], + "type": "bytes" + } + ] + } + }, + { + "name": "TransactionBufferExtendArgs", + "type": { + "kind": "struct", + "fields": [ + { + "name": "buffer", + "type": "bytes" + } + ] + } + }, { "name": "VaultTransactionCreateArgs", "type": { @@ -3118,6 +3385,31 @@ "code": 6039, "name": "SpendingLimitInvalidAmount", "msg": "Invalid SpendingLimit amount" + }, + { + "code": 6040, + "name": "InvalidInstructionArgs", + "msg": "Invalid Instruction Arguments" + }, + { + "code": 6041, + "name": "FinalBufferHashMismatch", + "msg": "Final message buffer hash doesnt match the expected hash" + }, + { + "code": 6042, + "name": "FinalBufferSizeExceeded", + "msg": "Final buffer size cannot exceed 4000 bytes" + }, + { + "code": 6043, + "name": "FinalBufferSizeMismatch", + "msg": "Final buffer size mismatch" + }, + { + "code": 6044, + "name": "MultisigCreateDeprecated", + "msg": "multisig_create has been deprecated. Use multisig_create_v2 instead." } ], "metadata": { diff --git a/sdk/multisig/src/generated/accounts/TransactionBuffer.ts b/sdk/multisig/src/generated/accounts/TransactionBuffer.ts new file mode 100644 index 00000000..aa2e1f2b --- /dev/null +++ b/sdk/multisig/src/generated/accounts/TransactionBuffer.ts @@ -0,0 +1,201 @@ +/** + * This code was GENERATED using the solita package. + * Please DO NOT EDIT THIS FILE, instead rerun solita to update it or write a wrapper to add functionality. + * + * See: https://github.com/metaplex-foundation/solita + */ + +import * as web3 from '@solana/web3.js' +import * as beetSolana from '@metaplex-foundation/beet-solana' +import * as beet from '@metaplex-foundation/beet' + +/** + * Arguments used to create {@link TransactionBuffer} + * @category Accounts + * @category generated + */ +export type TransactionBufferArgs = { + multisig: web3.PublicKey + creator: web3.PublicKey + bufferIndex: number + vaultIndex: number + finalBufferHash: number[] /* size: 32 */ + finalBufferSize: number + buffer: Uint8Array +} + +export const transactionBufferDiscriminator = [ + 90, 36, 35, 219, 93, 225, 110, 96, +] +/** + * Holds the data for the {@link TransactionBuffer} Account and provides de/serialization + * functionality for that data + * + * @category Accounts + * @category generated + */ +export class TransactionBuffer implements TransactionBufferArgs { + private constructor( + readonly multisig: web3.PublicKey, + readonly creator: web3.PublicKey, + readonly bufferIndex: number, + readonly vaultIndex: number, + readonly finalBufferHash: number[] /* size: 32 */, + readonly finalBufferSize: number, + readonly buffer: Uint8Array + ) {} + + /** + * Creates a {@link TransactionBuffer} instance from the provided args. + */ + static fromArgs(args: TransactionBufferArgs) { + return new TransactionBuffer( + args.multisig, + args.creator, + args.bufferIndex, + args.vaultIndex, + args.finalBufferHash, + args.finalBufferSize, + args.buffer + ) + } + + /** + * Deserializes the {@link TransactionBuffer} from the data of the provided {@link web3.AccountInfo}. + * @returns a tuple of the account data and the offset up to which the buffer was read to obtain it. + */ + static fromAccountInfo( + accountInfo: web3.AccountInfo, + offset = 0 + ): [TransactionBuffer, number] { + return TransactionBuffer.deserialize(accountInfo.data, offset) + } + + /** + * Retrieves the account info from the provided address and deserializes + * the {@link TransactionBuffer} from its data. + * + * @throws Error if no account info is found at the address or if deserialization fails + */ + static async fromAccountAddress( + connection: web3.Connection, + address: web3.PublicKey, + commitmentOrConfig?: web3.Commitment | web3.GetAccountInfoConfig + ): Promise { + const accountInfo = await connection.getAccountInfo( + address, + commitmentOrConfig + ) + if (accountInfo == null) { + throw new Error(`Unable to find TransactionBuffer account at ${address}`) + } + return TransactionBuffer.fromAccountInfo(accountInfo, 0)[0] + } + + /** + * Provides a {@link web3.Connection.getProgramAccounts} config builder, + * to fetch accounts matching filters that can be specified via that builder. + * + * @param programId - the program that owns the accounts we are filtering + */ + static gpaBuilder( + programId: web3.PublicKey = new web3.PublicKey( + 'SQDS4ep65T869zMMBKyuUq6aD6EgTu8psMjkvj52pCf' + ) + ) { + return beetSolana.GpaBuilder.fromStruct(programId, transactionBufferBeet) + } + + /** + * Deserializes the {@link TransactionBuffer} from the provided data Buffer. + * @returns a tuple of the account data and the offset up to which the buffer was read to obtain it. + */ + static deserialize(buf: Buffer, offset = 0): [TransactionBuffer, number] { + return transactionBufferBeet.deserialize(buf, offset) + } + + /** + * Serializes the {@link TransactionBuffer} into a Buffer. + * @returns a tuple of the created Buffer and the offset up to which the buffer was written to store it. + */ + serialize(): [Buffer, number] { + return transactionBufferBeet.serialize({ + accountDiscriminator: transactionBufferDiscriminator, + ...this, + }) + } + + /** + * Returns the byteSize of a {@link Buffer} holding the serialized data of + * {@link TransactionBuffer} for the provided args. + * + * @param args need to be provided since the byte size for this account + * depends on them + */ + static byteSize(args: TransactionBufferArgs) { + const instance = TransactionBuffer.fromArgs(args) + return transactionBufferBeet.toFixedFromValue({ + accountDiscriminator: transactionBufferDiscriminator, + ...instance, + }).byteSize + } + + /** + * Fetches the minimum balance needed to exempt an account holding + * {@link TransactionBuffer} data from rent + * + * @param args need to be provided since the byte size for this account + * depends on them + * @param connection used to retrieve the rent exemption information + */ + static async getMinimumBalanceForRentExemption( + args: TransactionBufferArgs, + connection: web3.Connection, + commitment?: web3.Commitment + ): Promise { + return connection.getMinimumBalanceForRentExemption( + TransactionBuffer.byteSize(args), + commitment + ) + } + + /** + * Returns a readable version of {@link TransactionBuffer} properties + * and can be used to convert to JSON and/or logging + */ + pretty() { + return { + multisig: this.multisig.toBase58(), + creator: this.creator.toBase58(), + bufferIndex: this.bufferIndex, + vaultIndex: this.vaultIndex, + finalBufferHash: this.finalBufferHash, + finalBufferSize: this.finalBufferSize, + buffer: this.buffer, + } + } +} + +/** + * @category Accounts + * @category generated + */ +export const transactionBufferBeet = new beet.FixableBeetStruct< + TransactionBuffer, + TransactionBufferArgs & { + accountDiscriminator: number[] /* size: 8 */ + } +>( + [ + ['accountDiscriminator', beet.uniformFixedSizeArray(beet.u8, 8)], + ['multisig', beetSolana.publicKey], + ['creator', beetSolana.publicKey], + ['bufferIndex', beet.u8], + ['vaultIndex', beet.u8], + ['finalBufferHash', beet.uniformFixedSizeArray(beet.u8, 32)], + ['finalBufferSize', beet.u16], + ['buffer', beet.bytes], + ], + TransactionBuffer.fromArgs, + 'TransactionBuffer' +) diff --git a/sdk/multisig/src/generated/accounts/index.ts b/sdk/multisig/src/generated/accounts/index.ts index 10449a64..d5e0eab1 100644 --- a/sdk/multisig/src/generated/accounts/index.ts +++ b/sdk/multisig/src/generated/accounts/index.ts @@ -4,6 +4,7 @@ export * from './Multisig' export * from './ProgramConfig' export * from './Proposal' export * from './SpendingLimit' +export * from './TransactionBuffer' export * from './VaultBatchTransaction' export * from './VaultTransaction' @@ -14,6 +15,7 @@ import { Multisig } from './Multisig' import { ProgramConfig } from './ProgramConfig' import { Proposal } from './Proposal' import { SpendingLimit } from './SpendingLimit' +import { TransactionBuffer } from './TransactionBuffer' import { VaultTransaction } from './VaultTransaction' export const accountProviders = { @@ -24,5 +26,6 @@ export const accountProviders = { ProgramConfig, Proposal, SpendingLimit, + TransactionBuffer, VaultTransaction, } diff --git a/sdk/multisig/src/generated/errors/index.ts b/sdk/multisig/src/generated/errors/index.ts index c9050b8b..6678b936 100644 --- a/sdk/multisig/src/generated/errors/index.ts +++ b/sdk/multisig/src/generated/errors/index.ts @@ -921,6 +921,123 @@ createErrorFromNameLookup.set( () => new SpendingLimitInvalidAmountError() ) +/** + * InvalidInstructionArgs: 'Invalid Instruction Arguments' + * + * @category Errors + * @category generated + */ +export class InvalidInstructionArgsError extends Error { + readonly code: number = 0x1798 + readonly name: string = 'InvalidInstructionArgs' + constructor() { + super('Invalid Instruction Arguments') + if (typeof Error.captureStackTrace === 'function') { + Error.captureStackTrace(this, InvalidInstructionArgsError) + } + } +} + +createErrorFromCodeLookup.set(0x1798, () => new InvalidInstructionArgsError()) +createErrorFromNameLookup.set( + 'InvalidInstructionArgs', + () => new InvalidInstructionArgsError() +) + +/** + * FinalBufferHashMismatch: 'Final message buffer hash doesnt match the expected hash' + * + * @category Errors + * @category generated + */ +export class FinalBufferHashMismatchError extends Error { + readonly code: number = 0x1799 + readonly name: string = 'FinalBufferHashMismatch' + constructor() { + super('Final message buffer hash doesnt match the expected hash') + if (typeof Error.captureStackTrace === 'function') { + Error.captureStackTrace(this, FinalBufferHashMismatchError) + } + } +} + +createErrorFromCodeLookup.set(0x1799, () => new FinalBufferHashMismatchError()) +createErrorFromNameLookup.set( + 'FinalBufferHashMismatch', + () => new FinalBufferHashMismatchError() +) + +/** + * FinalBufferSizeExceeded: 'Final buffer size cannot exceed 4000 bytes' + * + * @category Errors + * @category generated + */ +export class FinalBufferSizeExceededError extends Error { + readonly code: number = 0x179a + readonly name: string = 'FinalBufferSizeExceeded' + constructor() { + super('Final buffer size cannot exceed 4000 bytes') + if (typeof Error.captureStackTrace === 'function') { + Error.captureStackTrace(this, FinalBufferSizeExceededError) + } + } +} + +createErrorFromCodeLookup.set(0x179a, () => new FinalBufferSizeExceededError()) +createErrorFromNameLookup.set( + 'FinalBufferSizeExceeded', + () => new FinalBufferSizeExceededError() +) + +/** + * FinalBufferSizeMismatch: 'Final buffer size mismatch' + * + * @category Errors + * @category generated + */ +export class FinalBufferSizeMismatchError extends Error { + readonly code: number = 0x179b + readonly name: string = 'FinalBufferSizeMismatch' + constructor() { + super('Final buffer size mismatch') + if (typeof Error.captureStackTrace === 'function') { + Error.captureStackTrace(this, FinalBufferSizeMismatchError) + } + } +} + +createErrorFromCodeLookup.set(0x179b, () => new FinalBufferSizeMismatchError()) +createErrorFromNameLookup.set( + 'FinalBufferSizeMismatch', + () => new FinalBufferSizeMismatchError() +) + +/** + * MultisigCreateDeprecated: 'multisig_create has been deprecated. Use multisig_create_v2 instead.' + * + * @category Errors + * @category generated + */ +export class MultisigCreateDeprecatedError extends Error { + readonly code: number = 0x179c + readonly name: string = 'MultisigCreateDeprecated' + constructor() { + super( + 'multisig_create has been deprecated. Use multisig_create_v2 instead.' + ) + if (typeof Error.captureStackTrace === 'function') { + Error.captureStackTrace(this, MultisigCreateDeprecatedError) + } + } +} + +createErrorFromCodeLookup.set(0x179c, () => new MultisigCreateDeprecatedError()) +createErrorFromNameLookup.set( + 'MultisigCreateDeprecated', + () => new MultisigCreateDeprecatedError() +) + /** * Attempts to resolve a custom program error from the provided error code. * @category Errors diff --git a/sdk/multisig/src/generated/instructions/index.ts b/sdk/multisig/src/generated/instructions/index.ts index 23c7f196..9f89535d 100644 --- a/sdk/multisig/src/generated/instructions/index.ts +++ b/sdk/multisig/src/generated/instructions/index.ts @@ -22,10 +22,15 @@ export * from './programConfigSetTreasury' export * from './proposalActivate' export * from './proposalApprove' export * from './proposalCancel' +export * from './proposalCancelV2' export * from './proposalCreate' export * from './proposalReject' export * from './spendingLimitUse' +export * from './transactionBufferClose' +export * from './transactionBufferCreate' +export * from './transactionBufferExtend' export * from './vaultBatchTransactionAccountClose' export * from './vaultTransactionAccountsClose' export * from './vaultTransactionCreate' +export * from './vaultTransactionCreateFromBuffer' export * from './vaultTransactionExecute' diff --git a/sdk/multisig/src/generated/instructions/multisigCreate.ts b/sdk/multisig/src/generated/instructions/multisigCreate.ts index 6391bf3c..3b0a7a04 100644 --- a/sdk/multisig/src/generated/instructions/multisigCreate.ts +++ b/sdk/multisig/src/generated/instructions/multisigCreate.ts @@ -7,50 +7,28 @@ import * as beet from '@metaplex-foundation/beet' import * as web3 from '@solana/web3.js' -import { - MultisigCreateArgs, - multisigCreateArgsBeet, -} from '../types/MultisigCreateArgs' /** * @category Instructions * @category MultisigCreate * @category generated */ -export type MultisigCreateInstructionArgs = { - args: MultisigCreateArgs -} -/** - * @category Instructions - * @category MultisigCreate - * @category generated - */ -export const multisigCreateStruct = new beet.FixableBeetArgsStruct< - MultisigCreateInstructionArgs & { - instructionDiscriminator: number[] /* size: 8 */ - } ->( - [ - ['instructionDiscriminator', beet.uniformFixedSizeArray(beet.u8, 8)], - ['args', multisigCreateArgsBeet], - ], +export const multisigCreateStruct = new beet.BeetArgsStruct<{ + instructionDiscriminator: number[] /* size: 8 */ +}>( + [['instructionDiscriminator', beet.uniformFixedSizeArray(beet.u8, 8)]], 'MultisigCreateInstructionArgs' ) /** * Accounts required by the _multisigCreate_ instruction * - * @property [_writable_] multisig - * @property [**signer**] createKey - * @property [_writable_, **signer**] creator + * @property [] null * @category Instructions * @category MultisigCreate * @category generated */ export type MultisigCreateInstructionAccounts = { - multisig: web3.PublicKey - createKey: web3.PublicKey - creator: web3.PublicKey - systemProgram?: web3.PublicKey + null: web3.PublicKey anchorRemainingAccounts?: web3.AccountMeta[] } @@ -62,39 +40,20 @@ export const multisigCreateInstructionDiscriminator = [ * Creates a _MultisigCreate_ instruction. * * @param accounts that will be accessed while the instruction is processed - * @param args to provide as instruction data to the program - * * @category Instructions * @category MultisigCreate * @category generated */ export function createMultisigCreateInstruction( accounts: MultisigCreateInstructionAccounts, - args: MultisigCreateInstructionArgs, programId = new web3.PublicKey('SQDS4ep65T869zMMBKyuUq6aD6EgTu8psMjkvj52pCf') ) { const [data] = multisigCreateStruct.serialize({ instructionDiscriminator: multisigCreateInstructionDiscriminator, - ...args, }) const keys: web3.AccountMeta[] = [ { - pubkey: accounts.multisig, - isWritable: true, - isSigner: false, - }, - { - pubkey: accounts.createKey, - isWritable: false, - isSigner: true, - }, - { - pubkey: accounts.creator, - isWritable: true, - isSigner: true, - }, - { - pubkey: accounts.systemProgram ?? web3.SystemProgram.programId, + pubkey: accounts.null, isWritable: false, isSigner: false, }, diff --git a/sdk/multisig/src/generated/instructions/proposalCancelV2.ts b/sdk/multisig/src/generated/instructions/proposalCancelV2.ts new file mode 100644 index 00000000..0be313c6 --- /dev/null +++ b/sdk/multisig/src/generated/instructions/proposalCancelV2.ts @@ -0,0 +1,115 @@ +/** + * This code was GENERATED using the solita package. + * Please DO NOT EDIT THIS FILE, instead rerun solita to update it or write a wrapper to add functionality. + * + * See: https://github.com/metaplex-foundation/solita + */ + +import * as beet from '@metaplex-foundation/beet' +import * as web3 from '@solana/web3.js' +import { + ProposalVoteArgs, + proposalVoteArgsBeet, +} from '../types/ProposalVoteArgs' + +/** + * @category Instructions + * @category ProposalCancelV2 + * @category generated + */ +export type ProposalCancelV2InstructionArgs = { + args: ProposalVoteArgs +} +/** + * @category Instructions + * @category ProposalCancelV2 + * @category generated + */ +export const proposalCancelV2Struct = new beet.FixableBeetArgsStruct< + ProposalCancelV2InstructionArgs & { + instructionDiscriminator: number[] /* size: 8 */ + } +>( + [ + ['instructionDiscriminator', beet.uniformFixedSizeArray(beet.u8, 8)], + ['args', proposalVoteArgsBeet], + ], + 'ProposalCancelV2InstructionArgs' +) +/** + * Accounts required by the _proposalCancelV2_ instruction + * + * @property [] proposalVoteItemMultisig + * @property [_writable_, **signer**] proposalVoteItemMember + * @property [_writable_] proposalVoteItemProposal + * @category Instructions + * @category ProposalCancelV2 + * @category generated + */ +export type ProposalCancelV2InstructionAccounts = { + proposalVoteItemMultisig: web3.PublicKey + proposalVoteItemMember: web3.PublicKey + proposalVoteItemProposal: web3.PublicKey + systemProgram?: web3.PublicKey + anchorRemainingAccounts?: web3.AccountMeta[] +} + +export const proposalCancelV2InstructionDiscriminator = [ + 205, 41, 194, 61, 220, 139, 16, 247, +] + +/** + * Creates a _ProposalCancelV2_ instruction. + * + * @param accounts that will be accessed while the instruction is processed + * @param args to provide as instruction data to the program + * + * @category Instructions + * @category ProposalCancelV2 + * @category generated + */ +export function createProposalCancelV2Instruction( + accounts: ProposalCancelV2InstructionAccounts, + args: ProposalCancelV2InstructionArgs, + programId = new web3.PublicKey('SQDS4ep65T869zMMBKyuUq6aD6EgTu8psMjkvj52pCf') +) { + const [data] = proposalCancelV2Struct.serialize({ + instructionDiscriminator: proposalCancelV2InstructionDiscriminator, + ...args, + }) + const keys: web3.AccountMeta[] = [ + { + pubkey: accounts.proposalVoteItemMultisig, + isWritable: false, + isSigner: false, + }, + { + pubkey: accounts.proposalVoteItemMember, + isWritable: true, + isSigner: true, + }, + { + pubkey: accounts.proposalVoteItemProposal, + isWritable: true, + isSigner: false, + }, + { + pubkey: accounts.systemProgram ?? web3.SystemProgram.programId, + isWritable: false, + isSigner: false, + }, + ] + + if (accounts.anchorRemainingAccounts != null) { + for (const acc of accounts.anchorRemainingAccounts) { + keys.push(acc) + } + } + + const ix = new web3.TransactionInstruction({ + programId, + keys, + data, + }) + return ix +} diff --git a/sdk/multisig/src/generated/instructions/transactionBufferClose.ts b/sdk/multisig/src/generated/instructions/transactionBufferClose.ts new file mode 100644 index 00000000..b8ea8e86 --- /dev/null +++ b/sdk/multisig/src/generated/instructions/transactionBufferClose.ts @@ -0,0 +1,88 @@ +/** + * This code was GENERATED using the solita package. + * Please DO NOT EDIT THIS FILE, instead rerun solita to update it or write a wrapper to add functionality. + * + * See: https://github.com/metaplex-foundation/solita + */ + +import * as beet from '@metaplex-foundation/beet' +import * as web3 from '@solana/web3.js' + +/** + * @category Instructions + * @category TransactionBufferClose + * @category generated + */ +export const transactionBufferCloseStruct = new beet.BeetArgsStruct<{ + instructionDiscriminator: number[] /* size: 8 */ +}>( + [['instructionDiscriminator', beet.uniformFixedSizeArray(beet.u8, 8)]], + 'TransactionBufferCloseInstructionArgs' +) +/** + * Accounts required by the _transactionBufferClose_ instruction + * + * @property [] multisig + * @property [_writable_] transactionBuffer + * @property [**signer**] creator + * @category Instructions + * @category TransactionBufferClose + * @category generated + */ +export type TransactionBufferCloseInstructionAccounts = { + multisig: web3.PublicKey + transactionBuffer: web3.PublicKey + creator: web3.PublicKey + anchorRemainingAccounts?: web3.AccountMeta[] +} + +export const transactionBufferCloseInstructionDiscriminator = [ + 17, 182, 208, 228, 136, 24, 178, 102, +] + +/** + * Creates a _TransactionBufferClose_ instruction. + * + * @param accounts that will be accessed while the instruction is processed + * @category Instructions + * @category TransactionBufferClose + * @category generated + */ +export function createTransactionBufferCloseInstruction( + accounts: TransactionBufferCloseInstructionAccounts, + programId = new web3.PublicKey('SQDS4ep65T869zMMBKyuUq6aD6EgTu8psMjkvj52pCf') +) { + const [data] = transactionBufferCloseStruct.serialize({ + instructionDiscriminator: transactionBufferCloseInstructionDiscriminator, + }) + const keys: web3.AccountMeta[] = [ + { + pubkey: accounts.multisig, + isWritable: false, + isSigner: false, + }, + { + pubkey: accounts.transactionBuffer, + isWritable: true, + isSigner: false, + }, + { + pubkey: accounts.creator, + isWritable: false, + isSigner: true, + }, + ] + + if (accounts.anchorRemainingAccounts != null) { + for (const acc of accounts.anchorRemainingAccounts) { + keys.push(acc) + } + } + + const ix = new web3.TransactionInstruction({ + programId, + keys, + data, + }) + return ix +} diff --git a/sdk/multisig/src/generated/instructions/transactionBufferCreate.ts b/sdk/multisig/src/generated/instructions/transactionBufferCreate.ts new file mode 100644 index 00000000..6887a94d --- /dev/null +++ b/sdk/multisig/src/generated/instructions/transactionBufferCreate.ts @@ -0,0 +1,122 @@ +/** + * This code was GENERATED using the solita package. + * Please DO NOT EDIT THIS FILE, instead rerun solita to update it or write a wrapper to add functionality. + * + * See: https://github.com/metaplex-foundation/solita + */ + +import * as beet from '@metaplex-foundation/beet' +import * as web3 from '@solana/web3.js' +import { + TransactionBufferCreateArgs, + transactionBufferCreateArgsBeet, +} from '../types/TransactionBufferCreateArgs' + +/** + * @category Instructions + * @category TransactionBufferCreate + * @category generated + */ +export type TransactionBufferCreateInstructionArgs = { + args: TransactionBufferCreateArgs +} +/** + * @category Instructions + * @category TransactionBufferCreate + * @category generated + */ +export const transactionBufferCreateStruct = new beet.FixableBeetArgsStruct< + TransactionBufferCreateInstructionArgs & { + instructionDiscriminator: number[] /* size: 8 */ + } +>( + [ + ['instructionDiscriminator', beet.uniformFixedSizeArray(beet.u8, 8)], + ['args', transactionBufferCreateArgsBeet], + ], + 'TransactionBufferCreateInstructionArgs' +) +/** + * Accounts required by the _transactionBufferCreate_ instruction + * + * @property [] multisig + * @property [_writable_] transactionBuffer + * @property [**signer**] creator + * @property [_writable_, **signer**] rentPayer + * @category Instructions + * @category TransactionBufferCreate + * @category generated + */ +export type TransactionBufferCreateInstructionAccounts = { + multisig: web3.PublicKey + transactionBuffer: web3.PublicKey + creator: web3.PublicKey + rentPayer: web3.PublicKey + systemProgram?: web3.PublicKey + anchorRemainingAccounts?: web3.AccountMeta[] +} + +export const transactionBufferCreateInstructionDiscriminator = [ + 245, 201, 113, 108, 37, 63, 29, 89, +] + +/** + * Creates a _TransactionBufferCreate_ instruction. + * + * @param accounts that will be accessed while the instruction is processed + * @param args to provide as instruction data to the program + * + * @category Instructions + * @category TransactionBufferCreate + * @category generated + */ +export function createTransactionBufferCreateInstruction( + accounts: TransactionBufferCreateInstructionAccounts, + args: TransactionBufferCreateInstructionArgs, + programId = new web3.PublicKey('SQDS4ep65T869zMMBKyuUq6aD6EgTu8psMjkvj52pCf') +) { + const [data] = transactionBufferCreateStruct.serialize({ + instructionDiscriminator: transactionBufferCreateInstructionDiscriminator, + ...args, + }) + const keys: web3.AccountMeta[] = [ + { + pubkey: accounts.multisig, + isWritable: false, + isSigner: false, + }, + { + pubkey: accounts.transactionBuffer, + isWritable: true, + isSigner: false, + }, + { + pubkey: accounts.creator, + isWritable: false, + isSigner: true, + }, + { + pubkey: accounts.rentPayer, + isWritable: true, + isSigner: true, + }, + { + pubkey: accounts.systemProgram ?? web3.SystemProgram.programId, + isWritable: false, + isSigner: false, + }, + ] + + if (accounts.anchorRemainingAccounts != null) { + for (const acc of accounts.anchorRemainingAccounts) { + keys.push(acc) + } + } + + const ix = new web3.TransactionInstruction({ + programId, + keys, + data, + }) + return ix +} diff --git a/sdk/multisig/src/generated/instructions/transactionBufferExtend.ts b/sdk/multisig/src/generated/instructions/transactionBufferExtend.ts new file mode 100644 index 00000000..50d2a126 --- /dev/null +++ b/sdk/multisig/src/generated/instructions/transactionBufferExtend.ts @@ -0,0 +1,109 @@ +/** + * This code was GENERATED using the solita package. + * Please DO NOT EDIT THIS FILE, instead rerun solita to update it or write a wrapper to add functionality. + * + * See: https://github.com/metaplex-foundation/solita + */ + +import * as beet from '@metaplex-foundation/beet' +import * as web3 from '@solana/web3.js' +import { + TransactionBufferExtendArgs, + transactionBufferExtendArgsBeet, +} from '../types/TransactionBufferExtendArgs' + +/** + * @category Instructions + * @category TransactionBufferExtend + * @category generated + */ +export type TransactionBufferExtendInstructionArgs = { + args: TransactionBufferExtendArgs +} +/** + * @category Instructions + * @category TransactionBufferExtend + * @category generated + */ +export const transactionBufferExtendStruct = new beet.FixableBeetArgsStruct< + TransactionBufferExtendInstructionArgs & { + instructionDiscriminator: number[] /* size: 8 */ + } +>( + [ + ['instructionDiscriminator', beet.uniformFixedSizeArray(beet.u8, 8)], + ['args', transactionBufferExtendArgsBeet], + ], + 'TransactionBufferExtendInstructionArgs' +) +/** + * Accounts required by the _transactionBufferExtend_ instruction + * + * @property [] multisig + * @property [_writable_] transactionBuffer + * @property [**signer**] creator + * @category Instructions + * @category TransactionBufferExtend + * @category generated + */ +export type TransactionBufferExtendInstructionAccounts = { + multisig: web3.PublicKey + transactionBuffer: web3.PublicKey + creator: web3.PublicKey + anchorRemainingAccounts?: web3.AccountMeta[] +} + +export const transactionBufferExtendInstructionDiscriminator = [ + 230, 157, 67, 56, 5, 238, 245, 146, +] + +/** + * Creates a _TransactionBufferExtend_ instruction. + * + * @param accounts that will be accessed while the instruction is processed + * @param args to provide as instruction data to the program + * + * @category Instructions + * @category TransactionBufferExtend + * @category generated + */ +export function createTransactionBufferExtendInstruction( + accounts: TransactionBufferExtendInstructionAccounts, + args: TransactionBufferExtendInstructionArgs, + programId = new web3.PublicKey('SQDS4ep65T869zMMBKyuUq6aD6EgTu8psMjkvj52pCf') +) { + const [data] = transactionBufferExtendStruct.serialize({ + instructionDiscriminator: transactionBufferExtendInstructionDiscriminator, + ...args, + }) + const keys: web3.AccountMeta[] = [ + { + pubkey: accounts.multisig, + isWritable: false, + isSigner: false, + }, + { + pubkey: accounts.transactionBuffer, + isWritable: true, + isSigner: false, + }, + { + pubkey: accounts.creator, + isWritable: false, + isSigner: true, + }, + ] + + if (accounts.anchorRemainingAccounts != null) { + for (const acc of accounts.anchorRemainingAccounts) { + keys.push(acc) + } + } + + const ix = new web3.TransactionInstruction({ + programId, + keys, + data, + }) + return ix +} diff --git a/sdk/multisig/src/generated/instructions/vaultTransactionCreateFromBuffer.ts b/sdk/multisig/src/generated/instructions/vaultTransactionCreateFromBuffer.ts new file mode 100644 index 00000000..74864079 --- /dev/null +++ b/sdk/multisig/src/generated/instructions/vaultTransactionCreateFromBuffer.ts @@ -0,0 +1,139 @@ +/** + * This code was GENERATED using the solita package. + * Please DO NOT EDIT THIS FILE, instead rerun solita to update it or write a wrapper to add functionality. + * + * See: https://github.com/metaplex-foundation/solita + */ + +import * as beet from '@metaplex-foundation/beet' +import * as web3 from '@solana/web3.js' +import { + VaultTransactionCreateArgs, + vaultTransactionCreateArgsBeet, +} from '../types/VaultTransactionCreateArgs' + +/** + * @category Instructions + * @category VaultTransactionCreateFromBuffer + * @category generated + */ +export type VaultTransactionCreateFromBufferInstructionArgs = { + args: VaultTransactionCreateArgs +} +/** + * @category Instructions + * @category VaultTransactionCreateFromBuffer + * @category generated + */ +export const vaultTransactionCreateFromBufferStruct = + new beet.FixableBeetArgsStruct< + VaultTransactionCreateFromBufferInstructionArgs & { + instructionDiscriminator: number[] /* size: 8 */ + } + >( + [ + ['instructionDiscriminator', beet.uniformFixedSizeArray(beet.u8, 8)], + ['args', vaultTransactionCreateArgsBeet], + ], + 'VaultTransactionCreateFromBufferInstructionArgs' + ) +/** + * Accounts required by the _vaultTransactionCreateFromBuffer_ instruction + * + * @property [_writable_] vaultTransactionCreateItemMultisig + * @property [_writable_] vaultTransactionCreateItemTransaction + * @property [**signer**] vaultTransactionCreateItemCreator + * @property [_writable_, **signer**] vaultTransactionCreateItemRentPayer + * @property [] vaultTransactionCreateItemSystemProgram + * @property [_writable_] transactionBuffer + * @property [_writable_, **signer**] creator + * @category Instructions + * @category VaultTransactionCreateFromBuffer + * @category generated + */ +export type VaultTransactionCreateFromBufferInstructionAccounts = { + vaultTransactionCreateItemMultisig: web3.PublicKey + vaultTransactionCreateItemTransaction: web3.PublicKey + vaultTransactionCreateItemCreator: web3.PublicKey + vaultTransactionCreateItemRentPayer: web3.PublicKey + vaultTransactionCreateItemSystemProgram: web3.PublicKey + transactionBuffer: web3.PublicKey + creator: web3.PublicKey + anchorRemainingAccounts?: web3.AccountMeta[] +} + +export const vaultTransactionCreateFromBufferInstructionDiscriminator = [ + 222, 54, 149, 68, 87, 246, 48, 231, +] + +/** + * Creates a _VaultTransactionCreateFromBuffer_ instruction. + * + * @param accounts that will be accessed while the instruction is processed + * @param args to provide as instruction data to the program + * + * @category Instructions + * @category VaultTransactionCreateFromBuffer + * @category generated + */ +export function createVaultTransactionCreateFromBufferInstruction( + accounts: VaultTransactionCreateFromBufferInstructionAccounts, + args: VaultTransactionCreateFromBufferInstructionArgs, + programId = new web3.PublicKey('SQDS4ep65T869zMMBKyuUq6aD6EgTu8psMjkvj52pCf') +) { + const [data] = vaultTransactionCreateFromBufferStruct.serialize({ + instructionDiscriminator: + vaultTransactionCreateFromBufferInstructionDiscriminator, + ...args, + }) + const keys: web3.AccountMeta[] = [ + { + pubkey: accounts.vaultTransactionCreateItemMultisig, + isWritable: true, + isSigner: false, + }, + { + pubkey: accounts.vaultTransactionCreateItemTransaction, + isWritable: true, + isSigner: false, + }, + { + pubkey: accounts.vaultTransactionCreateItemCreator, + isWritable: false, + isSigner: true, + }, + { + pubkey: accounts.vaultTransactionCreateItemRentPayer, + isWritable: true, + isSigner: true, + }, + { + pubkey: accounts.vaultTransactionCreateItemSystemProgram, + isWritable: false, + isSigner: false, + }, + { + pubkey: accounts.transactionBuffer, + isWritable: true, + isSigner: false, + }, + { + pubkey: accounts.creator, + isWritable: true, + isSigner: true, + }, + ] + + if (accounts.anchorRemainingAccounts != null) { + for (const acc of accounts.anchorRemainingAccounts) { + keys.push(acc) + } + } + + const ix = new web3.TransactionInstruction({ + programId, + keys, + data, + }) + return ix +} diff --git a/sdk/multisig/src/generated/types/MultisigCreateArgs.ts b/sdk/multisig/src/generated/types/MultisigCreateArgs.ts deleted file mode 100644 index 0cb11d72..00000000 --- a/sdk/multisig/src/generated/types/MultisigCreateArgs.ts +++ /dev/null @@ -1,34 +0,0 @@ -/** - * This code was GENERATED using the solita package. - * Please DO NOT EDIT THIS FILE, instead rerun solita to update it or write a wrapper to add functionality. - * - * See: https://github.com/metaplex-foundation/solita - */ - -import * as web3 from '@solana/web3.js' -import * as beet from '@metaplex-foundation/beet' -import * as beetSolana from '@metaplex-foundation/beet-solana' -import { Member, memberBeet } from './Member' -export type MultisigCreateArgs = { - configAuthority: beet.COption - threshold: number - members: Member[] - timeLock: number - memo: beet.COption -} - -/** - * @category userTypes - * @category generated - */ -export const multisigCreateArgsBeet = - new beet.FixableBeetArgsStruct( - [ - ['configAuthority', beet.coption(beetSolana.publicKey)], - ['threshold', beet.u16], - ['members', beet.array(memberBeet)], - ['timeLock', beet.u32], - ['memo', beet.coption(beet.utf8String)], - ], - 'MultisigCreateArgs' - ) diff --git a/sdk/multisig/src/generated/types/TransactionBufferCreateArgs.ts b/sdk/multisig/src/generated/types/TransactionBufferCreateArgs.ts new file mode 100644 index 00000000..7c34fd6c --- /dev/null +++ b/sdk/multisig/src/generated/types/TransactionBufferCreateArgs.ts @@ -0,0 +1,31 @@ +/** + * This code was GENERATED using the solita package. + * Please DO NOT EDIT THIS FILE, instead rerun solita to update it or write a wrapper to add functionality. + * + * See: https://github.com/metaplex-foundation/solita + */ + +import * as beet from '@metaplex-foundation/beet' +export type TransactionBufferCreateArgs = { + bufferIndex: number + vaultIndex: number + finalBufferHash: number[] /* size: 32 */ + finalBufferSize: number + buffer: Uint8Array +} + +/** + * @category userTypes + * @category generated + */ +export const transactionBufferCreateArgsBeet = + new beet.FixableBeetArgsStruct( + [ + ['bufferIndex', beet.u8], + ['vaultIndex', beet.u8], + ['finalBufferHash', beet.uniformFixedSizeArray(beet.u8, 32)], + ['finalBufferSize', beet.u16], + ['buffer', beet.bytes], + ], + 'TransactionBufferCreateArgs' + ) diff --git a/sdk/multisig/src/generated/types/TransactionBufferExtendArgs.ts b/sdk/multisig/src/generated/types/TransactionBufferExtendArgs.ts new file mode 100644 index 00000000..404076e5 --- /dev/null +++ b/sdk/multisig/src/generated/types/TransactionBufferExtendArgs.ts @@ -0,0 +1,21 @@ +/** + * This code was GENERATED using the solita package. + * Please DO NOT EDIT THIS FILE, instead rerun solita to update it or write a wrapper to add functionality. + * + * See: https://github.com/metaplex-foundation/solita + */ + +import * as beet from '@metaplex-foundation/beet' +export type TransactionBufferExtendArgs = { + buffer: Uint8Array +} + +/** + * @category userTypes + * @category generated + */ +export const transactionBufferExtendArgsBeet = + new beet.FixableBeetArgsStruct( + [['buffer', beet.bytes]], + 'TransactionBufferExtendArgs' + ) diff --git a/sdk/multisig/src/generated/types/index.ts b/sdk/multisig/src/generated/types/index.ts index c90dc772..896e0cea 100644 --- a/sdk/multisig/src/generated/types/index.ts +++ b/sdk/multisig/src/generated/types/index.ts @@ -7,7 +7,6 @@ export * from './MultisigAddMemberArgs' export * from './MultisigAddSpendingLimitArgs' export * from './MultisigChangeThresholdArgs' export * from './MultisigCompiledInstruction' -export * from './MultisigCreateArgs' export * from './MultisigCreateArgsV2' export * from './MultisigMessageAddressTableLookup' export * from './MultisigRemoveMemberArgs' @@ -25,6 +24,8 @@ export * from './ProposalCreateArgs' export * from './ProposalStatus' export * from './ProposalVoteArgs' export * from './SpendingLimitUseArgs' +export * from './TransactionBufferCreateArgs' +export * from './TransactionBufferExtendArgs' export * from './VaultTransactionCreateArgs' export * from './VaultTransactionMessage' export * from './Vote' diff --git a/sdk/multisig/src/instructions/index.ts b/sdk/multisig/src/instructions/index.ts index d3f7d0a1..20bacba7 100644 --- a/sdk/multisig/src/instructions/index.ts +++ b/sdk/multisig/src/instructions/index.ts @@ -18,6 +18,7 @@ export * from "./multisigSetTimeLock.js"; export * from "./proposalActivate.js"; export * from "./proposalApprove.js"; export * from "./proposalCancel.js"; +export * from "./proposalCancelV2.js"; export * from "./proposalCreate.js"; export * from "./proposalReject.js"; export * from "./spendingLimitUse.js"; diff --git a/sdk/multisig/src/instructions/multisigCreate.ts b/sdk/multisig/src/instructions/multisigCreate.ts index c7b8bd22..92b03c50 100644 --- a/sdk/multisig/src/instructions/multisigCreate.ts +++ b/sdk/multisig/src/instructions/multisigCreate.ts @@ -29,18 +29,29 @@ export function multisigCreate({ }): TransactionInstruction { return createMultisigCreateInstruction( { - creator, - createKey, - multisig: multisigPda, - }, - { - args: { - configAuthority, - threshold, - members, - timeLock, - memo: memo ?? null, - }, + null: PublicKey.default, + anchorRemainingAccounts: [ + { + pubkey: creator, + isWritable: true, + isSigner: true, + }, + { + pubkey: createKey, + isWritable: false, + isSigner: true, + }, + { + pubkey: multisigPda, + isWritable: true, + isSigner: false, + }, + { + pubkey: createKey, + isWritable: false, + isSigner: true, + } + ] }, programId ); diff --git a/sdk/multisig/src/instructions/proposalCancelV2.ts b/sdk/multisig/src/instructions/proposalCancelV2.ts new file mode 100644 index 00000000..205f3a22 --- /dev/null +++ b/sdk/multisig/src/instructions/proposalCancelV2.ts @@ -0,0 +1,29 @@ +import { PublicKey } from "@solana/web3.js"; +import { createProposalCancelV2Instruction, PROGRAM_ID } from "../generated"; +import { getProposalPda } from "../pda"; + +export function proposalCancelV2({ + multisigPda, + transactionIndex, + member, + memo, + programId = PROGRAM_ID, +}: { + multisigPda: PublicKey; + transactionIndex: bigint; + member: PublicKey; + memo?: string; + programId?: PublicKey; +}) { + const [proposalPda] = getProposalPda({ + multisigPda, + transactionIndex, + programId, + }); + + return createProposalCancelV2Instruction( + { proposalVoteItemMultisig: multisigPda, proposalVoteItemProposal: proposalPda, proposalVoteItemMember: member }, + { args: { memo: memo ?? null } }, + programId + ); +} diff --git a/sdk/multisig/src/rpc/index.ts b/sdk/multisig/src/rpc/index.ts index a1537b96..2eed0925 100644 --- a/sdk/multisig/src/rpc/index.ts +++ b/sdk/multisig/src/rpc/index.ts @@ -17,6 +17,7 @@ export * from "./multisigSetTimeLock.js"; export * from "./proposalActivate.js"; export * from "./proposalApprove.js"; export * from "./proposalCancel.js"; +export * from "./proposalCancelV2.js"; export * from "./proposalCreate.js"; export * from "./proposalReject.js"; export * from "./spendingLimitUse.js"; diff --git a/sdk/multisig/src/rpc/proposalCancelV2.ts b/sdk/multisig/src/rpc/proposalCancelV2.ts new file mode 100644 index 00000000..2c0faea0 --- /dev/null +++ b/sdk/multisig/src/rpc/proposalCancelV2.ts @@ -0,0 +1,51 @@ +import { + Connection, + PublicKey, + SendOptions, + Signer, + TransactionSignature, + } from "@solana/web3.js"; + import * as transactions from "../transactions"; + import { translateAndThrowAnchorError } from "../errors"; + + /** Cancel a config transaction on behalf of the `member`. */ + export async function proposalCancelV2({ + connection, + feePayer, + member, + multisigPda, + transactionIndex, + memo, + sendOptions, + programId, + }: { + connection: Connection; + feePayer: Signer; + member: Signer; + multisigPda: PublicKey; + transactionIndex: bigint; + memo?: string; + sendOptions?: SendOptions; + programId?: PublicKey; + }): Promise { + const blockhash = (await connection.getLatestBlockhash()).blockhash; + + const tx = transactions.proposalCancelV2({ + blockhash, + feePayer: feePayer.publicKey, + multisigPda, + transactionIndex, + member: member.publicKey, + memo, + programId, + }); + + tx.sign([feePayer, member]); + + try { + return await connection.sendTransaction(tx, sendOptions); + } catch (err) { + translateAndThrowAnchorError(err); + } + } + \ No newline at end of file diff --git a/sdk/multisig/src/transactions/index.ts b/sdk/multisig/src/transactions/index.ts index 8beba2c6..437e9d12 100644 --- a/sdk/multisig/src/transactions/index.ts +++ b/sdk/multisig/src/transactions/index.ts @@ -18,6 +18,7 @@ export * from "./multisigSetTimeLock.js"; export * from "./proposalActivate.js"; export * from "./proposalApprove.js"; export * from "./proposalCancel.js"; +export * from "./proposalCancelV2.js"; export * from "./proposalCreate.js"; export * from "./proposalReject.js"; export * from "./spendingLimitUse.js"; diff --git a/sdk/multisig/src/transactions/proposalCancelV2.ts b/sdk/multisig/src/transactions/proposalCancelV2.ts new file mode 100644 index 00000000..4afbca17 --- /dev/null +++ b/sdk/multisig/src/transactions/proposalCancelV2.ts @@ -0,0 +1,46 @@ +import { + PublicKey, + TransactionMessage, + VersionedTransaction, + } from "@solana/web3.js"; + + import * as instructions from "../instructions/index.js"; + + /** + * Returns unsigned `VersionedTransaction` that needs to be + * signed by `member` and `feePayer` before sending it. + */ + export function proposalCancelV2({ + blockhash, + feePayer, + multisigPda, + transactionIndex, + member, + memo, + programId, + }: { + blockhash: string; + feePayer: PublicKey; + multisigPda: PublicKey; + transactionIndex: bigint; + member: PublicKey; + memo?: string; + programId?: PublicKey; + }): VersionedTransaction { + const message = new TransactionMessage({ + payerKey: feePayer, + recentBlockhash: blockhash, + instructions: [ + instructions.proposalCancelV2({ + member, + multisigPda, + transactionIndex, + memo, + programId, + }), + ], + }).compileToV0Message(); + + return new VersionedTransaction(message); + } + \ No newline at end of file diff --git a/tests/index.ts b/tests/index.ts index af1b1757..5ea50433 100644 --- a/tests/index.ts +++ b/tests/index.ts @@ -1,16 +1,27 @@ // The order of imports is the order the test suite will run in. -import "./suites/program-config-init"; +import "./suites/program-config-init" +import "./suites/account-migrations"; +import "./suites/examples/batch-sol-transfer"; +import "./suites/examples/create-mint"; +import "./suites/examples/immediate-execution"; +import "./suites/examples/spending-limits"; +import "./suites/examples/transaction-buffer"; +import "./suites/instructions/batchAccountsClose"; +import "./suites/instructions/cancelRealloc"; +import "./suites/instructions/configTransactionAccountsClose"; +import "./suites/instructions/configTransactionExecute"; import "./suites/instructions/multisigCreate"; import "./suites/instructions/multisigCreateV2"; import "./suites/instructions/multisigSetRentCollector"; -import "./suites/instructions/configTransactionExecute"; -import "./suites/instructions/configTransactionAccountsClose"; +import "./suites/instructions/transactionBufferClose"; +import "./suites/instructions/transactionBufferCreate"; +import "./suites/instructions/transactionBufferExtend"; import "./suites/instructions/vaultBatchTransactionAccountClose"; -import "./suites/instructions/batchAccountsClose"; import "./suites/instructions/vaultTransactionAccountsClose"; +import "./suites/instructions/vaultTransactionCreateFromBuffer"; import "./suites/multisig-sdk"; -import "./suites/account-migrations"; -import "./suites/examples/batch-sol-transfer"; -import "./suites/examples/create-mint"; -import "./suites/examples/immediate-execution"; -import "./suites/examples/spending-limits"; + +// // Uncomment to enable the heapTest instruction testing +// //import "./suites/instructions/heapTest"; +// import "./suites/examples/custom-heap"; + diff --git a/tests/suites/examples/custom-heap.ts b/tests/suites/examples/custom-heap.ts new file mode 100644 index 00000000..cfe99ad1 --- /dev/null +++ b/tests/suites/examples/custom-heap.ts @@ -0,0 +1,332 @@ +import { + Keypair, + LAMPORTS_PER_SOL, + PublicKey, + SystemProgram, + TransactionMessage, + VersionedTransaction, + Transaction, + ComputeBudgetProgram, +} from "@solana/web3.js"; +import * as multisig from "@sqds/multisig"; +import { + TransactionBufferCreateArgs, + TransactionBufferCreateInstructionArgs, + VaultTransactionCreateArgs, + VaultTransactionCreateFromBufferInstructionArgs, +} from "@sqds/multisig/lib/generated"; +import assert from "assert"; +import { BN } from "bn.js"; +import * as crypto from "crypto"; +import { + TestMembers, + createAutonomousMultisigV2, + createLocalhostConnection, + createTestTransferInstruction, + generateMultisigMembers, + getTestProgramId, + processBufferInChunks, +} from "../../utils"; + +const programId = getTestProgramId(); +const connection = createLocalhostConnection(); + +describe("Examples / Custom Heap Usage", () => { + let members: TestMembers; + + const createKey = Keypair.generate(); + + let multisigPda = multisig.getMultisigPda({ + createKey: createKey.publicKey, + programId, + })[0]; + + const [vaultPda] = multisig.getVaultPda({ + multisigPda, + index: 0, + programId, + }); + + // Set up a multisig with some transactions. + before(async () => { + members = await generateMultisigMembers(connection); + + // Create new autonomous multisig with rentCollector set to its default vault. + await createAutonomousMultisigV2({ + connection, + createKey, + members, + threshold: 1, + timeLock: 0, + rentCollector: vaultPda, + programId, + }); + + // Airdrop some SOL to the vault + let signature = await connection.requestAirdrop( + vaultPda, + 10 * LAMPORTS_PER_SOL + ); + await connection.confirmTransaction(signature); + }); + + // We expect this to succeed when requesting extra heap. + it("execute large vault transaction (custom heap)", async () => { + const transactionIndex = 1n; + + const testIx = await createTestTransferInstruction(vaultPda, vaultPda, 1); + + let instructions = []; + + // Add 64 transfer instructions to the message. + for (let i = 0; i <= 59; i++) { + instructions.push(testIx); + } + + const testTransferMessage = new TransactionMessage({ + payerKey: vaultPda, + recentBlockhash: (await connection.getLatestBlockhash()).blockhash, + instructions: instructions, + }); + + //region Create & Upload Buffer + // Serialize the message. Must be done with this util function + const messageBuffer = + multisig.utils.transactionMessageToMultisigTransactionMessageBytes({ + message: testTransferMessage, + addressLookupTableAccounts: [], + vaultPda, + }); + + const [transactionBuffer, _] = PublicKey.findProgramAddressSync( + [ + Buffer.from("multisig"), + multisigPda.toBuffer(), + Buffer.from("transaction_buffer"), + new BN(Number(transactionIndex)).toBuffer("le", 8), + ], + programId + ); + + const messageHash = crypto + .createHash("sha256") + .update(messageBuffer) + .digest(); + + // Slice the message buffer into two parts. + const firstSlice = messageBuffer.slice(0, 700); + const bufferLength = messageBuffer.length; + + const ix = multisig.generated.createTransactionBufferCreateInstruction( + { + multisig: multisigPda, + transactionBuffer, + creator: members.proposer.publicKey, + rentPayer: members.proposer.publicKey, + systemProgram: SystemProgram.programId, + }, + { + args: { + vaultIndex: 0, + // Must be a SHA256 hash of the message buffer. + finalBufferHash: Array.from(messageHash), + finalBufferSize: bufferLength, + buffer: firstSlice, + } as TransactionBufferCreateArgs, + } as TransactionBufferCreateInstructionArgs, + programId + ); + + const message = new TransactionMessage({ + payerKey: members.proposer.publicKey, + recentBlockhash: (await connection.getLatestBlockhash()).blockhash, + instructions: [ix], + }).compileToV0Message(); + + const tx = new VersionedTransaction(message); + tx.sign([members.proposer]); + + // Send first transaction. + const signature1 = await connection.sendRawTransaction(tx.serialize(), { + skipPreflight: true, + }); + await connection.confirmTransaction(signature1); + + const transactionBufferAccount = await connection.getAccountInfo( + transactionBuffer + ); + + const [txBufferDeser1] = + multisig.generated.TransactionBuffer.fromAccountInfo( + transactionBufferAccount! + ); + + // Check buffer account exists. + assert.notEqual(transactionBufferAccount, null); + assert.ok(transactionBufferAccount?.data.length! > 0); + + // First chunk uploaded. Check that length is as expected. + assert.equal(txBufferDeser1.buffer.length, 700); + + // Process the buffer in <=700 byte chunks. + await processBufferInChunks( + members.proposer as Keypair, + multisigPda, + transactionBuffer, + messageBuffer, + connection, + programId, + 700, + 700 + ); + + // Get account info and deserialize to run checks. + const transactionBufferInfo2 = await connection.getAccountInfo( + transactionBuffer + ); + const [txBufferDeser2] = + multisig.generated.TransactionBuffer.fromAccountInfo( + transactionBufferInfo2! + ); + + // Final chunk uploaded. Check that length is as expected. + assert.equal(txBufferDeser2.buffer.length, messageBuffer.byteLength); + + // Derive vault transaction PDA. + const [transactionPda] = multisig.getTransactionPda({ + multisigPda, + index: transactionIndex, + programId, + }); + //endregion + + //region Create Transaction From Buffer + // Create final instruction. + const thirdIx = + multisig.generated.createVaultTransactionCreateFromBufferInstruction( + { + vaultTransactionCreateItemMultisig: multisigPda, + transactionBuffer, + vaultTransactionCreateItemTransaction: transactionPda, + vaultTransactionCreateItemCreator: members.proposer.publicKey, + creator: members.proposer.publicKey, + vaultTransactionCreateItemRentPayer: members.proposer.publicKey, + vaultTransactionCreateItemSystemProgram: SystemProgram.programId, + }, + { + args: { + ephemeralSigners: 0, + memo: null, + } as VaultTransactionCreateArgs, + } as VaultTransactionCreateFromBufferInstructionArgs, + programId + ); + + // Request heap memory + const prelimHeap = ComputeBudgetProgram.requestHeapFrame({ + bytes: 8 * 32 * 1024, + }); + + const prelimHeapCU = ComputeBudgetProgram.setComputeUnitLimit({ + units: 1_400_000, + }); + + const bufferConvertMessage = new TransactionMessage({ + payerKey: members.proposer.publicKey, + recentBlockhash: (await connection.getLatestBlockhash()).blockhash, + instructions: [prelimHeap, prelimHeapCU, thirdIx], + }).compileToV0Message(); + + const bufferConvertTx = new VersionedTransaction(bufferConvertMessage); + + bufferConvertTx.sign([members.proposer]); + + // Send buffer conversion transaction. + const signature3 = await connection.sendRawTransaction( + bufferConvertTx.serialize(), + { + skipPreflight: true, + } + ); + await connection.confirmTransaction(signature3); + + const transactionInfo = + await multisig.accounts.VaultTransaction.fromAccountAddress( + connection, + transactionPda + ); + + // Ensure final vault transaction has 60 instructions + assert.equal(transactionInfo.message.instructions.length, 60); + //endregion + + //region Create, Vote, and Execute + // Create a proposal for the newly uploaded transaction. + const signature4 = await multisig.rpc.proposalCreate({ + connection, + feePayer: members.almighty, + multisigPda, + transactionIndex: 1n, + creator: members.almighty, + isDraft: false, + programId, + }); + await connection.confirmTransaction(signature4); + + // Approve the proposal. + const signature5 = await multisig.rpc.proposalApprove({ + connection, + feePayer: members.almighty, + multisigPda, + transactionIndex: 1n, + member: members.almighty, + programId, + }); + await connection.confirmTransaction(signature5); + + // Execute the transaction. + const executeIx = await multisig.instructions.vaultTransactionExecute({ + connection, + multisigPda, + transactionIndex: 1n, + member: members.almighty.publicKey, + programId, + }); + + // Request heap for execution (it's very much needed here). + const computeBudgetIx = ComputeBudgetProgram.requestHeapFrame({ + bytes: 8 * 32 * 1024, + }); + + const computeBudgetCUIx = ComputeBudgetProgram.setComputeUnitLimit({ + units: 1_400_000, + }); + + const executeTx = new Transaction().add( + computeBudgetIx, + computeBudgetCUIx, + executeIx.instruction + ); + const signature6 = await connection.sendTransaction( + executeTx, + [members.almighty], + { skipPreflight: true } + ); + + await connection.confirmTransaction(signature6); + + const proposal = await multisig.getProposalPda({ + multisigPda, + transactionIndex: 1n, + programId, + })[0]; + + const proposalInfo = await multisig.accounts.Proposal.fromAccountAddress( + connection, + proposal + ); + + assert.equal(proposalInfo.status.__kind, "Executed"); + //endregion + }); +}); diff --git a/tests/suites/examples/spending-limits.ts b/tests/suites/examples/spending-limits.ts index 3cc41a5b..2760e77c 100644 --- a/tests/suites/examples/spending-limits.ts +++ b/tests/suites/examples/spending-limits.ts @@ -17,6 +17,7 @@ import { } from "@solana/spl-token"; import assert from "assert"; import { + comparePubkeys, createAutonomousMultisig, createLocalhostConnection, generateFundedKeypair, @@ -36,6 +37,7 @@ describe("Examples / Spending Limits", () => { let multisigPda: PublicKey; let members: TestMembers; + let nonMember: Keypair; let solSpendingLimitParams: multisig.types.ConfigActionRecord["AddSpendingLimit"]; let splSpendingLimitParams: multisig.types.ConfigActionRecord["AddSpendingLimit"]; let splMint: PublicKey; @@ -52,6 +54,8 @@ describe("Examples / Spending Limits", () => { }) )[0]; + nonMember = await generateFundedKeypair(connection); + // Set params for creating a Spending Limit for SOL tokens. solSpendingLimitParams = { createKey: Keypair.generate().publicKey, @@ -60,7 +64,7 @@ describe("Examples / Spending Limits", () => { mint: PublicKey.default, amount: 10 * LAMPORTS_PER_SOL, period: Period.OneTime, - members: [members.almighty.publicKey], + members: [members.almighty.publicKey, nonMember.publicKey], destinations: [ Keypair.generate().publicKey, Keypair.generate().publicKey, @@ -99,7 +103,7 @@ describe("Examples / Spending Limits", () => { mint: splMint, amount: 10 * 10 ** mintDecimals, period: Period.OneTime, - members: [members.almighty.publicKey], + members: [members.almighty.publicKey, nonMember.publicKey], destinations: [ Keypair.generate().publicKey, Keypair.generate().publicKey, @@ -272,8 +276,12 @@ describe("Examples / Spending Limits", () => { ); assert.strictEqual(solSpendingLimitAccount.bump, solSpendingLimitBump); assert.deepEqual( - solSpendingLimitAccount.members.map((k) => k.toBase58()), - solSpendingLimitParams.members.map((k) => k.toBase58()) + solSpendingLimitAccount.members + .sort(comparePubkeys) + .map((k) => k.toBase58()), + solSpendingLimitParams.members + .sort(comparePubkeys) + .map((k) => k.toBase58()) ); assert.deepEqual( solSpendingLimitAccount.destinations.map((k) => k.toBase58()), @@ -290,7 +298,8 @@ describe("Examples / Spending Limits", () => { programId, }); - const signature = await multisig.rpc + // Member of the multisig that can use the Spending Limit. + let signature = await multisig.rpc .spendingLimitUse({ connection, feePayer: members.almighty, @@ -302,7 +311,7 @@ describe("Examples / Spending Limits", () => { mint: undefined, vaultIndex: solSpendingLimitParams.vaultIndex, // Use the entire amount. - amount: solSpendingLimitParams.amount as number, + amount: (solSpendingLimitParams.amount as number) / 2, // SOL has 9 decimals. decimals: 9, // Transfer tokens to one of the allowed destinations. @@ -318,7 +327,46 @@ describe("Examples / Spending Limits", () => { await connection.confirmTransaction(signature); // Fetch the Spending Limit account. - const solSpendingLimitAccount = await SpendingLimit.fromAccountAddress( + let solSpendingLimitAccount = await SpendingLimit.fromAccountAddress( + connection, + solSpendingLimitPda + ); + + // We used the half of the amount. + assert.strictEqual( + solSpendingLimitAccount.remainingAmount.toString(), + String((solSpendingLimitParams.amount as number) / 2) + ); + + // Non-member of the multisig that can use the Spending Limit. + signature = await multisig.rpc + .spendingLimitUse({ + connection, + feePayer: members.almighty, + // A member that can use the Spending Limit. + member: nonMember, + multisigPda, + spendingLimit: solSpendingLimitPda, + // We don't need to specify the mint, because this Spending Limit is for SOL. + mint: undefined, + vaultIndex: solSpendingLimitParams.vaultIndex, + // Use the entire amount. + amount: (solSpendingLimitParams.amount as number) / 2, + // SOL has 9 decimals. + decimals: 9, + // Transfer tokens to one of the allowed destinations. + destination: solSpendingLimitParams.destinations[0], + // You can optionally add a memo. + memo: "Using my allowance!", + programId, + }) + .catch((err) => { + console.log(err.logs); + throw err; + }); + await connection.confirmTransaction(signature); + + solSpendingLimitAccount = await SpendingLimit.fromAccountAddress( connection, solSpendingLimitPda ); diff --git a/tests/suites/examples/transaction-buffer.ts b/tests/suites/examples/transaction-buffer.ts new file mode 100644 index 00000000..3178c389 --- /dev/null +++ b/tests/suites/examples/transaction-buffer.ts @@ -0,0 +1,364 @@ +import { + AccountMeta, + Keypair, + LAMPORTS_PER_SOL, + PublicKey, + SystemProgram, + Transaction, + TransactionMessage, + VersionedTransaction +} from "@solana/web3.js"; +import * as multisig from "@sqds/multisig"; +import { + TransactionBufferCreateArgs, + TransactionBufferCreateInstructionArgs, + TransactionBufferExtendArgs, + TransactionBufferExtendInstructionArgs, + VaultTransactionCreateArgs, + VaultTransactionCreateFromBufferInstructionArgs, +} from "@sqds/multisig/lib/generated"; +import assert from "assert"; +import * as crypto from "crypto"; +import { + TestMembers, + createAutonomousMultisigV2, + createLocalhostConnection, + createTestTransferInstruction, + generateMultisigMembers, + getTestProgramId +} from "../../utils"; + +const programId = getTestProgramId(); + +describe("Examples / Transaction Buffers", () => { + const connection = createLocalhostConnection(); + + let members: TestMembers; + + const createKey = Keypair.generate(); + + let multisigPda = multisig.getMultisigPda({ + createKey: createKey.publicKey, + programId, + })[0]; + + const [vaultPda] = multisig.getVaultPda({ + multisigPda, + index: 0, + programId, + }); + + before(async () => { + members = await generateMultisigMembers(connection); + + multisigPda = ( + await createAutonomousMultisigV2({ + connection, + members: members, + createKey: createKey, + threshold: 1, + timeLock: 0, + programId, + rentCollector: vaultPda, + }) + )[0]; + + // Airdrop some SOL to the vault + let signature = await connection.requestAirdrop( + vaultPda, + 10 * LAMPORTS_PER_SOL + ); + await connection.confirmTransaction(signature); + }); + + it("set buffer, extend, and create", async () => { + const transactionIndex = 1n; + const bufferIndex = 0; + + const testIx = createTestTransferInstruction(vaultPda, vaultPda, 1); + + let instructions = []; + + // Add 32 transfer instructions to the message. + for (let i = 0; i <= 22; i++) { + instructions.push(testIx); + } + + const testTransferMessage = new TransactionMessage({ + payerKey: vaultPda, + recentBlockhash: (await connection.getLatestBlockhash()).blockhash, + instructions: instructions, + }); + + // Serialize the message. Must be done with this util function + const messageBuffer = + multisig.utils.transactionMessageToMultisigTransactionMessageBytes({ + message: testTransferMessage, + addressLookupTableAccounts: [], + vaultPda, + }); + + const [transactionBuffer, _] = await PublicKey.findProgramAddressSync( + [ + Buffer.from("multisig"), + multisigPda.toBuffer(), + Buffer.from("transaction_buffer"), + members.almighty.publicKey.toBuffer(), + Buffer.from([bufferIndex]) + ], + programId + ); + + const messageHash = crypto + .createHash("sha256") + .update(messageBuffer) + .digest(); + + // Slice the message buffer into two parts. + const firstSlice = messageBuffer.slice(0, 400); + + const ix = multisig.generated.createTransactionBufferCreateInstruction( + { + multisig: multisigPda, + transactionBuffer, + creator: members.almighty.publicKey, + rentPayer: members.almighty.publicKey, + }, + { + args: { + bufferIndex: bufferIndex, + vaultIndex: 0, + // Must be a SHA256 hash of the message buffer. + finalBufferHash: Array.from(messageHash), + finalBufferSize: messageBuffer.length, + buffer: firstSlice, + } as TransactionBufferCreateArgs, + } as TransactionBufferCreateInstructionArgs, + programId + ); + + const message = new TransactionMessage({ + payerKey: members.almighty.publicKey, + recentBlockhash: (await connection.getLatestBlockhash()).blockhash, + instructions: [ix], + }).compileToV0Message(); + + const tx = new VersionedTransaction(message); + + tx.sign([members.almighty]); + + // Send first transaction. + const signature = await connection.sendTransaction(tx, { + skipPreflight: true, + }); + await connection.confirmTransaction(signature); + + const transactionBufferAccount = await connection.getAccountInfo( + transactionBuffer + ); + + // Check buffer account exists. + assert.notEqual(transactionBufferAccount, null); + assert.ok(transactionBufferAccount?.data.length! > 0); + + // Need to add some deserialization to check if it actually worked. + const transactionBufferInfo1 = await connection.getAccountInfo( + transactionBuffer + ); + const [txBufferDeser1] = + await multisig.generated.TransactionBuffer.fromAccountInfo( + transactionBufferInfo1! + ); + + // First chunk uploaded. Check that length is as expected. + assert.equal(txBufferDeser1.buffer.length, 400); + + const secondSlice = messageBuffer.slice(400, messageBuffer.byteLength); + + // Extned the buffer. + const secondIx = + multisig.generated.createTransactionBufferExtendInstruction( + { + multisig: multisigPda, + transactionBuffer, + creator: members.almighty.publicKey, + }, + { + args: { + buffer: secondSlice, + } as TransactionBufferExtendArgs, + } as TransactionBufferExtendInstructionArgs, + programId + ); + + const secondMessage = new TransactionMessage({ + payerKey: members.almighty.publicKey, + recentBlockhash: (await connection.getLatestBlockhash()).blockhash, + instructions: [secondIx], + }).compileToV0Message(); + + const secondTx = new VersionedTransaction(secondMessage); + + secondTx.sign([members.almighty]); + + // Send second transaction to extend. + const secondSignature = await connection.sendTransaction(secondTx, { + skipPreflight: true, + }); + + await connection.confirmTransaction(secondSignature); + + // Need to add some deserialization to check if it actually worked. + const transactionBufferInfo2 = await connection.getAccountInfo( + transactionBuffer + ); + const [txBufferDeser2] = + await multisig.generated.TransactionBuffer.fromAccountInfo( + transactionBufferInfo2! + ); + + // Full buffer uploaded. Check that length is as expected. + assert.equal(txBufferDeser2.buffer.length, messageBuffer.byteLength); + + // Derive vault transaction PDA. + const [transactionPda] = multisig.getTransactionPda({ + multisigPda, + index: transactionIndex, + programId, + }); + + const transactionBufferMeta: AccountMeta = { + pubkey: transactionBuffer, + isWritable: true, + isSigner: false + } + // Create final instruction. + const thirdIx = + multisig.generated.createVaultTransactionCreateFromBufferInstruction( + { + vaultTransactionCreateItemMultisig: multisigPda, + vaultTransactionCreateItemTransaction: transactionPda, + vaultTransactionCreateItemCreator: members.almighty.publicKey, + vaultTransactionCreateItemRentPayer: members.almighty.publicKey, + vaultTransactionCreateItemSystemProgram: SystemProgram.programId, + creator: members.almighty.publicKey, + transactionBuffer: transactionBuffer, + }, + { + args: { + vaultIndex: 0, + transactionMessage: new Uint8Array(6).fill(0), + ephemeralSigners: 0, + memo: null, + } as VaultTransactionCreateArgs, + } as VaultTransactionCreateFromBufferInstructionArgs, + programId + ); + + // Add third instruction to the message. + const thirdMessage = new TransactionMessage({ + payerKey: members.almighty.publicKey, + recentBlockhash: (await connection.getLatestBlockhash()).blockhash, + instructions: [thirdIx], + }).compileToV0Message(); + + const thirdTx = new VersionedTransaction(thirdMessage); + + thirdTx.sign([members.almighty]); + + // Send final transaction. + const thirdSignature = await connection.sendTransaction(thirdTx, { + skipPreflight: true, + }); + + await connection.confirmTransaction(thirdSignature); + + const transactionInfo = + await multisig.accounts.VaultTransaction.fromAccountAddress( + connection, + transactionPda + ); + + // Ensure final vault transaction has 23 instructions + assert.equal(transactionInfo.message.instructions.length, 23); + }); + + it("create proposal, approve, execute from buffer derived transaction", async () => { + const transactionIndex = 1n; + + // Derive vault transaction PDA. + const [transactionPda] = multisig.getTransactionPda({ + multisigPda, + index: transactionIndex, + programId, + }); + + const transactionInfo = + await multisig.accounts.VaultTransaction.fromAccountAddress( + connection, + transactionPda + ); + + // Check that we're dealing with the same account from last test. + assert.equal(transactionInfo.message.instructions.length, 23); + + const [proposalPda] = multisig.getProposalPda({ + multisigPda, + transactionIndex, + programId, + }); + + const signature = await multisig.rpc.proposalCreate({ + connection, + feePayer: members.almighty, + multisigPda, + transactionIndex, + creator: members.almighty, + isDraft: false, + programId, + }); + await connection.confirmTransaction(signature); + + const signature3 = await multisig.rpc.proposalApprove({ + connection, + feePayer: members.almighty, + multisigPda, + transactionIndex, + member: members.almighty, + programId, + }); + await connection.confirmTransaction(signature3); + + // Fetch the proposal account. + let proposalAccount1 = await multisig.accounts.Proposal.fromAccountAddress( + connection, + proposalPda + ); + + const ix = await multisig.instructions.vaultTransactionExecute({ + connection, + multisigPda, + transactionIndex, + member: members.almighty.publicKey, + programId, + }); + + const tx = new Transaction().add(ix.instruction); + const signature4 = await connection.sendTransaction( + tx, + [members.almighty], + { skipPreflight: true } + ); + + await connection.confirmTransaction(signature4); + + // Fetch the proposal account. + let proposalAccount = await multisig.accounts.Proposal.fromAccountAddress( + connection, + proposalPda + ); + + // Check status. + assert.equal(proposalAccount.status.__kind, "Executed"); + }); +}); diff --git a/tests/suites/instructions/batchAccountsClose.ts b/tests/suites/instructions/batchAccountsClose.ts index 41f9af80..492d7c34 100644 --- a/tests/suites/instructions/batchAccountsClose.ts +++ b/tests/suites/instructions/batchAccountsClose.ts @@ -269,13 +269,13 @@ describe("Instructions / batch_accounts_close", () => { multisig: multisigPda, rentCollector: vaultPda, proposal: multisig.getProposalPda({ - multisigPda: otherMultisig, + multisigPda, transactionIndex: 1n, programId, })[0], batch: multisig.getTransactionPda({ - multisigPda, - index: testMultisig.rejectedBatchIndex, + multisigPda: otherMultisig, + index: 1n, programId, })[0], }, @@ -297,7 +297,7 @@ describe("Instructions / batch_accounts_close", () => { connection .sendTransaction(tx) .catch(multisig.errors.translateAndThrowAnchorError), - /Proposal is for another multisig/ + /Transaction is for another multisig/ ); }); @@ -434,6 +434,42 @@ describe("Instructions / batch_accounts_close", () => { assert.equal(await connection.getAccountInfo(proposalPda), null); }); + it("close accounts for Stale batch with no Proposal", async () => { + const batchIndex = testMultisig.staleDraftBatchNoProposalIndex; + + const multisigAccount = await Multisig.fromAccountAddress( + connection, + multisigPda + ); + + const proposalPda = multisig.getProposalPda({ + multisigPda, + transactionIndex: batchIndex, + programId, + })[0]; + + // Make sure proposal account doesn't exist. + assert.equal(await connection.getAccountInfo(proposalPda), null); + + let signature = await multisig.rpc.batchAccountsClose({ + connection, + feePayer: members.almighty, + multisigPda, + rentCollector: multisigAccount.rentCollector!, + batchIndex, + programId, + }); + await connection.confirmTransaction(signature); + + // Make sure batch and proposal accounts are closed. + const batchPda = multisig.getTransactionPda({ + multisigPda, + index: batchIndex, + programId, + })[0]; + assert.equal(await connection.getAccountInfo(batchPda), null); + }); + it("close accounts for Executed batch", async () => { const batchIndex = testMultisig.executedBatchIndex; diff --git a/tests/suites/instructions/cancelRealloc.ts b/tests/suites/instructions/cancelRealloc.ts new file mode 100644 index 00000000..2ad7172f --- /dev/null +++ b/tests/suites/instructions/cancelRealloc.ts @@ -0,0 +1,450 @@ +import * as multisig from "@sqds/multisig"; +import assert from "assert"; +import { + createAutonomousMultisig, + createLocalhostConnection, + createTestTransferInstruction, + generateMultisigMembers, + getTestProgramId, + TestMembers, +} from "../../utils"; +import { Keypair, LAMPORTS_PER_SOL, PublicKey, TransactionMessage } from "@solana/web3.js"; + +const { Multisig, Proposal } = multisig.accounts; + +const programId = getTestProgramId(); +const connection = createLocalhostConnection(); + +describe("Instructions / proposal_cancel_v2", () => { + let members: TestMembers; + let multisigPda: PublicKey; + let newVotingMember = new Keypair(); + let newVotingMember2 = new Keypair(); + let newVotingMember3 = new Keypair(); + let newVotingMember4 = new Keypair(); + let addMemberCollection = [ + {key: newVotingMember.publicKey, permissions: multisig.types.Permissions.all()}, + {key: newVotingMember2.publicKey, permissions: multisig.types.Permissions.all()}, + {key: newVotingMember3.publicKey, permissions: multisig.types.Permissions.all()}, + {key: newVotingMember4.publicKey, permissions: multisig.types.Permissions.all()}, + ]; + let cancelVotesCollection = [ + newVotingMember, + newVotingMember2, + newVotingMember3, + newVotingMember4, + ]; + let originalCancel: Keypair; + + before(async () => { + members = await generateMultisigMembers(connection); + // Create new autonomous multisig. + multisigPda = ( + await createAutonomousMultisig({ + connection, + members, + threshold: 2, + timeLock: 0, + programId, + }) + )[0]; + + }); + + // multisig current has a threhsold of 2 with two voting members. + // create a proposal to add a member to the multisig (which we will cancel) + // the proposal size will be allocated to TOTAL members length + it("cancel basic config tx proposal", async () => { + // Create a config transaction. + const transactionIndex = 1n; + const [proposalPda] = multisig.getProposalPda({ + multisigPda, + transactionIndex, + programId, + }); + + let signature = await multisig.rpc.configTransactionCreate({ + connection, + feePayer: members.proposer, + multisigPda, + transactionIndex, + creator: members.proposer.publicKey, + actions: [{ __kind: "AddMember", newMember: {key: newVotingMember.publicKey, permissions: multisig.types.Permissions.all()} }], + programId, + }); + await connection.confirmTransaction(signature); + + // Create a proposal for the transaction. + signature = await multisig.rpc.proposalCreate({ + connection, + feePayer: members.proposer, + multisigPda, + transactionIndex, + creator: members.proposer, + programId, + }); + await connection.confirmTransaction(signature); + + // Proposal status must be "Cancelled". + let proposalAccount = await Proposal.fromAccountAddress( + connection, + proposalPda + ); + + // Approve the proposal 1. + signature = await multisig.rpc.proposalApprove({ + connection, + feePayer: members.voter, + multisigPda, + transactionIndex, + member: members.voter, + programId, + }); + await connection.confirmTransaction(signature); + + // Approve the proposal 2. + signature = await multisig.rpc.proposalApprove({ + connection, + feePayer: members.almighty, + multisigPda, + transactionIndex, + member: members.almighty, + programId, + }); + await connection.confirmTransaction(signature); + + // Proposal is now ready to execute, cast the 2 cancels using the new functionality. + signature = await multisig.rpc.proposalCancelV2({ + connection, + feePayer: members.voter, + member: members.voter, + multisigPda, + transactionIndex, + programId, + }); + await connection.confirmTransaction(signature); + + // Proposal is now ready to execute, cast the 2 cancels using the new functionality. + signature = await multisig.rpc.proposalCancelV2({ + connection, + feePayer: members.almighty, + member: members.almighty, + multisigPda, + transactionIndex, + programId, + }); + await connection.confirmTransaction(signature); + + // Proposal status must be "Cancelled". + proposalAccount = await Proposal.fromAccountAddress( + connection, + proposalPda + ); + assert.ok(multisig.types.isProposalStatusCancelled(proposalAccount.status)); + }); + + // in order to test this, we create a basic transfer transaction + // then we vote to approve it + // then we cast 1 cancel vote + // then we change the state of the multisig so the new amount of voting members is greater than the last total size + // then we change the threshold to be greater than the last total size + // then we change the state of the multisig so that one original cancel voter is removed + // then we vote to cancel (and be able to close the transfer transaction) + it("cancel tx with stale state size", async () => { + // Create a config transaction. + let transactionIndex = 2n; + const [proposalPda] = multisig.getProposalPda({ + multisigPda, + transactionIndex, + programId, + }); + + // Default vault. + const [vaultPda, vaultBump] = multisig.getVaultPda({ + multisigPda, + index: 0, + programId, + }); + const testPayee = Keypair.generate(); + const testIx1 = await createTestTransferInstruction( + vaultPda, + testPayee.publicKey, + 1 * LAMPORTS_PER_SOL + ); + const testTransferMessage = new TransactionMessage({ + payerKey: vaultPda, + recentBlockhash: (await connection.getLatestBlockhash()).blockhash, + instructions: [testIx1], + }); + + let signature = await multisig.rpc.vaultTransactionCreate({ + connection, + feePayer: members.proposer, + multisigPda, + transactionIndex, + creator: members.proposer.publicKey, + vaultIndex: 0, + ephemeralSigners: 0, + transactionMessage: testTransferMessage, + memo: "Transfer 1 SOL to a test account", + programId, + }); + await connection.confirmTransaction(signature); + + // Create a proposal for the transaction. + signature = await multisig.rpc.proposalCreate({ + connection, + feePayer: members.proposer, + multisigPda, + transactionIndex, + creator: members.proposer, + programId, + }); + await connection.confirmTransaction(signature); + + // Approve the proposal 1. + signature = await multisig.rpc.proposalApprove({ + connection, + feePayer: members.voter, + multisigPda, + transactionIndex, + member: members.voter, + programId, + }); + await connection.confirmTransaction(signature); + + // Approve the proposal 2. + signature = await multisig.rpc.proposalApprove({ + connection, + feePayer: members.almighty, + multisigPda, + transactionIndex, + member: members.almighty, + programId, + }); + await connection.confirmTransaction(signature); + + // Proposal status must be "Approved". + let proposalAccount = await Proposal.fromAccountAddress( + connection, + proposalPda + ); + assert.ok(multisig.types.isProposalStatusApproved(proposalAccount.status)); + // check the account size + + + // TX/Proposal is now in an approved/ready state. + // Now cancel vec has enough room for 4 votes. + + // Cast the 1 cancel using the new functionality and the 'voter' member. + signature = await multisig.rpc.proposalCancelV2({ + connection, + feePayer: members.voter, + member: members.voter, + multisigPda, + transactionIndex, + programId, + }); + await connection.confirmTransaction(signature); + // set the original cancel voter + originalCancel = members.voter; + + // ensure that the account size has not changed yet + + // Change the multisig state to have 5 voting members. + // loop through the process to add the 4 members + for (let i = 0; i < addMemberCollection.length; i++) { + const newMember = addMemberCollection[i]; + transactionIndex++; + signature = await multisig.rpc.configTransactionCreate({ + connection, + feePayer: members.proposer, + multisigPda, + transactionIndex, + creator: members.proposer.publicKey, + actions: [{ __kind: "AddMember", newMember }], + programId, + }); + await connection.confirmTransaction(signature); + + // Create a proposal for the transaction. + signature = await multisig.rpc.proposalCreate({ + connection, + feePayer: members.proposer, + multisigPda, + transactionIndex, + creator: members.proposer, + programId, + }); + await connection.confirmTransaction(signature); + + // Proposal status must be "Cancelled". + proposalAccount = await Proposal.fromAccountAddress( + connection, + proposalPda + ); + + // Approve the proposal 1. + signature = await multisig.rpc.proposalApprove({ + connection, + feePayer: members.voter, + multisigPda, + transactionIndex, + member: members.voter, + programId, + }); + await connection.confirmTransaction(signature); + + // Approve the proposal 2. + signature = await multisig.rpc.proposalApprove({ + connection, + feePayer: members.almighty, + multisigPda, + transactionIndex, + member: members.almighty, + programId, + }); + await connection.confirmTransaction(signature); + + // use the execute only member to execute + signature = await multisig.rpc.configTransactionExecute({ + connection, + feePayer: members.executor, + multisigPda, + transactionIndex, + member: members.executor, + rentPayer: members.executor, + programId, + }); + await connection.confirmTransaction(signature); + + } + + // assert that our member length is now 8 + let multisigAccount = await Multisig.fromAccountAddress( + connection, + multisigPda + ); + assert.strictEqual(multisigAccount.members.length, 8); + + transactionIndex++; + // now remove the original cancel voter + signature = await multisig.rpc.configTransactionCreate({ + connection, + feePayer: members.proposer, + multisigPda, + transactionIndex, + creator: members.proposer.publicKey, + actions: [{ __kind: "RemoveMember", oldMember: originalCancel.publicKey }, { __kind: "ChangeThreshold", newThreshold: 5 }], + programId, + }); + await connection.confirmTransaction(signature); + // create the remove proposal + signature = await multisig.rpc.proposalCreate({ + connection, + feePayer: members.proposer, + multisigPda, + transactionIndex, + creator: members.proposer, + programId, + }); + await connection.confirmTransaction(signature); + // approve the proposal 1 + signature = await multisig.rpc.proposalApprove({ + connection, + feePayer: members.voter, + multisigPda, + transactionIndex, + member: members.voter, + programId, + }); + await connection.confirmTransaction(signature); + // approve the proposal 2 + signature = await multisig.rpc.proposalApprove({ + connection, + feePayer: members.almighty, + multisigPda, + transactionIndex, + member: members.almighty, + programId, + }); + await connection.confirmTransaction(signature); + // execute the proposal + signature = await multisig.rpc.configTransactionExecute({ + connection, + feePayer: members.executor, + multisigPda, + transactionIndex, + member: members.executor, + rentPayer: members.executor, + programId, + }); + await connection.confirmTransaction(signature); + // now assert we have 7 members + multisigAccount = await Multisig.fromAccountAddress( + connection, + multisigPda + ); + assert.strictEqual(multisigAccount.members.length, 7); + assert.strictEqual(multisigAccount.threshold, 5); + + // so now our threshold should be 5 for cancelling, which exceeds the original space allocated at the beginning + // get the original proposer and assert the originalCancel is in the cancel array + proposalAccount = await Proposal.fromAccountAddress( + connection, + proposalPda + ); + assert.strictEqual(proposalAccount.cancelled.length, 1); + let deprecatedCancelVote = proposalAccount.cancelled[0]; + assert.ok(deprecatedCancelVote.equals(originalCancel.publicKey)); + + // get the pre realloc size + const rawProposal = await connection.getAccountInfo(proposalPda); + const rawProposalData = rawProposal?.data.length; + + // now cast a cancel against it with the first all perm key + signature = await multisig.rpc.proposalCancelV2({ + connection, + feePayer: members.almighty, + member: members.almighty, + multisigPda, + transactionIndex: 2n, + programId, + }); + await connection.confirmTransaction(signature); + // now assert that the cancelled array only has 1 key and it is the one that just voted + proposalAccount = await Proposal.fromAccountAddress( + connection, + proposalPda + ); + // check the data length to ensure it has changed + const updatedRawProposal = await connection.getAccountInfo(proposalPda); + const updatedRawProposalData = updatedRawProposal?.data.length; + assert.notStrictEqual(updatedRawProposalData, rawProposalData); + assert.strictEqual(proposalAccount.cancelled.length, 1); + let newCancelVote = proposalAccount.cancelled[0]; + assert.ok(newCancelVote.equals(members.almighty.publicKey)); + // now cast 4 more cancels with the new key + for (let i = 0; i < cancelVotesCollection.length; i++) { + signature = await multisig.rpc.proposalCancelV2({ + connection, + feePayer: members.executor, + member: cancelVotesCollection[i], + multisigPda, + transactionIndex: 2n, + programId, + }); + await connection.confirmTransaction(signature); + } + + // now assert the proposals is cancelled + proposalAccount = await Proposal.fromAccountAddress( + connection, + proposalPda + ); + assert.ok(multisig.types.isProposalStatusCancelled(proposalAccount.status)); + // assert there are 5 cancelled votes + assert.strictEqual(proposalAccount.cancelled.length, 5); + }); + + }); diff --git a/tests/suites/instructions/configTransactionAccountsClose.ts b/tests/suites/instructions/configTransactionAccountsClose.ts index 78f8495b..455f63a0 100644 --- a/tests/suites/instructions/configTransactionAccountsClose.ts +++ b/tests/suites/instructions/configTransactionAccountsClose.ts @@ -25,11 +25,12 @@ describe("Instructions / config_transaction_accounts_close", () => { let members: TestMembers; let multisigPda: PublicKey; const staleTransactionIndex = 1n; - const executedTransactionIndex = 2n; - const activeTransactionIndex = 3n; - const approvedTransactionIndex = 4n; - const rejectedTransactionIndex = 5n; - const cancelledTransactionIndex = 6n; + const staleNoProposalTransactionIndex = 2n; + const executedTransactionIndex = 3n; + const activeTransactionIndex = 4n; + const approvedTransactionIndex = 5n; + const rejectedTransactionIndex = 6n; + const cancelledTransactionIndex = 7n; // Set up a multisig with config transactions. before(async () => { @@ -82,6 +83,24 @@ describe("Instructions / config_transaction_accounts_close", () => { // This transaction will become stale when the second config transaction is executed. //endregion + //region Stale and No Proposal + // Create a config transaction (Stale and No Proposal). + signature = await multisig.rpc.configTransactionCreate({ + connection, + feePayer: members.proposer, + multisigPda, + transactionIndex: staleNoProposalTransactionIndex, + creator: members.proposer.publicKey, + actions: [{ __kind: "ChangeThreshold", newThreshold: 1 }], + programId, + }); + await connection.confirmTransaction(signature); + + // No proposal created for this transaction. + + // This transaction will become stale when the config transaction is executed. + //endregion + //region Executed // Create a config transaction (Executed). signature = await multisig.rpc.configTransactionCreate({ @@ -524,7 +543,7 @@ describe("Instructions / config_transaction_accounts_close", () => { connection .sendTransaction(tx) .catch(multisig.errors.translateAndThrowAnchorError), - /Proposal is for another multisig/ + /A seeds constraint was violated/ ); }); @@ -621,7 +640,7 @@ describe("Instructions / config_transaction_accounts_close", () => { rentCollector: vaultPda, proposal: multisig.getProposalPda({ multisigPda, - transactionIndex: rejectedTransactionIndex, + transactionIndex: 1n, programId, })[0], transaction: multisig.getTransactionPda({ @@ -693,7 +712,7 @@ describe("Instructions / config_transaction_accounts_close", () => { connection .sendTransaction(tx) .catch(multisig.errors.translateAndThrowAnchorError), - /Transaction doesn't match proposal/ + /A seeds constraint was violated/ ); }); @@ -744,6 +763,53 @@ describe("Instructions / config_transaction_accounts_close", () => { assert.ok(postBalance === preBalance + accountsRent); }); + it("close accounts for Stale transaction with No Proposal", async () => { + const transactionIndex = staleNoProposalTransactionIndex; + + const multisigAccount = await Multisig.fromAccountAddress( + connection, + multisigPda + ); + + // Make sure there's no proposal. + let proposalAccount = await connection.getAccountInfo( + multisig.getProposalPda({ + multisigPda, + transactionIndex, + programId, + })[0] + ); + assert.equal(proposalAccount, null); + + // Make sure the transaction is stale. + assert.ok( + transactionIndex <= + multisig.utils.toBigInt(multisigAccount.staleTransactionIndex) + ); + + const [vaultPda] = multisig.getVaultPda({ + multisigPda, + index: 0, + programId, + }); + + const preBalance = await connection.getBalance(vaultPda); + + const sig = await multisig.rpc.configTransactionAccountsClose({ + connection, + feePayer: members.almighty, + multisigPda, + rentCollector: vaultPda, + transactionIndex, + programId, + }); + await connection.confirmTransaction(sig); + + const postBalance = await connection.getBalance(vaultPda); + const accountsRent = 1_503_360; // Rent for the transaction account. + assert.equal(postBalance, preBalance + accountsRent); + }); + it("close accounts for Executed transaction", async () => { const transactionIndex = executedTransactionIndex; diff --git a/tests/suites/instructions/heapTest.ts b/tests/suites/instructions/heapTest.ts new file mode 100644 index 00000000..df4ed900 --- /dev/null +++ b/tests/suites/instructions/heapTest.ts @@ -0,0 +1,65 @@ +import { + ComputeBudgetProgram, + Keypair, + LAMPORTS_PER_SOL, + TransactionMessage, + VersionedTransaction +} from "@solana/web3.js"; +import * as multisig from "@sqds/multisig"; +import { + HeapTestInstructionArgs +} from "@sqds/multisig/lib/generated"; +import { + createLocalhostConnection, + getTestProgramId +} from "../../utils"; + +const programId = getTestProgramId(); +const connection = createLocalhostConnection(); + +describe("Instructions / transaction_buffer_close", () => { + it("heap test", async () => { + + let keypair = Keypair.generate(); + // Request airdrop + let signature = await connection.requestAirdrop( + keypair.publicKey, + 10 * LAMPORTS_PER_SOL + ); + await connection.confirmTransaction(signature); + const createArgs: HeapTestInstructionArgs = { + length: 25000, + }; + const heapTestIx = multisig.generated.createHeapTestInstruction( + { + authority: keypair.publicKey, + }, + createArgs, + programId + ); + const computeBudgetIx = ComputeBudgetProgram.requestHeapFrame({ + bytes: 8 * 32 * 1024 + }); + const computeBudgetCUIx = ComputeBudgetProgram.setComputeUnitLimit({ + units: 1_400_000 + }); + // const heapTestMessage = new TransactionMessage({ + // payerKey: keypair.publicKey, + // recentBlockhash: (await connection.getLatestBlockhash()).blockhash, + // instructions: [computeBudgetIx, computeBudgetCUIx, heapTestIx], + // }).compileToV0Message(); + const heapTestMessage = new TransactionMessage({ + payerKey: keypair.publicKey, + recentBlockhash: (await connection.getLatestBlockhash()).blockhash, + instructions: [computeBudgetCUIx, heapTestIx], + }).compileToV0Message(); + const heapTestTx = new VersionedTransaction(heapTestMessage); + heapTestTx.sign([keypair]); + const heapTestSig = await connection.sendRawTransaction(heapTestTx.serialize(), { skipPreflight: true }); + console.log(heapTestSig); + await connection.confirmTransaction(heapTestSig); + + + + }); +}); \ No newline at end of file diff --git a/tests/suites/instructions/multisigCreate.ts b/tests/suites/instructions/multisigCreate.ts index ba0d16c4..e9df7c90 100644 --- a/tests/suites/instructions/multisigCreate.ts +++ b/tests/suites/instructions/multisigCreate.ts @@ -1,16 +1,15 @@ +import { Keypair } from "@solana/web3.js"; import * as multisig from "@sqds/multisig"; +import assert from "assert"; import { - comparePubkeys, createAutonomousMultisig, createControlledMultisig, createLocalhostConnection, generateFundedKeypair, generateMultisigMembers, getTestProgramId, - TestMembers, + TestMembers } from "../../utils"; -import { Keypair, PublicKey } from "@solana/web3.js"; -import assert from "assert"; const { Multisig } = multisig.accounts; const { Permission, Permissions } = multisig.types; @@ -57,7 +56,7 @@ describe("Instructions / multisig_create", () => { sendOptions: { skipPreflight: true }, programId, }), - /Found multiple members with the same pubkey/ + /Deprecated/ ); }); @@ -123,7 +122,7 @@ describe("Instructions / multisig_create", () => { sendOptions: { skipPreflight: true }, programId, }), - /Members don't include any proposers/ + /Deprecated/ ); }); @@ -158,7 +157,7 @@ describe("Instructions / multisig_create", () => { sendOptions: { skipPreflight: true }, programId, }), - /Member has unknown permission/ + /Deprecated/ ); }); @@ -191,7 +190,7 @@ describe("Instructions / multisig_create", () => { sendOptions: { skipPreflight: true }, programId, }), - /Invalid threshold, must be between 1 and number of members/ + /Deprecated/ ); }); @@ -239,94 +238,43 @@ describe("Instructions / multisig_create", () => { sendOptions: { skipPreflight: true }, programId, }), - /Invalid threshold, must be between 1 and number of members with Vote permission/ + /Deprecated/ ); }); - it("create a new autonomous multisig", async () => { + it("error: create a new autonomous multisig (deprecated)", async () => { const createKey = Keypair.generate(); - - const [multisigPda, multisigBump] = await createAutonomousMultisig({ - connection, - createKey, - members, - threshold: 2, - timeLock: 0, - programId, - }); - - const multisigAccount = await Multisig.fromAccountAddress( - connection, - multisigPda - ); - assert.strictEqual( - multisigAccount.configAuthority.toBase58(), - PublicKey.default.toBase58() - ); - assert.strictEqual(multisigAccount.threshold, 2); - assert.deepEqual( - multisigAccount.members, - [ - { - key: members.almighty.publicKey, - permissions: { - mask: Permission.Initiate | Permission.Vote | Permission.Execute, - }, - }, - { - key: members.proposer.publicKey, - permissions: { - mask: Permission.Initiate, - }, - }, - { - key: members.voter.publicKey, - permissions: { - mask: Permission.Vote, - }, - }, - { - key: members.executor.publicKey, - permissions: { - mask: Permission.Execute, - }, - }, - ].sort((a, b) => comparePubkeys(a.key, b.key)) - ); - assert.strictEqual(multisigAccount.rentCollector, null); - assert.strictEqual(multisigAccount.transactionIndex.toString(), "0"); - assert.strictEqual(multisigAccount.staleTransactionIndex.toString(), "0"); - assert.strictEqual( - multisigAccount.createKey.toBase58(), - createKey.publicKey.toBase58() + assert.rejects( + () => + createAutonomousMultisig({ + connection, + createKey, + members, + threshold: 2, + timeLock: 0, + programId, + }), + /Deprecated/ ); - assert.strictEqual(multisigAccount.bump, multisigBump); + }); - it("create a new controlled multisig", async () => { + it("error: create a new controlled multisig (deprecated)", async () => { const createKey = Keypair.generate(); const configAuthority = await generateFundedKeypair(connection); - - const [multisigPda] = await createControlledMultisig({ - connection, - createKey, - configAuthority: configAuthority.publicKey, - members, - threshold: 2, - timeLock: 0, - programId, - }); - - const multisigAccount = await Multisig.fromAccountAddress( - connection, - multisigPda + assert.rejects( + () => + createControlledMultisig({ + connection, + createKey, + configAuthority: configAuthority.publicKey, + members, + threshold: 2, + timeLock: 0, + programId, + }), + /Deprecated/ ); - assert.strictEqual( - multisigAccount.configAuthority.toBase58(), - configAuthority.publicKey.toBase58() - ); - // We can skip the rest of the assertions because they are already tested - // in the previous case and will be the same here. }); }); diff --git a/tests/suites/instructions/transactionBufferClose.ts b/tests/suites/instructions/transactionBufferClose.ts new file mode 100644 index 00000000..9569f69e --- /dev/null +++ b/tests/suites/instructions/transactionBufferClose.ts @@ -0,0 +1,181 @@ +import { + Keypair, + LAMPORTS_PER_SOL, + PublicKey, + SystemProgram, + TransactionMessage, + VersionedTransaction, +} from "@solana/web3.js"; +import * as multisig from "@sqds/multisig"; +import { + TransactionBufferCreateArgs, + TransactionBufferCreateInstructionArgs, +} from "@sqds/multisig/lib/generated"; +import assert from "assert"; +import { BN } from "bn.js"; +import * as crypto from "crypto"; +import { + TestMembers, + createAutonomousMultisigV2, + createLocalhostConnection, + createTestTransferInstruction, + generateMultisigMembers, + getTestProgramId, +} from "../../utils"; + +const programId = getTestProgramId(); +const connection = createLocalhostConnection(); + +describe("Instructions / transaction_buffer_close", () => { + let members: TestMembers; + let multisigPda: PublicKey; + let vaultPda: PublicKey; + let transactionBuffer: PublicKey; + + + before(async () => { + members = await generateMultisigMembers(connection); + + const createKey = Keypair.generate(); + multisigPda = (await createAutonomousMultisigV2({ + connection, + createKey, + members, + threshold: 2, + timeLock: 0, + rentCollector: vaultPda, + programId, + }))[0]; + + [vaultPda] = multisig.getVaultPda({ + multisigPda, + index: 0, + programId, + }); + + let signature = await connection.requestAirdrop( + vaultPda, + 10 * LAMPORTS_PER_SOL + ); + await connection.confirmTransaction(signature); + + const bufferIndex = 0; + const testIx = await createTestTransferInstruction( + vaultPda, + Keypair.generate().publicKey, + 0.1 * LAMPORTS_PER_SOL + ); + + const testTransferMessage = new TransactionMessage({ + payerKey: vaultPda, + recentBlockhash: (await connection.getLatestBlockhash()).blockhash, + instructions: [testIx], + }); + + const messageBuffer = multisig.utils.transactionMessageToMultisigTransactionMessageBytes({ + message: testTransferMessage, + addressLookupTableAccounts: [], + vaultPda, + }); + + [transactionBuffer] = PublicKey.findProgramAddressSync( + [ + Buffer.from("multisig"), + multisigPda.toBuffer(), + Buffer.from("transaction_buffer"), + members.proposer.publicKey.toBuffer(), + Uint8Array.from([bufferIndex]) + ], + programId + ); + const messageHash = crypto + .createHash("sha256") + .update(messageBuffer) + .digest(); + + const createIx = multisig.generated.createTransactionBufferCreateInstruction( + { + multisig: multisigPda, + transactionBuffer, + creator: members.proposer.publicKey, + rentPayer: members.proposer.publicKey, + systemProgram: SystemProgram.programId, + }, + { + args: { + bufferIndex: Number(bufferIndex), + vaultIndex: 0, + finalBufferHash: Array.from(messageHash), + finalBufferSize: messageBuffer.length, + buffer: messageBuffer, + } as TransactionBufferCreateArgs, + } as TransactionBufferCreateInstructionArgs, + programId + ); + + const createMessage = new TransactionMessage({ + payerKey: members.proposer.publicKey, + recentBlockhash: (await connection.getLatestBlockhash()).blockhash, + instructions: [createIx], + }).compileToV0Message(); + + const createTx = new VersionedTransaction(createMessage); + createTx.sign([members.proposer]); + + const createSig = await connection.sendRawTransaction(createTx.serialize(), { skipPreflight: true }); + await connection.confirmTransaction(createSig); + }); + + it("error: close buffer with non-creator signature", async () => { + const closeIx = multisig.generated.createTransactionBufferCloseInstruction( + { + multisig: multisigPda, + transactionBuffer, + creator: members.voter.publicKey, + }, + programId + ); + + const closeMessage = new TransactionMessage({ + payerKey: members.voter.publicKey, + recentBlockhash: (await connection.getLatestBlockhash()).blockhash, + instructions: [closeIx], + }).compileToV0Message(); + + const closeTx = new VersionedTransaction(closeMessage); + closeTx.sign([members.voter]); + + await assert.rejects( + () => + connection + .sendTransaction(closeTx) + .catch(multisig.errors.translateAndThrowAnchorError), + /(Unauthorized|ConstraintSeeds)/ + ); + }); + + it("close buffer with creator signature", async () => { + const closeIx = multisig.generated.createTransactionBufferCloseInstruction( + { + multisig: multisigPda, + transactionBuffer, + creator: members.proposer.publicKey, + }, + programId + ); + + const closeMessage = new TransactionMessage({ + payerKey: members.proposer.publicKey, + recentBlockhash: (await connection.getLatestBlockhash()).blockhash, + instructions: [closeIx], + }).compileToV0Message(); + + const closeTx = new VersionedTransaction(closeMessage); + closeTx.sign([members.proposer]); + + const closeSig = await connection.sendTransaction(closeTx, { skipPreflight: true }); + await connection.confirmTransaction(closeSig); + const transactionBufferAccount = await connection.getAccountInfo(transactionBuffer); + assert.equal(transactionBufferAccount, null, "Transaction buffer account should be closed"); + }); +}); \ No newline at end of file diff --git a/tests/suites/instructions/transactionBufferCreate.ts b/tests/suites/instructions/transactionBufferCreate.ts new file mode 100644 index 00000000..2de22078 --- /dev/null +++ b/tests/suites/instructions/transactionBufferCreate.ts @@ -0,0 +1,665 @@ +import { + Keypair, + LAMPORTS_PER_SOL, + PublicKey, + SystemProgram, + TransactionMessage, + VersionedTransaction, +} from "@solana/web3.js"; +import * as multisig from "@sqds/multisig"; +import { + TransactionBufferCreateArgs, + TransactionBufferCreateInstructionArgs, +} from "@sqds/multisig/lib/generated"; +import assert from "assert"; +import * as crypto from "crypto"; +import { + createAutonomousMultisigV2, + createLocalhostConnection, + createTestTransferInstruction, + generateMultisigMembers, + getTestProgramId, + TestMembers, +} from "../../utils"; + +const programId = getTestProgramId(); +const connection = createLocalhostConnection(); + +describe("Instructions / transaction_buffer_create", () => { + let members: TestMembers; + + const createKey = Keypair.generate(); + + let multisigPda = multisig.getMultisigPda({ + createKey: createKey.publicKey, + programId, + })[0]; + + const [vaultPda] = multisig.getVaultPda({ + multisigPda, + index: 0, + programId, + }); + + // Set up a multisig with some transactions. + before(async () => { + members = await generateMultisigMembers(connection); + + // Create new autonomous multisig with rentCollector set to its default vault. + multisigPda = ( + await createAutonomousMultisigV2({ + connection, + createKey, + members, + threshold: 2, + timeLock: 0, + rentCollector: vaultPda, + programId, + }) + )[0]; + + // Airdrop some SOL to the vault + let signature = await connection.requestAirdrop( + vaultPda, + 10 * LAMPORTS_PER_SOL + ); + await connection.confirmTransaction(signature); + }); + + it("set transaction buffer", async () => { + const bufferIndex = 0; + + const testPayee = Keypair.generate(); + const testIx = createTestTransferInstruction( + vaultPda, + testPayee.publicKey, + 1 * LAMPORTS_PER_SOL + ); + + // Initialize a transaction message with a single instruction. + const testTransferMessage = new TransactionMessage({ + payerKey: vaultPda, + recentBlockhash: (await connection.getLatestBlockhash()).blockhash, + instructions: [testIx], + }); + + // Serialize with SDK util + const messageBuffer = + multisig.utils.transactionMessageToMultisigTransactionMessageBytes({ + message: testTransferMessage, + addressLookupTableAccounts: [], + vaultPda, + }); + + const [transactionBuffer, _] = PublicKey.findProgramAddressSync( + [ + Buffer.from("multisig"), + multisigPda.toBuffer(), + Buffer.from("transaction_buffer"), + members.proposer.publicKey.toBuffer(), + Uint8Array.from([bufferIndex]) + ], + programId + ); + + // Convert to a SHA256 hash. + const messageHash = crypto + .createHash("sha256") + .update(messageBuffer) + .digest(); + + + const ix = multisig.generated.createTransactionBufferCreateInstruction( + { + multisig: multisigPda, + transactionBuffer, + creator: members.proposer.publicKey, + rentPayer: members.proposer.publicKey, + systemProgram: SystemProgram.programId, + }, + { + args: { + bufferIndex: bufferIndex, + vaultIndex: 0, + createKey: Keypair.generate(), + // Must be a SHA256 hash of the message buffer. + finalBufferHash: Array.from(messageHash), + finalBufferSize: messageBuffer.length, + buffer: messageBuffer, + } as TransactionBufferCreateArgs, + } as TransactionBufferCreateInstructionArgs, + programId + ); + + const message = new TransactionMessage({ + payerKey: members.proposer.publicKey, + recentBlockhash: (await connection.getLatestBlockhash()).blockhash, + instructions: [ix], + }).compileToV0Message(); + + const tx = new VersionedTransaction(message); + + tx.sign([members.proposer]); + + // Send transaction. + const signature = await connection.sendTransaction(tx, { + skipPreflight: true, + }); + await connection.confirmTransaction(signature); + + const transactionBufferAccount = await connection.getAccountInfo( + transactionBuffer + ); + + // Verify account exists. + assert.notEqual(transactionBufferAccount, null); + assert.ok(transactionBufferAccount?.data.length! > 0); + }); + + + + it("close transaction buffer", async () => { + const bufferIndex = 0; + + const [transactionBuffer, _] = PublicKey.findProgramAddressSync( + [ + Buffer.from("multisig"), + multisigPda.toBuffer(), + Buffer.from("transaction_buffer"), + members.proposer.publicKey.toBuffer(), + Uint8Array.from([bufferIndex]) + ], + programId + ); + + const ix = multisig.generated.createTransactionBufferCloseInstruction( + { + multisig: multisigPda, + transactionBuffer, + creator: members.proposer.publicKey, + }, + programId + ); + + const message = new TransactionMessage({ + payerKey: members.proposer.publicKey, + recentBlockhash: (await connection.getLatestBlockhash()).blockhash, + instructions: [ix], + }).compileToV0Message(); + + const tx = new VersionedTransaction(message); + + tx.sign([members.proposer]); + + // Send transaction. + const signature = await connection.sendTransaction(tx, { + skipPreflight: true, + }); + await connection.confirmTransaction(signature); + + const transactionBufferAccount = await connection.getAccountInfo( + transactionBuffer + ); + + // Verify account is closed. + assert.equal(transactionBufferAccount, null); + }); + + it("reinitalize transaction buffer after its been closed", async () => { + const bufferIndex = 0; + + const testPayee = Keypair.generate(); + const testIx = createTestTransferInstruction( + vaultPda, + testPayee.publicKey, + 1 * LAMPORTS_PER_SOL + ); + + // Initialize a transaction message with a single instruction. + const testTransferMessage = new TransactionMessage({ + payerKey: vaultPda, + recentBlockhash: (await connection.getLatestBlockhash()).blockhash, + instructions: [testIx], + }); + + // Serialize with SDK util + const messageBuffer = + multisig.utils.transactionMessageToMultisigTransactionMessageBytes({ + message: testTransferMessage, + addressLookupTableAccounts: [], + vaultPda, + }); + + const [transactionBuffer, _] = PublicKey.findProgramAddressSync( + [ + Buffer.from("multisig"), + multisigPda.toBuffer(), + Buffer.from("transaction_buffer"), + members.proposer.publicKey.toBuffer(), + Uint8Array.from([bufferIndex]) + ], + programId + ); + + // Convert to a SHA256 hash. + const messageHash = crypto + .createHash("sha256") + .update(messageBuffer) + .digest(); + + + const ix = multisig.generated.createTransactionBufferCreateInstruction( + { + multisig: multisigPda, + transactionBuffer, + creator: members.proposer.publicKey, + rentPayer: members.proposer.publicKey, + systemProgram: SystemProgram.programId, + }, + { + args: { + bufferIndex: bufferIndex, + vaultIndex: 0, + createKey: Keypair.generate(), + // Must be a SHA256 hash of the message buffer. + finalBufferHash: Array.from(messageHash), + finalBufferSize: messageBuffer.length, + buffer: messageBuffer, + } as TransactionBufferCreateArgs, + } as TransactionBufferCreateInstructionArgs, + programId + ); + + const message = new TransactionMessage({ + payerKey: members.proposer.publicKey, + recentBlockhash: (await connection.getLatestBlockhash()).blockhash, + instructions: [ix], + }).compileToV0Message(); + + const tx = new VersionedTransaction(message); + + tx.sign([members.proposer]); + + // Send transaction. + const signature = await connection.sendTransaction(tx, { + skipPreflight: true, + }); + await connection.confirmTransaction(signature); + + const transactionBufferAccount = await connection.getAccountInfo( + transactionBuffer + ); + + // Verify account exists. + assert.notEqual(transactionBufferAccount, null); + assert.ok(transactionBufferAccount?.data.length! > 0); + + const ix2 = multisig.generated.createTransactionBufferCloseInstruction( + { + multisig: multisigPda, + transactionBuffer, + creator: members.proposer.publicKey, + }, + programId + ); + + const message2 = new TransactionMessage({ + payerKey: members.proposer.publicKey, + recentBlockhash: (await connection.getLatestBlockhash()).blockhash, + instructions: [ix2], + }).compileToV0Message(); + + const tx2 = new VersionedTransaction(message2); + + tx2.sign([members.proposer]); + + // Send transaction. + const signature2 = await connection.sendTransaction(tx2, { + skipPreflight: true, + }); + await connection.confirmTransaction(signature2); + + const transactionBufferAccount2 = await connection.getAccountInfo( + transactionBuffer + ); + + // Verify account is closed. + assert.equal(transactionBufferAccount2, null); + }); + + // Test: Attempt to create a transaction buffer with a non-member + it("error: creating buffer as non-member", async () => { + const bufferIndex = 0; + // Create a keypair that is not a member of the multisig + const nonMember = Keypair.generate(); + // Airdrop some SOL to the non-member + const airdropSig = await connection.requestAirdrop( + nonMember.publicKey, + 1 * LAMPORTS_PER_SOL + ); + await connection.confirmTransaction(airdropSig); + + // Set up a test transaction + const testPayee = Keypair.generate(); + const testIx = await createTestTransferInstruction( + vaultPda, + testPayee.publicKey, + 1 * LAMPORTS_PER_SOL + ); + + // Create a transaction message + const testTransferMessage = new TransactionMessage({ + payerKey: vaultPda, + recentBlockhash: (await connection.getLatestBlockhash()).blockhash, + instructions: [testIx], + }); + + // Serialize the message buffer + const messageBuffer = + multisig.utils.transactionMessageToMultisigTransactionMessageBytes({ + message: testTransferMessage, + addressLookupTableAccounts: [], + vaultPda, + }); + + // Derive the transaction buffer PDA + const [transactionBuffer, _] = await PublicKey.findProgramAddressSync( + [ + Buffer.from("multisig"), + multisigPda.toBuffer(), + Buffer.from("transaction_buffer"), + nonMember.publicKey.toBuffer(), + Uint8Array.from([bufferIndex]), + ], + programId + ); + + // Create a hash of the message buffer + const messageHash = crypto + .createHash("sha256") + .update(messageBuffer) + .digest(); + + // Create the instruction to create a transaction buffer + const ix = multisig.generated.createTransactionBufferCreateInstruction( + { + multisig: multisigPda, + transactionBuffer, + creator: nonMember.publicKey, + rentPayer: nonMember.publicKey, + systemProgram: SystemProgram.programId, + }, + { + args: { + bufferIndex: bufferIndex, + vaultIndex: 0, + finalBufferHash: Array.from(messageHash), + finalBufferSize: messageBuffer.length, + buffer: messageBuffer, + } as TransactionBufferCreateArgs, + } as TransactionBufferCreateInstructionArgs, + programId + ); + + // Create and sign the transaction + const message = new TransactionMessage({ + payerKey: nonMember.publicKey, + recentBlockhash: (await connection.getLatestBlockhash()).blockhash, + instructions: [ix], + }).compileToV0Message(); + + const tx = new VersionedTransaction(message); + tx.sign([nonMember]); + + // Attempt to send the transaction and expect it to fail + await assert.rejects( + () => + connection + .sendTransaction(tx) + .catch(multisig.errors.translateAndThrowAnchorError), + /NotAMember/ + ); + }); + + // Test: Attempt to create a transaction buffer with a member without initiate permissions + it("error: creating buffer as member without proposer permissions", async () => { + const memberWithoutInitiatePermissions = members.voter; + + const bufferIndex = 0; + + // Set up a test transaction + const testPayee = Keypair.generate(); + const testIx = await createTestTransferInstruction( + vaultPda, + testPayee.publicKey, + 1 * LAMPORTS_PER_SOL + ); + + // Create a transaction message + const testTransferMessage = new TransactionMessage({ + payerKey: vaultPda, + recentBlockhash: (await connection.getLatestBlockhash()).blockhash, + instructions: [testIx], + }); + + // Serialize the message buffer + const messageBuffer = + multisig.utils.transactionMessageToMultisigTransactionMessageBytes({ + message: testTransferMessage, + addressLookupTableAccounts: [], + vaultPda, + }); + + // Derive the transaction buffer PDA + const [transactionBuffer, _] = await PublicKey.findProgramAddressSync( + [ + Buffer.from("multisig"), + multisigPda.toBuffer(), + Buffer.from("transaction_buffer"), + memberWithoutInitiatePermissions.publicKey.toBuffer(), + Uint8Array.from([bufferIndex]), + ], + programId + ); + + // Create a hash of the message buffer + const messageHash = crypto + .createHash("sha256") + .update(messageBuffer) + .digest(); + + // Create the instruction to create a transaction buffer + const ix = multisig.generated.createTransactionBufferCreateInstruction( + { + multisig: multisigPda, + transactionBuffer, + creator: memberWithoutInitiatePermissions.publicKey, + rentPayer: memberWithoutInitiatePermissions.publicKey, + systemProgram: SystemProgram.programId, + }, + { + args: { + bufferIndex: bufferIndex, + vaultIndex: 0, + finalBufferHash: Array.from(messageHash), + finalBufferSize: messageBuffer.length, + buffer: messageBuffer, + } as TransactionBufferCreateArgs, + } as TransactionBufferCreateInstructionArgs, + programId + ); + + // Create and sign the transaction + const message = new TransactionMessage({ + payerKey: memberWithoutInitiatePermissions.publicKey, + recentBlockhash: (await connection.getLatestBlockhash()).blockhash, + instructions: [ix], + }).compileToV0Message(); + + const tx = new VersionedTransaction(message); + tx.sign([memberWithoutInitiatePermissions]); + + // Attempt to send the transaction and expect it to fail + await assert.rejects( + () => + connection + .sendTransaction(tx) + .catch(multisig.errors.translateAndThrowAnchorError), + /Unauthorized/ + ); + }); + + // Test: Attempt to create a transaction buffer with an invalid index + it("error: creating buffer for invalid index", async () => { + // Use an invalid buffer index (non-u8 value) + const invalidBufferIndex = "random_string"; + + // Set up a test transaction + const testPayee = Keypair.generate(); + const testIx = await createTestTransferInstruction( + vaultPda, + testPayee.publicKey, + 1 * LAMPORTS_PER_SOL + ); + + // Create a transaction message + const testTransferMessage = new TransactionMessage({ + payerKey: vaultPda, + recentBlockhash: (await connection.getLatestBlockhash()).blockhash, + instructions: [testIx], + }); + + // Serialize the message buffer + const messageBuffer = + multisig.utils.transactionMessageToMultisigTransactionMessageBytes({ + message: testTransferMessage, + addressLookupTableAccounts: [], + vaultPda, + }); + + // Derive the transaction buffer PDA with the invalid index + const [transactionBuffer, _] = await PublicKey.findProgramAddressSync( + [ + Buffer.from("multisig"), + multisigPda.toBuffer(), + Buffer.from("transaction_buffer"), + members.proposer.publicKey.toBuffer(), + Buffer.from(invalidBufferIndex), + ], + programId + ); + + // Create a hash of the message buffer + const messageHash = crypto + .createHash("sha256") + .update(messageBuffer) + .digest(); + + // Create the instruction to create a transaction buffer + const ix = multisig.generated.createTransactionBufferCreateInstruction( + { + multisig: multisigPda, + transactionBuffer, + creator: members.proposer.publicKey, + rentPayer: members.proposer.publicKey, + systemProgram: SystemProgram.programId, + }, + { + args: { + bufferIndex: 0, + vaultIndex: 0, + finalBufferHash: Array.from(messageHash), + finalBufferSize: messageBuffer.length, + buffer: messageBuffer, + } as TransactionBufferCreateArgs, + } as TransactionBufferCreateInstructionArgs, + programId + ); + + // Create and sign the transaction + const message = new TransactionMessage({ + payerKey: members.proposer.publicKey, + recentBlockhash: (await connection.getLatestBlockhash()).blockhash, + instructions: [ix], + }).compileToV0Message(); + + const tx = new VersionedTransaction(message); + // Not signing with the create_key on purpose + tx.sign([members.proposer]); + + // Attempt to send the transaction and expect it to fail + await assert.rejects( + () => + connection + .sendTransaction(tx) + .catch(multisig.errors.translateAndThrowAnchorError), + /A seeds constraint was violated/ + ); + }); + + + it("error: creating buffer exceeding maximum size", async () => { + const bufferIndex = 0; + + // Create a large buffer that exceeds the maximum size + const largeBuffer = Buffer.alloc(500, 1); // 500 bytes, filled with 1s + + // Derive the transaction buffer PDA + const [transactionBuffer, _] = await PublicKey.findProgramAddressSync( + [ + Buffer.from("multisig"), + multisigPda.toBuffer(), + Buffer.from("transaction_buffer"), + members.proposer.publicKey.toBuffer(), + Uint8Array.from([bufferIndex]), + ], + programId + ); + + // Create a hash of the large buffer + const messageHash = crypto + .createHash("sha256") + .update(largeBuffer) + .digest(); + + // Create the instruction to create a transaction buffer + const ix = multisig.generated.createTransactionBufferCreateInstruction( + { + multisig: multisigPda, + transactionBuffer, + creator: members.proposer.publicKey, + rentPayer: members.proposer.publicKey, + systemProgram: SystemProgram.programId, + }, + { + args: { + bufferIndex: bufferIndex, + vaultIndex: 0, + finalBufferHash: Array.from(messageHash), + finalBufferSize: 10128 + 1, + buffer: largeBuffer, + } as TransactionBufferCreateArgs, + } as TransactionBufferCreateInstructionArgs, + programId + ); + + // Create and sign the transaction + const message = new TransactionMessage({ + payerKey: members.proposer.publicKey, + recentBlockhash: (await connection.getLatestBlockhash()).blockhash, + instructions: [ix], + }).compileToV0Message(); + + const tx = new VersionedTransaction(message); + tx.sign([members.proposer]); + + // Attempt to send the transaction and expect it to fail + await assert.rejects( + () => + connection + .sendTransaction(tx) + .catch(multisig.errors.translateAndThrowAnchorError), + /FinalBufferSizeExceeded/ // Assuming this is the error thrown for exceeding buffer size + ); + }); +}); diff --git a/tests/suites/instructions/transactionBufferExtend.ts b/tests/suites/instructions/transactionBufferExtend.ts new file mode 100644 index 00000000..789bf0af --- /dev/null +++ b/tests/suites/instructions/transactionBufferExtend.ts @@ -0,0 +1,441 @@ +import { + Keypair, + LAMPORTS_PER_SOL, + PublicKey, + SystemProgram, + TransactionMessage, + VersionedTransaction, +} from "@solana/web3.js"; +import * as multisig from "@sqds/multisig"; +import { + TransactionBufferCreateArgs, + TransactionBufferCreateInstructionArgs, + TransactionBufferExtendArgs, + TransactionBufferExtendInstructionArgs, +} from "@sqds/multisig/lib/generated"; +import assert from "assert"; +import * as crypto from "crypto"; +import { + TestMembers, + createAutonomousMultisigV2, + createLocalhostConnection, + createTestTransferInstruction, + generateMultisigMembers, + getTestProgramId, +} from "../../utils"; + +const programId = getTestProgramId(); +const connection = createLocalhostConnection(); + +describe("Instructions / transaction_buffer_extend", () => { + let members: TestMembers; + let transactionBufferAccount: PublicKey; + + const createKey = Keypair.generate(); + + let multisigPda = multisig.getMultisigPda({ + createKey: createKey.publicKey, + programId, + })[0]; + + const [vaultPda] = multisig.getVaultPda({ + multisigPda, + index: 0, + programId, + }); + + // Set up a multisig with some transactions. + before(async () => { + members = await generateMultisigMembers(connection); + + // Create new autonomous multisig with rentCollector set to its default vault. + await createAutonomousMultisigV2({ + connection, + createKey, + members, + threshold: 1, + timeLock: 0, + rentCollector: vaultPda, + programId, + }); + + // Airdrop some SOL to the vault + let signature = await connection.requestAirdrop( + vaultPda, + 10 * LAMPORTS_PER_SOL + ); + await connection.confirmTransaction(signature); + }); + + // Helper function to create a transaction buffer + async function createTransactionBuffer(creator: Keypair, transactionIndex: bigint) { + const [transactionBuffer, _] = await PublicKey.findProgramAddressSync( + [ + Buffer.from("multisig"), + multisigPda.toBuffer(), + Buffer.from("transaction_buffer"), + creator.publicKey.toBuffer(), + Buffer.from([Number(transactionIndex)]) + ], + programId + ); + + const testIx = await createTestTransferInstruction( + vaultPda, + Keypair.generate().publicKey, + 1 * LAMPORTS_PER_SOL + ); + + const testTransferMessage = new TransactionMessage({ + payerKey: vaultPda, + recentBlockhash: (await connection.getLatestBlockhash()).blockhash, + instructions: [testIx], + }); + + const messageBuffer = multisig.utils.transactionMessageToMultisigTransactionMessageBytes({ + message: testTransferMessage, + addressLookupTableAccounts: [], + vaultPda, + }); + + const messageHash = crypto.createHash("sha256").update(messageBuffer).digest(); + + const createIx = multisig.generated.createTransactionBufferCreateInstruction( + { + multisig: multisigPda, + transactionBuffer, + creator: creator.publicKey, + rentPayer: creator.publicKey, + systemProgram: SystemProgram.programId, + }, + { + args: { + bufferIndex: Number(transactionIndex), + vaultIndex: 0, + finalBufferHash: Array.from(messageHash), + finalBufferSize: messageBuffer.length, + buffer: messageBuffer.slice(0, 750), + } as TransactionBufferCreateArgs, + } as TransactionBufferCreateInstructionArgs, + programId + ); + + const createMessage = new TransactionMessage({ + payerKey: creator.publicKey, + recentBlockhash: (await connection.getLatestBlockhash()).blockhash, + instructions: [createIx], + }).compileToV0Message(); + + const createTx = new VersionedTransaction(createMessage); + createTx.sign([creator]); + + const sig = await connection.sendTransaction(createTx, { skipPreflight: true }); + await connection.confirmTransaction(sig); + + return transactionBuffer; + } + + // Helper function to close a transaction buffer + async function closeTransactionBuffer(creator: Keypair, transactionBuffer: PublicKey) { + const closeIx = multisig.generated.createTransactionBufferCloseInstruction( + { + multisig: multisigPda, + transactionBuffer, + creator: creator.publicKey, + }, + programId + ); + + const closeMessage = new TransactionMessage({ + payerKey: creator.publicKey, + recentBlockhash: (await connection.getLatestBlockhash()).blockhash, + instructions: [closeIx], + }).compileToV0Message(); + + const closeTx = new VersionedTransaction(closeMessage); + closeTx.sign([creator]); + + const sig = await connection.sendTransaction(closeTx, { skipPreflight: true }); + + await connection.confirmTransaction(sig); + } + + it("set transaction buffer and extend", async () => { + const transactionIndex = 1n; + + const testPayee = Keypair.generate(); + const testIx = await createTestTransferInstruction( + vaultPda, + testPayee.publicKey, + 1 * LAMPORTS_PER_SOL + ); + + let instructions = []; + + // Add 28 transfer instructions to the message. + for (let i = 0; i <= 42; i++) { + instructions.push(testIx); + } + + const testTransferMessage = new TransactionMessage({ + payerKey: vaultPda, + recentBlockhash: (await connection.getLatestBlockhash()).blockhash, + instructions: instructions, + }); + + // Serialize with SDK util + const messageBuffer = multisig.utils.transactionMessageToMultisigTransactionMessageBytes({ + message: testTransferMessage, + addressLookupTableAccounts: [], + vaultPda, + }); + + const [transactionBuffer, _] = await PublicKey.findProgramAddressSync( + [ + Buffer.from("multisig"), + multisigPda.toBuffer(), + Buffer.from("transaction_buffer"), + members.proposer.publicKey.toBuffer(), + Buffer.from([Number(transactionIndex)]) + ], + programId + ); + // Convert message buffer to a SHA256 hash. + const messageHash = crypto + .createHash("sha256") + .update(messageBuffer) + .digest(); + + // Slice the first 750 bytes of the message buffer. + const firstHalf = messageBuffer.slice(0, 750); + + const ix = multisig.generated.createTransactionBufferCreateInstruction( + { + multisig: multisigPda, + transactionBuffer, + creator: members.proposer.publicKey, + rentPayer: members.proposer.publicKey, + systemProgram: SystemProgram.programId, + }, + { + args: { + bufferIndex: Number(transactionIndex), + vaultIndex: 0, + // Must be a SHA256 hash of the message buffer. + finalBufferHash: Array.from(messageHash), + finalBufferSize: messageBuffer.length, + buffer: firstHalf, + } as TransactionBufferCreateArgs, + } as TransactionBufferCreateInstructionArgs, + programId + ); + + const message = new TransactionMessage({ + payerKey: members.proposer.publicKey, + recentBlockhash: (await connection.getLatestBlockhash()).blockhash, + instructions: [ix], + }).compileToV0Message(); + + const tx = new VersionedTransaction(message); + + tx.sign([members.proposer]); + + // Send first transaction. + const signature = await connection.sendTransaction(tx, { + skipPreflight: true, + }); + + await connection.confirmTransaction(signature); + + const transactionBufferAccount = await connection.getAccountInfo( + transactionBuffer + ); + + // Ensure the transaction buffer account exists. + assert.notEqual(transactionBufferAccount, null); + assert.ok(transactionBufferAccount?.data.length! > 0); + + // Need to add some deserialization to check if it actually worked. + const transactionBufferInfo1 = await connection.getAccountInfo(transactionBuffer); + const [txBufferDeser1] = await multisig.generated.TransactionBuffer.fromAccountInfo( + transactionBufferInfo1! + ); + + // First chunk uploaded. Check that length is as expected. + assert.equal(txBufferDeser1.buffer.length, 750); + + // Slice that last bytes of the message buffer. + const secondHalf = messageBuffer.slice( + 750, + messageBuffer.byteLength + ); + + const secondIx = + multisig.generated.createTransactionBufferExtendInstruction( + { + multisig: multisigPda, + transactionBuffer, + creator: members.proposer.publicKey, + }, + { + args: { + buffer: secondHalf, + } as TransactionBufferExtendArgs, + } as TransactionBufferExtendInstructionArgs, + programId + ); + + const secondMessage = new TransactionMessage({ + payerKey: members.proposer.publicKey, + recentBlockhash: (await connection.getLatestBlockhash()).blockhash, + instructions: [secondIx], + }).compileToV0Message(); + + const secondTx = new VersionedTransaction(secondMessage); + + secondTx.sign([members.proposer]); + + // Send second transaction. + const secondSignature = await connection.sendTransaction(secondTx, { + skipPreflight: true, + }); + + await connection.confirmTransaction(secondSignature); + + // Need to add some deserialization to check if it actually worked. + const transactionBufferInfo2 = await connection.getAccountInfo(transactionBuffer); + const [txBufferDeser2] = await multisig.generated.TransactionBuffer.fromAccountInfo( + transactionBufferInfo2! + ); + + // Buffer fully uploaded. Check that length is as expected. + assert.equal(txBufferDeser2.buffer.length, messageBuffer.byteLength); + + // Close the transaction buffer account. + await closeTransactionBuffer(members.proposer, transactionBuffer); + + // Fetch the transaction buffer account. + const closedTransactionBufferInfo = await connection.getAccountInfo( + transactionBuffer + ); + assert.equal(closedTransactionBufferInfo, null); + }); + + // Test: Attempt to extend a transaction buffer as a non-member + it("error: extending buffer as non-member", async () => { + const transactionIndex = 1n; + const nonMember = Keypair.generate(); + await connection.requestAirdrop(nonMember.publicKey, 1 * LAMPORTS_PER_SOL); + + const transactionBuffer = await createTransactionBuffer(members.almighty, transactionIndex); + + const dummyData = Buffer.alloc(100, 1); + const ix = multisig.generated.createTransactionBufferExtendInstruction( + { + multisig: multisigPda, + transactionBuffer, + creator: nonMember.publicKey, + }, + { + args: { + buffer: dummyData, + } as TransactionBufferExtendArgs, + } as TransactionBufferExtendInstructionArgs, + programId + ); + + const message = new TransactionMessage({ + payerKey: nonMember.publicKey, + recentBlockhash: (await connection.getLatestBlockhash()).blockhash, + instructions: [ix], + }).compileToV0Message(); + + const tx = new VersionedTransaction(message); + tx.sign([nonMember]); + + await assert.rejects( + () => connection.sendTransaction(tx).catch(multisig.errors.translateAndThrowAnchorError), + /(Unauthorized|ConstraintSeeds)/ + ); + + await closeTransactionBuffer(members.almighty, transactionBuffer); + }); + + // Test: Attempt to extend a transaction buffer past the 4000 byte limit + it("error: extending buffer past submitted byte value", async () => { + const transactionIndex = 1n; + + const transactionBuffer = await createTransactionBuffer(members.almighty, transactionIndex); + + const largeData = Buffer.alloc(500, 1); + const ix = multisig.generated.createTransactionBufferExtendInstruction( + { + multisig: multisigPda, + transactionBuffer, + creator: members.almighty.publicKey, + }, + { + args: { + buffer: largeData, + } as TransactionBufferExtendArgs, + } as TransactionBufferExtendInstructionArgs, + programId + ); + + const message = new TransactionMessage({ + payerKey: members.almighty.publicKey, + recentBlockhash: (await connection.getLatestBlockhash()).blockhash, + instructions: [ix], + }).compileToV0Message(); + + const tx = new VersionedTransaction(message); + tx.sign([members.almighty]); + + await assert.rejects( + () => connection.sendTransaction(tx).catch(multisig.errors.translateAndThrowAnchorError), + /FinalBufferSizeExceeded/ + ); + + await closeTransactionBuffer(members.almighty, transactionBuffer); + }); + + // Test: Attempt to extend a transaction buffer by a member who is not the original creator + it("error: extending buffer by non-creator member", async () => { + const transactionIndex = 1n; + + const transactionBuffer = await createTransactionBuffer(members.proposer, transactionIndex); + + const dummyData = Buffer.alloc(100, 1); + const extendIx = multisig.generated.createTransactionBufferExtendInstruction( + { + multisig: multisigPda, + transactionBuffer, + creator: members.almighty.publicKey, + }, + { + args: { + buffer: dummyData, + } as TransactionBufferExtendArgs, + } as TransactionBufferExtendInstructionArgs, + programId + ); + + const extendMessage = new TransactionMessage({ + payerKey: members.almighty.publicKey, + recentBlockhash: (await connection.getLatestBlockhash()).blockhash, + instructions: [extendIx], + }).compileToV0Message(); + + const extendTx = new VersionedTransaction(extendMessage); + extendTx.sign([members.almighty]); + + await assert.rejects( + () => connection.sendTransaction(extendTx).catch(multisig.errors.translateAndThrowAnchorError), + /(Unauthorized|ConstraintSeeds)/ + ); + + + await closeTransactionBuffer(members.proposer, transactionBuffer); + }); + +}); \ No newline at end of file diff --git a/tests/suites/instructions/vaultTransactionAccountsClose.ts b/tests/suites/instructions/vaultTransactionAccountsClose.ts index 9f0a35ee..054b6995 100644 --- a/tests/suites/instructions/vaultTransactionAccountsClose.ts +++ b/tests/suites/instructions/vaultTransactionAccountsClose.ts @@ -27,13 +27,14 @@ describe("Instructions / vault_transaction_accounts_close", () => { let members: TestMembers; let multisigPda: PublicKey; const staleNonApprovedTransactionIndex = 1n; - const staleApprovedTransactionIndex = 2n; - const executedConfigTransactionIndex = 3n; - const executedVaultTransactionIndex = 4n; - const activeTransactionIndex = 5n; - const approvedTransactionIndex = 6n; - const rejectedTransactionIndex = 7n; - const cancelledTransactionIndex = 8n; + const staleNoProposalTransactionIndex = 2n; + const staleApprovedTransactionIndex = 3n; + const executedConfigTransactionIndex = 4n; + const executedVaultTransactionIndex = 5n; + const activeTransactionIndex = 6n; + const approvedTransactionIndex = 7n; + const rejectedTransactionIndex = 8n; + const cancelledTransactionIndex = 9n; // Set up a multisig with some transactions. before(async () => { @@ -109,6 +110,26 @@ describe("Instructions / vault_transaction_accounts_close", () => { // This transaction will become stale when the config transaction is executed. //endregion + //region Stale and No Proposal + // Create a vault transaction (Stale and Non-Approved). + signature = await multisig.rpc.vaultTransactionCreate({ + connection, + feePayer: members.proposer, + multisigPda, + transactionIndex: staleNoProposalTransactionIndex, + vaultIndex: 0, + transactionMessage: testTransferMessage, + ephemeralSigners: 0, + creator: members.proposer.publicKey, + programId, + }); + await connection.confirmTransaction(signature); + + // No proposal created for this transaction. + + // This transaction will become stale when the config transaction is executed. + //endregion + //region Stale and Approved // Create a vault transaction (Stale and Approved). signature = await multisig.rpc.vaultTransactionCreate({ @@ -710,7 +731,7 @@ describe("Instructions / vault_transaction_accounts_close", () => { connection .sendTransaction(tx) .catch(multisig.errors.translateAndThrowAnchorError), - /Proposal is for another multisig/ + /A seeds constraint was violated/ ); }); @@ -849,7 +870,7 @@ describe("Instructions / vault_transaction_accounts_close", () => { rentCollector: vaultPda, proposal: multisig.getProposalPda({ multisigPda, - transactionIndex: rejectedTransactionIndex, + transactionIndex: 1n, programId, })[0], transaction: multisig.getTransactionPda({ @@ -921,7 +942,7 @@ describe("Instructions / vault_transaction_accounts_close", () => { connection .sendTransaction(tx) .catch(multisig.errors.translateAndThrowAnchorError), - /Transaction doesn't match proposal/ + /A seeds constraint was violated/ ); }); @@ -973,6 +994,53 @@ describe("Instructions / vault_transaction_accounts_close", () => { assert.equal(postBalance, preBalance + accountsRent); }); + it("close accounts for Stale transaction with No Proposal", async () => { + const transactionIndex = staleNoProposalTransactionIndex; + + const multisigAccount = await Multisig.fromAccountAddress( + connection, + multisigPda + ); + + // Make sure there's no proposal. + let proposalAccount = await connection.getAccountInfo( + multisig.getProposalPda({ + multisigPda, + transactionIndex, + programId, + })[0] + ); + assert.equal(proposalAccount, null); + + // Make sure the transaction is stale. + assert.ok( + transactionIndex <= + multisig.utils.toBigInt(multisigAccount.staleTransactionIndex) + ); + + const [vaultPda] = multisig.getVaultPda({ + multisigPda, + index: 0, + programId, + }); + + const preBalance = await connection.getBalance(vaultPda); + + const sig = await multisig.rpc.vaultTransactionAccountsClose({ + connection, + feePayer: members.almighty, + multisigPda, + rentCollector: vaultPda, + transactionIndex, + programId, + }); + await connection.confirmTransaction(sig); + + const postBalance = await connection.getBalance(vaultPda); + const accountsRent = 2_429_040; // Rent for the transaction account. + assert.equal(postBalance, preBalance + accountsRent); + }); + it("close accounts for Executed transaction", async () => { const transactionIndex = executedVaultTransactionIndex; diff --git a/tests/suites/instructions/vaultTransactionCreateFromBuffer.ts b/tests/suites/instructions/vaultTransactionCreateFromBuffer.ts new file mode 100644 index 00000000..82404864 --- /dev/null +++ b/tests/suites/instructions/vaultTransactionCreateFromBuffer.ts @@ -0,0 +1,676 @@ +import { + AccountMeta, + Keypair, + LAMPORTS_PER_SOL, + PublicKey, + SystemProgram, + Transaction, + TransactionMessage, + VersionedTransaction, + ComputeBudgetProgram +} from "@solana/web3.js"; +import * as multisig from "@sqds/multisig"; +import { + TransactionBufferCreateArgs, + TransactionBufferCreateInstructionArgs, + TransactionBufferExtendArgs, + TransactionBufferExtendInstructionArgs, + VaultTransactionCreateArgs, + VaultTransactionCreateFromBufferInstructionArgs +} from "@sqds/multisig/lib/generated"; +import assert from "assert"; +import * as crypto from "crypto"; +import { + TestMembers, + createAutonomousMultisigV2, + createLocalhostConnection, + createTestTransferInstruction, + generateMultisigMembers, + getLogs, + getTestProgramId, +} from "../../utils"; + +const programId = getTestProgramId(); +const connection = createLocalhostConnection(); + +describe("Instructions / vault_transaction_create_from_buffer", () => { + let members: TestMembers; + + const createKey = Keypair.generate(); + + let multisigPda = multisig.getMultisigPda({ + createKey: createKey.publicKey, + programId, + })[0]; + + const [vaultPda] = multisig.getVaultPda({ + multisigPda, + index: 0, + programId, + }); + + // Set up a multisig with some transactions. + before(async () => { + members = await generateMultisigMembers(connection); + + // Create new autonomous multisig with rentCollector set to its default vault. + await createAutonomousMultisigV2({ + connection, + createKey, + members, + threshold: 1, + timeLock: 0, + rentCollector: vaultPda, + programId, + }); + + // Airdrop some SOL to the vault + let signature = await connection.requestAirdrop( + vaultPda, + 10 * LAMPORTS_PER_SOL + ); + await connection.confirmTransaction(signature); + }); + + it("set buffer, extend, and create", async () => { + const transactionIndex = 1n; + const bufferIndex = 0; + + const testPayee = Keypair.generate(); + const testIx = createTestTransferInstruction( + vaultPda, + testPayee.publicKey, + 1 * LAMPORTS_PER_SOL + ); + + let instructions = []; + + // Add 48 transfer instructions to the message. + for (let i = 0; i <= 42; i++) { + instructions.push(testIx); + } + + const testTransferMessage = new TransactionMessage({ + payerKey: vaultPda, + recentBlockhash: (await connection.getLatestBlockhash()).blockhash, + instructions: instructions, + }); + + // Serialize the message. Must be done with this util function + const messageBuffer = + multisig.utils.transactionMessageToMultisigTransactionMessageBytes({ + message: testTransferMessage, + addressLookupTableAccounts: [], + vaultPda, + }); + + + const [transactionBuffer, _] = await PublicKey.findProgramAddressSync( + [ + Buffer.from("multisig"), + multisigPda.toBuffer(), + Buffer.from("transaction_buffer"), + members.proposer.publicKey.toBuffer(), + Uint8Array.from([bufferIndex]), + ], + programId + ); + + const messageHash = crypto + .createHash("sha256") + .update(messageBuffer) + .digest(); + + // Slice the message buffer into two parts. + const firstSlice = messageBuffer.slice(0, 700); + + const ix = multisig.generated.createTransactionBufferCreateInstruction( + { + multisig: multisigPda, + transactionBuffer, + creator: members.proposer.publicKey, + rentPayer: members.proposer.publicKey, + systemProgram: SystemProgram.programId, + }, + { + args: { + bufferIndex: bufferIndex, + vaultIndex: 0, + // Must be a SHA256 hash of the message buffer. + finalBufferHash: Array.from(messageHash), + finalBufferSize: messageBuffer.length, + buffer: firstSlice, + } as TransactionBufferCreateArgs, + } as TransactionBufferCreateInstructionArgs, + programId + ); + + const message = new TransactionMessage({ + payerKey: members.proposer.publicKey, + recentBlockhash: (await connection.getLatestBlockhash()).blockhash, + instructions: [ix], + }).compileToV0Message(); + + const tx = new VersionedTransaction(message); + + tx.sign([members.proposer]); + + // Send first transaction. + const signature = await connection.sendTransaction(tx, { + skipPreflight: true, + }); + await connection.confirmTransaction(signature); + + const transactionBufferAccount = await connection.getAccountInfo( + transactionBuffer + ); + + // Check buffer account exists. + assert.notEqual(transactionBufferAccount, null); + assert.ok(transactionBufferAccount?.data.length! > 0); + + // Need to add some deserialization to check if it actually worked. + const transactionBufferInfo1 = await connection.getAccountInfo( + transactionBuffer + ); + const [txBufferDeser1] = + await multisig.generated.TransactionBuffer.fromAccountInfo( + transactionBufferInfo1! + ); + + // First chunk uploaded. Check that length is as expected. + assert.equal(txBufferDeser1.buffer.length, 700); + + const secondSlice = messageBuffer.slice(700, messageBuffer.byteLength); + + // Extned the buffer. + const secondIx = + multisig.generated.createTransactionBufferExtendInstruction( + { + multisig: multisigPda, + transactionBuffer, + creator: members.proposer.publicKey, + }, + { + args: { + buffer: secondSlice, + } as TransactionBufferExtendArgs, + } as TransactionBufferExtendInstructionArgs, + programId + ); + + const secondMessage = new TransactionMessage({ + payerKey: members.proposer.publicKey, + recentBlockhash: (await connection.getLatestBlockhash()).blockhash, + instructions: [secondIx], + }).compileToV0Message(); + + const secondTx = new VersionedTransaction(secondMessage); + + secondTx.sign([members.proposer]); + + // Send second transaction to extend. + const secondSignature = await connection.sendTransaction(secondTx, { + skipPreflight: true, + }); + + await connection.confirmTransaction(secondSignature); + + // Need to add some deserialization to check if it actually worked. + const transactionBufferInfo2 = await connection.getAccountInfo( + transactionBuffer + ); + const [txBufferDeser2] = + multisig.generated.TransactionBuffer.fromAccountInfo( + transactionBufferInfo2! + ); + + // Final chunk uploaded. Check that length is as expected. + + assert.equal(txBufferDeser2.buffer.length, messageBuffer.byteLength); + + // Derive vault transaction PDA. + const [transactionPda] = multisig.getTransactionPda({ + multisigPda, + index: transactionIndex, + programId, + }); + + const transactionAccountInfo = await connection.getAccountInfo(transactionPda); + + + + const transactionBufferMeta: AccountMeta = { + pubkey: transactionBuffer, + isWritable: true, + isSigner: false + } + const mockTransferIx = SystemProgram.transfer({ + fromPubkey: members.proposer.publicKey, + toPubkey: members.almighty.publicKey, + lamports: 100 + }); + + + // Create final instruction. + const thirdIx = + multisig.generated.createVaultTransactionCreateFromBufferInstruction( + { + vaultTransactionCreateItemMultisig: multisigPda, + vaultTransactionCreateItemTransaction: transactionPda, + vaultTransactionCreateItemCreator: members.proposer.publicKey, + vaultTransactionCreateItemRentPayer: members.proposer.publicKey, + vaultTransactionCreateItemSystemProgram: SystemProgram.programId, + creator: members.proposer.publicKey, + transactionBuffer: transactionBuffer, + }, + { + args: { + vaultIndex: 0, + ephemeralSigners: 0, + transactionMessage: new Uint8Array(6).fill(0), + memo: null, + } as VaultTransactionCreateArgs, + } as VaultTransactionCreateFromBufferInstructionArgs, + programId + ); + + // Add third instruction to the message. + const blockhash = await connection.getLatestBlockhash(); + + const thirdMessage = new TransactionMessage({ + payerKey: members.proposer.publicKey, + recentBlockhash: blockhash.blockhash, + instructions: [thirdIx], + }).compileToV0Message(); + + const thirdTx = new VersionedTransaction(thirdMessage); + + thirdTx.sign([members.proposer]); + + // Send final transaction. + const thirdSignature = await connection.sendRawTransaction(thirdTx.serialize(), { + skipPreflight: true, + }); + + await connection.confirmTransaction({ + signature: thirdSignature, + blockhash: blockhash.blockhash, + lastValidBlockHeight: blockhash.lastValidBlockHeight, + }, "confirmed"); + + const transactionInfo = + await multisig.accounts.VaultTransaction.fromAccountAddress( + connection, + transactionPda + ); + + // Ensure final vault transaction has 43 instructions + assert.equal(transactionInfo.message.instructions.length, 43); + }); + + it("error: create from buffer with mismatched hash", async () => { + const transactionIndex = 2n; + const bufferIndex = 0; + + // Create a simple transfer instruction + const testIx = await createTestTransferInstruction( + vaultPda, + Keypair.generate().publicKey, + 0.1 * LAMPORTS_PER_SOL + ); + + const testTransferMessage = new TransactionMessage({ + payerKey: vaultPda, + recentBlockhash: (await connection.getLatestBlockhash()).blockhash, + instructions: [testIx], + }); + + const messageBuffer = + multisig.utils.transactionMessageToMultisigTransactionMessageBytes({ + message: testTransferMessage, + addressLookupTableAccounts: [], + vaultPda, + }); + + + const [transactionBuffer, _] = await PublicKey.findProgramAddressSync( + [ + Buffer.from("multisig"), + multisigPda.toBuffer(), + Buffer.from("transaction_buffer"), + members.proposer.publicKey.toBuffer(), + Uint8Array.from([bufferIndex]), + ], + programId + ); + + // Create a dummy hash of zeros + const dummyHash = new Uint8Array(32).fill(0); + + const createIx = + multisig.generated.createTransactionBufferCreateInstruction( + { + multisig: multisigPda, + transactionBuffer, + creator: members.proposer.publicKey, + rentPayer: members.proposer.publicKey, + systemProgram: SystemProgram.programId, + }, + { + args: { + bufferIndex, + vaultIndex: 0, + finalBufferHash: Array.from(dummyHash), + finalBufferSize: messageBuffer.length, + buffer: messageBuffer, + } as TransactionBufferCreateArgs, + } as TransactionBufferCreateInstructionArgs, + programId + ); + + + const createMessage = new TransactionMessage({ + payerKey: members.proposer.publicKey, + recentBlockhash: (await connection.getLatestBlockhash()).blockhash, + instructions: [createIx], + }).compileToV0Message(); + + const createTx = new VersionedTransaction(createMessage); + createTx.sign([members.proposer]); + + const createBufferSig = await connection.sendTransaction(createTx, { + skipPreflight: true, + }); + await connection.confirmTransaction(createBufferSig); + + const [transactionPda] = multisig.getTransactionPda({ + multisigPda, + index: transactionIndex, + programId, + }); + const transactionBufferMeta: AccountMeta = { + pubkey: transactionBuffer, + isWritable: true, + isSigner: false + } + + const createFromBufferIx = + multisig.generated.createVaultTransactionCreateFromBufferInstruction( + { + vaultTransactionCreateItemMultisig: multisigPda, + vaultTransactionCreateItemTransaction: transactionPda, + vaultTransactionCreateItemCreator: members.proposer.publicKey, + vaultTransactionCreateItemRentPayer: members.proposer.publicKey, + vaultTransactionCreateItemSystemProgram: SystemProgram.programId, + creator: members.proposer.publicKey, + transactionBuffer: transactionBuffer, + }, + { + args: { + vaultIndex: 0, + ephemeralSigners: 0, + transactionMessage: new Uint8Array(6).fill(0), + memo: null, + anchorRemainingAccounts: [transactionBufferMeta] + } as VaultTransactionCreateArgs, + } as VaultTransactionCreateFromBufferInstructionArgs, + programId + ); + + const createFromBufferMessage = new TransactionMessage({ + payerKey: members.proposer.publicKey, + recentBlockhash: (await connection.getLatestBlockhash()).blockhash, + instructions: [createFromBufferIx], + }).compileToV0Message(); + + + const createFromBufferTx = new VersionedTransaction( + createFromBufferMessage + ); + createFromBufferTx.sign([members.proposer]); + + await assert.rejects( + () => + connection + .sendTransaction(createFromBufferTx) + .catch(multisig.errors.translateAndThrowAnchorError), + /FinalBufferHashMismatch/ + ); + }); + + // We expect the program to run out of memory in a base case, given 43 transfers. + it("error: out of memory (no allocator)", async () => { + const [transactionPda] = multisig.getTransactionPda({ + multisigPda, + index: 1n, + programId, + }); + + const transactionInfo = + await multisig.accounts.VaultTransaction.fromAccountAddress( + connection, + transactionPda + ); + + // Check that we're dealing with the same account from first test. + assert.equal(transactionInfo.message.instructions.length, 43); + + const fourthSignature = await multisig.rpc.proposalCreate({ + connection, + feePayer: members.almighty, + multisigPda, + transactionIndex: 1n, + creator: members.almighty, + isDraft: false, + programId, + }); + await connection.confirmTransaction(fourthSignature); + + const fifthSignature = await multisig.rpc.proposalApprove({ + connection, + feePayer: members.almighty, + multisigPda, + transactionIndex: 1n, + member: members.almighty, + programId, + }); + await connection.confirmTransaction(fifthSignature); + + const executeIx = await multisig.instructions.vaultTransactionExecute({ + connection, + multisigPda, + transactionIndex: 1n, + member: members.almighty.publicKey, + programId, + }); + + const executeTx = new Transaction().add(executeIx.instruction); + const signature4 = await connection.sendTransaction( + executeTx, + [members.almighty], + { skipPreflight: true } + ); + await connection.confirmTransaction(signature4); + + const logs = (await getLogs(connection, signature4)).join(""); + + assert.match(logs, /Access violation in heap section at address/); + + }); + + it("handles buffer sizes up to 10128 bytes", async () => { + const transactionIndex = 2n; + const bufferIndex = 1; + const CHUNK_SIZE = 900; // Safe chunk size for buffer extension + + // Create dummy instruction with 200 bytes of random data + function createLargeInstruction() { + const randomData = crypto.randomBytes(200); + return { + programId: SystemProgram.programId, + keys: [{ pubkey: vaultPda, isSigner: false, isWritable: true }], + data: randomData + }; + } + + // Create 45 instructions to get close to but not exceed 10128 bytes + const instructions = Array(45).fill(null).map(() => createLargeInstruction()); + + const testTransferMessage = new TransactionMessage({ + payerKey: vaultPda, + recentBlockhash: (await connection.getLatestBlockhash()).blockhash, + instructions: instructions, + }); + + // Serialize the message + const messageBuffer = multisig.utils.transactionMessageToMultisigTransactionMessageBytes({ + message: testTransferMessage, + addressLookupTableAccounts: [], + vaultPda, + }); + + console.log(`Total message buffer size: ${messageBuffer.length} bytes`); + + // Verify buffer size is within limits + if (messageBuffer.length > 10128) { + throw new Error("Buffer size exceeds 10128 byte limit"); + } + + const [transactionBuffer] = await PublicKey.findProgramAddressSync( + [ + Buffer.from("multisig"), + multisigPda.toBuffer(), + Buffer.from("transaction_buffer"), + members.proposer.publicKey.toBuffer(), + Uint8Array.from([bufferIndex]), + ], + programId + ); + + const messageHash = crypto.createHash("sha256").update(messageBuffer).digest(); + + // Calculate number of chunks needed + const numChunks = Math.ceil(messageBuffer.length / CHUNK_SIZE); + console.log(`Uploading in ${numChunks} chunks`); + + // Initial buffer creation with first chunk + const firstChunk = messageBuffer.slice(0, CHUNK_SIZE); + const createIx = multisig.generated.createTransactionBufferCreateInstruction( + { + multisig: multisigPda, + transactionBuffer, + creator: members.proposer.publicKey, + rentPayer: members.proposer.publicKey, + systemProgram: SystemProgram.programId, + }, + { + args: { + bufferIndex, + vaultIndex: 0, + finalBufferHash: Array.from(messageHash), + finalBufferSize: messageBuffer.length, + buffer: firstChunk, + } as TransactionBufferCreateArgs, + } as TransactionBufferCreateInstructionArgs, + programId + ); + + // Send initial chunk + const createTx = new VersionedTransaction( + new TransactionMessage({ + payerKey: members.proposer.publicKey, + recentBlockhash: (await connection.getLatestBlockhash()).blockhash, + instructions: [createIx], + }).compileToV0Message() + ); + createTx.sign([members.proposer]); + const signature = await connection.sendTransaction(createTx, { skipPreflight: true }); + await connection.confirmTransaction(signature); + + // Extend buffer with remaining chunks + for (let i = 1; i < numChunks; i++) { + const start = i * CHUNK_SIZE; + const end = Math.min(start + CHUNK_SIZE, messageBuffer.length); + const chunk = messageBuffer.slice(start, end); + + const extendIx = multisig.generated.createTransactionBufferExtendInstruction( + { + multisig: multisigPda, + transactionBuffer, + creator: members.proposer.publicKey, + }, + { + args: { + buffer: chunk, + } as TransactionBufferExtendArgs, + } as TransactionBufferExtendInstructionArgs, + programId + ); + + const extendTx = new VersionedTransaction( + new TransactionMessage({ + payerKey: members.proposer.publicKey, + recentBlockhash: (await connection.getLatestBlockhash()).blockhash, + instructions: [extendIx], + }).compileToV0Message() + ); + extendTx.sign([members.proposer]); + const sig = await connection.sendRawTransaction(extendTx.serialize(), { skipPreflight: true }); + await connection.confirmTransaction(sig); + } + console.log("Buffer upload complete"); + // Verify final buffer size + const bufferAccount = await connection.getAccountInfo(transactionBuffer); + const [bufferData] = await multisig.generated.TransactionBuffer.fromAccountInfo(bufferAccount!); + assert.equal(bufferData.buffer.length, messageBuffer.length); + + // Create transaction from buffer + const [transactionPda] = multisig.getTransactionPda({ + multisigPda, + index: transactionIndex, + programId, + }); + + const createFromBufferIx = multisig.generated.createVaultTransactionCreateFromBufferInstruction( + { + vaultTransactionCreateItemMultisig: multisigPda, + vaultTransactionCreateItemTransaction: transactionPda, + vaultTransactionCreateItemCreator: members.proposer.publicKey, + vaultTransactionCreateItemRentPayer: members.proposer.publicKey, + vaultTransactionCreateItemSystemProgram: SystemProgram.programId, + creator: members.proposer.publicKey, + transactionBuffer: transactionBuffer, + }, + { + args: { + vaultIndex: 0, + ephemeralSigners: 0, + transactionMessage: new Uint8Array(6).fill(0), + memo: null, + } as VaultTransactionCreateArgs, + } as VaultTransactionCreateFromBufferInstructionArgs, + programId + ); + const requestHeapIx = ComputeBudgetProgram.requestHeapFrame({ + bytes: 262144 + }) + const finalTx = new VersionedTransaction( + new TransactionMessage({ + payerKey: members.proposer.publicKey, + recentBlockhash: (await connection.getLatestBlockhash()).blockhash, + instructions: [requestHeapIx, createFromBufferIx], + }).compileToV0Message() + ); + finalTx.sign([members.proposer]); + const finalSignature = await connection.sendRawTransaction(finalTx.serialize(), { skipPreflight: true }); + await connection.confirmTransaction(finalSignature); + + // Verify created transaction + const transactionInfo = await multisig.accounts.VaultTransaction.fromAccountAddress( + connection, + transactionPda + ); + assert.equal(transactionInfo.message.instructions.length, 45); + }); +}); diff --git a/tests/suites/multisig-sdk.ts b/tests/suites/multisig-sdk.ts index acb61cd9..db2da09c 100644 --- a/tests/suites/multisig-sdk.ts +++ b/tests/suites/multisig-sdk.ts @@ -843,7 +843,9 @@ describe("Multisig SDK", () => { ); }); - it("create a new Spending Limit for the controlled multisig", async () => { + it("create a new Spending Limit for the controlled multisig with member of the ms and non-member", async () => { + const nonMember = await generateFundedKeypair(connection); + const signature = await multisig.rpc.multisigAddSpendingLimit({ connection, feePayer: feePayer, @@ -856,7 +858,7 @@ describe("Multisig SDK", () => { period: multisig.generated.Period.Day, mint: Keypair.generate().publicKey, destinations: [Keypair.generate().publicKey], - members: [members.almighty.publicKey], + members: [members.almighty.publicKey, nonMember.publicKey], vaultIndex: 1, signers: [feePayer, members.almighty], sendOptions: { skipPreflight: true }, @@ -2077,6 +2079,101 @@ describe("Multisig SDK", () => { multisig.types.isProposalStatusCancelled(proposalAccount.status) ); }); + + it("proposal_cancel_v2", async () => { + // Create a config transaction. + const transactionIndex = 2n; + let newVotingMember = new Keypair(); + + const [proposalPda] = multisig.getProposalPda({ + multisigPda, + transactionIndex, + programId, + }); + + let signature = await multisig.rpc.configTransactionCreate({ + connection, + feePayer: members.proposer, + multisigPda, + transactionIndex, + creator: members.proposer.publicKey, + actions: [{ __kind: "AddMember", newMember: {key: newVotingMember.publicKey, permissions: multisig.types.Permissions.all()} }], + programId, + }); + await connection.confirmTransaction(signature); + + // Create a proposal for the transaction. + signature = await multisig.rpc.proposalCreate({ + connection, + feePayer: members.proposer, + multisigPda, + transactionIndex, + creator: members.proposer, + programId, + }); + await connection.confirmTransaction(signature); + + // Approve the proposal 1. + signature = await multisig.rpc.proposalApprove({ + connection, + feePayer: members.voter, + multisigPda, + transactionIndex, + member: members.voter, + programId, + }); + await connection.confirmTransaction(signature); + + // Approve the proposal 2. + signature = await multisig.rpc.proposalApprove({ + connection, + feePayer: members.almighty, + multisigPda, + transactionIndex, + member: members.almighty, + programId, + }); + await connection.confirmTransaction(signature); + + let proposalAccount = await Proposal.fromAccountAddress( + connection, + proposalPda + ); + // Our threshold is 2, so after the first cancel, the proposal is still `Approved`. + assert.ok( + multisig.types.isProposalStatusApproved(proposalAccount.status) + ); + + // Proposal is now ready to execute, cast the 2 cancels using the new functionality. + signature = await multisig.rpc.proposalCancelV2({ + connection, + feePayer: members.voter, + member: members.voter, + multisigPda, + transactionIndex, + programId, + }); + await connection.confirmTransaction(signature); + + // Proposal is now ready to execute, cast the 2 cancels using the new functionality. + signature = await multisig.rpc.proposalCancelV2({ + connection, + feePayer: members.almighty, + member: members.almighty, + multisigPda, + transactionIndex, + programId, + }); + await connection.confirmTransaction(signature); + + // Proposal status must be "Cancelled". + proposalAccount = await Proposal.fromAccountAddress( + connection, + proposalPda + ); + assert.ok(multisig.types.isProposalStatusCancelled(proposalAccount.status)); + }); + }); describe("vault_transaction_execute", () => { @@ -2256,13 +2353,20 @@ describe("Multisig SDK", () => { index: 0, programId, }); - + const programConfigPda = multisig.getProgramConfigPda({ programId })[0]; + const programConfig = await multisig.accounts.ProgramConfig.fromAccountAddress( + connection, + programConfigPda + ); + const treasury = programConfig.treasury; const multisigCreateArgs: Parameters< - typeof multisig.transactions.multisigCreate + typeof multisig.transactions.multisigCreateV2 >[0] = { blockhash: (await connection.getLatestBlockhash()).blockhash, createKey: createKey.publicKey, creator: multisigCreator.publicKey, + treasury: treasury, + rentCollector: null, multisigPda, configAuthority, timeLock: 0, @@ -2277,7 +2381,7 @@ describe("Multisig SDK", () => { }; const createMultisigTxWithoutMemo = - multisig.transactions.multisigCreate(multisigCreateArgs); + multisig.transactions.multisigCreateV2(multisigCreateArgs); const availableMemoSize = multisig.utils.getAvailableMemoSize( createMultisigTxWithoutMemo @@ -2285,7 +2389,7 @@ describe("Multisig SDK", () => { const memo = "a".repeat(availableMemoSize); - const createMultisigTxWithMemo = multisig.transactions.multisigCreate({ + const createMultisigTxWithMemo = multisig.transactions.multisigCreateV2({ ...multisigCreateArgs, memo, }); diff --git a/tests/suites/program-config-init.ts b/tests/suites/program-config-init.ts index e6ff2e5b..6f09f9e9 100644 --- a/tests/suites/program-config-init.ts +++ b/tests/suites/program-config-init.ts @@ -1,4 +1,11 @@ +import { + LAMPORTS_PER_SOL, + PublicKey, + TransactionMessage, + VersionedTransaction, +} from "@solana/web3.js"; import * as multisig from "@sqds/multisig"; +import assert from "assert"; import { createLocalhostConnection, generateFundedKeypair, @@ -7,13 +14,6 @@ import { getTestProgramId, getTestProgramTreasury, } from "../utils"; -import { - LAMPORTS_PER_SOL, - PublicKey, - TransactionMessage, - VersionedTransaction, -} from "@solana/web3.js"; -import assert from "assert"; const programId = getTestProgramId(); const programConfigInitializer = getTestProgramConfigInitializer(); @@ -161,7 +161,7 @@ describe("Initialize Global ProgramConfig", () => { }).compileToV0Message(); const tx = new VersionedTransaction(message); tx.sign([programConfigInitializer]); - const sig = await connection.sendRawTransaction(tx.serialize()); + const sig = await connection.sendRawTransaction(tx.serialize(), { skipPreflight: true }); await connection.confirmTransaction(sig); const programConfigData = diff --git a/tests/utils.ts b/tests/utils.ts index 1938cbe7..8cb14d99 100644 --- a/tests/utils.ts +++ b/tests/utils.ts @@ -1,3 +1,4 @@ +import { createMemoInstruction } from "@solana/spl-memo"; import { Connection, Keypair, @@ -5,12 +6,12 @@ import { PublicKey, SystemProgram, TransactionMessage, + VersionedTransaction, } from "@solana/web3.js"; import * as multisig from "@sqds/multisig"; +import assert from "assert"; import { readFileSync } from "fs"; import path from "path"; -import { createMemoInstruction } from "@solana/spl-memo"; -import assert from "assert"; const { Permission, Permissions } = multisig.types; const { Proposal } = multisig.accounts; @@ -124,6 +125,14 @@ export function createLocalhostConnection() { return new Connection("http://127.0.0.1:8899", "confirmed"); } +export const getLogs = async (connection: Connection, signature: string): Promise => { + const tx = await connection.getTransaction( + signature, + { commitment: "confirmed" } + ) + return tx!.meta!.logMessages || [] +} + export async function createAutonomousMultisig({ connection, createKey = Keypair.generate(), @@ -139,42 +148,22 @@ export async function createAutonomousMultisig({ connection: Connection; programId: PublicKey; }) { - const creator = await generateFundedKeypair(connection); const [multisigPda, multisigBump] = multisig.getMultisigPda({ createKey: createKey.publicKey, programId, }); - const signature = await multisig.rpc.multisigCreate({ + await createAutonomousMultisigV2({ connection, - creator, - multisigPda, - configAuthority: null, - timeLock, + createKey, + members, threshold, - members: [ - { key: members.almighty.publicKey, permissions: Permissions.all() }, - { - key: members.proposer.publicKey, - permissions: Permissions.fromPermissions([Permission.Initiate]), - }, - { - key: members.voter.publicKey, - permissions: Permissions.fromPermissions([Permission.Vote]), - }, - { - key: members.executor.publicKey, - permissions: Permissions.fromPermissions([Permission.Execute]), - }, - ], - createKey: createKey, - sendOptions: { skipPreflight: true }, + timeLock, + rentCollector: null, programId, }); - await connection.confirmTransaction(signature); - return [multisigPda, multisigBump] as const; } @@ -260,42 +249,23 @@ export async function createControlledMultisig({ connection: Connection; programId: PublicKey; }) { - const creator = await generateFundedKeypair(connection); const [multisigPda, multisigBump] = multisig.getMultisigPda({ createKey: createKey.publicKey, programId, }); - const signature = await multisig.rpc.multisigCreate({ + await createControlledMultisigV2({ connection, - creator, - multisigPda, - configAuthority, - timeLock, + createKey, + members, + rentCollector: null, threshold, - members: [ - { key: members.almighty.publicKey, permissions: Permissions.all() }, - { - key: members.proposer.publicKey, - permissions: Permissions.fromPermissions([Permission.Initiate]), - }, - { - key: members.voter.publicKey, - permissions: Permissions.fromPermissions([Permission.Vote]), - }, - { - key: members.executor.publicKey, - permissions: Permissions.fromPermissions([Permission.Execute]), - }, - ], - createKey: createKey, - sendOptions: { skipPreflight: true }, - programId, + configAuthority: configAuthority, + timeLock, + programId }); - await connection.confirmTransaction(signature); - return [multisigPda, multisigBump] as const; } @@ -374,6 +344,12 @@ export type MultisigWithRentReclamationAndVariousBatches = { * The proposal is stale. */ staleDraftBatchIndex: bigint; + /** + * Index of a batch with a proposal in the Draft state. + * The batch contains 1 transaction, which is not executed. + * The proposal is stale. + */ + staleDraftBatchNoProposalIndex: bigint; /** * Index of a batch with a proposal in the Approved state. * The batch contains 2 transactions, the first of which is executed, the second is not. @@ -491,13 +467,14 @@ export async function createAutonomousMultisigWithRentReclamationAndVariousBatch //endregion const staleDraftBatchIndex = 1n; - const staleApprovedBatchIndex = 2n; - const executedConfigTransactionIndex = 3n; - const executedBatchIndex = 4n; - const activeBatchIndex = 5n; - const approvedBatchIndex = 6n; - const rejectedBatchIndex = 7n; - const cancelledBatchIndex = 8n; + const staleDraftBatchNoProposalIndex = 2n; + const staleApprovedBatchIndex = 3n; + const executedConfigTransactionIndex = 4n; + const executedBatchIndex = 5n; + const activeBatchIndex = 6n; + const approvedBatchIndex = 7n; + const rejectedBatchIndex = 8n; + const cancelledBatchIndex = 9n; //region Stale batch with proposal in Draft state // Create a batch (Stale and Non-Approved). @@ -541,6 +518,24 @@ export async function createAutonomousMultisigWithRentReclamationAndVariousBatch // This batch will become stale when the config transaction is executed. //endregion + //region Stale batch with No Proposal + // Create a batch (Stale and Non-Approved). + signature = await multisig.rpc.batchCreate({ + connection, + feePayer: members.proposer, + multisigPda, + batchIndex: staleDraftBatchNoProposalIndex, + vaultIndex: 0, + creator: members.proposer, + programId, + }); + await connection.confirmTransaction(signature); + + // No Proposal for this batch. + + // This batch will become stale when the config transaction is executed. + //endregion + //region Stale batch with Approved proposal // Create a batch (Stale and Approved). signature = await multisig.rpc.batchCreate({ @@ -1148,6 +1143,7 @@ export async function createAutonomousMultisigWithRentReclamationAndVariousBatch return { multisigPda, staleDraftBatchIndex, + staleDraftBatchNoProposalIndex, staleApprovedBatchIndex, executedConfigTransactionIndex, executedBatchIndex, @@ -1191,3 +1187,57 @@ export function range(min: number, max: number, step: number = 1) { export function comparePubkeys(a: PublicKey, b: PublicKey) { return a.toBuffer().compare(b.toBuffer()); } + +export async function processBufferInChunks( + member: Keypair, + multisigPda: PublicKey, + bufferAccount: PublicKey, + buffer: Uint8Array, + connection: Connection, + programId: PublicKey, + chunkSize: number = 700, + startIndex: number = 0 +) { + const processChunk = async (startIndex: number) => { + if (startIndex >= buffer.length) { + return; + } + + const chunk = buffer.slice(startIndex, startIndex + chunkSize); + + const ix = multisig.generated.createTransactionBufferExtendInstruction( + { + multisig: multisigPda, + transactionBuffer: bufferAccount, + creator: member.publicKey, + }, + { + args: { + buffer: chunk, + }, + }, + programId + ); + + const message = new TransactionMessage({ + payerKey: member.publicKey, + recentBlockhash: (await connection.getLatestBlockhash()).blockhash, + instructions: [ix], + }).compileToV0Message(); + + const tx = new VersionedTransaction(message); + + tx.sign([member]); + + const signature = await connection.sendRawTransaction(tx.serialize(), { + skipPreflight: true, + }); + + await connection.confirmTransaction(signature); + + // Move to next chunk + await processChunk(startIndex + chunkSize); + }; + + await processChunk(startIndex); +} \ No newline at end of file