diff --git a/package.json b/package.json index aec14a2d..97c06d20 100644 --- a/package.json +++ b/package.json @@ -11,6 +11,7 @@ }, "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", 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 ea44c7d8..d835fd1a 100644 --- a/programs/squads_multisig_program/src/instructions/transaction_accounts_close.rs +++ b/programs/squads_multisig_program/src/instructions/transaction_accounts_close.rs @@ -1,3 +1,14 @@ +//! Contains instructions for closing accounts related to ConfigTransactions, +//! VaultTransactions and Batches. +//! +//! The differences between the 3 is minor but still exist. For example, +//! a ConfigTransaction's accounts can always be closed if the proposal is stale, +//! while for VaultTransactions and Batches it's not allowed if the proposal is stale but Approved, +//! because they still can be executed in such a case. +//! +//! The other reason we have 3 different instructions is purely related to Anchor API which +//! allows adding the `close` attribute only to `Account<'info, XXX>` types, which forces us +//! into having 3 different `Accounts` structs. use anchor_lang::prelude::*; use crate::errors::*; @@ -37,7 +48,6 @@ impl ConfigTransactionAccountsClose<'_> { } = self; //region multisig - // Has to have `rent_collector` set. let multisig_rent_collector_key = multisig .rent_collector @@ -46,7 +56,6 @@ impl ConfigTransactionAccountsClose<'_> { //endregion //region rent_collector - // Has to match the `multisig.rent_collector`. require_keys_eq!( multisig_rent_collector_key, @@ -56,7 +65,6 @@ impl ConfigTransactionAccountsClose<'_> { //endregion //region proposal - // Has to be for the `multisig`. require_keys_eq!( proposal.multisig, @@ -65,15 +73,32 @@ impl ConfigTransactionAccountsClose<'_> { ); // Has to be either stale or in a terminal state. - require!( - proposal.transaction_index <= multisig.stale_transaction_index - || proposal.status.is_terminal(), - MultisigError::InvalidProposalStatus - ); + let is_stale = proposal.transaction_index <= multisig.stale_transaction_index; + + 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, + }; + + require!(can_close, MultisigError::InvalidProposalStatus); //endregion //region transaction - // Has to be for the `multisig`. require_keys_eq!( transaction.multisig, @@ -92,17 +117,17 @@ impl ConfigTransactionAccountsClose<'_> { Ok(()) } - /// Close accounts for stale config transactions or config transactions in terminal states: `Executed`, `Rejected`, or `Cancelled`. + /// 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. Ok(()) } } -// The difference between `VaultTransactionAccountsClose` and `ConfigTransactionAccountsClose`: -// - types: VaultTransactionAccountsClose is for VaultTransaction, obviously; -// - closing conditions for stale txs are a bit more strict for VaultTransactionAccountsClose: -// not all stale transactions can be closed, only the ones that are also non-Approved. + #[derive(Accounts)] pub struct VaultTransactionAccountsClose<'info> { #[account( @@ -137,7 +162,6 @@ impl VaultTransactionAccountsClose<'_> { } = self; //region multisig - // Has to have `rent_collector` set. let multisig_rent_collector_key = multisig .rent_collector @@ -146,7 +170,6 @@ impl VaultTransactionAccountsClose<'_> { //endregion //region rent_collector - // Has to match the `multisig.rent_collector`. require_keys_eq!( multisig_rent_collector_key, @@ -156,7 +179,6 @@ impl VaultTransactionAccountsClose<'_> { //endregion //region proposal - // Has to be for the `multisig`. require_keys_eq!( proposal.multisig, @@ -164,19 +186,32 @@ impl VaultTransactionAccountsClose<'_> { MultisigError::ProposalForAnotherMultisig ); - // Has to be in a terminal state or stale and non-Approved, - // because vault transactions still can be executed if they - // were Approved before they became stale. let is_stale = proposal.transaction_index <= multisig.stale_transaction_index; - let is_approved = matches!(proposal.status, ProposalStatus::Approved { .. }); - require!( - proposal.status.is_terminal() || (is_stale && !is_approved), - MultisigError::InvalidProposalStatus - ); + + 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, + }; + + require!(can_close, MultisigError::InvalidProposalStatus); //endregion //region transaction - // Has to be for the `multisig`. require_keys_eq!( transaction.multisig, @@ -195,11 +230,285 @@ impl VaultTransactionAccountsClose<'_> { Ok(()) } - /// Close accounts for vault transactions in terminal states: `Executed`, `Rejected`, or `Cancelled` - /// or non-Approved stale vault transactions. + /// 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. Ok(()) } } + +//region VaultBatchTransactionAccountClose + +#[derive(AnchorSerialize, AnchorDeserialize)] +pub struct VaultBatchTransactionAccountCloseArgs { + pub transaction_index: u32, +} + +#[derive(Accounts)] +#[instruction(args: VaultBatchTransactionAccountCloseArgs)] +pub struct VaultBatchTransactionAccountClose<'info> { + #[account( + seeds = [SEED_PREFIX, SEED_MULTISIG, multisig.create_key.as_ref()], + bump = multisig.bump, + )] + pub multisig: Account<'info, Multisig>, + + pub proposal: Account<'info, Proposal>, + + /// `Batch` corresponding to the `proposal`. + pub batch: Account<'info, Batch>, + + /// `VaultBatchTransaction` account to close. + #[account( + mut, + close = rent_collector, + seeds = [ + SEED_PREFIX, + multisig.key().as_ref(), + SEED_TRANSACTION, + &batch.index.to_le_bytes(), + SEED_BATCH_TRANSACTION, + &args.transaction_index.to_le_bytes(), + ], + bump = transaction.bump, + )] + pub transaction: Account<'info, VaultBatchTransaction>, + + /// The rent collector. + /// CHECK: We do the checks in validate(). + #[account(mut)] + pub rent_collector: AccountInfo<'info>, + + pub system_program: Program<'info, System>, +} + +impl VaultBatchTransactionAccountClose<'_> { + fn validate(&self, args: &VaultBatchTransactionAccountCloseArgs) -> Result<()> { + let Self { + multisig, + proposal, + batch, + rent_collector, + .. + } = self; + + // `args.transaction_index` is checked with Anchor via `transaction`'s seeds. + + //region multisig + // Has to have `rent_collector` set. + let multisig_rent_collector_key = multisig + .rent_collector + .ok_or(MultisigError::RentReclamationDisabled)? + .key(); + //endregion + + //region rent_collector + // Has to match the `multisig.rent_collector`. + require_keys_eq!( + multisig_rent_collector_key, + rent_collector.key(), + MultisigError::InvalidRentCollector + ); + //endregion + + //region proposal + // Has to be for the `multisig`. + require_keys_eq!( + proposal.multisig, + multisig.key(), + MultisigError::ProposalForAnotherMultisig + ); + //endregion + + //region batch + // Has to be for the `multisig`. + require_keys_eq!( + batch.multisig, + multisig.key(), + MultisigError::TransactionForAnotherMultisig + ); + + // Has to be for the `proposal`. + require_eq!( + batch.index, + proposal.transaction_index, + MultisigError::TransactionForAnotherMultisig + ); + //endregion + + //region transaction + // Has to be for the `batch`. + // This is checked with Anchor via `transaction`'s seeds. + + let is_batch_transaction_executed = + args.transaction_index <= batch.executed_transaction_index; + + let is_proposal_stale = proposal.transaction_index <= multisig.stale_transaction_index; + + // Batch transactions that are marked as executed within the batch can be closed, + // otherwise we need to check the proposal status. + let can_close = is_batch_transaction_executed + || match proposal.status { + // Transactions of Draft proposals can only be closed if stale, + // so the proposal can't be activated anymore. + ProposalStatus::Draft { .. } => is_proposal_stale, + // Transactions of Active proposals can only be closed if stale, + // so the proposal can't be voted on anymore. + ProposalStatus::Active { .. } => is_proposal_stale, + // Transactions of Approved proposals for `Batch`es cannot be closed even if stale, + // because they still can be executed. + ProposalStatus::Approved { .. } => false, + // Transactions of Rejected proposals can be closed. + ProposalStatus::Rejected { .. } => true, + // Transactions of Executed proposals can be closed. + ProposalStatus::Executed { .. } => true, + // Transactions of Cancelled proposals can be closed. + ProposalStatus::Cancelled { .. } => true, + // Should never really be in this state. + ProposalStatus::Executing => false, + }; + + require!(can_close, MultisigError::InvalidProposalStatus); + //endregion + + Ok(()) + } + + /// Closes a `VaultBatchTransaction` belonging to the `batch` and `proposal`. + /// `transaction` can be closed if either: + /// - it's marked as executed within the `batch`; + /// - the `proposal` is in a terminal state: `Executed`, `Rejected`, or `Cancelled`. + /// - the `proposal` is stale and not `Approved`. + #[access_control(_ctx.accounts.validate(&_args))] + pub fn vault_batch_transaction_account_close( + _ctx: Context, + _args: VaultBatchTransactionAccountCloseArgs, + ) -> Result<()> { + // Anchor will close the account for us. + Ok(()) + } +} +//endregion + +//region BatchAccountsClose +#[derive(Accounts)] +pub struct BatchAccountsClose<'info> { + #[account( + seeds = [SEED_PREFIX, SEED_MULTISIG, multisig.create_key.as_ref()], + bump = multisig.bump, + )] + pub multisig: Account<'info, Multisig>, + + #[account(mut, close = rent_collector)] + pub proposal: Account<'info, Proposal>, + + /// `Batch` corresponding to the `proposal`. + #[account(mut, close = rent_collector)] + pub batch: Account<'info, Batch>, + + /// The rent collector. + /// CHECK: We do the checks in validate(). + #[account(mut)] + pub rent_collector: AccountInfo<'info>, + + pub system_program: Program<'info, System>, +} + +impl BatchAccountsClose<'_> { + fn validate(&self) -> Result<()> { + let Self { + multisig, + proposal, + batch, + rent_collector, + .. + } = self; + + //region multisig + // Has to have `rent_collector` set. + let multisig_rent_collector_key = multisig + .rent_collector + .ok_or(MultisigError::RentReclamationDisabled)? + .key(); + //endregion + + //region rent_collector + // Has to match the `multisig.rent_collector`. + require_keys_eq!( + multisig_rent_collector_key, + rent_collector.key(), + MultisigError::InvalidRentCollector + ); + //endregion + + //region proposal + // Has to be for the `multisig`. + require_keys_eq!( + proposal.multisig, + multisig.key(), + MultisigError::ProposalForAnotherMultisig + ); + + let is_stale = proposal.transaction_index <= multisig.stale_transaction_index; + + 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'es 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, + }; + + require!(can_close, MultisigError::InvalidProposalStatus); + //endregion + + //region batch + // Has to be for the `multisig`. + require_keys_eq!( + batch.multisig, + multisig.key(), + MultisigError::TransactionForAnotherMultisig + ); + + // Has to be for the `proposal`. + require_eq!( + batch.index, + proposal.transaction_index, + MultisigError::TransactionForAnotherMultisig + ); + //endregion + + Ok(()) + } + + /// Closes Batch and the corresponding Proposal accounts for proposals in terminal states: + /// `Executed`, `Rejected`, or `Cancelled` or stale proposals that aren't Approved. + /// + /// WARNING: Make sure that to call this instruction only after all `VaultBatchTransaction`s + /// are already closed via `vault_batch_transaction_account_close`, + /// because the latter requires existing `Batch` and `Proposal` accounts, which this instruction closes. + /// There is no on-chain check preventing you from closing the `Batch` and `Proposal` accounts + /// first, so you will end up with no way to close the corresponding `VaultBatchTransaction`s. + #[access_control(_ctx.accounts.validate())] + pub fn batch_accounts_close(_ctx: Context) -> Result<()> { + // Anchor will close the accounts for us. + Ok(()) + } +} +//endregion diff --git a/programs/squads_multisig_program/src/lib.rs b/programs/squads_multisig_program/src/lib.rs index 83fe93fb..4c201d91 100644 --- a/programs/squads_multisig_program/src/lib.rs +++ b/programs/squads_multisig_program/src/lib.rs @@ -189,15 +189,47 @@ pub mod squads_multisig_program { SpendingLimitUse::spending_limit_use(ctx, args) } + /// 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<()> { ConfigTransactionAccountsClose::config_transaction_accounts_close(ctx) } + /// 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<()> { VaultTransactionAccountsClose::vault_transaction_accounts_close(ctx) } + + /// Closes a `VaultBatchTransaction` belonging to the `batch` and `proposal`. + /// `transaction` can be closed if either: + /// - it's marked as executed within the `batch`; + /// - the `proposal` is in a terminal state: `Executed`, `Rejected`, or `Cancelled`. + /// - the `proposal` is stale and not `Approved`. + pub fn vault_batch_transaction_account_close( + ctx: Context, + args: VaultBatchTransactionAccountCloseArgs, + ) -> Result<()> { + VaultBatchTransactionAccountClose::vault_batch_transaction_account_close(ctx, args) + } + + /// Closes Batch and the corresponding Proposal accounts for proposals in terminal states: + /// `Executed`, `Rejected`, or `Cancelled` or stale proposals that aren't Approved. + /// + /// WARNING: Make sure to call this instruction only after all `VaultBatchTransaction`s + /// are already closed via `vault_batch_transaction_account_close`, + /// because the latter requires existing `Batch` and `Proposal` accounts, which this instruction closes. + /// There is no on-chain check preventing you from closing the `Batch` and `Proposal` accounts + /// first, so you will end up with no way to close the corresponding `VaultBatchTransaction`s. + pub fn batch_accounts_close(ctx: Context) -> Result<()> { + BatchAccountsClose::batch_accounts_close(ctx) + } } diff --git a/programs/squads_multisig_program/src/state/proposal.rs b/programs/squads_multisig_program/src/state/proposal.rs index 0dfca040..646eb099 100644 --- a/programs/squads_multisig_program/src/state/proposal.rs +++ b/programs/squads_multisig_program/src/state/proposal.rs @@ -146,12 +146,3 @@ pub enum ProposalStatus { /// Proposal has been cancelled. Cancelled { timestamp: i64 }, } - -impl ProposalStatus { - pub fn is_terminal(&self) -> bool { - match self { - Self::Rejected { .. } | Self::Executed { .. } | Self::Cancelled { .. } => true, - _ => false, - } - } -} diff --git a/sdk/multisig/idl/squads_multisig_program.json b/sdk/multisig/idl/squads_multisig_program.json index ff2ca21d..882f825c 100644 --- a/sdk/multisig/idl/squads_multisig_program.json +++ b/sdk/multisig/idl/squads_multisig_program.json @@ -998,6 +998,12 @@ }, { "name": "configTransactionAccountsClose", + "docs": [ + "Closes a `ConfigTransaction` and the corresponding `Proposal`.", + "`transaction` can be closed if either:", + "- the `proposal` is in a terminal state: `Executed`, `Rejected`, or `Cancelled`.", + "- the `proposal` is stale." + ], "accounts": [ { "name": "multisig", @@ -1035,6 +1041,12 @@ }, { "name": "vaultTransactionAccountsClose", + "docs": [ + "Closes a `VaultTransaction` and the corresponding `Proposal`.", + "`transaction` can be closed if either:", + "- the `proposal` is in a terminal state: `Executed`, `Rejected`, or `Cancelled`.", + "- the `proposal` is stale and not `Approved`." + ], "accounts": [ { "name": "multisig", @@ -1069,6 +1081,112 @@ } ], "args": [] + }, + { + "name": "vaultBatchTransactionAccountClose", + "docs": [ + "Closes a `VaultBatchTransaction` belonging to the `batch` and `proposal`.", + "`transaction` can be closed if either:", + "- it's marked as executed within the `batch`;", + "- the `proposal` is in a terminal state: `Executed`, `Rejected`, or `Cancelled`.", + "- the `proposal` is stale and not `Approved`." + ], + "accounts": [ + { + "name": "multisig", + "isMut": false, + "isSigner": false + }, + { + "name": "proposal", + "isMut": false, + "isSigner": false + }, + { + "name": "batch", + "isMut": false, + "isSigner": false, + "docs": [ + "`Batch` corresponding to the `proposal`." + ] + }, + { + "name": "transaction", + "isMut": true, + "isSigner": false, + "docs": [ + "`VaultBatchTransaction` account to close." + ] + }, + { + "name": "rentCollector", + "isMut": true, + "isSigner": false, + "docs": [ + "The rent collector." + ] + }, + { + "name": "systemProgram", + "isMut": false, + "isSigner": false + } + ], + "args": [ + { + "name": "args", + "type": { + "defined": "VaultBatchTransactionAccountCloseArgs" + } + } + ] + }, + { + "name": "batchAccountsClose", + "docs": [ + "Closes Batch and the corresponding Proposal accounts for proposals in terminal states:", + "`Executed`, `Rejected`, or `Cancelled` or stale proposals that aren't Approved.", + "", + "WARNING: Make sure to call this instruction only after all `VaultBatchTransaction`s", + "are already closed via `vault_batch_transaction_account_close`,", + "because the latter requires existing `Batch` and `Proposal` accounts, which this instruction closes.", + "There is no on-chain check preventing you from closing the `Batch` and `Proposal` accounts", + "first, so you will end up with no way to close the corresponding `VaultBatchTransaction`s." + ], + "accounts": [ + { + "name": "multisig", + "isMut": false, + "isSigner": false + }, + { + "name": "proposal", + "isMut": true, + "isSigner": false + }, + { + "name": "batch", + "isMut": true, + "isSigner": false, + "docs": [ + "`Batch` corresponding to the `proposal`." + ] + }, + { + "name": "rentCollector", + "isMut": true, + "isSigner": false, + "docs": [ + "The rent collector." + ] + }, + { + "name": "systemProgram", + "isMut": false, + "isSigner": false + } + ], + "args": [] } ], "accounts": [ @@ -1964,6 +2082,18 @@ ] } }, + { + "name": "VaultBatchTransactionAccountCloseArgs", + "type": { + "kind": "struct", + "fields": [ + { + "name": "transactionIndex", + "type": "u32" + } + ] + } + }, { "name": "VaultTransactionCreateArgs", "type": { diff --git a/sdk/multisig/src/generated/instructions/batchAccountsClose.ts b/sdk/multisig/src/generated/instructions/batchAccountsClose.ts new file mode 100644 index 00000000..3625cb41 --- /dev/null +++ b/sdk/multisig/src/generated/instructions/batchAccountsClose.ts @@ -0,0 +1,101 @@ +/** + * 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 BatchAccountsClose + * @category generated + */ +export const batchAccountsCloseStruct = new beet.BeetArgsStruct<{ + instructionDiscriminator: number[] /* size: 8 */ +}>( + [['instructionDiscriminator', beet.uniformFixedSizeArray(beet.u8, 8)]], + 'BatchAccountsCloseInstructionArgs' +) +/** + * Accounts required by the _batchAccountsClose_ instruction + * + * @property [] multisig + * @property [_writable_] proposal + * @property [_writable_] batch + * @property [_writable_] rentCollector + * @category Instructions + * @category BatchAccountsClose + * @category generated + */ +export type BatchAccountsCloseInstructionAccounts = { + multisig: web3.PublicKey + proposal: web3.PublicKey + batch: web3.PublicKey + rentCollector: web3.PublicKey + systemProgram?: web3.PublicKey + anchorRemainingAccounts?: web3.AccountMeta[] +} + +export const batchAccountsCloseInstructionDiscriminator = [ + 218, 196, 7, 175, 130, 102, 11, 255, +] + +/** + * Creates a _BatchAccountsClose_ instruction. + * + * @param accounts that will be accessed while the instruction is processed + * @category Instructions + * @category BatchAccountsClose + * @category generated + */ +export function createBatchAccountsCloseInstruction( + accounts: BatchAccountsCloseInstructionAccounts, + programId = new web3.PublicKey('SQDS4ep65T869zMMBKyuUq6aD6EgTu8psMjkvj52pCf') +) { + const [data] = batchAccountsCloseStruct.serialize({ + instructionDiscriminator: batchAccountsCloseInstructionDiscriminator, + }) + const keys: web3.AccountMeta[] = [ + { + pubkey: accounts.multisig, + isWritable: false, + isSigner: false, + }, + { + pubkey: accounts.proposal, + isWritable: true, + isSigner: false, + }, + { + pubkey: accounts.batch, + isWritable: true, + isSigner: false, + }, + { + pubkey: accounts.rentCollector, + 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/index.ts b/sdk/multisig/src/generated/instructions/index.ts index ba889ba6..afe1787c 100644 --- a/sdk/multisig/src/generated/instructions/index.ts +++ b/sdk/multisig/src/generated/instructions/index.ts @@ -1,3 +1,4 @@ +export * from './batchAccountsClose' export * from './batchAddTransaction' export * from './batchCreate' export * from './batchExecuteTransaction' @@ -18,6 +19,7 @@ export * from './proposalCancel' export * from './proposalCreate' export * from './proposalReject' export * from './spendingLimitUse' +export * from './vaultBatchTransactionAccountClose' export * from './vaultTransactionAccountsClose' export * from './vaultTransactionCreate' export * from './vaultTransactionExecute' diff --git a/sdk/multisig/src/generated/instructions/vaultBatchTransactionAccountClose.ts b/sdk/multisig/src/generated/instructions/vaultBatchTransactionAccountClose.ts new file mode 100644 index 00000000..b91a8ef9 --- /dev/null +++ b/sdk/multisig/src/generated/instructions/vaultBatchTransactionAccountClose.ts @@ -0,0 +1,130 @@ +/** + * 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 { + VaultBatchTransactionAccountCloseArgs, + vaultBatchTransactionAccountCloseArgsBeet, +} from '../types/VaultBatchTransactionAccountCloseArgs' + +/** + * @category Instructions + * @category VaultBatchTransactionAccountClose + * @category generated + */ +export type VaultBatchTransactionAccountCloseInstructionArgs = { + args: VaultBatchTransactionAccountCloseArgs +} +/** + * @category Instructions + * @category VaultBatchTransactionAccountClose + * @category generated + */ +export const vaultBatchTransactionAccountCloseStruct = new beet.BeetArgsStruct< + VaultBatchTransactionAccountCloseInstructionArgs & { + instructionDiscriminator: number[] /* size: 8 */ + } +>( + [ + ['instructionDiscriminator', beet.uniformFixedSizeArray(beet.u8, 8)], + ['args', vaultBatchTransactionAccountCloseArgsBeet], + ], + 'VaultBatchTransactionAccountCloseInstructionArgs' +) +/** + * Accounts required by the _vaultBatchTransactionAccountClose_ instruction + * + * @property [] multisig + * @property [] proposal + * @property [] batch + * @property [_writable_] transaction + * @property [_writable_] rentCollector + * @category Instructions + * @category VaultBatchTransactionAccountClose + * @category generated + */ +export type VaultBatchTransactionAccountCloseInstructionAccounts = { + multisig: web3.PublicKey + proposal: web3.PublicKey + batch: web3.PublicKey + transaction: web3.PublicKey + rentCollector: web3.PublicKey + systemProgram?: web3.PublicKey + anchorRemainingAccounts?: web3.AccountMeta[] +} + +export const vaultBatchTransactionAccountCloseInstructionDiscriminator = [ + 134, 18, 19, 106, 129, 68, 97, 247, +] + +/** + * Creates a _VaultBatchTransactionAccountClose_ 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 VaultBatchTransactionAccountClose + * @category generated + */ +export function createVaultBatchTransactionAccountCloseInstruction( + accounts: VaultBatchTransactionAccountCloseInstructionAccounts, + args: VaultBatchTransactionAccountCloseInstructionArgs, + programId = new web3.PublicKey('SQDS4ep65T869zMMBKyuUq6aD6EgTu8psMjkvj52pCf') +) { + const [data] = vaultBatchTransactionAccountCloseStruct.serialize({ + instructionDiscriminator: + vaultBatchTransactionAccountCloseInstructionDiscriminator, + ...args, + }) + const keys: web3.AccountMeta[] = [ + { + pubkey: accounts.multisig, + isWritable: false, + isSigner: false, + }, + { + pubkey: accounts.proposal, + isWritable: false, + isSigner: false, + }, + { + pubkey: accounts.batch, + isWritable: false, + isSigner: false, + }, + { + pubkey: accounts.transaction, + isWritable: true, + isSigner: false, + }, + { + pubkey: accounts.rentCollector, + 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/types/VaultBatchTransactionAccountCloseArgs.ts b/sdk/multisig/src/generated/types/VaultBatchTransactionAccountCloseArgs.ts new file mode 100644 index 00000000..2e892606 --- /dev/null +++ b/sdk/multisig/src/generated/types/VaultBatchTransactionAccountCloseArgs.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 VaultBatchTransactionAccountCloseArgs = { + transactionIndex: number +} + +/** + * @category userTypes + * @category generated + */ +export const vaultBatchTransactionAccountCloseArgsBeet = + new beet.BeetArgsStruct( + [['transactionIndex', beet.u32]], + 'VaultBatchTransactionAccountCloseArgs' + ) diff --git a/sdk/multisig/src/generated/types/index.ts b/sdk/multisig/src/generated/types/index.ts index b95a9b75..904d8582 100644 --- a/sdk/multisig/src/generated/types/index.ts +++ b/sdk/multisig/src/generated/types/index.ts @@ -19,6 +19,7 @@ export * from './ProposalCreateArgs' export * from './ProposalStatus' export * from './ProposalVoteArgs' export * from './SpendingLimitUseArgs' +export * from './VaultBatchTransactionAccountCloseArgs' export * from './VaultTransactionCreateArgs' export * from './VaultTransactionMessage' export * from './Vote' diff --git a/sdk/multisig/src/instructions/batchAccountsClose.ts b/sdk/multisig/src/instructions/batchAccountsClose.ts new file mode 100644 index 00000000..a11a2830 --- /dev/null +++ b/sdk/multisig/src/instructions/batchAccountsClose.ts @@ -0,0 +1,46 @@ +import { PublicKey } from "@solana/web3.js"; +import { createBatchAccountsCloseInstruction, PROGRAM_ID } from "../generated"; +import { getProposalPda, getTransactionPda } from "../pda"; + +/** + * Closes Batch and the corresponding Proposal accounts for proposals in terminal states: + * `Executed`, `Rejected`, or `Cancelled` or stale proposals that aren't Approved. + * + * WARNING: Make sure to call this instruction only after all `VaultBatchTransaction`s + * are already closed via `vault_batch_transaction_account_close`, + * because the latter requires existing `Batch` and `Proposal` accounts, which this instruction closes. + * There is no on-chain check preventing you from closing the `Batch` and `Proposal` accounts + * first, so you will end up with no way to close the corresponding `VaultBatchTransaction`s. + */ +export function batchAccountsClose({ + multisigPda, + rentCollector, + batchIndex, + programId = PROGRAM_ID, +}: { + multisigPda: PublicKey; + rentCollector: PublicKey; + batchIndex: bigint; + programId?: PublicKey; +}) { + const [proposalPda] = getProposalPda({ + multisigPda, + transactionIndex: batchIndex, + programId, + }); + const [batchPda] = getTransactionPda({ + multisigPda, + index: batchIndex, + programId, + }); + + return createBatchAccountsCloseInstruction( + { + multisig: multisigPda, + rentCollector, + proposal: proposalPda, + batch: batchPda, + }, + programId + ); +} diff --git a/sdk/multisig/src/instructions/index.ts b/sdk/multisig/src/instructions/index.ts index fda9101c..9d59148a 100644 --- a/sdk/multisig/src/instructions/index.ts +++ b/sdk/multisig/src/instructions/index.ts @@ -1,3 +1,4 @@ +export * from "./batchAccountsClose.js"; export * from "./batchAddTransaction.js"; export * from "./batchCreate.js"; export * from "./batchExecuteTransaction.js"; @@ -16,6 +17,7 @@ export * from "./proposalCancel.js"; export * from "./proposalCreate.js"; export * from "./proposalReject.js"; export * from "./spendingLimitUse.js"; +export * from "./vaultBatchTransactionAccountClose.js"; export * from "./vaultTransactionAccountsClose.js"; export * from "./vaultTransactionCreate.js"; export * from "./vaultTransactionExecute.js"; diff --git a/sdk/multisig/src/instructions/vaultBatchTransactionAccountClose.ts b/sdk/multisig/src/instructions/vaultBatchTransactionAccountClose.ts new file mode 100644 index 00000000..0d923da0 --- /dev/null +++ b/sdk/multisig/src/instructions/vaultBatchTransactionAccountClose.ts @@ -0,0 +1,60 @@ +import { PublicKey } from "@solana/web3.js"; +import { + createVaultBatchTransactionAccountCloseInstruction, + PROGRAM_ID, +} from "../generated"; +import { + getBatchTransactionPda, + getProposalPda, + getTransactionPda, +} from "../pda"; + +/** + * Closes a VaultBatchTransaction belonging to the Batch and Proposal defined by `batchIndex`. + * VaultBatchTransaction can be closed if either: + * - it's marked as executed within the batch; + * - the proposal is in a terminal state: `Executed`, `Rejected`, or `Cancelled`. + * - the proposal is stale and not `Approved`. + */ +export function vaultBatchTransactionAccountClose({ + multisigPda, + rentCollector, + batchIndex, + transactionIndex, + programId = PROGRAM_ID, +}: { + multisigPda: PublicKey; + rentCollector: PublicKey; + batchIndex: bigint; + transactionIndex: number; + programId?: PublicKey; +}) { + const [proposalPda] = getProposalPda({ + multisigPda, + transactionIndex: batchIndex, + programId, + }); + const [batchPda] = getTransactionPda({ + multisigPda, + index: batchIndex, + programId, + }); + const [batchTransactionPda] = getBatchTransactionPda({ + multisigPda, + batchIndex, + transactionIndex, + programId, + }); + + return createVaultBatchTransactionAccountCloseInstruction( + { + multisig: multisigPda, + rentCollector, + proposal: proposalPda, + batch: batchPda, + transaction: batchTransactionPda, + }, + { args: { transactionIndex } }, + programId + ); +} diff --git a/sdk/multisig/src/rpc/batchAccountsClose.ts b/sdk/multisig/src/rpc/batchAccountsClose.ts new file mode 100644 index 00000000..22d16e11 --- /dev/null +++ b/sdk/multisig/src/rpc/batchAccountsClose.ts @@ -0,0 +1,56 @@ +import { + Connection, + PublicKey, + SendOptions, + Signer, + TransactionSignature, +} from "@solana/web3.js"; +import * as transactions from "../transactions/index.js"; +import { translateAndThrowAnchorError } from "../errors"; + +/** + * Closes Batch and the corresponding Proposal accounts for proposals in terminal states: + * `Executed`, `Rejected`, or `Cancelled` or stale proposals that aren't Approved. + * + * WARNING: Make sure to call this instruction only after all `VaultBatchTransaction`s + * are already closed via `vault_batch_transaction_account_close`, + * because the latter requires existing `Batch` and `Proposal` accounts, which this instruction closes. + * There is no on-chain check preventing you from closing the `Batch` and `Proposal` accounts + * first, so you will end up with no way to close the corresponding `VaultBatchTransaction`s. + */ +export async function batchAccountsClose({ + connection, + feePayer, + multisigPda, + rentCollector, + batchIndex, + sendOptions, + programId, +}: { + connection: Connection; + feePayer: Signer; + multisigPda: PublicKey; + rentCollector: PublicKey; + batchIndex: bigint; + sendOptions?: SendOptions; + programId?: PublicKey; +}): Promise { + const blockhash = (await connection.getLatestBlockhash()).blockhash; + + const tx = transactions.batchAccountsClose({ + blockhash, + feePayer: feePayer.publicKey, + rentCollector, + batchIndex, + multisigPda, + programId, + }); + + tx.sign([feePayer]); + + try { + return await connection.sendTransaction(tx, sendOptions); + } catch (err) { + translateAndThrowAnchorError(err); + } +} diff --git a/sdk/multisig/src/rpc/index.ts b/sdk/multisig/src/rpc/index.ts index 1cc703ba..f47d3692 100644 --- a/sdk/multisig/src/rpc/index.ts +++ b/sdk/multisig/src/rpc/index.ts @@ -1,3 +1,4 @@ +export * from "./batchAccountsClose.js"; export * from "./batchAddTransaction.js"; export * from "./batchCreate.js"; export * from "./batchExecuteTransaction.js"; @@ -16,6 +17,7 @@ export * from "./proposalCancel.js"; export * from "./proposalCreate.js"; export * from "./proposalReject.js"; export * from "./spendingLimitUse.js"; +export * from "./vaultBatchTransactionAccountClose.js"; export * from "./vaultTransactionAccountsClose.js"; export * from "./vaultTransactionCreate.js"; export * from "./vaultTransactionExecute.js"; diff --git a/sdk/multisig/src/rpc/vaultBatchTransactionAccountClose.ts b/sdk/multisig/src/rpc/vaultBatchTransactionAccountClose.ts new file mode 100644 index 00000000..3f5f3451 --- /dev/null +++ b/sdk/multisig/src/rpc/vaultBatchTransactionAccountClose.ts @@ -0,0 +1,56 @@ +import { + Connection, + PublicKey, + SendOptions, + Signer, + TransactionSignature, +} from "@solana/web3.js"; +import * as transactions from "../transactions/index.js"; +import { translateAndThrowAnchorError } from "../errors"; + +/** + * Closes a VaultBatchTransaction belonging to the Batch and Proposal defined by `batchIndex`. + * VaultBatchTransaction can be closed if either: + * - it's marked as executed within the batch; + * - the proposal is in a terminal state: `Executed`, `Rejected`, or `Cancelled`. + * - the proposal is stale and not `Approved`. + */ +export async function vaultBatchTransactionAccountClose({ + connection, + feePayer, + multisigPda, + rentCollector, + batchIndex, + transactionIndex, + sendOptions, + programId, +}: { + connection: Connection; + feePayer: Signer; + multisigPda: PublicKey; + rentCollector: PublicKey; + batchIndex: bigint; + transactionIndex: number; + sendOptions?: SendOptions; + programId?: PublicKey; +}): Promise { + const blockhash = (await connection.getLatestBlockhash()).blockhash; + + const tx = transactions.vaultBatchTransactionAccountClose({ + blockhash, + feePayer: feePayer.publicKey, + rentCollector, + batchIndex, + transactionIndex, + multisigPda, + programId, + }); + + tx.sign([feePayer]); + + try { + return await connection.sendTransaction(tx, sendOptions); + } catch (err) { + translateAndThrowAnchorError(err); + } +} diff --git a/sdk/multisig/src/transactions/batchAccountsClose.ts b/sdk/multisig/src/transactions/batchAccountsClose.ts new file mode 100644 index 00000000..fe48a990 --- /dev/null +++ b/sdk/multisig/src/transactions/batchAccountsClose.ts @@ -0,0 +1,37 @@ +import { + PublicKey, + TransactionMessage, + VersionedTransaction, +} from "@solana/web3.js"; +import * as instructions from "../instructions/index.js"; + +export function batchAccountsClose({ + blockhash, + feePayer, + multisigPda, + rentCollector, + batchIndex, + programId, +}: { + blockhash: string; + feePayer: PublicKey; + multisigPda: PublicKey; + rentCollector: PublicKey; + batchIndex: bigint; + programId?: PublicKey; +}): VersionedTransaction { + const message = new TransactionMessage({ + payerKey: feePayer, + recentBlockhash: blockhash, + instructions: [ + instructions.batchAccountsClose({ + multisigPda, + rentCollector, + batchIndex, + programId, + }), + ], + }).compileToV0Message(); + + return new VersionedTransaction(message); +} diff --git a/sdk/multisig/src/transactions/index.ts b/sdk/multisig/src/transactions/index.ts index 1cc703ba..f47d3692 100644 --- a/sdk/multisig/src/transactions/index.ts +++ b/sdk/multisig/src/transactions/index.ts @@ -1,3 +1,4 @@ +export * from "./batchAccountsClose.js"; export * from "./batchAddTransaction.js"; export * from "./batchCreate.js"; export * from "./batchExecuteTransaction.js"; @@ -16,6 +17,7 @@ export * from "./proposalCancel.js"; export * from "./proposalCreate.js"; export * from "./proposalReject.js"; export * from "./spendingLimitUse.js"; +export * from "./vaultBatchTransactionAccountClose.js"; export * from "./vaultTransactionAccountsClose.js"; export * from "./vaultTransactionCreate.js"; export * from "./vaultTransactionExecute.js"; diff --git a/sdk/multisig/src/transactions/vaultBatchTransactionAccountClose.ts b/sdk/multisig/src/transactions/vaultBatchTransactionAccountClose.ts new file mode 100644 index 00000000..a5c92d0b --- /dev/null +++ b/sdk/multisig/src/transactions/vaultBatchTransactionAccountClose.ts @@ -0,0 +1,47 @@ +import { + PublicKey, + TransactionMessage, + VersionedTransaction, +} from "@solana/web3.js"; +import * as instructions from "../instructions/index.js"; + +/** + * Closes a VaultBatchTransaction belonging to the Batch and Proposal defined by `batchIndex`. + * VaultBatchTransaction can be closed if either: + * - it's marked as executed within the batch; + * - the proposal is in a terminal state: `Executed`, `Rejected`, or `Cancelled`. + * - the proposal is stale and not `Approved`. + */ +export function vaultBatchTransactionAccountClose({ + blockhash, + feePayer, + multisigPda, + rentCollector, + batchIndex, + transactionIndex, + programId, +}: { + blockhash: string; + feePayer: PublicKey; + multisigPda: PublicKey; + rentCollector: PublicKey; + batchIndex: bigint; + transactionIndex: number; + programId?: PublicKey; +}): VersionedTransaction { + const message = new TransactionMessage({ + payerKey: feePayer, + recentBlockhash: blockhash, + instructions: [ + instructions.vaultBatchTransactionAccountClose({ + multisigPda, + rentCollector, + batchIndex, + transactionIndex, + programId, + }), + ], + }).compileToV0Message(); + + return new VersionedTransaction(message); +} diff --git a/tests/suites/instructions/batchAccountsClose.ts b/tests/suites/instructions/batchAccountsClose.ts new file mode 100644 index 00000000..a4204a4d --- /dev/null +++ b/tests/suites/instructions/batchAccountsClose.ts @@ -0,0 +1,470 @@ +import { + Keypair, + PublicKey, + TransactionMessage, + VersionedTransaction, +} from "@solana/web3.js"; +import { createMemoInstruction } from "@solana/spl-memo"; +import * as multisig from "@sqds/multisig"; +import assert from "assert"; +import { + createAutonomousMultisig, + createAutonomousMultisigWithRentReclamationAndVariousBatches, + createLocalhostConnection, + generateFundedKeypair, + generateMultisigMembers, + getTestProgramId, + MultisigWithRentReclamationAndVariousBatches, + TestMembers, +} from "../../utils"; + +const { Multisig, Proposal } = multisig.accounts; + +const programId = getTestProgramId(); +const connection = createLocalhostConnection(); + +describe("Instructions / batch_accounts_close", () => { + let members: TestMembers; + let multisigPda: PublicKey; + let testMultisig: MultisigWithRentReclamationAndVariousBatches; + + // Set up a multisig with some batches. + before(async () => { + members = await generateMultisigMembers(connection); + + const createKey = Keypair.generate(); + multisigPda = multisig.getMultisigPda({ + createKey: createKey.publicKey, + programId, + })[0]; + const [vaultPda] = multisig.getVaultPda({ + multisigPda, + index: 0, + programId, + }); + + // Create new autonomous multisig with rentCollector set to its default vault. + testMultisig = + await createAutonomousMultisigWithRentReclamationAndVariousBatches({ + connection, + createKey, + members, + threshold: 2, + rentCollector: vaultPda, + programId, + }); + }); + + it("error: rent reclamation is not enabled", async () => { + // Create a multisig with rent reclamation disabled. + const multisigPda = ( + await createAutonomousMultisig({ + connection, + members, + threshold: 1, + timeLock: 0, + rentCollector: null, + programId, + }) + )[0]; + + const vaultPda = multisig.getVaultPda({ + multisigPda: multisigPda, + index: 0, + programId, + })[0]; + + const testMessage = new TransactionMessage({ + payerKey: vaultPda, + recentBlockhash: (await connection.getLatestBlockhash()).blockhash, + instructions: [ + createMemoInstruction("First memo instruction", [vaultPda]), + ], + }); + + // Create a batch. + const batchIndex = 1n; + let signature = await multisig.rpc.batchCreate({ + connection, + feePayer: members.proposer, + multisigPda, + batchIndex: batchIndex, + vaultIndex: 0, + creator: members.proposer, + programId, + }); + await connection.confirmTransaction(signature); + + // Create a draft proposal for the batch. + signature = await multisig.rpc.proposalCreate({ + connection, + feePayer: members.proposer, + multisigPda, + transactionIndex: batchIndex, + creator: members.proposer, + isDraft: true, + programId, + }); + await connection.confirmTransaction(signature); + + // Add a transaction to the batch. + signature = await multisig.rpc.batchAddTransaction({ + connection, + feePayer: members.proposer, + multisigPda, + batchIndex: batchIndex, + vaultIndex: 0, + transactionIndex: 1, + transactionMessage: testMessage, + member: members.proposer, + ephemeralSigners: 0, + programId, + }); + await connection.confirmTransaction(signature); + + // Activate the proposal. + signature = await multisig.rpc.proposalActivate({ + connection, + feePayer: members.proposer, + multisigPda, + transactionIndex: batchIndex, + member: members.proposer, + programId, + }); + await connection.confirmTransaction(signature); + + // Reject the proposal. + signature = await multisig.rpc.proposalReject({ + connection, + feePayer: members.voter, + multisigPda, + transactionIndex: batchIndex, + member: members.voter, + programId, + }); + await connection.confirmTransaction(signature); + signature = await multisig.rpc.proposalReject({ + connection, + feePayer: members.almighty, + multisigPda, + transactionIndex: batchIndex, + member: members.almighty, + programId, + }); + await connection.confirmTransaction(signature); + + // Attempt to close the accounts. + await assert.rejects( + () => + multisig.rpc.batchAccountsClose({ + connection, + feePayer: members.almighty, + multisigPda, + rentCollector: Keypair.generate().publicKey, + batchIndex, + programId, + }), + /RentReclamationDisabled: Rent reclamation is disabled for this multisig/ + ); + }); + + it("error: invalid rent_collector", async () => { + const batchIndex = testMultisig.rejectedBatchIndex; + + const fakeRentCollector = Keypair.generate().publicKey; + + await assert.rejects( + () => + multisig.rpc.batchAccountsClose({ + connection, + feePayer: members.almighty, + multisigPda, + rentCollector: fakeRentCollector, + batchIndex, + programId, + }), + /Invalid rent collector address/ + ); + }); + + it("error: accounts are for another multisig", async () => { + const vaultPda = multisig.getVaultPda({ + multisigPda, + index: 0, + programId, + })[0]; + + const testMessage = new TransactionMessage({ + payerKey: vaultPda, + recentBlockhash: (await connection.getLatestBlockhash()).blockhash, + instructions: [ + createMemoInstruction("First memo instruction", [vaultPda]), + ], + }); + + // Create another multisig. + const otherMultisig = ( + await createAutonomousMultisig({ + connection, + members, + threshold: 2, + timeLock: 0, + rentCollector: null, + programId, + }) + )[0]; + + // Create a batch. + const batchIndex = 1n; + let signature = await multisig.rpc.batchCreate({ + connection, + feePayer: members.proposer, + multisigPda: otherMultisig, + batchIndex: batchIndex, + vaultIndex: 0, + creator: members.proposer, + programId, + }); + await connection.confirmTransaction(signature); + + // Create a draft proposal for it. + signature = await multisig.rpc.proposalCreate({ + connection, + feePayer: members.proposer, + multisigPda: otherMultisig, + transactionIndex: batchIndex, + creator: members.proposer, + isDraft: true, + programId, + }); + await connection.confirmTransaction(signature); + + // Add a transaction to the batch. + signature = await multisig.rpc.batchAddTransaction({ + connection, + feePayer: members.proposer, + multisigPda: otherMultisig, + batchIndex: batchIndex, + vaultIndex: 0, + transactionIndex: 1, + transactionMessage: testMessage, + member: members.proposer, + ephemeralSigners: 0, + programId, + }); + await connection.confirmTransaction(signature); + + // Activate the proposal. + signature = await multisig.rpc.proposalActivate({ + connection, + feePayer: members.proposer, + multisigPda: otherMultisig, + transactionIndex: batchIndex, + member: members.proposer, + programId, + }); + await connection.confirmTransaction(signature); + + // Manually construct an instruction that uses proposal account from another multisig. + const ix = multisig.generated.createBatchAccountsCloseInstruction( + { + multisig: multisigPda, + rentCollector: vaultPda, + proposal: multisig.getProposalPda({ + multisigPda: otherMultisig, + transactionIndex: 1n, + programId, + })[0], + batch: multisig.getTransactionPda({ + multisigPda, + index: testMultisig.rejectedBatchIndex, + programId, + })[0], + }, + programId + ); + + const feePayer = await generateFundedKeypair(connection); + + const message = new TransactionMessage({ + payerKey: feePayer.publicKey, + recentBlockhash: (await connection.getLatestBlockhash()).blockhash, + instructions: [ix], + }).compileToV0Message(); + const tx = new VersionedTransaction(message); + tx.sign([feePayer]); + + await assert.rejects( + () => + connection + .sendTransaction(tx) + .catch(multisig.errors.translateAndThrowAnchorError), + /Proposal is for another multisig/ + ); + }); + + it("error: invalid proposal status (Active)", async () => { + const batchIndex = testMultisig.activeBatchIndex; + + const multisigAccount = await Multisig.fromAccountAddress( + connection, + multisigPda + ); + + await assert.rejects( + () => + multisig.rpc.batchAccountsClose({ + connection, + feePayer: members.almighty, + multisigPda, + rentCollector: multisigAccount.rentCollector!, + batchIndex, + programId, + }), + /Invalid proposal status/ + ); + }); + + it("error: invalid proposal status (Approved)", async () => { + const batchIndex = testMultisig.approvedBatchIndex; + + const multisigAccount = await Multisig.fromAccountAddress( + connection, + multisigPda + ); + + await assert.rejects( + () => + multisig.rpc.batchAccountsClose({ + connection, + feePayer: members.almighty, + multisigPda, + rentCollector: multisigAccount.rentCollector!, + batchIndex, + programId, + }), + /Invalid proposal status/ + ); + }); + + it("error: invalid proposal status (Stale but Approved)", async () => { + const batchIndex = testMultisig.staleApprovedBatchIndex; + + const multisigAccount = await Multisig.fromAccountAddress( + connection, + multisigPda + ); + + await assert.rejects( + () => + multisig.rpc.batchAccountsClose({ + connection, + feePayer: members.almighty, + multisigPda, + rentCollector: multisigAccount.rentCollector!, + batchIndex, + programId, + }), + /Invalid proposal status/ + ); + }); + + it("close accounts for Stale batch", async () => { + const batchIndex = testMultisig.staleDraftBatchIndex; + + const multisigAccount = await Multisig.fromAccountAddress( + connection, + multisigPda + ); + + const 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); + const proposalPda = multisig.getProposalPda({ + multisigPda, + transactionIndex: batchIndex, + programId, + })[0]; + assert.equal(await connection.getAccountInfo(proposalPda), null); + + // NOTE: Batch transaction account is NOT closed, though. And now it's impossible to close it. + const transactionPda = multisig.getBatchTransactionPda({ + multisigPda, + batchIndex, + transactionIndex: 1, + programId, + })[0]; + assert.notEqual(await connection.getAccountInfo(transactionPda), null); + }); + + it("close accounts for Executed batch", async () => { + const batchIndex = testMultisig.executedBatchIndex; + + const multisigAccount = await Multisig.fromAccountAddress( + connection, + multisigPda + ); + + let signature = await multisig.rpc.batchAccountsClose({ + connection, + feePayer: members.almighty, + multisigPda, + rentCollector: multisigAccount.rentCollector!, + batchIndex, + programId, + }); + await connection.confirmTransaction(signature); + }); + + it("close accounts for Rejected batch", async () => { + const batchIndex = testMultisig.rejectedBatchIndex; + + const multisigAccount = await Multisig.fromAccountAddress( + connection, + multisigPda + ); + + let signature = await multisig.rpc.batchAccountsClose({ + connection, + feePayer: members.almighty, + multisigPda, + rentCollector: multisigAccount.rentCollector!, + batchIndex, + programId, + }); + await connection.confirmTransaction(signature); + }); + + it("close accounts for Cancelled batch", async () => { + const batchIndex = testMultisig.cancelledBatchIndex; + + const multisigAccount = await Multisig.fromAccountAddress( + connection, + multisigPda + ); + + let signature = await multisig.rpc.batchAccountsClose({ + connection, + feePayer: members.almighty, + multisigPda, + rentCollector: multisigAccount.rentCollector!, + batchIndex, + programId, + }); + await connection.confirmTransaction(signature); + }); +}); diff --git a/tests/suites/instructions/vaultBatchTransactionAccountClose.ts b/tests/suites/instructions/vaultBatchTransactionAccountClose.ts new file mode 100644 index 00000000..601a4631 --- /dev/null +++ b/tests/suites/instructions/vaultBatchTransactionAccountClose.ts @@ -0,0 +1,522 @@ +import { + Keypair, + PublicKey, + TransactionMessage, + VersionedTransaction, +} from "@solana/web3.js"; +import * as multisig from "@sqds/multisig"; +import assert from "assert"; +import { + createAutonomousMultisig, + createAutonomousMultisigWithRentReclamationAndVariousBatches, + createLocalhostConnection, + generateFundedKeypair, + generateMultisigMembers, + getTestProgramId, + MultisigWithRentReclamationAndVariousBatches, + TestMembers, +} from "../../utils"; +import { createMemoInstruction } from "@solana/spl-memo"; + +const { Multisig, Proposal } = multisig.accounts; + +const programId = getTestProgramId(); +const connection = createLocalhostConnection(); + +describe("Instructions / vault_batch_transaction_account_close", () => { + let members: TestMembers; + let multisigPda: PublicKey; + let testMultisig: MultisigWithRentReclamationAndVariousBatches; + + // Set up a multisig with some batches. + before(async () => { + members = await generateMultisigMembers(connection); + + const createKey = Keypair.generate(); + multisigPda = multisig.getMultisigPda({ + createKey: createKey.publicKey, + programId, + })[0]; + const [vaultPda] = multisig.getVaultPda({ + multisigPda, + index: 0, + programId, + }); + + // Create new autonomous multisig with rentCollector set to its default vault. + testMultisig = + await createAutonomousMultisigWithRentReclamationAndVariousBatches({ + connection, + createKey, + members, + threshold: 2, + rentCollector: vaultPda, + programId, + }); + }); + + it("error: rent reclamation is not enabled", async () => { + // Create a multisig with rent reclamation disabled. + const multisigPda = ( + await createAutonomousMultisig({ + connection, + members, + threshold: 1, + timeLock: 0, + rentCollector: null, + programId, + }) + )[0]; + + const vaultPda = multisig.getVaultPda({ + multisigPda: multisigPda, + index: 0, + programId, + })[0]; + + const testMessage = new TransactionMessage({ + payerKey: vaultPda, + recentBlockhash: (await connection.getLatestBlockhash()).blockhash, + instructions: [ + createMemoInstruction("First memo instruction", [vaultPda]), + ], + }); + + // Create a batch. + const batchIndex = 1n; + let signature = await multisig.rpc.batchCreate({ + connection, + feePayer: members.proposer, + multisigPda, + batchIndex: batchIndex, + vaultIndex: 0, + creator: members.proposer, + programId, + }); + await connection.confirmTransaction(signature); + + // Create a draft proposal for the batch. + signature = await multisig.rpc.proposalCreate({ + connection, + feePayer: members.proposer, + multisigPda, + transactionIndex: batchIndex, + creator: members.proposer, + isDraft: true, + programId, + }); + await connection.confirmTransaction(signature); + + // Add a transaction to the batch. + signature = await multisig.rpc.batchAddTransaction({ + connection, + feePayer: members.proposer, + multisigPda, + batchIndex: batchIndex, + vaultIndex: 0, + transactionIndex: 1, + transactionMessage: testMessage, + member: members.proposer, + ephemeralSigners: 0, + programId, + }); + await connection.confirmTransaction(signature); + + // Activate the proposal. + signature = await multisig.rpc.proposalActivate({ + connection, + feePayer: members.proposer, + multisigPda, + transactionIndex: batchIndex, + member: members.proposer, + programId, + }); + await connection.confirmTransaction(signature); + + // Reject the proposal. + signature = await multisig.rpc.proposalReject({ + connection, + feePayer: members.voter, + multisigPda, + transactionIndex: batchIndex, + member: members.voter, + programId, + }); + await connection.confirmTransaction(signature); + signature = await multisig.rpc.proposalReject({ + connection, + feePayer: members.almighty, + multisigPda, + transactionIndex: batchIndex, + member: members.almighty, + programId, + }); + await connection.confirmTransaction(signature); + + // Attempt to close the accounts. + await assert.rejects( + () => + multisig.rpc.vaultBatchTransactionAccountClose({ + connection, + feePayer: members.almighty, + multisigPda, + rentCollector: Keypair.generate().publicKey, + batchIndex, + transactionIndex: 1, + programId, + }), + /RentReclamationDisabled: Rent reclamation is disabled for this multisig/ + ); + }); + + it("error: invalid rent_collector", async () => { + const batchIndex = testMultisig.rejectedBatchIndex; + + const fakeRentCollector = Keypair.generate().publicKey; + + await assert.rejects( + () => + multisig.rpc.vaultBatchTransactionAccountClose({ + connection, + feePayer: members.almighty, + multisigPda, + rentCollector: fakeRentCollector, + batchIndex, + transactionIndex: 1, + programId, + }), + /Invalid rent collector address/ + ); + }); + + it("error: accounts are for another multisig", async () => { + const vaultPda = multisig.getVaultPda({ + multisigPda, + index: 0, + programId, + })[0]; + + const testMessage = new TransactionMessage({ + payerKey: vaultPda, + recentBlockhash: (await connection.getLatestBlockhash()).blockhash, + instructions: [ + createMemoInstruction("First memo instruction", [vaultPda]), + ], + }); + + // Create another multisig. + const otherMultisig = ( + await createAutonomousMultisig({ + connection, + members, + threshold: 2, + timeLock: 0, + rentCollector: null, + programId, + }) + )[0]; + + // Create a batch. + const batchIndex = 1n; + let signature = await multisig.rpc.batchCreate({ + connection, + feePayer: members.proposer, + multisigPda: otherMultisig, + batchIndex: batchIndex, + vaultIndex: 0, + creator: members.proposer, + programId, + }); + await connection.confirmTransaction(signature); + + // Create a draft proposal for it. + signature = await multisig.rpc.proposalCreate({ + connection, + feePayer: members.proposer, + multisigPda: otherMultisig, + transactionIndex: batchIndex, + creator: members.proposer, + isDraft: true, + programId, + }); + await connection.confirmTransaction(signature); + + // Add a transaction to the batch. + signature = await multisig.rpc.batchAddTransaction({ + connection, + feePayer: members.proposer, + multisigPda: otherMultisig, + batchIndex: batchIndex, + vaultIndex: 0, + transactionIndex: 1, + transactionMessage: testMessage, + member: members.proposer, + ephemeralSigners: 0, + programId, + }); + await connection.confirmTransaction(signature); + + // Activate the proposal. + signature = await multisig.rpc.proposalActivate({ + connection, + feePayer: members.proposer, + multisigPda: otherMultisig, + transactionIndex: batchIndex, + member: members.proposer, + programId, + }); + await connection.confirmTransaction(signature); + + // Manually construct an instruction that uses proposal account from another multisig. + const ix = + multisig.generated.createVaultBatchTransactionAccountCloseInstruction( + { + multisig: multisigPda, + rentCollector: vaultPda, + proposal: multisig.getProposalPda({ + multisigPda: otherMultisig, + transactionIndex: 1n, + programId, + })[0], + batch: multisig.getTransactionPda({ + multisigPda, + index: testMultisig.rejectedBatchIndex, + programId, + })[0], + transaction: multisig.getBatchTransactionPda({ + multisigPda, + batchIndex: testMultisig.rejectedBatchIndex, + transactionIndex: 1, + programId, + })[0], + }, + { args: { transactionIndex: 1 } }, + programId + ); + + const feePayer = await generateFundedKeypair(connection); + + const message = new TransactionMessage({ + payerKey: feePayer.publicKey, + recentBlockhash: (await connection.getLatestBlockhash()).blockhash, + instructions: [ix], + }).compileToV0Message(); + const tx = new VersionedTransaction(message); + tx.sign([feePayer]); + + await assert.rejects( + () => + connection + .sendTransaction(tx) + .catch(multisig.errors.translateAndThrowAnchorError), + /Proposal is for another multisig/ + ); + }); + + it("error: invalid proposal status (Active)", async () => { + const batchIndex = testMultisig.activeBatchIndex; + + const multisigAccount = await Multisig.fromAccountAddress( + connection, + multisigPda + ); + + await assert.rejects( + () => + multisig.rpc.vaultBatchTransactionAccountClose({ + connection, + feePayer: members.almighty, + multisigPda, + rentCollector: multisigAccount.rentCollector!, + batchIndex, + transactionIndex: 1, + programId, + }), + /Invalid proposal status/ + ); + }); + + it("error: invalid proposal status (Approved and non-executed transaction)", async () => { + const batchIndex = testMultisig.approvedBatchIndex; + + const multisigAccount = await Multisig.fromAccountAddress( + connection, + multisigPda + ); + + await assert.rejects( + () => + multisig.rpc.vaultBatchTransactionAccountClose({ + connection, + feePayer: members.almighty, + multisigPda, + rentCollector: multisigAccount.rentCollector!, + batchIndex, + // Second tx is not yet executed. + transactionIndex: 2, + programId, + }), + /Invalid proposal status/ + ); + }); + + it("error: invalid proposal status (Stale but Approved and non-executed)", async () => { + const batchIndex = testMultisig.staleApprovedBatchIndex; + + const multisigAccount = await Multisig.fromAccountAddress( + connection, + multisigPda + ); + + await assert.rejects( + () => + multisig.rpc.vaultBatchTransactionAccountClose({ + connection, + feePayer: members.almighty, + multisigPda, + rentCollector: multisigAccount.rentCollector!, + batchIndex, + // Second tx is not yet executed. + transactionIndex: 2, + programId, + }), + /Invalid proposal status/ + ); + }); + + it("close batch transaction for Stale batch", async () => { + const batchIndex = testMultisig.staleDraftBatchIndex; + + const multisigAccount = await Multisig.fromAccountAddress( + connection, + multisigPda + ); + + const signature = await multisig.rpc.vaultBatchTransactionAccountClose({ + connection, + feePayer: members.almighty, + multisigPda, + rentCollector: multisigAccount.rentCollector!, + batchIndex, + // First tx is already executed. + transactionIndex: 1, + programId, + }); + await connection.confirmTransaction(signature); + + // Make sure the account is closed. + const transactionPda1 = multisig.getBatchTransactionPda({ + multisigPda, + batchIndex, + transactionIndex: 1, + programId, + })[0]; + assert.equal(await connection.getAccountInfo(transactionPda1), null); + + // Make sure batch and proposal accounts are NOT closed. + const batchPda = multisig.getTransactionPda({ + multisigPda, + index: batchIndex, + programId, + })[0]; + assert.notEqual(await connection.getAccountInfo(batchPda), null); + const proposalPda = multisig.getProposalPda({ + multisigPda, + transactionIndex: batchIndex, + programId, + })[0]; + assert.notEqual(await connection.getAccountInfo(proposalPda), null); + }); + + it("close executed batch transaction for Approved batch", async () => { + const batchIndex = testMultisig.approvedBatchIndex; + + const multisigAccount = await Multisig.fromAccountAddress( + connection, + multisigPda + ); + + const signature = await multisig.rpc.vaultBatchTransactionAccountClose({ + connection, + feePayer: members.almighty, + multisigPda, + rentCollector: multisigAccount.rentCollector!, + batchIndex, + // First tx is already executed. + transactionIndex: 1, + programId, + }); + await connection.confirmTransaction(signature); + }); + + it("close batch transaction for Executed batch", async () => { + const batchIndex = testMultisig.executedBatchIndex; + + const multisigAccount = await Multisig.fromAccountAddress( + connection, + multisigPda + ); + + let signature = await multisig.rpc.vaultBatchTransactionAccountClose({ + connection, + feePayer: members.almighty, + multisigPda, + rentCollector: multisigAccount.rentCollector!, + batchIndex, + transactionIndex: 1, + programId, + }); + await connection.confirmTransaction(signature); + + signature = await multisig.rpc.vaultBatchTransactionAccountClose({ + connection, + feePayer: members.almighty, + multisigPda, + rentCollector: multisigAccount.rentCollector!, + batchIndex, + transactionIndex: 2, + programId, + }); + await connection.confirmTransaction(signature); + }); + + it("close batch transaction for Rejected batch", async () => { + const batchIndex = testMultisig.rejectedBatchIndex; + + const multisigAccount = await Multisig.fromAccountAddress( + connection, + multisigPda + ); + + let signature = await multisig.rpc.vaultBatchTransactionAccountClose({ + connection, + feePayer: members.almighty, + multisigPda, + rentCollector: multisigAccount.rentCollector!, + batchIndex, + transactionIndex: 1, + programId, + }); + await connection.confirmTransaction(signature); + }); + + it("close batch transaction for Cancelled batch", async () => { + const batchIndex = testMultisig.cancelledBatchIndex; + + const multisigAccount = await Multisig.fromAccountAddress( + connection, + multisigPda + ); + + let signature = await multisig.rpc.vaultBatchTransactionAccountClose({ + connection, + feePayer: members.almighty, + multisigPda, + rentCollector: multisigAccount.rentCollector!, + batchIndex, + transactionIndex: 1, + programId, + }); + await connection.confirmTransaction(signature); + }); +}); diff --git a/tests/suites/instructions/vaultTransactionAccountsClose.ts b/tests/suites/instructions/vaultTransactionAccountsClose.ts index a70a97ea..8659603b 100644 --- a/tests/suites/instructions/vaultTransactionAccountsClose.ts +++ b/tests/suites/instructions/vaultTransactionAccountsClose.ts @@ -90,7 +90,6 @@ describe("Instructions / vault_transaction_accounts_close", () => { vaultIndex: 0, transactionMessage: testTransferMessage, ephemeralSigners: 0, - addressLookupTableAccounts: [], creator: members.proposer.publicKey, programId, }); @@ -119,7 +118,6 @@ describe("Instructions / vault_transaction_accounts_close", () => { vaultIndex: 0, transactionMessage: testTransferMessage, ephemeralSigners: 0, - addressLookupTableAccounts: [], creator: members.proposer.publicKey, programId, }); diff --git a/tests/suites/multisig-sdk.ts b/tests/suites/multisig-sdk.ts index 100d5bfd..de913475 100644 --- a/tests/suites/multisig-sdk.ts +++ b/tests/suites/multisig-sdk.ts @@ -27,6 +27,8 @@ const { Permission, Permissions } = multisig.types; const programId = getTestProgramId(); import "./instructions/configTransactionAccountsClose"; +import "./instructions/vaultBatchTransactionAccountClose"; +import "./instructions/batchAccountsClose"; import "./instructions/vaultTransactionAccountsClose"; describe("Multisig SDK", () => { diff --git a/tests/utils.ts b/tests/utils.ts index dd3cb755..95162735 100644 --- a/tests/utils.ts +++ b/tests/utils.ts @@ -4,12 +4,16 @@ import { LAMPORTS_PER_SOL, PublicKey, SystemProgram, + TransactionMessage, } from "@solana/web3.js"; import * as multisig from "@sqds/multisig"; 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; export function getTestProgramId() { const programKeypair = Keypair.fromSecretKey( @@ -36,6 +40,18 @@ export type TestMembers = { executor: Keypair; }; +export async function generateFundedKeypair(connection: Connection) { + const keypair = Keypair.generate(); + + const tx = await connection.requestAirdrop( + keypair.publicKey, + 1 * LAMPORTS_PER_SOL + ); + await connection.confirmTransaction(tx); + + return keypair; +} + export async function generateMultisigMembers( connection: Connection ): Promise { @@ -66,6 +82,10 @@ export async function generateMultisigMembers( return members; } +export function createLocalhostConnection() { + return new Connection("http://127.0.0.1:8899", "confirmed"); +} + export async function createAutonomousMultisig({ connection, createKey = Keypair.generate(), @@ -182,20 +202,788 @@ export async function createControlledMultisig({ return [multisigPda, multisigBump] as const; } -export function createLocalhostConnection() { - return new Connection("http://127.0.0.1:8899", "confirmed"); -} +export type MultisigWithRentReclamationAndVariousBatches = { + multisigPda: PublicKey; + /** + * Index of a batch with a proposal in the Draft state. + * The batch contains 1 transaction, which is not executed. + * The proposal is stale. + */ + staleDraftBatchIndex: 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. + * The proposal is stale. + */ + staleApprovedBatchIndex: bigint; + /** Index of a config transaction that is executed, rendering the batches created before it stale. */ + executedConfigTransactionIndex: bigint; + /** + * Index of a batch with a proposal in the Executed state. + * The batch contains 2 transactions, both of which are executed. + */ + executedBatchIndex: bigint; + /** + * Index of a batch with a proposal in the Active state. + * The batch contains 1 transaction, which is not executed. + */ + activeBatchIndex: 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. + */ + approvedBatchIndex: bigint; + /** + * Index of a batch with a proposal in the Rejected state. + * The batch contains 1 transaction, which is not executed. + */ + rejectedBatchIndex: bigint; + /** + * Index of a batch with a proposal in the Cancelled state. + * The batch contains 1 transaction, which is not executed. + */ + cancelledBatchIndex: bigint; +}; -export async function generateFundedKeypair(connection: Connection) { - const keypair = Keypair.generate(); +export async function createAutonomousMultisigWithRentReclamationAndVariousBatches({ + connection, + createKey = Keypair.generate(), + members, + threshold, + rentCollector, + programId, +}: { + connection: Connection; + createKey?: Keypair; + members: TestMembers; + threshold: number; + rentCollector: PublicKey | null; + programId: PublicKey; +}): Promise { + const creator = await generateFundedKeypair(connection); - const tx = await connection.requestAirdrop( - keypair.publicKey, - 1 * LAMPORTS_PER_SOL + const [multisigPda, multisigBump] = multisig.getMultisigPda({ + createKey: createKey.publicKey, + programId, + }); + const [vaultPda] = multisig.getVaultPda({ + multisigPda, + index: 0, + programId, + }); + + //region Create a multisig + let signature = await multisig.rpc.multisigCreate({ + connection, + creator, + multisigPda, + configAuthority: null, + timeLock: 0, + 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, + rentCollector, + sendOptions: { skipPreflight: true }, + programId, + }); + await connection.confirmTransaction(signature); + //endregion + + //region Test instructions + const testMessage1 = new TransactionMessage({ + payerKey: vaultPda, + recentBlockhash: (await connection.getLatestBlockhash()).blockhash, + instructions: [createMemoInstruction("First memo instruction", [vaultPda])], + }); + const testMessage2 = new TransactionMessage({ + payerKey: vaultPda, + recentBlockhash: (await connection.getLatestBlockhash()).blockhash, + instructions: [ + createMemoInstruction("Second memo instruction", [vaultPda]), + ], + }); + //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; + + //region Stale batch with proposal in Draft state + // Create a batch (Stale and Non-Approved). + signature = await multisig.rpc.batchCreate({ + connection, + feePayer: members.proposer, + multisigPda, + batchIndex: staleDraftBatchIndex, + vaultIndex: 0, + creator: members.proposer, + programId, + }); + await connection.confirmTransaction(signature); + + // Create a draft proposal for the batch (Stale and Non-Approved). + signature = await multisig.rpc.proposalCreate({ + connection, + feePayer: members.proposer, + multisigPda, + transactionIndex: staleDraftBatchIndex, + creator: members.proposer, + isDraft: true, + programId, + }); + await connection.confirmTransaction(signature); + + // Add a transaction to the batch (Stale and Non-Approved). + signature = await multisig.rpc.batchAddTransaction({ + connection, + feePayer: members.proposer, + multisigPda, + batchIndex: staleDraftBatchIndex, + vaultIndex: 0, + transactionIndex: 1, + transactionMessage: testMessage1, + member: members.proposer, + ephemeralSigners: 0, + programId, + }); + await connection.confirmTransaction(signature); + // 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({ + connection, + feePayer: members.proposer, + multisigPda, + batchIndex: staleApprovedBatchIndex, + vaultIndex: 0, + creator: members.proposer, + programId, + }); + await connection.confirmTransaction(signature); + + // Create a draft proposal for the batch (Stale and Approved). + signature = await multisig.rpc.proposalCreate({ + connection, + feePayer: members.proposer, + multisigPda, + transactionIndex: staleApprovedBatchIndex, + creator: members.proposer, + isDraft: true, + programId, + }); + await connection.confirmTransaction(signature); + + // Add first transaction to the batch (Stale and Approved). + signature = await multisig.rpc.batchAddTransaction({ + connection, + feePayer: members.proposer, + multisigPda, + batchIndex: staleApprovedBatchIndex, + vaultIndex: 0, + transactionIndex: 1, + transactionMessage: testMessage1, + member: members.proposer, + ephemeralSigners: 0, + programId, + }); + await connection.confirmTransaction(signature); + + // Add second transaction to the batch (Stale and Approved). + signature = await multisig.rpc.batchAddTransaction({ + connection, + feePayer: members.proposer, + multisigPda, + batchIndex: staleApprovedBatchIndex, + vaultIndex: 0, + transactionIndex: 2, + transactionMessage: testMessage2, + member: members.proposer, + ephemeralSigners: 0, + programId, + }); + await connection.confirmTransaction(signature); + + // Activate the proposal (Stale and Approved). + signature = await multisig.rpc.proposalActivate({ + connection, + feePayer: members.proposer, + multisigPda, + transactionIndex: staleApprovedBatchIndex, + member: members.proposer, + programId, + }); + await connection.confirmTransaction(signature); + + // Approve the proposal (Stale and Approved). + signature = await multisig.rpc.proposalApprove({ + connection, + feePayer: members.voter, + multisigPda, + transactionIndex: staleApprovedBatchIndex, + member: members.voter, + programId, + }); + await connection.confirmTransaction(signature); + signature = await multisig.rpc.proposalApprove({ + connection, + feePayer: members.almighty, + multisigPda, + transactionIndex: staleApprovedBatchIndex, + member: members.almighty, + programId, + }); + await connection.confirmTransaction(signature); + + // Execute the first batch transaction proposal (Stale and Approved). + signature = await multisig.rpc.batchExecuteTransaction({ + connection, + feePayer: members.executor, + multisigPda, + batchIndex: staleApprovedBatchIndex, + transactionIndex: 1, + member: members.executor, + programId, + }); + await connection.confirmTransaction(signature); + // This proposal will become stale when the config transaction is executed. + //endregion + + //region Executed Config Transaction + // Create a vault transaction (Executed). + signature = await multisig.rpc.configTransactionCreate({ + connection, + feePayer: members.proposer, + multisigPda, + transactionIndex: executedConfigTransactionIndex, + creator: members.proposer.publicKey, + actions: [{ __kind: "ChangeThreshold", newThreshold: 1 }], + programId, + }); + await connection.confirmTransaction(signature); + + // Create a proposal for the transaction (Executed). + signature = await multisig.rpc.proposalCreate({ + connection, + feePayer: members.proposer, + multisigPda, + transactionIndex: executedConfigTransactionIndex, + creator: members.proposer, + programId, + }); + await connection.confirmTransaction(signature); + + // Approve the proposal by the first member. + signature = await multisig.rpc.proposalApprove({ + connection, + feePayer: members.voter, + multisigPda, + transactionIndex: executedConfigTransactionIndex, + member: members.voter, + programId, + }); + await connection.confirmTransaction(signature); + + // Approve the proposal by the second member. + signature = await multisig.rpc.proposalApprove({ + connection, + feePayer: members.almighty, + multisigPda, + transactionIndex: executedConfigTransactionIndex, + member: members.almighty, + programId, + }); + await connection.confirmTransaction(signature); + + // Execute the transaction. + signature = await multisig.rpc.configTransactionExecute({ + connection, + feePayer: members.almighty, + multisigPda, + transactionIndex: executedConfigTransactionIndex, + member: members.almighty, + rentPayer: members.almighty, + programId, + }); + await connection.confirmTransaction(signature); + //endregion + + //region batch with Executed proposal (all batch tx are executed) + // Create a batch (Executed). + signature = await multisig.rpc.batchCreate({ + connection, + feePayer: members.proposer, + multisigPda, + batchIndex: executedBatchIndex, + vaultIndex: 0, + creator: members.proposer, + programId, + }); + await connection.confirmTransaction(signature); + + // Create a draft proposal for the batch (Executed). + signature = await multisig.rpc.proposalCreate({ + connection, + feePayer: members.proposer, + multisigPda, + transactionIndex: executedBatchIndex, + creator: members.proposer, + isDraft: true, + programId, + }); + await connection.confirmTransaction(signature); + + // Add first transaction to the batch (Executed). + signature = await multisig.rpc.batchAddTransaction({ + connection, + feePayer: members.proposer, + multisigPda, + batchIndex: executedBatchIndex, + vaultIndex: 0, + transactionIndex: 1, + transactionMessage: testMessage1, + member: members.proposer, + ephemeralSigners: 0, + programId, + }); + await connection.confirmTransaction(signature); + + // Add second transaction to the batch (Executed). + signature = await multisig.rpc.batchAddTransaction({ + connection, + feePayer: members.proposer, + multisigPda, + batchIndex: executedBatchIndex, + vaultIndex: 0, + transactionIndex: 2, + transactionMessage: testMessage2, + member: members.proposer, + ephemeralSigners: 0, + programId, + }); + await connection.confirmTransaction(signature); + + // Activate the proposal (Executed). + signature = await multisig.rpc.proposalActivate({ + connection, + feePayer: members.proposer, + multisigPda, + transactionIndex: executedBatchIndex, + member: members.proposer, + programId, + }); + await connection.confirmTransaction(signature); + + // Approve the proposal (Executed). + signature = await multisig.rpc.proposalApprove({ + connection, + feePayer: members.voter, + multisigPda, + transactionIndex: executedBatchIndex, + member: members.voter, + programId, + }); + await connection.confirmTransaction(signature); + + // Execute the first batch transaction proposal (Executed). + signature = await multisig.rpc.batchExecuteTransaction({ + connection, + feePayer: members.executor, + multisigPda, + batchIndex: executedBatchIndex, + transactionIndex: 1, + member: members.executor, + programId, + }); + await connection.confirmTransaction(signature); + + // Execute the second batch transaction proposal (Executed). + signature = await multisig.rpc.batchExecuteTransaction({ + connection, + feePayer: members.executor, + multisigPda, + batchIndex: executedBatchIndex, + transactionIndex: 2, + member: members.executor, + programId, + }); + await connection.confirmTransaction(signature); + + // Make sure the proposal is executed. + let proposalAccount = await Proposal.fromAccountAddress( + connection, + multisig.getProposalPda({ + multisigPda, + transactionIndex: executedBatchIndex, + programId, + })[0] ); - await connection.confirmTransaction(tx); + assert.ok(multisig.types.isProposalStatusExecuted(proposalAccount.status)); + //endregion - return keypair; + //region batch with Active proposal + // Create a batch (Active). + signature = await multisig.rpc.batchCreate({ + connection, + feePayer: members.proposer, + multisigPda, + batchIndex: activeBatchIndex, + vaultIndex: 0, + creator: members.proposer, + programId, + }); + await connection.confirmTransaction(signature); + + // Create a draft proposal for the batch (Active). + signature = await multisig.rpc.proposalCreate({ + connection, + feePayer: members.proposer, + multisigPda, + transactionIndex: activeBatchIndex, + creator: members.proposer, + isDraft: true, + programId, + }); + await connection.confirmTransaction(signature); + + // Add a transaction to the batch (Active). + signature = await multisig.rpc.batchAddTransaction({ + connection, + feePayer: members.proposer, + multisigPda, + batchIndex: activeBatchIndex, + vaultIndex: 0, + transactionIndex: 1, + transactionMessage: testMessage1, + member: members.proposer, + ephemeralSigners: 0, + programId, + }); + await connection.confirmTransaction(signature); + + // Activate the proposal (Active). + signature = await multisig.rpc.proposalActivate({ + connection, + feePayer: members.proposer, + multisigPda, + transactionIndex: activeBatchIndex, + member: members.proposer, + programId, + }); + await connection.confirmTransaction(signature); + + // Make sure the proposal is Active. + proposalAccount = await Proposal.fromAccountAddress( + connection, + multisig.getProposalPda({ + multisigPda, + transactionIndex: activeBatchIndex, + programId, + })[0] + ); + assert.ok(multisig.types.isProposalStatusActive(proposalAccount.status)); + //endregion + + //region batch with Approved proposal + // Create a batch (Approved). + signature = await multisig.rpc.batchCreate({ + connection, + feePayer: members.proposer, + multisigPda, + batchIndex: approvedBatchIndex, + vaultIndex: 0, + creator: members.proposer, + programId, + }); + await connection.confirmTransaction(signature); + + // Create a draft proposal for the batch (Approved). + signature = await multisig.rpc.proposalCreate({ + connection, + feePayer: members.proposer, + multisigPda, + transactionIndex: approvedBatchIndex, + creator: members.proposer, + isDraft: true, + programId, + }); + await connection.confirmTransaction(signature); + + // Add first transaction to the batch (Approved). + signature = await multisig.rpc.batchAddTransaction({ + connection, + feePayer: members.proposer, + multisigPda, + batchIndex: approvedBatchIndex, + vaultIndex: 0, + transactionIndex: 1, + transactionMessage: testMessage1, + member: members.proposer, + ephemeralSigners: 0, + programId, + }); + await connection.confirmTransaction(signature); + + // Add second transaction to the batch (Approved). + signature = await multisig.rpc.batchAddTransaction({ + connection, + feePayer: members.proposer, + multisigPda, + batchIndex: approvedBatchIndex, + vaultIndex: 0, + transactionIndex: 2, + transactionMessage: testMessage2, + member: members.proposer, + ephemeralSigners: 0, + programId, + }); + await connection.confirmTransaction(signature); + + // Activate the proposal (Approved). + signature = await multisig.rpc.proposalActivate({ + connection, + feePayer: members.proposer, + multisigPda, + transactionIndex: approvedBatchIndex, + member: members.proposer, + programId, + }); + await connection.confirmTransaction(signature); + + // Approve the proposal (Approved). + signature = await multisig.rpc.proposalApprove({ + connection, + feePayer: members.voter, + multisigPda, + transactionIndex: approvedBatchIndex, + member: members.voter, + programId, + }); + await connection.confirmTransaction(signature); + + // Make sure the proposal is Approved. + proposalAccount = await Proposal.fromAccountAddress( + connection, + multisig.getProposalPda({ + multisigPda, + transactionIndex: approvedBatchIndex, + programId, + })[0] + ); + assert.ok(multisig.types.isProposalStatusApproved(proposalAccount.status)); + + // Execute first batch transaction (Approved). + signature = await multisig.rpc.batchExecuteTransaction({ + connection, + feePayer: members.executor, + multisigPda, + batchIndex: approvedBatchIndex, + transactionIndex: 1, + member: members.executor, + programId, + }); + await connection.confirmTransaction(signature); + //endregion + + //region batch with Rejected proposal + // Create a batch (Rejected). + signature = await multisig.rpc.batchCreate({ + connection, + feePayer: members.proposer, + multisigPda, + batchIndex: rejectedBatchIndex, + vaultIndex: 0, + creator: members.proposer, + programId, + }); + await connection.confirmTransaction(signature); + + // Create a draft proposal for the batch (Rejected). + signature = await multisig.rpc.proposalCreate({ + connection, + feePayer: members.proposer, + multisigPda, + transactionIndex: rejectedBatchIndex, + creator: members.proposer, + isDraft: true, + programId, + }); + await connection.confirmTransaction(signature); + + // Add a transaction to the batch (Rejected). + signature = await multisig.rpc.batchAddTransaction({ + connection, + feePayer: members.proposer, + multisigPda, + batchIndex: rejectedBatchIndex, + vaultIndex: 0, + transactionIndex: 1, + transactionMessage: testMessage1, + member: members.proposer, + ephemeralSigners: 0, + programId, + }); + await connection.confirmTransaction(signature); + + // Activate the proposal (Rejected). + signature = await multisig.rpc.proposalActivate({ + connection, + feePayer: members.proposer, + multisigPda, + transactionIndex: rejectedBatchIndex, + member: members.proposer, + programId, + }); + await connection.confirmTransaction(signature); + + // Reject the proposal (Rejected). + signature = await multisig.rpc.proposalReject({ + connection, + feePayer: members.voter, + multisigPda, + transactionIndex: rejectedBatchIndex, + member: members.voter, + programId, + }); + await connection.confirmTransaction(signature); + signature = await multisig.rpc.proposalReject({ + connection, + feePayer: members.almighty, + multisigPda, + transactionIndex: rejectedBatchIndex, + member: members.almighty, + programId, + }); + await connection.confirmTransaction(signature); + + // Make sure the proposal is Rejected. + proposalAccount = await Proposal.fromAccountAddress( + connection, + multisig.getProposalPda({ + multisigPda, + transactionIndex: rejectedBatchIndex, + programId, + })[0] + ); + assert.ok(multisig.types.isProposalStatusRejected(proposalAccount.status)); + //endregion + + //region batch with Cancelled proposal + // Create a batch (Cancelled). + signature = await multisig.rpc.batchCreate({ + connection, + feePayer: members.proposer, + multisigPda, + batchIndex: cancelledBatchIndex, + vaultIndex: 0, + creator: members.proposer, + programId, + }); + await connection.confirmTransaction(signature); + + // Create a draft proposal for the batch (Cancelled). + signature = await multisig.rpc.proposalCreate({ + connection, + feePayer: members.proposer, + multisigPda, + transactionIndex: cancelledBatchIndex, + creator: members.proposer, + isDraft: true, + programId, + }); + await connection.confirmTransaction(signature); + + // Add a transaction to the batch (Cancelled). + signature = await multisig.rpc.batchAddTransaction({ + connection, + feePayer: members.proposer, + multisigPda, + batchIndex: cancelledBatchIndex, + vaultIndex: 0, + transactionIndex: 1, + transactionMessage: testMessage1, + member: members.proposer, + ephemeralSigners: 0, + programId, + }); + await connection.confirmTransaction(signature); + + // Activate the proposal (Cancelled). + signature = await multisig.rpc.proposalActivate({ + connection, + feePayer: members.proposer, + multisigPda, + transactionIndex: cancelledBatchIndex, + member: members.proposer, + programId, + }); + await connection.confirmTransaction(signature); + + // Approve the proposal (Cancelled). + signature = await multisig.rpc.proposalApprove({ + connection, + feePayer: members.voter, + multisigPda, + transactionIndex: cancelledBatchIndex, + member: members.voter, + programId, + }); + await connection.confirmTransaction(signature); + + // Cancel the proposal (Cancelled). + signature = await multisig.rpc.proposalCancel({ + connection, + feePayer: members.almighty, + multisigPda, + transactionIndex: cancelledBatchIndex, + member: members.almighty, + programId, + }); + await connection.confirmTransaction(signature); + + // Make sure the proposal is Cancelled. + proposalAccount = await Proposal.fromAccountAddress( + connection, + multisig.getProposalPda({ + multisigPda, + transactionIndex: cancelledBatchIndex, + programId, + })[0] + ); + assert.ok(multisig.types.isProposalStatusCancelled(proposalAccount.status)); + //endregion + + return { + multisigPda, + staleDraftBatchIndex, + staleApprovedBatchIndex, + executedConfigTransactionIndex, + executedBatchIndex, + activeBatchIndex, + approvedBatchIndex, + rejectedBatchIndex, + cancelledBatchIndex, + }; } export function createTestTransferInstruction( diff --git a/yarn.lock b/yarn.lock index 294005c5..2b0b4510 100644 --- a/yarn.lock +++ b/yarn.lock @@ -127,6 +127,13 @@ dependencies: buffer "~6.0.3" +"@solana/spl-memo@^0.2.3": + version "0.2.3" + resolved "https://registry.yarnpkg.com/@solana/spl-memo/-/spl-memo-0.2.3.tgz#594a28c37b40c0e22143f38f71b4f56d1f5b24fd" + integrity sha512-CNsKSsl85ebuVoeGq1LDYi5M/PMs1Pxv2/UsyTgS6b30qrYqZOXha5ouZzgGKtJtZ3C3dxfOAEw6caJPN1N63w== + dependencies: + buffer "^6.0.3" + "@solana/spl-token@*", "@solana/spl-token@0.3.6", "@solana/spl-token@^0.3.6": version "0.3.6" resolved "https://registry.yarnpkg.com/@solana/spl-token/-/spl-token-0.3.6.tgz#35473ad2ed71fe91e5754a2ac72901e1b8b26a42"