diff --git a/programs/squads_multisig_program/src/errors.rs b/programs/squads_multisig_program/src/errors.rs index 7e42c6d5..98742f98 100644 --- a/programs/squads_multisig_program/src/errors.rs +++ b/programs/squads_multisig_program/src/errors.rs @@ -66,4 +66,14 @@ pub enum MultisigError { TimeLockExceedsMaxAllowed, #[msg("Account is not owned by Multisig program")] IllegalAccountOwner, + #[msg("Rent reclamation is disabled for this multisig")] + RentReclamationDisabled, + #[msg("Invalid rent collector address")] + InvalidRentCollector, + #[msg("Proposal is for another multisig")] + ProposalForAnotherMultisig, + #[msg("Transaction is for another multisig")] + TransactionForAnotherMultisig, + #[msg("Transaction doesn't match proposal")] + TransactionNotMatchingProposal, } diff --git a/programs/squads_multisig_program/src/instructions/mod.rs b/programs/squads_multisig_program/src/instructions/mod.rs index 79e3ec55..9b380443 100644 --- a/programs/squads_multisig_program/src/instructions/mod.rs +++ b/programs/squads_multisig_program/src/instructions/mod.rs @@ -11,6 +11,7 @@ pub use proposal_activate::*; pub use proposal_create::*; pub use proposal_vote::*; pub use spending_limit_use::*; +pub use transaction_accounts_close::*; pub use vault_transaction_create::*; pub use vault_transaction_execute::*; @@ -27,5 +28,6 @@ mod proposal_activate; mod proposal_create; mod proposal_vote; mod spending_limit_use; +mod transaction_accounts_close; mod vault_transaction_create; mod vault_transaction_execute; diff --git a/programs/squads_multisig_program/src/instructions/transaction_accounts_close.rs b/programs/squads_multisig_program/src/instructions/transaction_accounts_close.rs new file mode 100644 index 00000000..c5f34773 --- /dev/null +++ b/programs/squads_multisig_program/src/instructions/transaction_accounts_close.rs @@ -0,0 +1,101 @@ +use anchor_lang::prelude::*; + +use crate::errors::*; +use crate::state::*; + +#[derive(Accounts)] +pub struct ConfigTransactionAccountsClose<'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>, + + /// ConfigTransaction corresponding to the `proposal`. + #[account(mut, close = rent_collector)] + pub transaction: Account<'info, ConfigTransaction>, + + /// The rent collector. + /// CHECK: We do the checks in validate(). + #[account(mut)] + pub rent_collector: AccountInfo<'info>, + + pub system_program: Program<'info, System>, +} + +impl ConfigTransactionAccountsClose<'_> { + fn validate(&self) -> Result<()> { + let Self { + multisig, + proposal, + transaction, + 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 + ); + + // Has to be either stale or in a terminal state. + require!( + proposal.transaction_index <= multisig.stale_transaction_index + || proposal.status.is_terminal(), + MultisigError::InvalidProposalStatus + ); + //endregion + + //region transaction + + // Has to be for the `multisig`. + require_keys_eq!( + transaction.multisig, + multisig.key(), + MultisigError::TransactionForAnotherMultisig + ); + + // Has to be for the `proposal`. + require_eq!( + transaction.index, + proposal.transaction_index, + MultisigError::TransactionForAnotherMultisig + ); + //endregion + + Ok(()) + } + + /// Close accounts for stale config transactions or config transactions in terminal states: `Executed`, `Rejected`, or `Cancelled`. + #[access_control(_ctx.accounts.validate())] + pub fn config_transaction_accounts_close(_ctx: Context) -> Result<()> { + // Anchor will close the accounts for us. + Ok(()) + } +} diff --git a/programs/squads_multisig_program/src/lib.rs b/programs/squads_multisig_program/src/lib.rs index 9c4f5b97..b60b6ab5 100644 --- a/programs/squads_multisig_program/src/lib.rs +++ b/programs/squads_multisig_program/src/lib.rs @@ -188,4 +188,10 @@ pub mod squads_multisig_program { ) -> Result<()> { SpendingLimitUse::spending_limit_use(ctx, args) } + + pub fn config_transaction_accounts_close( + ctx: Context, + ) -> Result<()> { + ConfigTransactionAccountsClose::config_transaction_accounts_close(ctx) + } } diff --git a/programs/squads_multisig_program/src/state/proposal.rs b/programs/squads_multisig_program/src/state/proposal.rs index 646eb099..0dfca040 100644 --- a/programs/squads_multisig_program/src/state/proposal.rs +++ b/programs/squads_multisig_program/src/state/proposal.rs @@ -146,3 +146,12 @@ 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 c5c6aa72..939216f2 100644 --- a/sdk/multisig/idl/squads_multisig_program.json +++ b/sdk/multisig/idl/squads_multisig_program.json @@ -995,6 +995,43 @@ } } ] + }, + { + "name": "configTransactionAccountsClose", + "accounts": [ + { + "name": "multisig", + "isMut": false, + "isSigner": false + }, + { + "name": "proposal", + "isMut": true, + "isSigner": false + }, + { + "name": "transaction", + "isMut": true, + "isSigner": false, + "docs": [ + "ConfigTransaction corresponding to the `proposal`." + ] + }, + { + "name": "rentCollector", + "isMut": true, + "isSigner": false, + "docs": [ + "The rent collector." + ] + }, + { + "name": "systemProgram", + "isMut": false, + "isSigner": false + } + ], + "args": [] } ], "accounts": [ @@ -2479,6 +2516,31 @@ "code": 6031, "name": "IllegalAccountOwner", "msg": "Account is not owned by Multisig program" + }, + { + "code": 6032, + "name": "RentReclamationDisabled", + "msg": "Rent reclamation is disabled for this multisig" + }, + { + "code": 6033, + "name": "InvalidRentCollector", + "msg": "Invalid rent collector address" + }, + { + "code": 6034, + "name": "ProposalForAnotherMultisig", + "msg": "Proposal is for another multisig" + }, + { + "code": 6035, + "name": "TransactionForAnotherMultisig", + "msg": "Transaction is for another multisig" + }, + { + "code": 6036, + "name": "TransactionNotMatchingProposal", + "msg": "Transaction doesn't match proposal" } ], "metadata": { diff --git a/sdk/multisig/src/generated/errors/index.ts b/sdk/multisig/src/generated/errors/index.ts index dc951a14..5f70efb5 100644 --- a/sdk/multisig/src/generated/errors/index.ts +++ b/sdk/multisig/src/generated/errors/index.ts @@ -725,6 +725,130 @@ createErrorFromNameLookup.set( () => new IllegalAccountOwnerError() ) +/** + * RentReclamationDisabled: 'Rent reclamation is disabled for this multisig' + * + * @category Errors + * @category generated + */ +export class RentReclamationDisabledError extends Error { + readonly code: number = 0x1790 + readonly name: string = 'RentReclamationDisabled' + constructor() { + super('Rent reclamation is disabled for this multisig') + if (typeof Error.captureStackTrace === 'function') { + Error.captureStackTrace(this, RentReclamationDisabledError) + } + } +} + +createErrorFromCodeLookup.set(0x1790, () => new RentReclamationDisabledError()) +createErrorFromNameLookup.set( + 'RentReclamationDisabled', + () => new RentReclamationDisabledError() +) + +/** + * InvalidRentCollector: 'Invalid rent collector address' + * + * @category Errors + * @category generated + */ +export class InvalidRentCollectorError extends Error { + readonly code: number = 0x1791 + readonly name: string = 'InvalidRentCollector' + constructor() { + super('Invalid rent collector address') + if (typeof Error.captureStackTrace === 'function') { + Error.captureStackTrace(this, InvalidRentCollectorError) + } + } +} + +createErrorFromCodeLookup.set(0x1791, () => new InvalidRentCollectorError()) +createErrorFromNameLookup.set( + 'InvalidRentCollector', + () => new InvalidRentCollectorError() +) + +/** + * ProposalForAnotherMultisig: 'Proposal is for another multisig' + * + * @category Errors + * @category generated + */ +export class ProposalForAnotherMultisigError extends Error { + readonly code: number = 0x1792 + readonly name: string = 'ProposalForAnotherMultisig' + constructor() { + super('Proposal is for another multisig') + if (typeof Error.captureStackTrace === 'function') { + Error.captureStackTrace(this, ProposalForAnotherMultisigError) + } + } +} + +createErrorFromCodeLookup.set( + 0x1792, + () => new ProposalForAnotherMultisigError() +) +createErrorFromNameLookup.set( + 'ProposalForAnotherMultisig', + () => new ProposalForAnotherMultisigError() +) + +/** + * TransactionForAnotherMultisig: 'Transaction is for another multisig' + * + * @category Errors + * @category generated + */ +export class TransactionForAnotherMultisigError extends Error { + readonly code: number = 0x1793 + readonly name: string = 'TransactionForAnotherMultisig' + constructor() { + super('Transaction is for another multisig') + if (typeof Error.captureStackTrace === 'function') { + Error.captureStackTrace(this, TransactionForAnotherMultisigError) + } + } +} + +createErrorFromCodeLookup.set( + 0x1793, + () => new TransactionForAnotherMultisigError() +) +createErrorFromNameLookup.set( + 'TransactionForAnotherMultisig', + () => new TransactionForAnotherMultisigError() +) + +/** + * TransactionNotMatchingProposal: 'Transaction doesn't match proposal' + * + * @category Errors + * @category generated + */ +export class TransactionNotMatchingProposalError extends Error { + readonly code: number = 0x1794 + readonly name: string = 'TransactionNotMatchingProposal' + constructor() { + super("Transaction doesn't match proposal") + if (typeof Error.captureStackTrace === 'function') { + Error.captureStackTrace(this, TransactionNotMatchingProposalError) + } + } +} + +createErrorFromCodeLookup.set( + 0x1794, + () => new TransactionNotMatchingProposalError() +) +createErrorFromNameLookup.set( + 'TransactionNotMatchingProposal', + () => new TransactionNotMatchingProposalError() +) + /** * Attempts to resolve a custom program error from the provided error code. * @category Errors diff --git a/sdk/multisig/src/generated/instructions/configTransactionAccountsClose.ts b/sdk/multisig/src/generated/instructions/configTransactionAccountsClose.ts new file mode 100644 index 00000000..06909ff7 --- /dev/null +++ b/sdk/multisig/src/generated/instructions/configTransactionAccountsClose.ts @@ -0,0 +1,102 @@ +/** + * 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 ConfigTransactionAccountsClose + * @category generated + */ +export const configTransactionAccountsCloseStruct = new beet.BeetArgsStruct<{ + instructionDiscriminator: number[] /* size: 8 */ +}>( + [['instructionDiscriminator', beet.uniformFixedSizeArray(beet.u8, 8)]], + 'ConfigTransactionAccountsCloseInstructionArgs' +) +/** + * Accounts required by the _configTransactionAccountsClose_ instruction + * + * @property [] multisig + * @property [_writable_] proposal + * @property [_writable_] transaction + * @property [_writable_] rentCollector + * @category Instructions + * @category ConfigTransactionAccountsClose + * @category generated + */ +export type ConfigTransactionAccountsCloseInstructionAccounts = { + multisig: web3.PublicKey + proposal: web3.PublicKey + transaction: web3.PublicKey + rentCollector: web3.PublicKey + systemProgram?: web3.PublicKey + anchorRemainingAccounts?: web3.AccountMeta[] +} + +export const configTransactionAccountsCloseInstructionDiscriminator = [ + 80, 203, 84, 53, 151, 112, 187, 186, +] + +/** + * Creates a _ConfigTransactionAccountsClose_ instruction. + * + * @param accounts that will be accessed while the instruction is processed + * @category Instructions + * @category ConfigTransactionAccountsClose + * @category generated + */ +export function createConfigTransactionAccountsCloseInstruction( + accounts: ConfigTransactionAccountsCloseInstructionAccounts, + programId = new web3.PublicKey('SQDS4ep65T869zMMBKyuUq6aD6EgTu8psMjkvj52pCf') +) { + const [data] = configTransactionAccountsCloseStruct.serialize({ + instructionDiscriminator: + configTransactionAccountsCloseInstructionDiscriminator, + }) + const keys: web3.AccountMeta[] = [ + { + pubkey: accounts.multisig, + isWritable: false, + isSigner: false, + }, + { + pubkey: accounts.proposal, + isWritable: true, + 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/instructions/index.ts b/sdk/multisig/src/generated/instructions/index.ts index 846d448e..335a2d9a 100644 --- a/sdk/multisig/src/generated/instructions/index.ts +++ b/sdk/multisig/src/generated/instructions/index.ts @@ -1,6 +1,7 @@ export * from './batchAddTransaction' export * from './batchCreate' export * from './batchExecuteTransaction' +export * from './configTransactionAccountsClose' export * from './configTransactionCreate' export * from './configTransactionExecute' export * from './multisigAddMember' diff --git a/sdk/multisig/src/instructions/configTransactionAccountsClose.ts b/sdk/multisig/src/instructions/configTransactionAccountsClose.ts new file mode 100644 index 00000000..ac936017 --- /dev/null +++ b/sdk/multisig/src/instructions/configTransactionAccountsClose.ts @@ -0,0 +1,39 @@ +import { PublicKey } from "@solana/web3.js"; +import { + createConfigTransactionAccountsCloseInstruction, + PROGRAM_ID, +} from "../generated"; +import { getProposalPda, getTransactionPda } from "../pda"; + +export function configTransactionAccountsClose({ + multisigPda, + rentCollector, + transactionIndex, + programId = PROGRAM_ID, +}: { + multisigPda: PublicKey; + rentCollector: PublicKey; + transactionIndex: bigint; + programId?: PublicKey; +}) { + const [proposalPda] = getProposalPda({ + multisigPda, + transactionIndex, + programId, + }); + const [transactionPda] = getTransactionPda({ + multisigPda, + index: transactionIndex, + programId, + }); + + return createConfigTransactionAccountsCloseInstruction( + { + multisig: multisigPda, + rentCollector, + proposal: proposalPda, + transaction: transactionPda, + }, + programId + ); +} diff --git a/sdk/multisig/src/instructions/index.ts b/sdk/multisig/src/instructions/index.ts index 51c27359..8b131b39 100644 --- a/sdk/multisig/src/instructions/index.ts +++ b/sdk/multisig/src/instructions/index.ts @@ -1,6 +1,7 @@ export * from "./batchAddTransaction.js"; export * from "./batchCreate.js"; export * from "./batchExecuteTransaction.js"; +export * from "./configTransactionAccountsClose.js"; export * from "./configTransactionCreate.js"; export * from "./configTransactionExecute.js"; export * from "./multisigCreate.js"; diff --git a/sdk/multisig/src/rpc/configTransactionAccountsClose.ts b/sdk/multisig/src/rpc/configTransactionAccountsClose.ts new file mode 100644 index 00000000..d3224e53 --- /dev/null +++ b/sdk/multisig/src/rpc/configTransactionAccountsClose.ts @@ -0,0 +1,49 @@ +import { + Connection, + PublicKey, + SendOptions, + Signer, + TransactionSignature, +} from "@solana/web3.js"; +import * as transactions from "../transactions/index.js"; +import { translateAndThrowAnchorError } from "../errors"; + +/** + * Close the Proposal and ConfigTransaction accounts associated with a config transaction. + */ +export async function configTransactionAccountsClose({ + connection, + feePayer, + multisigPda, + rentCollector, + transactionIndex, + sendOptions, + programId, +}: { + connection: Connection; + feePayer: Signer; + multisigPda: PublicKey; + rentCollector: PublicKey; + transactionIndex: bigint; + sendOptions?: SendOptions; + programId?: PublicKey; +}): Promise { + const blockhash = (await connection.getLatestBlockhash()).blockhash; + + const tx = transactions.configTransactionAccountsClose({ + blockhash, + feePayer: feePayer.publicKey, + rentCollector, + transactionIndex, + 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 852cc22e..9392c87c 100644 --- a/sdk/multisig/src/rpc/index.ts +++ b/sdk/multisig/src/rpc/index.ts @@ -1,6 +1,7 @@ export * from "./batchAddTransaction.js"; export * from "./batchCreate.js"; export * from "./batchExecuteTransaction.js"; +export * from "./configTransactionAccountsClose.js"; export * from "./configTransactionCreate.js"; export * from "./configTransactionExecute.js"; export * from "./multisigAddMember.js"; diff --git a/sdk/multisig/src/transactions/configTransactionAccountsClose.ts b/sdk/multisig/src/transactions/configTransactionAccountsClose.ts new file mode 100644 index 00000000..339289c9 --- /dev/null +++ b/sdk/multisig/src/transactions/configTransactionAccountsClose.ts @@ -0,0 +1,37 @@ +import { + PublicKey, + TransactionMessage, + VersionedTransaction, +} from "@solana/web3.js"; +import * as instructions from "../instructions/index.js"; + +export function configTransactionAccountsClose({ + blockhash, + feePayer, + multisigPda, + rentCollector, + transactionIndex, + programId, +}: { + blockhash: string; + feePayer: PublicKey; + multisigPda: PublicKey; + rentCollector: PublicKey; + transactionIndex: bigint; + programId?: PublicKey; +}): VersionedTransaction { + const message = new TransactionMessage({ + payerKey: feePayer, + recentBlockhash: blockhash, + instructions: [ + instructions.configTransactionAccountsClose({ + multisigPda, + rentCollector, + transactionIndex, + programId, + }), + ], + }).compileToV0Message(); + + return new VersionedTransaction(message); +} diff --git a/sdk/multisig/src/transactions/index.ts b/sdk/multisig/src/transactions/index.ts index 852cc22e..9392c87c 100644 --- a/sdk/multisig/src/transactions/index.ts +++ b/sdk/multisig/src/transactions/index.ts @@ -1,6 +1,7 @@ export * from "./batchAddTransaction.js"; export * from "./batchCreate.js"; export * from "./batchExecuteTransaction.js"; +export * from "./configTransactionAccountsClose.js"; export * from "./configTransactionCreate.js"; export * from "./configTransactionExecute.js"; export * from "./multisigAddMember.js"; diff --git a/tests/index.ts b/tests/index.ts index 5cc5c4b4..f444cf10 100644 --- a/tests/index.ts +++ b/tests/index.ts @@ -1,7 +1,7 @@ // The order of imports is the order the test suite will run in. import "./suites/multisig-sdk"; import "./suites/account-migrations"; -import "./suites/examples/batch-sol-transfer"; -import "./suites/examples/create-mint"; -import "./suites/examples/immediate-execution"; -import "./suites/examples/spending-limits"; +// import "./suites/examples/batch-sol-transfer"; +// import "./suites/examples/create-mint"; +// import "./suites/examples/immediate-execution"; +// import "./suites/examples/spending-limits"; diff --git a/tests/suites/multisig-sdk.ts b/tests/suites/multisig-sdk.ts index f2b13422..19096427 100644 --- a/tests/suites/multisig-sdk.ts +++ b/tests/suites/multisig-sdk.ts @@ -3,11 +3,10 @@ import { LAMPORTS_PER_SOL, PublicKey, TransactionMessage, + VersionedTransaction, } from "@solana/web3.js"; import * as multisig from "@sqds/multisig"; import * as assert from "assert"; -import * as path from "path"; -import { readFileSync } from "fs"; import { createAutonomousMultisig, createControlledMultisig, @@ -2684,6 +2683,857 @@ describe("Multisig SDK", () => { }); }); + describe.only("config_transaction_accounts_close", () => { + let multisigPda: PublicKey; + const staleTransactionIndex = 1n; + const executedTransactionIndex = 2n; + const activeTransactionIndex = 3n; + const approvedTransactionIndex = 4n; + const rejectedTransactionIndex = 5n; + const cancelledTransactionIndex = 6n; + + // Set up a multisig with 3 config transactions: 1 Approved, 1 Rejected, 1 Cancelled. + before(async () => { + 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. + await createAutonomousMultisig({ + connection, + createKey, + members, + threshold: 2, + timeLock: 0, + rentCollector: vaultPda, + programId, + }); + + //region Stale + // Create a config transaction (Stale). + let signature = await multisig.rpc.configTransactionCreate({ + connection, + feePayer: members.proposer, + multisigPda, + transactionIndex: staleTransactionIndex, + creator: members.proposer.publicKey, + actions: [{ __kind: "ChangeThreshold", newThreshold: 1 }], + programId, + }); + await connection.confirmTransaction(signature); + + // Create a proposal for the transaction (Stale). + signature = await multisig.rpc.proposalCreate({ + connection, + feePayer: members.proposer, + multisigPda, + transactionIndex: staleTransactionIndex, + creator: members.proposer, + programId, + }); + await connection.confirmTransaction(signature); + // This transaction will become stale when the second config transaction is executed. + //endregion + + //region Executed + // Create a config transaction (Executed). + signature = await multisig.rpc.configTransactionCreate({ + connection, + feePayer: members.proposer, + multisigPda, + transactionIndex: executedTransactionIndex, + 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: executedTransactionIndex, + 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: executedTransactionIndex, + 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: executedTransactionIndex, + member: members.almighty, + programId, + }); + await connection.confirmTransaction(signature); + + // Execute the transaction. + signature = await multisig.rpc.configTransactionExecute({ + connection, + feePayer: members.almighty, + multisigPda, + transactionIndex: executedTransactionIndex, + member: members.almighty, + rentPayer: members.almighty, + programId, + }); + await connection.confirmTransaction(signature); + + //endregion + + //region Active + // Create a config transaction (Active). + signature = await multisig.rpc.configTransactionCreate({ + connection, + feePayer: members.proposer, + multisigPda, + transactionIndex: activeTransactionIndex, + creator: members.proposer.publicKey, + actions: [{ __kind: "ChangeThreshold", newThreshold: 1 }], + programId, + }); + await connection.confirmTransaction(signature); + + // Create a proposal for the transaction (Active). + signature = await multisig.rpc.proposalCreate({ + connection, + feePayer: members.proposer, + multisigPda, + transactionIndex: activeTransactionIndex, + creator: members.proposer, + programId, + }); + await connection.confirmTransaction(signature); + + // Make sure the proposal is active. + let proposalAccount = await Proposal.fromAccountAddress( + connection, + multisig.getProposalPda({ + multisigPda, + transactionIndex: activeTransactionIndex, + programId, + })[0] + ); + assert.ok(multisig.types.isProposalStatusActive(proposalAccount.status)); + //endregion + + //region Approved + // Create a config transaction (Approved). + signature = await multisig.rpc.configTransactionCreate({ + connection, + feePayer: members.proposer, + multisigPda, + transactionIndex: approvedTransactionIndex, + creator: members.proposer.publicKey, + actions: [{ __kind: "ChangeThreshold", newThreshold: 1 }], + programId, + }); + await connection.confirmTransaction(signature); + + // Create a proposal for the transaction (Approved). + signature = await multisig.rpc.proposalCreate({ + connection, + feePayer: members.proposer, + multisigPda, + transactionIndex: approvedTransactionIndex, + creator: members.proposer, + programId, + }); + await connection.confirmTransaction(signature); + + // Approve the proposal. + signature = await multisig.rpc.proposalApprove({ + connection, + feePayer: members.voter, + multisigPda, + transactionIndex: approvedTransactionIndex, + member: members.voter, + programId, + }); + await connection.confirmTransaction(signature); + + // Make sure the proposal is approved. + proposalAccount = await Proposal.fromAccountAddress( + connection, + multisig.getProposalPda({ + multisigPda, + transactionIndex: approvedTransactionIndex, + programId, + })[0] + ); + assert.ok( + multisig.types.isProposalStatusApproved(proposalAccount.status) + ); + //endregion + + //region Rejected + // Create a config transaction (Rejected). + signature = await multisig.rpc.configTransactionCreate({ + connection, + feePayer: members.proposer, + multisigPda, + transactionIndex: rejectedTransactionIndex, + creator: members.proposer.publicKey, + actions: [{ __kind: "ChangeThreshold", newThreshold: 3 }], + programId, + }); + await connection.confirmTransaction(signature); + + // Create a proposal for the transaction (Rejected). + signature = await multisig.rpc.proposalCreate({ + connection, + feePayer: members.proposer, + multisigPda, + transactionIndex: rejectedTransactionIndex, + creator: members.proposer, + programId, + }); + await connection.confirmTransaction(signature); + + // Our threshold is 1, and 2 voters, so the cutoff is 2... + + // Reject the proposal by the first member. + signature = await multisig.rpc.proposalReject({ + connection, + feePayer: members.voter, + multisigPda, + transactionIndex: rejectedTransactionIndex, + member: members.voter, + programId, + }); + await connection.confirmTransaction(signature); + + // Reject the proposal by the second member. + signature = await multisig.rpc.proposalReject({ + connection, + feePayer: members.almighty, + multisigPda, + transactionIndex: rejectedTransactionIndex, + member: members.almighty, + programId, + }); + await connection.confirmTransaction(signature); + + // Make sure the proposal is rejected. + proposalAccount = await Proposal.fromAccountAddress( + connection, + multisig.getProposalPda({ + multisigPda, + transactionIndex: rejectedTransactionIndex, + programId, + })[0] + ); + assert.ok( + multisig.types.isProposalStatusRejected(proposalAccount.status) + ); + //endregion + + //region Cancelled + // Create a config transaction (Cancelled). + signature = await multisig.rpc.configTransactionCreate({ + connection, + feePayer: members.proposer, + multisigPda, + transactionIndex: cancelledTransactionIndex, + creator: members.proposer.publicKey, + actions: [{ __kind: "ChangeThreshold", newThreshold: 3 }], + programId, + }); + await connection.confirmTransaction(signature); + + // Create a proposal for the transaction (Cancelled). + signature = await multisig.rpc.proposalCreate({ + connection, + feePayer: members.proposer, + multisigPda, + transactionIndex: cancelledTransactionIndex, + creator: members.proposer, + programId, + }); + await connection.confirmTransaction(signature); + + // Approve the proposal. + signature = await multisig.rpc.proposalApprove({ + connection, + feePayer: members.voter, + multisigPda, + transactionIndex: cancelledTransactionIndex, + member: members.voter, + programId, + }); + await connection.confirmTransaction(signature); + + // Cancel the proposal (The proposal should be approved at this point). + signature = await multisig.rpc.proposalCancel({ + connection, + feePayer: members.voter, + multisigPda, + transactionIndex: cancelledTransactionIndex, + member: members.voter, + programId, + }); + await connection.confirmTransaction(signature); + + // Make sure the proposal is cancelled. + proposalAccount = await Proposal.fromAccountAddress( + connection, + multisig.getProposalPda({ + multisigPda, + transactionIndex: cancelledTransactionIndex, + programId, + })[0] + ); + assert.ok( + multisig.types.isProposalStatusCancelled(proposalAccount.status) + ); + + //endregion + }); + + it("error: rent reclamation is not enabled", async () => { + // Create a multisig with rent reclamation disabled. + const multisigPda = ( + await createAutonomousMultisig({ + connection, + members, + threshold: 2, + timeLock: 0, + rentCollector: null, + programId, + }) + )[0]; + + // Create a config transaction. + const transactionIndex = 1n; + let signature = await multisig.rpc.configTransactionCreate({ + connection, + feePayer: members.proposer, + multisigPda, + transactionIndex, + creator: members.proposer.publicKey, + actions: [{ __kind: "ChangeThreshold", newThreshold: 1 }], + programId, + }); + await connection.confirmTransaction(signature); + + // Create a proposal for the transaction. + signature = await multisig.rpc.proposalCreate({ + connection, + feePayer: members.proposer, + multisigPda, + transactionIndex, + creator: members.proposer, + programId, + }); + await connection.confirmTransaction(signature); + + // Approve the proposal by the first member. + signature = await multisig.rpc.proposalApprove({ + connection, + feePayer: members.voter, + multisigPda, + transactionIndex, + 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, + member: members.almighty, + programId, + }); + await connection.confirmTransaction(signature); + + // Execute the transaction. + signature = await multisig.rpc.configTransactionExecute({ + connection, + feePayer: members.almighty, + multisigPda, + transactionIndex, + member: members.almighty, + rentPayer: members.almighty, + programId, + }); + await connection.confirmTransaction(signature); + + // Attempt to close the accounts. + await assert.rejects( + () => + multisig.rpc.configTransactionAccountsClose({ + connection, + feePayer: members.almighty, + multisigPda, + rentCollector: Keypair.generate().publicKey, + transactionIndex, + programId, + }), + /RentReclamationDisabled: Rent reclamation is disabled for this multisig/ + ); + }); + + it("error: invalid rent_collector", async () => { + const transactionIndex = approvedTransactionIndex; + + const fakeRentCollector = Keypair.generate().publicKey; + + await assert.rejects( + () => + multisig.rpc.configTransactionAccountsClose({ + connection, + feePayer: members.almighty, + multisigPda, + rentCollector: fakeRentCollector, + transactionIndex, + programId, + }), + /Invalid rent collector address/ + ); + }); + + it("error: proposal is for another multisig", async () => { + const vaultPda = multisig.getVaultPda({ + multisigPda, + index: 0, + programId, + })[0]; + + // Create another multisig. + const otherMultisig = ( + await createAutonomousMultisig({ + connection, + members, + threshold: 2, + timeLock: 0, + rentCollector: null, + programId, + }) + )[0]; + // Create a config transaction for it. + let signature = await multisig.rpc.configTransactionCreate({ + connection, + feePayer: members.proposer, + multisigPda: otherMultisig, + transactionIndex: 1n, + creator: members.proposer.publicKey, + actions: [{ __kind: "ChangeThreshold", newThreshold: 1 }], + programId, + }); + await connection.confirmTransaction(signature); + // Create a proposal for it. + signature = await multisig.rpc.proposalCreate({ + connection, + feePayer: members.proposer, + multisigPda: otherMultisig, + transactionIndex: 1n, + creator: members.proposer, + programId, + }); + await connection.confirmTransaction(signature); + + // Manually construct an instruction that uses the proposal account from the other multisig. + const ix = + multisig.generated.createConfigTransactionAccountsCloseInstruction( + { + multisig: multisigPda, + rentCollector: vaultPda, + proposal: multisig.getProposalPda({ + multisigPda: otherMultisig, + transactionIndex: 1n, + programId, + })[0], + transaction: multisig.getTransactionPda({ + multisigPda: otherMultisig, + index: 1n, + 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 () => { + // Create a config transaction. + const transactionIndex = activeTransactionIndex; + + const multisigAccount = await Multisig.fromAccountAddress( + connection, + multisigPda + ); + + await assert.rejects( + () => + multisig.rpc.configTransactionAccountsClose({ + connection, + feePayer: members.almighty, + multisigPda, + rentCollector: multisigAccount.rentCollector!, + transactionIndex, + programId, + }), + /Invalid proposal status/ + ); + }); + + it("error: invalid proposal status (Approved)", async () => { + // Create a config transaction. + const transactionIndex = approvedTransactionIndex; + + const multisigAccount = await Multisig.fromAccountAddress( + connection, + multisigPda + ); + + await assert.rejects( + () => + multisig.rpc.configTransactionAccountsClose({ + connection, + feePayer: members.almighty, + multisigPda, + rentCollector: multisigAccount.rentCollector!, + transactionIndex, + programId, + }), + /Invalid proposal status/ + ); + }); + + it("error: transaction is for another multisig", async () => { + // Create another multisig. + const otherMultisig = ( + await createAutonomousMultisig({ + connection, + members, + threshold: 2, + timeLock: 0, + rentCollector: null, + programId, + }) + )[0]; + // Create a config transaction for it. + let signature = await multisig.rpc.configTransactionCreate({ + connection, + feePayer: members.proposer, + multisigPda: otherMultisig, + transactionIndex: 1n, + creator: members.proposer.publicKey, + actions: [{ __kind: "ChangeThreshold", newThreshold: 1 }], + programId, + }); + await connection.confirmTransaction(signature); + // Create a proposal for it. + signature = await multisig.rpc.proposalCreate({ + connection, + feePayer: members.proposer, + multisigPda: otherMultisig, + transactionIndex: 1n, + creator: members.proposer, + programId, + }); + await connection.confirmTransaction(signature); + + const vaultPda = multisig.getVaultPda({ + multisigPda, + index: 0, + programId, + })[0]; + + const feePayer = await generateFundedKeypair(connection); + + // Manually construct an instruction that uses transaction that doesn't match proposal. + const ix = + multisig.generated.createConfigTransactionAccountsCloseInstruction( + { + multisig: multisigPda, + rentCollector: vaultPda, + proposal: multisig.getProposalPda({ + multisigPda, + transactionIndex: rejectedTransactionIndex, + programId, + })[0], + transaction: multisig.getTransactionPda({ + multisigPda: otherMultisig, + index: 1n, + programId, + })[0], + }, + programId + ); + + 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), + /Transaction is for another multisig/ + ); + }); + + it("error: transaction doesn't match proposal", async () => { + const feePayer = await generateFundedKeypair(connection); + + const vaultPda = multisig.getVaultPda({ + multisigPda, + index: 0, + programId, + })[0]; + + // Manually construct an instruction that uses transaction that doesn't match proposal. + const ix = + multisig.generated.createConfigTransactionAccountsCloseInstruction( + { + multisig: multisigPda, + rentCollector: vaultPda, + proposal: multisig.getProposalPda({ + multisigPda, + transactionIndex: rejectedTransactionIndex, + programId, + })[0], + transaction: multisig.getTransactionPda({ + multisigPda, + // Wrong transaction index. + index: approvedTransactionIndex, + programId, + })[0], + }, + programId + ); + + 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), + /Transaction is for another multisig/ + ); + }); + + it("close accounts for Stale transaction", async () => { + // Close the accounts for the Approved transaction. + const transactionIndex = staleTransactionIndex; + + const multisigAccount = await Multisig.fromAccountAddress( + connection, + multisigPda + ); + + // Make sure the proposal is still active. + let proposalAccount = await Proposal.fromAccountAddress( + connection, + multisig.getProposalPda({ + multisigPda, + transactionIndex, + programId, + })[0] + ); + assert.ok(multisig.types.isProposalStatusActive(proposalAccount.status)); + + // Make sure the proposal is stale. + assert.ok( + proposalAccount.transactionIndex <= + multisigAccount.staleTransactionIndex + ); + + const [vaultPda] = multisig.getVaultPda({ + multisigPda, + index: 0, + programId, + }); + + const preBalance = await connection.getBalance(vaultPda); + + const sig = await multisig.rpc.configTransactionAccountsClose({ + connection, + feePayer: members.almighty, + multisigPda, + rentCollector: vaultPda, + transactionIndex, + programId, + }); + await connection.confirmTransaction(sig); + + const postBalance = await connection.getBalance(vaultPda); + const accountsRent = 5554080; + assert.ok(postBalance === preBalance + accountsRent); + }); + + it("close accounts for Executed transaction", async () => { + const transactionIndex = executedTransactionIndex; + + // Make sure the proposal is Executed. + let proposalAccount = await Proposal.fromAccountAddress( + connection, + multisig.getProposalPda({ + multisigPda, + transactionIndex, + programId, + })[0] + ); + assert.ok( + multisig.types.isProposalStatusExecuted(proposalAccount.status) + ); + + const [vaultPda] = multisig.getVaultPda({ + multisigPda, + index: 0, + programId, + }); + + const preBalance = await connection.getBalance(vaultPda); + + const sig = await multisig.rpc.configTransactionAccountsClose({ + connection, + feePayer: members.almighty, + multisigPda, + rentCollector: vaultPda, + transactionIndex, + programId, + }); + await connection.confirmTransaction(sig); + + const postBalance = await connection.getBalance(vaultPda); + const accountsRent = 5554080; + assert.ok(postBalance === preBalance + accountsRent); + }); + + it("close accounts for Rejected transaction", async () => { + const transactionIndex = rejectedTransactionIndex; + + // Make sure the proposal is Rejected. + let proposalAccount = await Proposal.fromAccountAddress( + connection, + multisig.getProposalPda({ + multisigPda, + transactionIndex, + programId, + })[0] + ); + assert.ok( + multisig.types.isProposalStatusRejected(proposalAccount.status) + ); + + const [vaultPda] = multisig.getVaultPda({ + multisigPda, + index: 0, + programId, + }); + + const preBalance = await connection.getBalance(vaultPda); + + const sig = await multisig.rpc.configTransactionAccountsClose({ + connection, + feePayer: members.almighty, + multisigPda, + rentCollector: vaultPda, + transactionIndex, + programId, + }); + await connection.confirmTransaction(sig); + + const postBalance = await connection.getBalance(vaultPda); + const accountsRent = 5554080; + assert.ok(postBalance === preBalance + accountsRent); + }); + + it("close accounts for Cancelled transaction", async () => { + const transactionIndex = cancelledTransactionIndex; + + // Make sure the proposal is Cancelled. + let proposalAccount = await Proposal.fromAccountAddress( + connection, + multisig.getProposalPda({ + multisigPda, + transactionIndex, + programId, + })[0] + ); + assert.ok( + multisig.types.isProposalStatusCancelled(proposalAccount.status) + ); + + const [vaultPda] = multisig.getVaultPda({ + multisigPda, + index: 0, + programId, + }); + + const preBalance = await connection.getBalance(vaultPda); + + const sig = await multisig.rpc.configTransactionAccountsClose({ + connection, + feePayer: members.almighty, + multisigPda, + rentCollector: vaultPda, + transactionIndex, + programId, + }); + await connection.confirmTransaction(sig); + + const postBalance = await connection.getBalance(vaultPda); + const accountsRent = 5554080; + assert.ok(postBalance === preBalance + accountsRent); + }); + }); + describe("utils", () => { describe("getAvailableMemoSize", () => { it("provides estimates for available size to use for memo", async () => {