From 72e3c3b542c7ba9a5d0a7e0d6d784d889727d009 Mon Sep 17 00:00:00 2001 From: Vladimir Guguiev <1524432+vovacodes@users.noreply.github.com> Date: Mon, 13 Nov 2023 17:48:26 +0100 Subject: [PATCH 1/7] feat(multisig): add rent_collector field to Multisig account --- Anchor.toml | 3 + .../config_transaction_execute.rs | 1 + .../src/instructions/multisig_config.rs | 1 + .../src/instructions/multisig_create.rs | 8 ++- .../src/state/multisig.rs | 15 +++-- sdk/multisig/idl/squads_multisig_program.json | 21 +++++-- .../src/generated/accounts/Multisig.ts | 10 ++-- .../src/generated/types/MultisigCreateArgs.ts | 2 + .../src/instructions/multisigCreate.ts | 3 + sdk/multisig/src/rpc/multisigCreate.ts | 3 + .../src/transactions/multisigCreate.ts | 3 + .../pre-rent-collector/multisig-account.json | 14 +++++ tests/index.ts | 1 + tests/suites/account-migrations.ts | 59 +++++++++++++++++++ tests/suites/examples/batch-sol-transfer.ts | 1 + tests/suites/examples/create-mint.ts | 1 + tests/suites/examples/immediate-execution.ts | 1 + tests/suites/examples/spending-limits.ts | 1 + tests/suites/multisig-sdk.ts | 58 +++++++++++++++++- tests/utils.ts | 6 ++ 20 files changed, 195 insertions(+), 17 deletions(-) create mode 100644 tests/fixtures/pre-rent-collector/multisig-account.json create mode 100644 tests/suites/account-migrations.ts diff --git a/Anchor.toml b/Anchor.toml index e4a4e958..0b16474d 100644 --- a/Anchor.toml +++ b/Anchor.toml @@ -23,6 +23,9 @@ url = "https://api.devnet.solana.com" [[test.validator.clone]] address = "TokenzQdBNbLqP5VEhdkAS6EPFLC1PHnBqCXEpPxuEb" +[[test.validator.account]] +address = "D3oQ6QxSYk6aKUsmBTa9BghFQvbRi7kxP6h95NSdjjXz" +filename = "tests/fixtures/pre-rent-collector/multisig-account.json" [scripts] test = "npx mocha --node-option require=ts-node/register --extension ts -t 1000000 tests/index.ts" diff --git a/programs/squads_multisig_program/src/instructions/config_transaction_execute.rs b/programs/squads_multisig_program/src/instructions/config_transaction_execute.rs index dd2691a7..7825cd78 100644 --- a/programs/squads_multisig_program/src/instructions/config_transaction_execute.rs +++ b/programs/squads_multisig_program/src/instructions/config_transaction_execute.rs @@ -126,6 +126,7 @@ impl<'info> ConfigTransactionExecute<'info> { let reallocated = Multisig::realloc_if_needed( multisig.to_account_info(), new_members_length, + multisig.rent_collector.is_some(), rent_payer.to_account_info(), system_program.to_account_info(), )?; diff --git a/programs/squads_multisig_program/src/instructions/multisig_config.rs b/programs/squads_multisig_program/src/instructions/multisig_config.rs index d77fba0e..53ffed51 100644 --- a/programs/squads_multisig_program/src/instructions/multisig_config.rs +++ b/programs/squads_multisig_program/src/instructions/multisig_config.rs @@ -95,6 +95,7 @@ impl MultisigConfig<'_> { let reallocated = Multisig::realloc_if_needed( multisig.to_account_info(), multisig.members.len() + 1, + multisig.rent_collector.is_some(), rent_payer.to_account_info(), system_program.to_account_info(), )?; diff --git a/programs/squads_multisig_program/src/instructions/multisig_create.rs b/programs/squads_multisig_program/src/instructions/multisig_create.rs index 7bcf8342..05803539 100644 --- a/programs/squads_multisig_program/src/instructions/multisig_create.rs +++ b/programs/squads_multisig_program/src/instructions/multisig_create.rs @@ -11,8 +11,11 @@ pub struct MultisigCreateArgs { pub threshold: u16, /// The members of the multisig. pub members: Vec, - /// How many seconds must pass between transaction voting settlement and execution. + /// How many seconds must pass between transaction voting, settlement, and execution. pub time_lock: u32, + /// The address where the rent for the accounts related to executed, rejected, or cancelled + /// transactions can be reclaimed. If set to `None`, the rent reclamation feature is turned off. + pub rent_collector: Option, /// Memo is used for indexing only. pub memo: Option, } @@ -23,7 +26,7 @@ pub struct MultisigCreate<'info> { #[account( init, payer = creator, - space = Multisig::size(args.members.len()), + space = Multisig::size(args.members.len(), args.rent_collector.is_some()), seeds = [SEED_PREFIX, SEED_MULTISIG, create_key.key().as_ref()], bump )] @@ -62,6 +65,7 @@ impl MultisigCreate<'_> { multisig.create_key = ctx.accounts.create_key.key(); multisig.bump = ctx.bumps.multisig; multisig.members = members; + multisig.rent_collector = args.rent_collector; multisig.invariant()?; diff --git a/programs/squads_multisig_program/src/state/multisig.rs b/programs/squads_multisig_program/src/state/multisig.rs index 5bf0c245..0ec941b8 100644 --- a/programs/squads_multisig_program/src/state/multisig.rs +++ b/programs/squads_multisig_program/src/state/multisig.rs @@ -31,8 +31,9 @@ pub struct Multisig { /// Last stale transaction index. All transactions up until this index are stale. /// This index is updated when multisig config (members/threshold/time_lock) changes. pub stale_transaction_index: u64, - /// Reserved for future use. - pub _reserved: u8, + /// The address where the rent for the accounts related to executed, rejected, or cancelled + /// transactions can be reclaimed. If set to `None`, the rent reclamation feature is turned off. + pub rent_collector: Option, /// Bump for the multisig PDA seed. pub bump: u8, /// Members of the multisig. @@ -40,7 +41,9 @@ pub struct Multisig { } impl Multisig { - pub fn size(members_length: usize) -> usize { + pub fn size(members_length: usize, is_rent_collector_set: bool) -> usize { + let rent_collector_size = if is_rent_collector_set { 32 } else { 0 }; + 8 + // anchor account discriminator 32 + // create_key 32 + // config_authority @@ -48,7 +51,8 @@ impl Multisig { 4 + // time_lock 8 + // transaction_index 8 + // stale_transaction_index - 1 + // _reserved + 1 + // rent_collector Option discriminator + rent_collector_size + // rent_collector 1 + // bump 4 + // members vector length members_length * Member::INIT_SPACE // members @@ -80,6 +84,7 @@ impl Multisig { pub fn realloc_if_needed<'a>( multisig: AccountInfo<'a>, members_length: usize, + is_rent_collector_set: bool, rent_payer: AccountInfo<'a>, system_program: AccountInfo<'a>, ) -> Result { @@ -92,7 +97,7 @@ impl Multisig { require_keys_eq!(*multisig.owner, id(), MultisigError::IllegalAccountOwner); let current_account_size = multisig.data.borrow().len(); - let account_size_to_fit_members = Multisig::size(members_length); + let account_size_to_fit_members = Multisig::size(members_length, is_rent_collector_set); // Check if we need to reallocate space. if current_account_size >= account_size_to_fit_members { diff --git a/sdk/multisig/idl/squads_multisig_program.json b/sdk/multisig/idl/squads_multisig_program.json index 4a94b836..c5c6aa72 100644 --- a/sdk/multisig/idl/squads_multisig_program.json +++ b/sdk/multisig/idl/squads_multisig_program.json @@ -1218,11 +1218,14 @@ "type": "u64" }, { - "name": "reserved", + "name": "rentCollector", "docs": [ - "Reserved for future use." + "The address where the rent for the accounts related to executed, rejected, or cancelled", + "transactions can be reclaimed. If set to `None`, the rent reclamation feature is turned off." ], - "type": "u8" + "type": { + "option": "publicKey" + } }, { "name": "bump", @@ -1777,10 +1780,20 @@ { "name": "timeLock", "docs": [ - "How many seconds must pass between transaction voting settlement and execution." + "How many seconds must pass between transaction voting, settlement, and execution." ], "type": "u32" }, + { + "name": "rentCollector", + "docs": [ + "The address where the rent for the accounts related to executed, rejected, or cancelled", + "transactions can be reclaimed. If set to `None`, the rent reclamation feature is turned off." + ], + "type": { + "option": "publicKey" + } + }, { "name": "memo", "docs": [ diff --git a/sdk/multisig/src/generated/accounts/Multisig.ts b/sdk/multisig/src/generated/accounts/Multisig.ts index bc3abf9f..43917686 100644 --- a/sdk/multisig/src/generated/accounts/Multisig.ts +++ b/sdk/multisig/src/generated/accounts/Multisig.ts @@ -22,7 +22,7 @@ export type MultisigArgs = { timeLock: number transactionIndex: beet.bignum staleTransactionIndex: beet.bignum - reserved: number + rentCollector: beet.COption bump: number members: Member[] } @@ -43,7 +43,7 @@ export class Multisig implements MultisigArgs { readonly timeLock: number, readonly transactionIndex: beet.bignum, readonly staleTransactionIndex: beet.bignum, - readonly reserved: number, + readonly rentCollector: beet.COption, readonly bump: number, readonly members: Member[] ) {} @@ -59,7 +59,7 @@ export class Multisig implements MultisigArgs { args.timeLock, args.transactionIndex, args.staleTransactionIndex, - args.reserved, + args.rentCollector, args.bump, args.members ) @@ -196,7 +196,7 @@ export class Multisig implements MultisigArgs { } return x })(), - reserved: this.reserved, + rentCollector: this.rentCollector, bump: this.bump, members: this.members, } @@ -221,7 +221,7 @@ export const multisigBeet = new beet.FixableBeetStruct< ['timeLock', beet.u32], ['transactionIndex', beet.u64], ['staleTransactionIndex', beet.u64], - ['reserved', beet.u8], + ['rentCollector', beet.coption(beetSolana.publicKey)], ['bump', beet.u8], ['members', beet.array(memberBeet)], ], diff --git a/sdk/multisig/src/generated/types/MultisigCreateArgs.ts b/sdk/multisig/src/generated/types/MultisigCreateArgs.ts index 0cb11d72..8cecd01c 100644 --- a/sdk/multisig/src/generated/types/MultisigCreateArgs.ts +++ b/sdk/multisig/src/generated/types/MultisigCreateArgs.ts @@ -14,6 +14,7 @@ export type MultisigCreateArgs = { threshold: number members: Member[] timeLock: number + rentCollector: beet.COption memo: beet.COption } @@ -28,6 +29,7 @@ export const multisigCreateArgsBeet = ['threshold', beet.u16], ['members', beet.array(memberBeet)], ['timeLock', beet.u32], + ['rentCollector', beet.coption(beetSolana.publicKey)], ['memo', beet.coption(beet.utf8String)], ], 'MultisigCreateArgs' diff --git a/sdk/multisig/src/instructions/multisigCreate.ts b/sdk/multisig/src/instructions/multisigCreate.ts index a1eca83d..ec67b74f 100644 --- a/sdk/multisig/src/instructions/multisigCreate.ts +++ b/sdk/multisig/src/instructions/multisigCreate.ts @@ -13,6 +13,7 @@ export function multisigCreate({ members, timeLock, createKey, + rentCollector, memo, programId = PROGRAM_ID, }: { @@ -23,6 +24,7 @@ export function multisigCreate({ members: Member[]; timeLock: number; createKey: PublicKey; + rentCollector: PublicKey | null; memo?: string; programId?: PublicKey; }): TransactionInstruction { @@ -38,6 +40,7 @@ export function multisigCreate({ threshold, members, timeLock, + rentCollector, memo: memo ?? null, }, }, diff --git a/sdk/multisig/src/rpc/multisigCreate.ts b/sdk/multisig/src/rpc/multisigCreate.ts index 7e623cbd..00b0e9fa 100644 --- a/sdk/multisig/src/rpc/multisigCreate.ts +++ b/sdk/multisig/src/rpc/multisigCreate.ts @@ -19,6 +19,7 @@ export async function multisigCreate({ threshold, members, timeLock, + rentCollector, memo, sendOptions, programId, @@ -31,6 +32,7 @@ export async function multisigCreate({ threshold: number; members: Member[]; timeLock: number; + rentCollector: PublicKey | null; memo?: string; sendOptions?: SendOptions; programId?: PublicKey; @@ -46,6 +48,7 @@ export async function multisigCreate({ threshold, members, timeLock, + rentCollector, memo, programId, }); diff --git a/sdk/multisig/src/transactions/multisigCreate.ts b/sdk/multisig/src/transactions/multisigCreate.ts index 1a7a0134..b3a6b8fa 100644 --- a/sdk/multisig/src/transactions/multisigCreate.ts +++ b/sdk/multisig/src/transactions/multisigCreate.ts @@ -16,6 +16,7 @@ export function multisigCreate({ threshold, members, timeLock, + rentCollector, memo, programId, }: { @@ -27,6 +28,7 @@ export function multisigCreate({ threshold: number; members: Member[]; timeLock: number; + rentCollector: PublicKey | null; memo?: string; programId?: PublicKey; }): VersionedTransaction { @@ -38,6 +40,7 @@ export function multisigCreate({ members, timeLock, createKey, + rentCollector, memo, programId, }); diff --git a/tests/fixtures/pre-rent-collector/multisig-account.json b/tests/fixtures/pre-rent-collector/multisig-account.json new file mode 100644 index 00000000..fcbe137a --- /dev/null +++ b/tests/fixtures/pre-rent-collector/multisig-account.json @@ -0,0 +1,14 @@ +{ + "pubkey": "D3oQ6QxSYk6aKUsmBTa9BghFQvbRi7kxP6h95NSdjjXz", + "account": { + "data": [ + "4HR5ukShT+zNTJi8iaK0vFB3njzSJsrdqadupYCdWOeWj4BEG1e+PAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAgAAAAAAAAAAAAAAAAAAAAAAAAAAAAD/BAAAAAtcpDyLpF2qchUWtTgirLBsA2j2iPAZDq+XxsCCt1WhB5FLFhBcntmrxzNiVBqkZ/dbZzcanECcFEnjEIDcEviBBJHOyve0yVuLqE8ovLX9iU3bzxCrwWswxDKug53w+fYoApq+6sGT0Dqd9Ue2QdU25mqbfrbZ1YwT4jlNIIDiQw7fAQ==", + "base64" + ], + "executable": false, + "lamports": 2505600, + "owner": "GyhGAqjokLwF9UXdQ2dR5Zwiup242j4mX4J1tSMKyAmD", + "rentEpoch": 18446744073709551615, + "space": 232 + } +} diff --git a/tests/index.ts b/tests/index.ts index c658c09e..5cc5c4b4 100644 --- a/tests/index.ts +++ b/tests/index.ts @@ -1,5 +1,6 @@ // 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"; diff --git a/tests/suites/account-migrations.ts b/tests/suites/account-migrations.ts new file mode 100644 index 00000000..994b46b0 --- /dev/null +++ b/tests/suites/account-migrations.ts @@ -0,0 +1,59 @@ +import assert from "assert"; +import { Keypair, LAMPORTS_PER_SOL, PublicKey } from "@solana/web3.js"; +import * as multisig from "@sqds/multisig"; +import { createLocalhostConnection, getTestProgramId } from "../utils"; + +const { Multisig } = multisig.accounts; +const { toBigInt } = multisig.utils; + +const programId = getTestProgramId(); + +describe("Account Schema Migrations", () => { + const connection = createLocalhostConnection(); + + it("Multisig account created before introduction of rent_collector field should load by program", async () => { + const memberKeypair = Keypair.fromSecretKey( + new Uint8Array([ + 56, 145, 84, 172, 159, 38, 155, 221, 251, 78, 28, 43, 31, 8, 69, 68, + 160, 49, 219, 216, 250, 32, 126, 39, 214, 117, 166, 11, 252, 178, 65, + 130, 11, 92, 164, 60, 139, 164, 93, 170, 114, 21, 22, 181, 56, 34, 172, + 176, 108, 3, 104, 246, 136, 240, 25, 14, 175, 151, 198, 192, 130, 183, + 85, 161, + ]) + ); + // Fund the member wallet. + const tx = await connection.requestAirdrop( + memberKeypair.publicKey, + 1 * LAMPORTS_PER_SOL + ); + await connection.confirmTransaction(tx); + + // This is the account that was created before the `rent_collector` field was added to the schema. + const oldMultisigPda = new PublicKey( + "D3oQ6QxSYk6aKUsmBTa9BghFQvbRi7kxP6h95NSdjjXz" + ); + + // Should deserialize with the latest SDK. + const oldMultisigAccount = await Multisig.fromAccountAddress( + connection, + oldMultisigPda + ); + + // Should deserialize `rent_collector` as null. + assert.equal(oldMultisigAccount.rentCollector, null); + + // Should work with the latest version of the program. + // This transaction will fail if the program cannot deserialize the multisig account. + const sig = await multisig.rpc.configTransactionCreate({ + connection, + multisigPda: oldMultisigPda, + feePayer: memberKeypair, + transactionIndex: toBigInt(oldMultisigAccount.transactionIndex) + 1n, + actions: [{ __kind: "SetTimeLock", newTimeLock: 300 }], + creator: memberKeypair.publicKey, + rentPayer: memberKeypair.publicKey, + programId, + }); + await connection.confirmTransaction(sig); + }); +}); diff --git a/tests/suites/examples/batch-sol-transfer.ts b/tests/suites/examples/batch-sol-transfer.ts index 6bcfab07..50d7252b 100644 --- a/tests/suites/examples/batch-sol-transfer.ts +++ b/tests/suites/examples/batch-sol-transfer.ts @@ -40,6 +40,7 @@ describe("Examples / Batch SOL Transfer", () => { members, threshold: 2, timeLock: 0, + rentCollector: null, programId, }); diff --git a/tests/suites/examples/create-mint.ts b/tests/suites/examples/create-mint.ts index c7441bd8..d5769dd5 100644 --- a/tests/suites/examples/create-mint.ts +++ b/tests/suites/examples/create-mint.ts @@ -34,6 +34,7 @@ describe("Examples / Create Mint", () => { members, threshold: 2, timeLock: 0, + rentCollector: null, programId, }); diff --git a/tests/suites/examples/immediate-execution.ts b/tests/suites/examples/immediate-execution.ts index 43bcdbf8..5b72dbb2 100644 --- a/tests/suites/examples/immediate-execution.ts +++ b/tests/suites/examples/immediate-execution.ts @@ -32,6 +32,7 @@ describe("Examples / Immediate Execution", () => { members, threshold: 1, timeLock: 0, + rentCollector: null, programId, }); diff --git a/tests/suites/examples/spending-limits.ts b/tests/suites/examples/spending-limits.ts index 3cc41a5b..39d5d7bb 100644 --- a/tests/suites/examples/spending-limits.ts +++ b/tests/suites/examples/spending-limits.ts @@ -48,6 +48,7 @@ describe("Examples / Spending Limits", () => { members, threshold: 1, timeLock: 0, + rentCollector: null, programId, }) )[0]; diff --git a/tests/suites/multisig-sdk.ts b/tests/suites/multisig-sdk.ts index 988a9a00..f2b13422 100644 --- a/tests/suites/multisig-sdk.ts +++ b/tests/suites/multisig-sdk.ts @@ -66,6 +66,7 @@ describe("Multisig SDK", () => { }, ], createKey, + rentCollector: null, sendOptions: { skipPreflight: true }, programId, }), @@ -90,6 +91,7 @@ describe("Multisig SDK", () => { configAuthority: null, timeLock: 0, threshold: 1, + rentCollector: null, members: [ { key: members.almighty.publicKey, @@ -132,6 +134,7 @@ describe("Multisig SDK", () => { timeLock: 0, threshold: 1, members: [], + rentCollector: null, sendOptions: { skipPreflight: true }, programId, }), @@ -167,6 +170,7 @@ describe("Multisig SDK", () => { }, }, ], + rentCollector: null, sendOptions: { skipPreflight: true }, programId, }), @@ -200,6 +204,7 @@ describe("Multisig SDK", () => { key: m.publicKey, permissions: Permissions.all(), })), + rentCollector: null, sendOptions: { skipPreflight: true }, programId, }), @@ -248,6 +253,7 @@ describe("Multisig SDK", () => { ], // Threshold is 3, but there are only 2 voters. threshold: 3, + rentCollector: null, sendOptions: { skipPreflight: true }, programId, }), @@ -264,6 +270,7 @@ describe("Multisig SDK", () => { members, threshold: 2, timeLock: 0, + rentCollector: null, programId, }); @@ -305,7 +312,7 @@ describe("Multisig SDK", () => { }, ].sort((a, b) => comparePubkeys(a.key, b.key)) ); - assert.strictEqual(multisigAccount.reserved, 0); + assert.strictEqual(multisigAccount.rentCollector, null); assert.strictEqual(multisigAccount.transactionIndex.toString(), "0"); assert.strictEqual(multisigAccount.staleTransactionIndex.toString(), "0"); assert.strictEqual( @@ -315,6 +322,31 @@ describe("Multisig SDK", () => { assert.strictEqual(multisigAccount.bump, multisigBump); }); + it("create a new autonomous multisig with rent reclamation enabled", async () => { + const createKey = Keypair.generate(); + const rentCollector = Keypair.generate().publicKey; + + const [multisigPda, multisigBump] = await createAutonomousMultisig({ + connection, + createKey, + members, + threshold: 2, + timeLock: 0, + rentCollector, + programId, + }); + + const multisigAccount = await Multisig.fromAccountAddress( + connection, + multisigPda + ); + + assert.strictEqual( + multisigAccount.rentCollector?.toBase58(), + rentCollector.toBase58() + ); + }); + it("create a new controlled multisig", async () => { const createKey = Keypair.generate(); const configAuthority = await generateFundedKeypair(connection); @@ -326,6 +358,7 @@ describe("Multisig SDK", () => { members, threshold: 2, timeLock: 0, + rentCollector: null, programId, }); @@ -368,6 +401,7 @@ describe("Multisig SDK", () => { members, threshold: 2, timeLock: 0, + rentCollector: null, programId, }) )[0]; @@ -559,6 +593,7 @@ describe("Multisig SDK", () => { members, threshold: 2, timeLock: 0, + rentCollector: null, programId, }) )[0]; @@ -594,6 +629,7 @@ describe("Multisig SDK", () => { members, threshold: 1, timeLock: 0, + rentCollector: null, programId, }) )[0]; @@ -645,6 +681,7 @@ describe("Multisig SDK", () => { threshold: 1, configAuthority: configAuthority.publicKey, timeLock: 0, + rentCollector: null, programId, }) )[0]; @@ -700,6 +737,7 @@ describe("Multisig SDK", () => { threshold: 1, timeLock: 0, configAuthority: configAuthority.publicKey, + rentCollector: null, programId, }) )[0]; @@ -750,6 +788,7 @@ describe("Multisig SDK", () => { members, threshold: 1, timeLock: 0, + rentCollector: null, programId, }) )[0]; @@ -849,6 +888,7 @@ describe("Multisig SDK", () => { members, threshold: 2, timeLock: 0, + rentCollector: null, programId, }) )[0]; @@ -902,6 +942,7 @@ describe("Multisig SDK", () => { members, threshold: 1, timeLock: 0, + rentCollector: null, programId, }) )[0]; @@ -1004,6 +1045,7 @@ describe("Multisig SDK", () => { members, threshold: 1, timeLock: 0, + rentCollector: null, programId, }) )[0]; @@ -1017,6 +1059,7 @@ describe("Multisig SDK", () => { threshold: 2, timeLock: 0, createKey: Keypair.generate(), + rentCollector: null, programId, }); }); @@ -1036,6 +1079,7 @@ describe("Multisig SDK", () => { members, threshold: 2, timeLock: 0, + rentCollector: null, programId, }) )[0]; @@ -1114,6 +1158,7 @@ describe("Multisig SDK", () => { members, threshold: 2, timeLock: 0, + rentCollector: null, programId, }) )[0]; @@ -1176,6 +1221,7 @@ describe("Multisig SDK", () => { members, threshold: 2, timeLock: 0, + rentCollector: null, programId, }) )[0]; @@ -1253,6 +1299,7 @@ describe("Multisig SDK", () => { members, threshold: 2, timeLock: 0, + rentCollector: null, programId, }) )[0]; @@ -1265,6 +1312,7 @@ describe("Multisig SDK", () => { members, threshold: 2, timeLock: 0, + rentCollector: null, programId, }) )[0]; @@ -1407,6 +1455,7 @@ describe("Multisig SDK", () => { members, threshold: 2, timeLock: 0, + rentCollector: null, programId, }) )[0]; @@ -1585,6 +1634,7 @@ describe("Multisig SDK", () => { members, threshold: 2, timeLock: 0, + rentCollector: null, programId, }) )[0]; @@ -1784,6 +1834,7 @@ describe("Multisig SDK", () => { members, threshold: 2, timeLock: 0, + rentCollector: null, programId, }) )[0]; @@ -1965,6 +2016,7 @@ describe("Multisig SDK", () => { members, threshold: 2, timeLock: 0, + rentCollector: null, programId, }) )[0]; @@ -2200,6 +2252,7 @@ describe("Multisig SDK", () => { members, threshold: 2, timeLock: 0, + rentCollector: null, programId, }) )[0]; @@ -2323,6 +2376,7 @@ describe("Multisig SDK", () => { members, threshold: 2, timeLock: 0, + rentCollector: null, programId, }) )[0]; @@ -2488,6 +2542,7 @@ describe("Multisig SDK", () => { members, threshold: 2, timeLock: 0, + rentCollector: null, programId, }) )[0]; @@ -2659,6 +2714,7 @@ describe("Multisig SDK", () => { permissions: Permissions.all(), }, ], + rentCollector: null, threshold: 1, programId, }; diff --git a/tests/utils.ts b/tests/utils.ts index d88f2c74..dd3cb755 100644 --- a/tests/utils.ts +++ b/tests/utils.ts @@ -72,12 +72,14 @@ export async function createAutonomousMultisig({ members, threshold, timeLock, + rentCollector, programId, }: { createKey?: Keypair; members: TestMembers; threshold: number; timeLock: number; + rentCollector: PublicKey | null; connection: Connection; programId: PublicKey; }) { @@ -111,6 +113,7 @@ export async function createAutonomousMultisig({ }, ], createKey: createKey, + rentCollector, sendOptions: { skipPreflight: true }, programId, }); @@ -127,6 +130,7 @@ export async function createControlledMultisig({ members, threshold, timeLock, + rentCollector, programId, }: { createKey?: Keypair; @@ -134,6 +138,7 @@ export async function createControlledMultisig({ members: TestMembers; threshold: number; timeLock: number; + rentCollector: PublicKey | null; connection: Connection; programId: PublicKey; }) { @@ -167,6 +172,7 @@ export async function createControlledMultisig({ }, ], createKey: createKey, + rentCollector, sendOptions: { skipPreflight: true }, programId, }); From 4bdff43d07f32ee8971dbf5318fd9ea0a4a30c69 Mon Sep 17 00:00:00 2001 From: Vladimir Guguiev <1524432+vovacodes@users.noreply.github.com> Date: Thu, 16 Nov 2023 00:00:23 +0100 Subject: [PATCH 2/7] feat(rent-reclamation): add config_transaction_accounts_close --- .../squads_multisig_program/src/errors.rs | 10 + .../src/instructions/mod.rs | 2 + .../transaction_accounts_close.rs | 101 +++ programs/squads_multisig_program/src/lib.rs | 6 + .../src/state/proposal.rs | 9 + sdk/multisig/idl/squads_multisig_program.json | 62 ++ sdk/multisig/src/generated/errors/index.ts | 124 +++ .../configTransactionAccountsClose.ts | 102 +++ .../src/generated/instructions/index.ts | 1 + .../configTransactionAccountsClose.ts | 39 + sdk/multisig/src/instructions/index.ts | 1 + .../src/rpc/configTransactionAccountsClose.ts | 49 + sdk/multisig/src/rpc/index.ts | 1 + .../configTransactionAccountsClose.ts | 37 + sdk/multisig/src/transactions/index.ts | 1 + tests/suites/multisig-sdk.ts | 854 +++++++++++++++++- 16 files changed, 1397 insertions(+), 2 deletions(-) create mode 100644 programs/squads_multisig_program/src/instructions/transaction_accounts_close.rs create mode 100644 sdk/multisig/src/generated/instructions/configTransactionAccountsClose.ts create mode 100644 sdk/multisig/src/instructions/configTransactionAccountsClose.ts create mode 100644 sdk/multisig/src/rpc/configTransactionAccountsClose.ts create mode 100644 sdk/multisig/src/transactions/configTransactionAccountsClose.ts 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/suites/multisig-sdk.ts b/tests/suites/multisig-sdk.ts index f2b13422..2e92bb39 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("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 () => { From 5819c4bc7b5280052fa212d473ca5df56bd8f613 Mon Sep 17 00:00:00 2001 From: Vladimir Guguiev <1524432+vovacodes@users.noreply.github.com> Date: Mon, 20 Nov 2023 00:22:51 +0100 Subject: [PATCH 3/7] feat(vault_transaction_accounts_close): instruction, sdk, tests --- Anchor.toml | 4 - .../transaction_accounts_close.rs | 104 ++ programs/squads_multisig_program/src/lib.rs | 6 + sdk/multisig/idl/squads_multisig_program.json | 37 + .../src/generated/instructions/index.ts | 1 + .../vaultTransactionAccountsClose.ts | 102 ++ sdk/multisig/src/instructions/index.ts | 1 + .../vaultTransactionAccountsClose.ts | 39 + sdk/multisig/src/rpc/index.ts | 1 + .../src/rpc/vaultTransactionAccountsClose.ts | 49 + sdk/multisig/src/transactions/index.ts | 1 + .../vaultTransactionAccountsClose.ts | 37 + .../configTransactionAccountsClose.ts | 858 +++++++++++++ .../vaultTransactionAccountsClose.ts | 1089 +++++++++++++++++ tests/suites/multisig-sdk.ts | 854 +------------ 15 files changed, 2328 insertions(+), 855 deletions(-) create mode 100644 sdk/multisig/src/generated/instructions/vaultTransactionAccountsClose.ts create mode 100644 sdk/multisig/src/instructions/vaultTransactionAccountsClose.ts create mode 100644 sdk/multisig/src/rpc/vaultTransactionAccountsClose.ts create mode 100644 sdk/multisig/src/transactions/vaultTransactionAccountsClose.ts create mode 100644 tests/suites/instructions/configTransactionAccountsClose.ts create mode 100644 tests/suites/instructions/vaultTransactionAccountsClose.ts diff --git a/Anchor.toml b/Anchor.toml index 0b16474d..e29658e6 100644 --- a/Anchor.toml +++ b/Anchor.toml @@ -19,10 +19,6 @@ wallet = "~/.config/solana/id.json" [test.validator] url = "https://api.devnet.solana.com" -# Token2022 -[[test.validator.clone]] -address = "TokenzQdBNbLqP5VEhdkAS6EPFLC1PHnBqCXEpPxuEb" - [[test.validator.account]] address = "D3oQ6QxSYk6aKUsmBTa9BghFQvbRi7kxP6h95NSdjjXz" filename = "tests/fixtures/pre-rent-collector/multisig-account.json" 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 c5f34773..ea44c7d8 100644 --- a/programs/squads_multisig_program/src/instructions/transaction_accounts_close.rs +++ b/programs/squads_multisig_program/src/instructions/transaction_accounts_close.rs @@ -99,3 +99,107 @@ impl ConfigTransactionAccountsClose<'_> { 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( + 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>, + + /// VaultTransaction corresponding to the `proposal`. + #[account(mut, close = rent_collector)] + pub transaction: Account<'info, VaultTransaction>, + + /// The rent collector. + /// CHECK: We do the checks in validate(). + #[account(mut)] + pub rent_collector: AccountInfo<'info>, + + pub system_program: Program<'info, System>, +} + +impl VaultTransactionAccountsClose<'_> { + 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 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 + ); + //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 vault transactions in terminal states: `Executed`, `Rejected`, or `Cancelled` + /// or non-Approved stale vault transactions. + #[access_control(_ctx.accounts.validate())] + pub fn vault_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 b60b6ab5..83fe93fb 100644 --- a/programs/squads_multisig_program/src/lib.rs +++ b/programs/squads_multisig_program/src/lib.rs @@ -194,4 +194,10 @@ pub mod squads_multisig_program { ) -> Result<()> { ConfigTransactionAccountsClose::config_transaction_accounts_close(ctx) } + + pub fn vault_transaction_accounts_close( + ctx: Context, + ) -> Result<()> { + VaultTransactionAccountsClose::vault_transaction_accounts_close(ctx) + } } diff --git a/sdk/multisig/idl/squads_multisig_program.json b/sdk/multisig/idl/squads_multisig_program.json index 939216f2..ff2ca21d 100644 --- a/sdk/multisig/idl/squads_multisig_program.json +++ b/sdk/multisig/idl/squads_multisig_program.json @@ -1032,6 +1032,43 @@ } ], "args": [] + }, + { + "name": "vaultTransactionAccountsClose", + "accounts": [ + { + "name": "multisig", + "isMut": false, + "isSigner": false + }, + { + "name": "proposal", + "isMut": true, + "isSigner": false + }, + { + "name": "transaction", + "isMut": true, + "isSigner": false, + "docs": [ + "VaultTransaction corresponding to the `proposal`." + ] + }, + { + "name": "rentCollector", + "isMut": true, + "isSigner": false, + "docs": [ + "The rent collector." + ] + }, + { + "name": "systemProgram", + "isMut": false, + "isSigner": false + } + ], + "args": [] } ], "accounts": [ diff --git a/sdk/multisig/src/generated/instructions/index.ts b/sdk/multisig/src/generated/instructions/index.ts index 335a2d9a..ba889ba6 100644 --- a/sdk/multisig/src/generated/instructions/index.ts +++ b/sdk/multisig/src/generated/instructions/index.ts @@ -18,5 +18,6 @@ export * from './proposalCancel' export * from './proposalCreate' export * from './proposalReject' export * from './spendingLimitUse' +export * from './vaultTransactionAccountsClose' export * from './vaultTransactionCreate' export * from './vaultTransactionExecute' diff --git a/sdk/multisig/src/generated/instructions/vaultTransactionAccountsClose.ts b/sdk/multisig/src/generated/instructions/vaultTransactionAccountsClose.ts new file mode 100644 index 00000000..ba2b867f --- /dev/null +++ b/sdk/multisig/src/generated/instructions/vaultTransactionAccountsClose.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 VaultTransactionAccountsClose + * @category generated + */ +export const vaultTransactionAccountsCloseStruct = new beet.BeetArgsStruct<{ + instructionDiscriminator: number[] /* size: 8 */ +}>( + [['instructionDiscriminator', beet.uniformFixedSizeArray(beet.u8, 8)]], + 'VaultTransactionAccountsCloseInstructionArgs' +) +/** + * Accounts required by the _vaultTransactionAccountsClose_ instruction + * + * @property [] multisig + * @property [_writable_] proposal + * @property [_writable_] transaction + * @property [_writable_] rentCollector + * @category Instructions + * @category VaultTransactionAccountsClose + * @category generated + */ +export type VaultTransactionAccountsCloseInstructionAccounts = { + multisig: web3.PublicKey + proposal: web3.PublicKey + transaction: web3.PublicKey + rentCollector: web3.PublicKey + systemProgram?: web3.PublicKey + anchorRemainingAccounts?: web3.AccountMeta[] +} + +export const vaultTransactionAccountsCloseInstructionDiscriminator = [ + 196, 71, 187, 176, 2, 35, 170, 165, +] + +/** + * Creates a _VaultTransactionAccountsClose_ instruction. + * + * @param accounts that will be accessed while the instruction is processed + * @category Instructions + * @category VaultTransactionAccountsClose + * @category generated + */ +export function createVaultTransactionAccountsCloseInstruction( + accounts: VaultTransactionAccountsCloseInstructionAccounts, + programId = new web3.PublicKey('SQDS4ep65T869zMMBKyuUq6aD6EgTu8psMjkvj52pCf') +) { + const [data] = vaultTransactionAccountsCloseStruct.serialize({ + instructionDiscriminator: + vaultTransactionAccountsCloseInstructionDiscriminator, + }) + 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/instructions/index.ts b/sdk/multisig/src/instructions/index.ts index 8b131b39..fda9101c 100644 --- a/sdk/multisig/src/instructions/index.ts +++ b/sdk/multisig/src/instructions/index.ts @@ -16,5 +16,6 @@ export * from "./proposalCancel.js"; export * from "./proposalCreate.js"; export * from "./proposalReject.js"; export * from "./spendingLimitUse.js"; +export * from "./vaultTransactionAccountsClose.js"; export * from "./vaultTransactionCreate.js"; export * from "./vaultTransactionExecute.js"; diff --git a/sdk/multisig/src/instructions/vaultTransactionAccountsClose.ts b/sdk/multisig/src/instructions/vaultTransactionAccountsClose.ts new file mode 100644 index 00000000..93e6aeab --- /dev/null +++ b/sdk/multisig/src/instructions/vaultTransactionAccountsClose.ts @@ -0,0 +1,39 @@ +import { PublicKey } from "@solana/web3.js"; +import { + createVaultTransactionAccountsCloseInstruction, + PROGRAM_ID, +} from "../generated"; +import { getProposalPda, getTransactionPda } from "../pda"; + +export function vaultTransactionAccountsClose({ + 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 createVaultTransactionAccountsCloseInstruction( + { + multisig: multisigPda, + rentCollector, + proposal: proposalPda, + transaction: transactionPda, + }, + programId + ); +} diff --git a/sdk/multisig/src/rpc/index.ts b/sdk/multisig/src/rpc/index.ts index 9392c87c..1cc703ba 100644 --- a/sdk/multisig/src/rpc/index.ts +++ b/sdk/multisig/src/rpc/index.ts @@ -16,5 +16,6 @@ export * from "./proposalCancel.js"; export * from "./proposalCreate.js"; export * from "./proposalReject.js"; export * from "./spendingLimitUse.js"; +export * from "./vaultTransactionAccountsClose.js"; export * from "./vaultTransactionCreate.js"; export * from "./vaultTransactionExecute.js"; diff --git a/sdk/multisig/src/rpc/vaultTransactionAccountsClose.ts b/sdk/multisig/src/rpc/vaultTransactionAccountsClose.ts new file mode 100644 index 00000000..edcf1fc6 --- /dev/null +++ b/sdk/multisig/src/rpc/vaultTransactionAccountsClose.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 vaultTransactionAccountsClose({ + 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.vaultTransactionAccountsClose({ + 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/transactions/index.ts b/sdk/multisig/src/transactions/index.ts index 9392c87c..1cc703ba 100644 --- a/sdk/multisig/src/transactions/index.ts +++ b/sdk/multisig/src/transactions/index.ts @@ -16,5 +16,6 @@ export * from "./proposalCancel.js"; export * from "./proposalCreate.js"; export * from "./proposalReject.js"; export * from "./spendingLimitUse.js"; +export * from "./vaultTransactionAccountsClose.js"; export * from "./vaultTransactionCreate.js"; export * from "./vaultTransactionExecute.js"; diff --git a/sdk/multisig/src/transactions/vaultTransactionAccountsClose.ts b/sdk/multisig/src/transactions/vaultTransactionAccountsClose.ts new file mode 100644 index 00000000..b3759d06 --- /dev/null +++ b/sdk/multisig/src/transactions/vaultTransactionAccountsClose.ts @@ -0,0 +1,37 @@ +import { + PublicKey, + TransactionMessage, + VersionedTransaction, +} from "@solana/web3.js"; +import * as instructions from "../instructions/index.js"; + +export function vaultTransactionAccountsClose({ + 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.vaultTransactionAccountsClose({ + multisigPda, + rentCollector, + transactionIndex, + programId, + }), + ], + }).compileToV0Message(); + + return new VersionedTransaction(message); +} diff --git a/tests/suites/instructions/configTransactionAccountsClose.ts b/tests/suites/instructions/configTransactionAccountsClose.ts new file mode 100644 index 00000000..c39de3fe --- /dev/null +++ b/tests/suites/instructions/configTransactionAccountsClose.ts @@ -0,0 +1,858 @@ +import { + Keypair, + PublicKey, + TransactionMessage, + VersionedTransaction, +} from "@solana/web3.js"; +import * as multisig from "@sqds/multisig"; +import assert from "assert"; +import { + createAutonomousMultisig, + createLocalhostConnection, + generateFundedKeypair, + generateMultisigMembers, + getTestProgramId, + TestMembers, +} from "../../utils"; + +const { Multisig, Proposal } = multisig.accounts; + +const programId = getTestProgramId(); +const connection = createLocalhostConnection(); + +describe("Instructions / config_transaction_accounts_close", () => { + let members: TestMembers; + let multisigPda: PublicKey; + const staleTransactionIndex = 1n; + const executedTransactionIndex = 2n; + const activeTransactionIndex = 3n; + const approvedTransactionIndex = 4n; + const rejectedTransactionIndex = 5n; + const cancelledTransactionIndex = 6n; + + // Set up a multisig with config transactions. + 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. + 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 () => { + 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 () => { + 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 () => { + 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); + }); +}); diff --git a/tests/suites/instructions/vaultTransactionAccountsClose.ts b/tests/suites/instructions/vaultTransactionAccountsClose.ts new file mode 100644 index 00000000..a70a97ea --- /dev/null +++ b/tests/suites/instructions/vaultTransactionAccountsClose.ts @@ -0,0 +1,1089 @@ +import { + Keypair, + LAMPORTS_PER_SOL, + PublicKey, + TransactionMessage, + VersionedTransaction, +} from "@solana/web3.js"; +import * as multisig from "@sqds/multisig"; +import assert from "assert"; +import { + createAutonomousMultisig, + createLocalhostConnection, + createTestTransferInstruction, + generateFundedKeypair, + generateMultisigMembers, + getTestProgramId, + TestMembers, +} from "../../utils"; + +const { Multisig, Proposal } = multisig.accounts; + +const programId = getTestProgramId(); +const connection = createLocalhostConnection(); + +describe("Instructions / vault_transaction_accounts_close", () => { + let members: TestMembers; + let multisigPda: PublicKey; + const staleNonApprovedTransactionIndex = 1n; + const staleApprovedTransactionIndex = 2n; + const executedConfigTransactionIndex = 3n; + const executedVaultTransactionIndex = 4n; + const activeTransactionIndex = 5n; + const approvedTransactionIndex = 6n; + const rejectedTransactionIndex = 7n; + const cancelledTransactionIndex = 8n; + + // Set up a multisig with some transactions. + 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. + await createAutonomousMultisig({ + connection, + createKey, + members, + threshold: 2, + timeLock: 0, + rentCollector: vaultPda, + programId, + }); + + // Test transfer instruction. + const testPayee = Keypair.generate(); + const testIx = await createTestTransferInstruction( + vaultPda, + testPayee.publicKey, + 1 * LAMPORTS_PER_SOL + ); + const testTransferMessage = new TransactionMessage({ + payerKey: vaultPda, + recentBlockhash: (await connection.getLatestBlockhash()).blockhash, + instructions: [testIx], + }); + + // Airdrop some SOL to the vault + let signature = await connection.requestAirdrop( + vaultPda, + 10 * LAMPORTS_PER_SOL + ); + await connection.confirmTransaction(signature); + + //region Stale and Non-Approved + // Create a vault transaction (Stale and Non-Approved). + signature = await multisig.rpc.vaultTransactionCreate({ + connection, + feePayer: members.proposer, + multisigPda, + transactionIndex: staleNonApprovedTransactionIndex, + vaultIndex: 0, + transactionMessage: testTransferMessage, + ephemeralSigners: 0, + addressLookupTableAccounts: [], + creator: members.proposer.publicKey, + programId, + }); + await connection.confirmTransaction(signature); + + // Create a proposal for the transaction (Stale and Non-Approved). + signature = await multisig.rpc.proposalCreate({ + connection, + feePayer: members.proposer, + multisigPda, + transactionIndex: staleNonApprovedTransactionIndex, + creator: members.proposer, + programId, + }); + await connection.confirmTransaction(signature); + // This transaction will become stale when the config transaction is executed. + //endregion + + //region Stale and Approved + // Create a vault transaction (Stale and Approved). + signature = await multisig.rpc.vaultTransactionCreate({ + connection, + feePayer: members.proposer, + multisigPda, + transactionIndex: staleApprovedTransactionIndex, + vaultIndex: 0, + transactionMessage: testTransferMessage, + ephemeralSigners: 0, + addressLookupTableAccounts: [], + creator: members.proposer.publicKey, + programId, + }); + await connection.confirmTransaction(signature); + + // Create a proposal for the transaction (Stale and Approved). + signature = await multisig.rpc.proposalCreate({ + connection, + feePayer: members.proposer, + multisigPda, + transactionIndex: staleApprovedTransactionIndex, + 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: staleApprovedTransactionIndex, + 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: staleApprovedTransactionIndex, + member: members.almighty, + programId, + }); + await connection.confirmTransaction(signature); + + // Make sure the proposal is approved. + let proposalAccount = await Proposal.fromAccountAddress( + connection, + multisig.getProposalPda({ + multisigPda, + transactionIndex: staleApprovedTransactionIndex, + programId, + })[0] + ); + assert.ok(multisig.types.isProposalStatusApproved(proposalAccount.status)); + + // This transaction 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 Executed Vault transaction + // Create a vault transaction (Executed). + signature = await multisig.rpc.vaultTransactionCreate({ + connection, + feePayer: members.proposer, + multisigPda, + transactionIndex: executedVaultTransactionIndex, + vaultIndex: 0, + transactionMessage: testTransferMessage, + ephemeralSigners: 0, + addressLookupTableAccounts: [], + creator: members.proposer.publicKey, + programId, + }); + await connection.confirmTransaction(signature); + + // Create a proposal for the transaction (Approved). + signature = await multisig.rpc.proposalCreate({ + connection, + feePayer: members.proposer, + multisigPda, + transactionIndex: executedVaultTransactionIndex, + creator: members.proposer, + programId, + }); + await connection.confirmTransaction(signature); + + // Approve the proposal. + signature = await multisig.rpc.proposalApprove({ + connection, + feePayer: members.voter, + multisigPda, + transactionIndex: executedVaultTransactionIndex, + member: members.voter, + programId, + }); + await connection.confirmTransaction(signature); + + // Execute the transaction. + signature = await multisig.rpc.vaultTransactionExecute({ + connection, + feePayer: members.executor, + multisigPda, + transactionIndex: executedVaultTransactionIndex, + member: members.executor.publicKey, + signers: [members.executor], + programId, + }); + await connection.confirmTransaction(signature); + + // Make sure the proposal is executed. + proposalAccount = await Proposal.fromAccountAddress( + connection, + multisig.getProposalPda({ + multisigPda, + transactionIndex: executedVaultTransactionIndex, + programId, + })[0] + ); + assert.ok(multisig.types.isProposalStatusExecuted(proposalAccount.status)); + //endregion + + //region Active + // Create a vault transaction (Active). + signature = await multisig.rpc.vaultTransactionCreate({ + connection, + feePayer: members.proposer, + multisigPda, + transactionIndex: activeTransactionIndex, + vaultIndex: 0, + transactionMessage: testTransferMessage, + ephemeralSigners: 0, + addressLookupTableAccounts: [], + creator: members.proposer.publicKey, + 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. + proposalAccount = await Proposal.fromAccountAddress( + connection, + multisig.getProposalPda({ + multisigPda, + transactionIndex: activeTransactionIndex, + programId, + })[0] + ); + assert.ok(multisig.types.isProposalStatusActive(proposalAccount.status)); + //endregion + + //region Approved + // Create a vault transaction (Approved). + signature = await multisig.rpc.vaultTransactionCreate({ + connection, + feePayer: members.proposer, + multisigPda, + transactionIndex: approvedTransactionIndex, + vaultIndex: 0, + transactionMessage: testTransferMessage, + ephemeralSigners: 0, + addressLookupTableAccounts: [], + creator: members.proposer.publicKey, + 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 vault transaction (Rejected). + signature = await multisig.rpc.vaultTransactionCreate({ + connection, + feePayer: members.proposer, + multisigPda, + transactionIndex: rejectedTransactionIndex, + vaultIndex: 0, + transactionMessage: testTransferMessage, + ephemeralSigners: 0, + addressLookupTableAccounts: [], + creator: members.proposer.publicKey, + 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 vault transaction (Cancelled). + signature = await multisig.rpc.vaultTransactionCreate({ + connection, + feePayer: members.proposer, + multisigPda, + transactionIndex: cancelledTransactionIndex, + vaultIndex: 0, + transactionMessage: testTransferMessage, + ephemeralSigners: 0, + addressLookupTableAccounts: [], + creator: members.proposer.publicKey, + 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: 1, + timeLock: 0, + rentCollector: null, + programId, + }) + )[0]; + + const vaultPda = multisig.getVaultPda({ + multisigPda: multisigPda, + index: 0, + programId, + })[0]; + + const testPayee = Keypair.generate(); + const testIx = await createTestTransferInstruction( + vaultPda, + testPayee.publicKey, + 1 * LAMPORTS_PER_SOL + ); + const testTransferMessage = new TransactionMessage({ + payerKey: vaultPda, + recentBlockhash: (await connection.getLatestBlockhash()).blockhash, + instructions: [testIx], + }); + + // Create a vault transaction. + const transactionIndex = 1n; + let signature = await multisig.rpc.vaultTransactionCreate({ + connection, + feePayer: members.proposer, + multisigPda, + transactionIndex, + vaultIndex: 0, + transactionMessage: testTransferMessage, + ephemeralSigners: 0, + addressLookupTableAccounts: [], + creator: members.proposer.publicKey, + 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 a member. + signature = await multisig.rpc.proposalApprove({ + connection, + feePayer: members.voter, + multisigPda, + transactionIndex, + member: members.voter, + programId, + }); + await connection.confirmTransaction(signature); + + // Cancel the proposal. + signature = await multisig.rpc.proposalCancel({ + connection, + feePayer: members.voter, + multisigPda, + transactionIndex, + member: members.voter, + programId, + }); + await connection.confirmTransaction(signature); + + // Attempt to close the accounts. + await assert.rejects( + () => + multisig.rpc.vaultTransactionAccountsClose({ + 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 = staleApprovedTransactionIndex; + + const fakeRentCollector = Keypair.generate().publicKey; + + await assert.rejects( + () => + multisig.rpc.vaultTransactionAccountsClose({ + 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]; + + const testPayee = Keypair.generate(); + const testIx = await createTestTransferInstruction( + vaultPda, + testPayee.publicKey, + 1 * LAMPORTS_PER_SOL + ); + const testTransferMessage = new TransactionMessage({ + payerKey: vaultPda, + recentBlockhash: (await connection.getLatestBlockhash()).blockhash, + instructions: [testIx], + }); + + // Create another multisig. + const otherMultisig = ( + await createAutonomousMultisig({ + connection, + members, + threshold: 2, + timeLock: 0, + rentCollector: null, + programId, + }) + )[0]; + // Create a vault transaction for it. + let signature = await multisig.rpc.vaultTransactionCreate({ + connection, + feePayer: members.proposer, + multisigPda: otherMultisig, + transactionIndex: 1n, + vaultIndex: 0, + transactionMessage: testTransferMessage, + ephemeralSigners: 0, + addressLookupTableAccounts: [], + creator: members.proposer.publicKey, + 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.createVaultTransactionAccountsCloseInstruction( + { + 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 () => { + const transactionIndex = activeTransactionIndex; + + const multisigAccount = await Multisig.fromAccountAddress( + connection, + multisigPda + ); + + await assert.rejects( + () => + multisig.rpc.vaultTransactionAccountsClose({ + connection, + feePayer: members.almighty, + multisigPda, + rentCollector: multisigAccount.rentCollector!, + transactionIndex, + programId, + }), + /Invalid proposal status/ + ); + }); + + it("error: invalid proposal status (Approved)", async () => { + const transactionIndex = approvedTransactionIndex; + + const multisigAccount = await Multisig.fromAccountAddress( + connection, + multisigPda + ); + + await assert.rejects( + () => + multisig.rpc.vaultTransactionAccountsClose({ + connection, + feePayer: members.almighty, + multisigPda, + rentCollector: multisigAccount.rentCollector!, + transactionIndex, + programId, + }), + /Invalid proposal status/ + ); + }); + + it("error: invalid proposal status (Stale but Approved)", async () => { + const transactionIndex = staleApprovedTransactionIndex; + + const multisigAccount = await Multisig.fromAccountAddress( + connection, + multisigPda + ); + + // Make sure the proposal is stale. + assert.ok( + transactionIndex <= + multisig.utils.toBigInt(multisigAccount.staleTransactionIndex) + ); + + await assert.rejects( + () => + multisig.rpc.vaultTransactionAccountsClose({ + 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 vault transaction for it. + const vaultPda = multisig.getVaultPda({ + multisigPda, + index: 0, + programId, + })[0]; + const testPayee = Keypair.generate(); + const testIx = await createTestTransferInstruction( + vaultPda, + testPayee.publicKey, + 1 * LAMPORTS_PER_SOL + ); + const testTransferMessage = new TransactionMessage({ + payerKey: vaultPda, + recentBlockhash: (await connection.getLatestBlockhash()).blockhash, + instructions: [testIx], + }); + let signature = await multisig.rpc.vaultTransactionCreate({ + connection, + feePayer: members.proposer, + multisigPda: otherMultisig, + transactionIndex: 1n, + vaultIndex: 0, + transactionMessage: testTransferMessage, + ephemeralSigners: 0, + addressLookupTableAccounts: [], + creator: members.proposer.publicKey, + 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 feePayer = await generateFundedKeypair(connection); + + // Manually construct an instruction that uses transaction that doesn't match proposal. + const ix = + multisig.generated.createVaultTransactionAccountsCloseInstruction( + { + 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.createVaultTransactionAccountsCloseInstruction( + { + 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 = staleNonApprovedTransactionIndex; + + 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.vaultTransactionAccountsClose({ + connection, + feePayer: members.almighty, + multisigPda, + rentCollector: vaultPda, + transactionIndex, + programId, + }); + await connection.confirmTransaction(sig); + + const postBalance = await connection.getBalance(vaultPda); + const accountsRent = 6479760; + assert.equal(postBalance, preBalance + accountsRent); + }); + + it("close accounts for Executed transaction", async () => { + const transactionIndex = executedVaultTransactionIndex; + + // 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.vaultTransactionAccountsClose({ + connection, + feePayer: members.almighty, + multisigPda, + rentCollector: vaultPda, + transactionIndex, + programId, + }); + await connection.confirmTransaction(sig); + + const postBalance = await connection.getBalance(vaultPda); + const accountsRent = 6479760; + assert.equal(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.vaultTransactionAccountsClose({ + connection, + feePayer: members.almighty, + multisigPda, + rentCollector: vaultPda, + transactionIndex, + programId, + }); + await connection.confirmTransaction(sig); + + const postBalance = await connection.getBalance(vaultPda); + const accountsRent = 6479760; + assert.equal(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.vaultTransactionAccountsClose({ + connection, + feePayer: members.almighty, + multisigPda, + rentCollector: vaultPda, + transactionIndex, + programId, + }); + await connection.confirmTransaction(sig); + + const postBalance = await connection.getBalance(vaultPda); + const accountsRent = 6479760; + assert.equal(postBalance, preBalance + accountsRent); + }); +}); diff --git a/tests/suites/multisig-sdk.ts b/tests/suites/multisig-sdk.ts index 2e92bb39..100d5bfd 100644 --- a/tests/suites/multisig-sdk.ts +++ b/tests/suites/multisig-sdk.ts @@ -26,6 +26,9 @@ const { Permission, Permissions } = multisig.types; const programId = getTestProgramId(); +import "./instructions/configTransactionAccountsClose"; +import "./instructions/vaultTransactionAccountsClose"; + describe("Multisig SDK", () => { const connection = createLocalhostConnection(); @@ -2683,857 +2686,6 @@ describe("Multisig SDK", () => { }); }); - describe("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 () => { From ac150d5a417942e7681f5519fa7e2cf5f0a65305 Mon Sep 17 00:00:00 2001 From: Vladimir Guguiev <1524432+vovacodes@users.noreply.github.com> Date: Thu, 23 Nov 2023 12:03:45 +0100 Subject: [PATCH 4/7] feat(rent-reclamation): add vault_batch_transaction_account_close and batch_accounts_close --- package.json | 1 + .../transaction_accounts_close.rs | 365 +++++++- programs/squads_multisig_program/src/lib.rs | 32 + .../src/state/proposal.rs | 9 - sdk/multisig/idl/squads_multisig_program.json | 130 +++ .../instructions/batchAccountsClose.ts | 101 +++ .../src/generated/instructions/index.ts | 2 + .../vaultBatchTransactionAccountClose.ts | 130 +++ .../VaultBatchTransactionAccountCloseArgs.ts | 21 + sdk/multisig/src/generated/types/index.ts | 1 + .../src/instructions/batchAccountsClose.ts | 46 + sdk/multisig/src/instructions/index.ts | 2 + .../vaultBatchTransactionAccountClose.ts | 60 ++ sdk/multisig/src/rpc/batchAccountsClose.ts | 56 ++ sdk/multisig/src/rpc/index.ts | 2 + .../rpc/vaultBatchTransactionAccountClose.ts | 56 ++ .../src/transactions/batchAccountsClose.ts | 37 + sdk/multisig/src/transactions/index.ts | 2 + .../vaultBatchTransactionAccountClose.ts | 47 + .../suites/instructions/batchAccountsClose.ts | 470 ++++++++++ .../vaultBatchTransactionAccountClose.ts | 522 +++++++++++ .../vaultTransactionAccountsClose.ts | 2 - tests/suites/multisig-sdk.ts | 2 + tests/utils.ts | 808 +++++++++++++++++- yarn.lock | 7 + 25 files changed, 2862 insertions(+), 49 deletions(-) create mode 100644 sdk/multisig/src/generated/instructions/batchAccountsClose.ts create mode 100644 sdk/multisig/src/generated/instructions/vaultBatchTransactionAccountClose.ts create mode 100644 sdk/multisig/src/generated/types/VaultBatchTransactionAccountCloseArgs.ts create mode 100644 sdk/multisig/src/instructions/batchAccountsClose.ts create mode 100644 sdk/multisig/src/instructions/vaultBatchTransactionAccountClose.ts create mode 100644 sdk/multisig/src/rpc/batchAccountsClose.ts create mode 100644 sdk/multisig/src/rpc/vaultBatchTransactionAccountClose.ts create mode 100644 sdk/multisig/src/transactions/batchAccountsClose.ts create mode 100644 sdk/multisig/src/transactions/vaultBatchTransactionAccountClose.ts create mode 100644 tests/suites/instructions/batchAccountsClose.ts create mode 100644 tests/suites/instructions/vaultBatchTransactionAccountClose.ts 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" From e81feb61f812df0f1ad7ab5afaa3bd656a20cd58 Mon Sep 17 00:00:00 2001 From: Vladimir Guguiev <1524432+vovacodes@users.noreply.github.com> Date: Fri, 24 Nov 2023 13:26:56 +0100 Subject: [PATCH 5/7] feat(rent-reclamation): add ConfigAction::SetRentCollector and implement its handling --- .../config_transaction_execute.rs | 35 ++- .../src/state/config_transaction.rs | 2 + sdk/multisig/idl/squads_multisig_program.json | 11 + .../src/generated/types/ConfigAction.ts | 13 + .../suites/instructions/batchAccountsClose.ts | 2 +- .../instructions/configTransactionExecute.ts | 276 ++++++++++++++++++ tests/suites/multisig-sdk.ts | 157 +--------- 7 files changed, 337 insertions(+), 159 deletions(-) create mode 100644 tests/suites/instructions/configTransactionExecute.ts diff --git a/programs/squads_multisig_program/src/instructions/config_transaction_execute.rs b/programs/squads_multisig_program/src/instructions/config_transaction_execute.rs index 7825cd78..b135d456 100644 --- a/programs/squads_multisig_program/src/instructions/config_transaction_execute.rs +++ b/programs/squads_multisig_program/src/instructions/config_transaction_execute.rs @@ -109,9 +109,32 @@ impl<'info> ConfigTransactionExecute<'info> { let rent = Rent::get()?; // Check applying the config actions will require reallocation of space for the multisig account. + + // Handle growing members vector. let new_members_length = members_length_after_actions(multisig.members.len(), &transaction.actions); - if new_members_length > multisig.members.len() { + let needs_members_allocation = new_members_length > multisig.members.len(); + + // Handle growing rent_collector changing from None -> Some(Pubkey). + // The `rent_collector` field after applying the actions will be: + // - the `new_rent_collector` of the last `SetRentCollector` action, if any present among the actions. + // - the current `rent_collector` if no `SetRentCollector` action is present among the actions. + let new_rent_collector = transaction + .actions + .iter() + .rev() + .find_map(|action| { + if let ConfigAction::SetRentCollector { new_rent_collector } = action { + Some(*new_rent_collector) + } else { + None + } + }) + .unwrap_or(multisig.rent_collector); + let needs_rent_collector_allocation = + multisig.rent_collector.is_none() && new_rent_collector.is_some(); + + if needs_members_allocation || needs_rent_collector_allocation { let rent_payer = &ctx .accounts .rent_payer @@ -126,7 +149,7 @@ impl<'info> ConfigTransactionExecute<'info> { let reallocated = Multisig::realloc_if_needed( multisig.to_account_info(), new_members_length, - multisig.rent_collector.is_some(), + new_rent_collector.is_some(), rent_payer.to_account_info(), system_program.to_account_info(), )?; @@ -281,6 +304,13 @@ impl<'info> ConfigTransactionExecute<'info> { // We don't need to invalidate prior transactions here because adding // a spending limit doesn't affect the consensus parameters of the multisig. } + + ConfigAction::SetRentCollector { new_rent_collector } => { + multisig.rent_collector = *new_rent_collector; + + // We don't need to invalidate prior transactions here because changing + // `rent_collector` doesn't affect the consensus parameters of the multisig. + } } } @@ -313,6 +343,7 @@ fn members_length_after_actions(members_length: usize, actions: &[ConfigAction]) ConfigAction::SetTimeLock { .. } => acc, ConfigAction::AddSpendingLimit { .. } => acc, ConfigAction::RemoveSpendingLimit { .. } => acc, + ConfigAction::SetRentCollector { .. } => acc, }); let abs_members_delta = diff --git a/programs/squads_multisig_program/src/state/config_transaction.rs b/programs/squads_multisig_program/src/state/config_transaction.rs index 71011120..24fa069a 100644 --- a/programs/squads_multisig_program/src/state/config_transaction.rs +++ b/programs/squads_multisig_program/src/state/config_transaction.rs @@ -73,4 +73,6 @@ pub enum ConfigAction { }, /// Remove a spending limit from the multisig. RemoveSpendingLimit { spending_limit: Pubkey }, + /// Set the `rent_collector` config parameter of the multisig. + SetRentCollector { new_rent_collector: Option }, } diff --git a/sdk/multisig/idl/squads_multisig_program.json b/sdk/multisig/idl/squads_multisig_program.json index 882f825c..d8bc4b81 100644 --- a/sdk/multisig/idl/squads_multisig_program.json +++ b/sdk/multisig/idl/squads_multisig_program.json @@ -2426,6 +2426,17 @@ "type": "publicKey" } ] + }, + { + "name": "SetRentCollector", + "fields": [ + { + "name": "newRentCollector", + "type": { + "option": "publicKey" + } + } + ] } ] } diff --git a/sdk/multisig/src/generated/types/ConfigAction.ts b/sdk/multisig/src/generated/types/ConfigAction.ts index 278b5657..6a277758 100644 --- a/sdk/multisig/src/generated/types/ConfigAction.ts +++ b/sdk/multisig/src/generated/types/ConfigAction.ts @@ -34,6 +34,7 @@ export type ConfigActionRecord = { destinations: web3.PublicKey[] } RemoveSpendingLimit: { spendingLimit: web3.PublicKey } + SetRentCollector: { newRentCollector: beet.COption } } /** @@ -70,6 +71,10 @@ export const isConfigActionRemoveSpendingLimit = ( x: ConfigAction ): x is ConfigAction & { __kind: 'RemoveSpendingLimit' } => x.__kind === 'RemoveSpendingLimit' +export const isConfigActionSetRentCollector = ( + x: ConfigAction +): x is ConfigAction & { __kind: 'SetRentCollector' } => + x.__kind === 'SetRentCollector' /** * @category userTypes @@ -131,4 +136,12 @@ export const configActionBeet = beet.dataEnum([ 'ConfigActionRecord["RemoveSpendingLimit"]' ), ], + + [ + 'SetRentCollector', + new beet.FixableBeetArgsStruct( + [['newRentCollector', beet.coption(beetSolana.publicKey)]], + 'ConfigActionRecord["SetRentCollector"]' + ), + ], ]) as beet.FixableBeet diff --git a/tests/suites/instructions/batchAccountsClose.ts b/tests/suites/instructions/batchAccountsClose.ts index a4204a4d..4d949008 100644 --- a/tests/suites/instructions/batchAccountsClose.ts +++ b/tests/suites/instructions/batchAccountsClose.ts @@ -18,7 +18,7 @@ import { TestMembers, } from "../../utils"; -const { Multisig, Proposal } = multisig.accounts; +const { Multisig } = multisig.accounts; const programId = getTestProgramId(); const connection = createLocalhostConnection(); diff --git a/tests/suites/instructions/configTransactionExecute.ts b/tests/suites/instructions/configTransactionExecute.ts new file mode 100644 index 00000000..f14dfd6a --- /dev/null +++ b/tests/suites/instructions/configTransactionExecute.ts @@ -0,0 +1,276 @@ +import * as multisig from "@sqds/multisig"; +import assert from "assert"; +import { + createAutonomousMultisig, + createLocalhostConnection, + generateMultisigMembers, + getTestProgramId, + TestMembers, +} from "../../utils"; + +const { Multisig, Proposal } = multisig.accounts; + +const programId = getTestProgramId(); +const connection = createLocalhostConnection(); + +describe("Instructions / config_transaction_execute", () => { + let members: TestMembers; + + before(async () => { + members = await generateMultisigMembers(connection); + }); + + it("error: invalid proposal status (Rejected)", async () => { + // Create new autonomous multisig. + 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: 3 }], + 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); + + // Reject the proposal by a member. + // Our threshold is 2 out of 2 voting members, so the cutoff is 1. + signature = await multisig.rpc.proposalReject({ + connection, + feePayer: members.voter, + multisigPda, + transactionIndex, + member: members.voter, + programId, + }); + await connection.confirmTransaction(signature); + + // Attempt to execute a transaction with a rejected proposal. + await assert.rejects( + () => + multisig.rpc.configTransactionExecute({ + connection, + feePayer: members.almighty, + multisigPda, + transactionIndex, + rentPayer: members.almighty, + member: members.almighty, + programId, + }), + /Invalid proposal status/ + ); + }); + + it("execute config transaction with ChangeThreshold action", async () => { + // Create new autonomous multisig. + const multisigPda = ( + await createAutonomousMultisig({ + connection, + members, + threshold: 1, + 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. + signature = await multisig.rpc.proposalApprove({ + connection, + feePayer: members.voter, + multisigPda, + transactionIndex, + member: members.voter, + programId, + }); + await connection.confirmTransaction(signature); + + // Execute the approved config transaction. + signature = await multisig.rpc.configTransactionExecute({ + connection, + feePayer: members.almighty, + multisigPda, + transactionIndex, + member: members.almighty, + rentPayer: members.almighty, + programId, + }); + await connection.confirmTransaction(signature); + + // Verify the proposal account. + const [proposalPda] = multisig.getProposalPda({ + multisigPda, + transactionIndex, + programId, + }); + const proposalAccount = await Proposal.fromAccountAddress( + connection, + proposalPda + ); + assert.ok(multisig.types.isProposalStatusExecuted(proposalAccount.status)); + + // Verify the multisig account. + const multisigAccount = await Multisig.fromAccountAddress( + connection, + multisigPda + ); + // The threshold should have been updated. + assert.strictEqual(multisigAccount.threshold, 1); + // The stale transaction index should be updated and set to 1. + assert.strictEqual(multisigAccount.staleTransactionIndex.toString(), "1"); + }); + + it("execute config transaction with SetRentCollector action", async () => { + // Create new autonomous multisig without rent_collector. + const multisigPda = ( + await createAutonomousMultisig({ + connection, + members, + threshold: 1, + timeLock: 0, + rentCollector: null, + programId, + }) + )[0]; + + const multisigAccountInfoPreExecution = await connection.getAccountInfo( + multisigPda + )!; + + const vaultPda = multisig.getVaultPda({ + multisigPda, + index: 0, + 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: "SetRentCollector", newRentCollector: vaultPda }], + programId, + }); + await connection.confirmTransaction(signature); + + // Create a proposal for the transaction (Approved). + signature = await multisig.rpc.proposalCreate({ + connection, + feePayer: members.proposer, + multisigPda, + transactionIndex, + creator: members.proposer, + programId, + }); + await connection.confirmTransaction(signature); + + // Approve the proposal. + signature = await multisig.rpc.proposalApprove({ + connection, + feePayer: members.voter, + multisigPda, + transactionIndex, + member: members.voter, + programId, + }); + await connection.confirmTransaction(signature); + + // Execute the approved config transaction. + signature = await multisig.rpc.configTransactionExecute({ + connection, + feePayer: members.almighty, + multisigPda, + transactionIndex, + member: members.almighty, + rentPayer: members.almighty, + programId, + }); + await connection.confirmTransaction(signature); + + // Verify the proposal account. + const [proposalPda] = multisig.getProposalPda({ + multisigPda, + transactionIndex, + programId, + }); + const proposalAccount = await Proposal.fromAccountAddress( + connection, + proposalPda + ); + assert.ok(multisig.types.isProposalStatusExecuted(proposalAccount.status)); + + // Verify the multisig account. + const multisigAccountInfoPostExecution = await connection.getAccountInfo( + multisigPda + ); + const [multisigAccountPostExecution] = Multisig.fromAccountInfo( + multisigAccountInfoPostExecution! + ); + // The rentCollector should be updated. + assert.strictEqual( + multisigAccountPostExecution.rentCollector?.toBase58(), + vaultPda.toBase58() + ); + // The stale transaction index should NOT be updated and remain 0. + assert.strictEqual( + multisigAccountPostExecution.staleTransactionIndex.toString(), + "0" + ); + // multisig space should be reallocated: increased by at least 32 bytes of new rent_collector. + assert.ok( + multisigAccountInfoPostExecution!.data.length >= + multisigAccountInfoPreExecution!.data.length + 32 + ); + }); +}); diff --git a/tests/suites/multisig-sdk.ts b/tests/suites/multisig-sdk.ts index de913475..c28a2471 100644 --- a/tests/suites/multisig-sdk.ts +++ b/tests/suites/multisig-sdk.ts @@ -3,7 +3,6 @@ import { LAMPORTS_PER_SOL, PublicKey, TransactionMessage, - VersionedTransaction, } from "@solana/web3.js"; import * as multisig from "@sqds/multisig"; import * as assert from "assert"; @@ -26,6 +25,7 @@ const { Permission, Permissions } = multisig.types; const programId = getTestProgramId(); +import "./instructions/configTransactionExecute"; import "./instructions/configTransactionAccountsClose"; import "./instructions/vaultBatchTransactionAccountClose"; import "./instructions/batchAccountsClose"; @@ -2533,161 +2533,6 @@ describe("Multisig SDK", () => { it("error: execute reentrancy"); }); - describe("config_transaction_execute", () => { - let multisigPda: PublicKey; - const approvedTransactionIndex = 1n; - const rejectedTransactionIndex = 2n; - - before(async () => { - // Create new autonomous multisig. - multisigPda = ( - await createAutonomousMultisig({ - connection, - members, - threshold: 2, - timeLock: 0, - rentCollector: null, - programId, - }) - )[0]; - - // Create a config transaction (Approved). - let 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 by the first member. - signature = await multisig.rpc.proposalApprove({ - connection, - feePayer: members.voter, - multisigPda, - transactionIndex: approvedTransactionIndex, - 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: approvedTransactionIndex, - member: members.almighty, - programId, - }); - await connection.confirmTransaction(signature); - - // 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); - - // Reject the proposal by a member. - // Our threshold is 2 out of 2 voting members, so the cutoff is 1. - signature = await multisig.rpc.proposalReject({ - connection, - feePayer: members.voter, - multisigPda, - transactionIndex: rejectedTransactionIndex, - member: members.voter, - programId, - }); - await connection.confirmTransaction(signature); - }); - - it("execute a config transaction", async () => { - // Execute the approved config transaction. - const transactionIndex = 1n; - - const signature = await multisig.rpc.configTransactionExecute({ - connection, - feePayer: members.almighty, - multisigPda, - transactionIndex, - member: members.almighty, - rentPayer: members.almighty, - programId, - }); - await connection.confirmTransaction(signature); - - // Verify the proposal account. - const [proposalPda] = multisig.getProposalPda({ - multisigPda, - transactionIndex, - programId, - }); - const proposalAccount = await Proposal.fromAccountAddress( - connection, - proposalPda - ); - assert.ok( - multisig.types.isProposalStatusExecuted(proposalAccount.status) - ); - - // Verify the multisig account. - const multisigAccount = await Multisig.fromAccountAddress( - connection, - multisigPda - ); - // The threshold should have been updated. - assert.strictEqual(multisigAccount.threshold, 1); - }); - - it("error: invalid proposal status (Rejected)", async () => { - // Attempt to execute a transaction with a rejected proposal. - await assert.rejects( - () => - multisig.rpc.configTransactionExecute({ - connection, - feePayer: members.almighty, - multisigPda, - transactionIndex: rejectedTransactionIndex, - rentPayer: members.almighty, - member: members.almighty, - programId, - }), - /Invalid proposal status/ - ); - }); - }); - describe("utils", () => { describe("getAvailableMemoSize", () => { it("provides estimates for available size to use for memo", async () => { From 5ce68a52fdf16c158f1f650d731450c4bd5b9819 Mon Sep 17 00:00:00 2001 From: Vladimir Guguiev <1524432+vovacodes@users.noreply.github.com> Date: Sun, 26 Nov 2023 10:36:20 +0100 Subject: [PATCH 6/7] feat(rent-reclamation): add multisig_set_rent_collector --- .../src/instructions/multisig_config.rs | 52 ++++++++ programs/squads_multisig_program/src/lib.rs | 8 ++ sdk/multisig/idl/squads_multisig_program.json | 72 +++++++++++ .../src/generated/instructions/index.ts | 1 + .../instructions/multisigSetRentCollector.ts | 118 ++++++++++++++++++ .../types/MultisigSetRentCollectorArgs.ts | 27 ++++ sdk/multisig/src/generated/types/index.ts | 1 + sdk/multisig/src/instructions/index.ts | 1 + .../instructions/multisigSetRentCollector.ts | 34 +++++ sdk/multisig/src/rpc/index.ts | 1 + .../src/rpc/multisigSetRentCollector.ts | 55 ++++++++ sdk/multisig/src/transactions/index.ts | 1 + .../transactions/multisigSetRentCollector.ts | 47 +++++++ .../instructions/multisigSetRentCollector.ts | 110 ++++++++++++++++ tests/suites/multisig-sdk.ts | 1 + 15 files changed, 529 insertions(+) create mode 100644 sdk/multisig/src/generated/instructions/multisigSetRentCollector.ts create mode 100644 sdk/multisig/src/generated/types/MultisigSetRentCollectorArgs.ts create mode 100644 sdk/multisig/src/instructions/multisigSetRentCollector.ts create mode 100644 sdk/multisig/src/rpc/multisigSetRentCollector.ts create mode 100644 sdk/multisig/src/transactions/multisigSetRentCollector.ts create mode 100644 tests/suites/instructions/multisigSetRentCollector.ts diff --git a/programs/squads_multisig_program/src/instructions/multisig_config.rs b/programs/squads_multisig_program/src/instructions/multisig_config.rs index 53ffed51..81ebea70 100644 --- a/programs/squads_multisig_program/src/instructions/multisig_config.rs +++ b/programs/squads_multisig_program/src/instructions/multisig_config.rs @@ -38,6 +38,13 @@ pub struct MultisigSetConfigAuthorityArgs { pub memo: Option, } +#[derive(AnchorSerialize, AnchorDeserialize)] +pub struct MultisigSetRentCollectorArgs { + pub rent_collector: Option, + /// Memo is used for indexing only. + pub memo: Option, +} + #[derive(Accounts)] pub struct MultisigConfig<'info> { #[account( @@ -200,4 +207,49 @@ impl MultisigConfig<'_> { Ok(()) } + + /// Set the multisig `rent_collector` and reallocate space if necessary. + /// + /// NOTE: This instruction must be called only by the `config_authority` if one is set (Controlled Multisig). + /// Uncontrolled Mustisigs should use `config_transaction_create` instead. + #[access_control(ctx.accounts.validate())] + pub fn multisig_set_rent_collector( + ctx: Context, + args: MultisigSetRentCollectorArgs, + ) -> Result<()> { + let multisig = &mut ctx.accounts.multisig; + + let system_program = &ctx + .accounts + .system_program + .as_ref() + .ok_or(MultisigError::MissingAccount)?; + let rent_payer = &ctx + .accounts + .rent_payer + .as_ref() + .ok_or(MultisigError::MissingAccount)?; + + // Check if we need to reallocate space. + let reallocated = Multisig::realloc_if_needed( + multisig.to_account_info(), + multisig.members.len(), + args.rent_collector.is_some(), + rent_payer.to_account_info(), + system_program.to_account_info(), + )?; + + if reallocated { + multisig.reload()?; + } + + multisig.rent_collector = args.rent_collector; + + // We don't need to invalidate prior transactions here because changing + // `rent_collector` doesn't affect the consensus parameters of the multisig. + + multisig.invariant()?; + + Ok(()) + } } diff --git a/programs/squads_multisig_program/src/lib.rs b/programs/squads_multisig_program/src/lib.rs index 4c201d91..12149b22 100644 --- a/programs/squads_multisig_program/src/lib.rs +++ b/programs/squads_multisig_program/src/lib.rs @@ -89,6 +89,14 @@ pub mod squads_multisig_program { MultisigConfig::multisig_set_config_authority(ctx, args) } + /// Set the multisig `rent_collector`. + pub fn multisig_set_rent_collector( + ctx: Context, + args: MultisigSetRentCollectorArgs, + ) -> Result<()> { + MultisigConfig::multisig_set_rent_collector(ctx, args) + } + /// Create a new spending limit for the controlled multisig. pub fn multisig_add_spending_limit( ctx: Context, diff --git a/sdk/multisig/idl/squads_multisig_program.json b/sdk/multisig/idl/squads_multisig_program.json index d8bc4b81..0a5f736e 100644 --- a/sdk/multisig/idl/squads_multisig_program.json +++ b/sdk/multisig/idl/squads_multisig_program.json @@ -290,6 +290,55 @@ } ] }, + { + "name": "multisigSetRentCollector", + "docs": [ + "Set the multisig `rent_collector`." + ], + "accounts": [ + { + "name": "multisig", + "isMut": true, + "isSigner": false + }, + { + "name": "configAuthority", + "isMut": false, + "isSigner": true, + "docs": [ + "Multisig `config_authority` that must authorize the configuration change." + ] + }, + { + "name": "rentPayer", + "isMut": true, + "isSigner": true, + "isOptional": true, + "docs": [ + "The account that will be charged or credited in case the multisig account needs to reallocate space,", + "for example when adding a new member or a spending limit.", + "This is usually the same as `config_authority`, but can be a different account if needed." + ] + }, + { + "name": "systemProgram", + "isMut": false, + "isSigner": false, + "isOptional": true, + "docs": [ + "We might need it in case reallocation is needed." + ] + } + ], + "args": [ + { + "name": "args", + "type": { + "defined": "MultisigSetRentCollectorArgs" + } + } + ] + }, { "name": "multisigAddSpendingLimit", "docs": [ @@ -1936,6 +1985,29 @@ ] } }, + { + "name": "MultisigSetRentCollectorArgs", + "type": { + "kind": "struct", + "fields": [ + { + "name": "rentCollector", + "type": { + "option": "publicKey" + } + }, + { + "name": "memo", + "docs": [ + "Memo is used for indexing only." + ], + "type": { + "option": "string" + } + } + ] + } + }, { "name": "MultisigCreateArgs", "type": { diff --git a/sdk/multisig/src/generated/instructions/index.ts b/sdk/multisig/src/generated/instructions/index.ts index afe1787c..8b50f45a 100644 --- a/sdk/multisig/src/generated/instructions/index.ts +++ b/sdk/multisig/src/generated/instructions/index.ts @@ -12,6 +12,7 @@ export * from './multisigCreate' export * from './multisigRemoveMember' export * from './multisigRemoveSpendingLimit' export * from './multisigSetConfigAuthority' +export * from './multisigSetRentCollector' export * from './multisigSetTimeLock' export * from './proposalActivate' export * from './proposalApprove' diff --git a/sdk/multisig/src/generated/instructions/multisigSetRentCollector.ts b/sdk/multisig/src/generated/instructions/multisigSetRentCollector.ts new file mode 100644 index 00000000..b2a579ac --- /dev/null +++ b/sdk/multisig/src/generated/instructions/multisigSetRentCollector.ts @@ -0,0 +1,118 @@ +/** + * 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 { + MultisigSetRentCollectorArgs, + multisigSetRentCollectorArgsBeet, +} from '../types/MultisigSetRentCollectorArgs' + +/** + * @category Instructions + * @category MultisigSetRentCollector + * @category generated + */ +export type MultisigSetRentCollectorInstructionArgs = { + args: MultisigSetRentCollectorArgs +} +/** + * @category Instructions + * @category MultisigSetRentCollector + * @category generated + */ +export const multisigSetRentCollectorStruct = new beet.FixableBeetArgsStruct< + MultisigSetRentCollectorInstructionArgs & { + instructionDiscriminator: number[] /* size: 8 */ + } +>( + [ + ['instructionDiscriminator', beet.uniformFixedSizeArray(beet.u8, 8)], + ['args', multisigSetRentCollectorArgsBeet], + ], + 'MultisigSetRentCollectorInstructionArgs' +) +/** + * Accounts required by the _multisigSetRentCollector_ instruction + * + * @property [_writable_] multisig + * @property [**signer**] configAuthority + * @property [_writable_, **signer**] rentPayer (optional) + * @category Instructions + * @category MultisigSetRentCollector + * @category generated + */ +export type MultisigSetRentCollectorInstructionAccounts = { + multisig: web3.PublicKey + configAuthority: web3.PublicKey + rentPayer?: web3.PublicKey + systemProgram?: web3.PublicKey + anchorRemainingAccounts?: web3.AccountMeta[] +} + +export const multisigSetRentCollectorInstructionDiscriminator = [ + 48, 204, 65, 57, 210, 70, 156, 74, +] + +/** + * Creates a _MultisigSetRentCollector_ instruction. + * + * Optional accounts that are not provided default to the program ID since + * this was indicated in the IDL from which this instruction was generated. + * + * @param accounts that will be accessed while the instruction is processed + * @param args to provide as instruction data to the program + * + * @category Instructions + * @category MultisigSetRentCollector + * @category generated + */ +export function createMultisigSetRentCollectorInstruction( + accounts: MultisigSetRentCollectorInstructionAccounts, + args: MultisigSetRentCollectorInstructionArgs, + programId = new web3.PublicKey('SQDS4ep65T869zMMBKyuUq6aD6EgTu8psMjkvj52pCf') +) { + const [data] = multisigSetRentCollectorStruct.serialize({ + instructionDiscriminator: multisigSetRentCollectorInstructionDiscriminator, + ...args, + }) + const keys: web3.AccountMeta[] = [ + { + pubkey: accounts.multisig, + isWritable: true, + isSigner: false, + }, + { + pubkey: accounts.configAuthority, + isWritable: false, + isSigner: true, + }, + { + pubkey: accounts.rentPayer ?? programId, + isWritable: accounts.rentPayer != null, + isSigner: accounts.rentPayer != null, + }, + { + pubkey: accounts.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/MultisigSetRentCollectorArgs.ts b/sdk/multisig/src/generated/types/MultisigSetRentCollectorArgs.ts new file mode 100644 index 00000000..182c4425 --- /dev/null +++ b/sdk/multisig/src/generated/types/MultisigSetRentCollectorArgs.ts @@ -0,0 +1,27 @@ +/** + * This code was GENERATED using the solita package. + * Please DO NOT EDIT THIS FILE, instead rerun solita to update it or write a wrapper to add functionality. + * + * See: https://github.com/metaplex-foundation/solita + */ + +import * as web3 from '@solana/web3.js' +import * as beet from '@metaplex-foundation/beet' +import * as beetSolana from '@metaplex-foundation/beet-solana' +export type MultisigSetRentCollectorArgs = { + rentCollector: beet.COption + memo: beet.COption +} + +/** + * @category userTypes + * @category generated + */ +export const multisigSetRentCollectorArgsBeet = + new beet.FixableBeetArgsStruct( + [ + ['rentCollector', beet.coption(beetSolana.publicKey)], + ['memo', beet.coption(beet.utf8String)], + ], + 'MultisigSetRentCollectorArgs' + ) diff --git a/sdk/multisig/src/generated/types/index.ts b/sdk/multisig/src/generated/types/index.ts index 904d8582..e53cad58 100644 --- a/sdk/multisig/src/generated/types/index.ts +++ b/sdk/multisig/src/generated/types/index.ts @@ -12,6 +12,7 @@ export * from './MultisigMessageAddressTableLookup' export * from './MultisigRemoveMemberArgs' export * from './MultisigRemoveSpendingLimitArgs' export * from './MultisigSetConfigAuthorityArgs' +export * from './MultisigSetRentCollectorArgs' export * from './MultisigSetTimeLockArgs' export * from './Period' export * from './Permissions' diff --git a/sdk/multisig/src/instructions/index.ts b/sdk/multisig/src/instructions/index.ts index 9d59148a..ab51b43b 100644 --- a/sdk/multisig/src/instructions/index.ts +++ b/sdk/multisig/src/instructions/index.ts @@ -10,6 +10,7 @@ export * from "./multisigAddMember.js"; export * from "./multisigAddSpendingLimit.js"; export * from "./multisigRemoveSpendingLimit.js"; export * from "./multisigSetConfigAuthority.js"; +export * from "./multisigSetRentCollector.js"; export * from "./multisigSetTimeLock.js"; export * from "./proposalActivate.js"; export * from "./proposalApprove.js"; diff --git a/sdk/multisig/src/instructions/multisigSetRentCollector.ts b/sdk/multisig/src/instructions/multisigSetRentCollector.ts new file mode 100644 index 00000000..ac73fdd1 --- /dev/null +++ b/sdk/multisig/src/instructions/multisigSetRentCollector.ts @@ -0,0 +1,34 @@ +import { PublicKey, SystemProgram } from "@solana/web3.js"; +import { createMultisigSetRentCollectorInstruction } from "../generated"; + +export function multisigSetRentCollector({ + multisigPda, + configAuthority, + newRentCollector, + rentPayer, + memo, + programId, +}: { + multisigPda: PublicKey; + configAuthority: PublicKey; + newRentCollector: PublicKey | null; + rentPayer: PublicKey; + memo?: string; + programId?: PublicKey; +}) { + return createMultisigSetRentCollectorInstruction( + { + multisig: multisigPda, + configAuthority, + rentPayer, + systemProgram: SystemProgram.programId, + }, + { + args: { + rentCollector: newRentCollector, + memo: memo ?? null, + }, + }, + programId + ); +} diff --git a/sdk/multisig/src/rpc/index.ts b/sdk/multisig/src/rpc/index.ts index f47d3692..984572a2 100644 --- a/sdk/multisig/src/rpc/index.ts +++ b/sdk/multisig/src/rpc/index.ts @@ -10,6 +10,7 @@ export * from "./multisigAddSpendingLimit.js"; export * from "./multisigRemoveSpendingLimit.js"; export * from "./multisigCreate.js"; export * from "./multisigSetConfigAuthority.js"; +export * from "./multisigSetRentCollector.js"; export * from "./multisigSetTimeLock.js"; export * from "./proposalActivate.js"; export * from "./proposalApprove.js"; diff --git a/sdk/multisig/src/rpc/multisigSetRentCollector.ts b/sdk/multisig/src/rpc/multisigSetRentCollector.ts new file mode 100644 index 00000000..7af41e1a --- /dev/null +++ b/sdk/multisig/src/rpc/multisigSetRentCollector.ts @@ -0,0 +1,55 @@ +import { + Connection, + PublicKey, + SendOptions, + Signer, + TransactionSignature, +} from "@solana/web3.js"; +import * as transactions from "../transactions"; +import { translateAndThrowAnchorError } from "../errors"; + +/** Set the multisig `rent_collector`. */ +export async function multisigSetRentCollector({ + connection, + feePayer, + multisigPda, + configAuthority, + newRentCollector, + rentPayer, + memo, + signers, + sendOptions, + programId, +}: { + connection: Connection; + feePayer: Signer; + multisigPda: PublicKey; + configAuthority: PublicKey; + newRentCollector: PublicKey | null; + rentPayer: PublicKey; + memo?: string; + signers?: Signer[]; + sendOptions?: SendOptions; + programId?: PublicKey; +}): Promise { + const blockhash = (await connection.getLatestBlockhash()).blockhash; + + const tx = transactions.multisigSetRentCollector({ + blockhash, + feePayer: feePayer.publicKey, + multisigPda, + configAuthority, + newRentCollector, + rentPayer, + memo, + programId, + }); + + tx.sign([feePayer, ...(signers ?? [])]); + + try { + return await connection.sendTransaction(tx, sendOptions); + } catch (err) { + translateAndThrowAnchorError(err); + } +} diff --git a/sdk/multisig/src/transactions/index.ts b/sdk/multisig/src/transactions/index.ts index f47d3692..984572a2 100644 --- a/sdk/multisig/src/transactions/index.ts +++ b/sdk/multisig/src/transactions/index.ts @@ -10,6 +10,7 @@ export * from "./multisigAddSpendingLimit.js"; export * from "./multisigRemoveSpendingLimit.js"; export * from "./multisigCreate.js"; export * from "./multisigSetConfigAuthority.js"; +export * from "./multisigSetRentCollector.js"; export * from "./multisigSetTimeLock.js"; export * from "./proposalActivate.js"; export * from "./proposalApprove.js"; diff --git a/sdk/multisig/src/transactions/multisigSetRentCollector.ts b/sdk/multisig/src/transactions/multisigSetRentCollector.ts new file mode 100644 index 00000000..bba34ed1 --- /dev/null +++ b/sdk/multisig/src/transactions/multisigSetRentCollector.ts @@ -0,0 +1,47 @@ +import { + PublicKey, + TransactionMessage, + VersionedTransaction, +} from "@solana/web3.js"; +import * as instructions from "../instructions"; + +/** + * Returns unsigned `VersionedTransaction` that needs to be + * signed by `configAuthority` and `feePayer` before sending it. + */ +export function multisigSetRentCollector({ + blockhash, + feePayer, + multisigPda, + configAuthority, + newRentCollector, + rentPayer, + memo, + programId, +}: { + blockhash: string; + feePayer: PublicKey; + multisigPda: PublicKey; + configAuthority: PublicKey; + newRentCollector: PublicKey | null; + rentPayer: PublicKey; + memo?: string; + programId?: PublicKey; +}): VersionedTransaction { + const message = new TransactionMessage({ + payerKey: feePayer, + recentBlockhash: blockhash, + instructions: [ + instructions.multisigSetRentCollector({ + multisigPda, + configAuthority, + newRentCollector, + rentPayer, + memo, + programId, + }), + ], + }).compileToV0Message(); + + return new VersionedTransaction(message); +} diff --git a/tests/suites/instructions/multisigSetRentCollector.ts b/tests/suites/instructions/multisigSetRentCollector.ts new file mode 100644 index 00000000..cc0d4775 --- /dev/null +++ b/tests/suites/instructions/multisigSetRentCollector.ts @@ -0,0 +1,110 @@ +import { Keypair, PublicKey } from "@solana/web3.js"; +import { + createControlledMultisig, + createLocalhostConnection, + generateFundedKeypair, + generateMultisigMembers, + getTestProgramId, + TestMembers, +} from "../../utils"; +import * as multisig from "@sqds/multisig"; +import assert from "assert"; + +const { Multisig } = multisig.accounts; + +const programId = getTestProgramId(); +const connection = createLocalhostConnection(); + +describe("Instructions / multisig_set_rent_collector", () => { + let members: TestMembers; + let multisigPda: PublicKey; + let configAuthority: Keypair; + + before(async () => { + configAuthority = await generateFundedKeypair(connection); + + members = await generateMultisigMembers(connection); + + // Create new controlled multisig with no rent_collector. + multisigPda = ( + await createControlledMultisig({ + connection, + createKey: Keypair.generate(), + configAuthority: configAuthority.publicKey, + members, + threshold: 1, + timeLock: 0, + rentCollector: null, + programId, + }) + )[0]; + }); + + it("set `rent_collector` for the controlled multisig", async () => { + const multisigAccountInfoPreExecution = await connection.getAccountInfo( + multisigPda + )!; + + const vaultPda = multisig.getVaultPda({ + multisigPda, + index: 0, + programId, + })[0]; + + const signature = await multisig.rpc.multisigSetRentCollector({ + connection, + multisigPda, + feePayer: configAuthority, + configAuthority: configAuthority.publicKey, + newRentCollector: vaultPda, + rentPayer: configAuthority.publicKey, + programId, + signers: [configAuthority], + }); + await connection.confirmTransaction(signature, "confirmed"); + + // Verify the multisig account. + const multisigAccountInfoPostExecution = await connection.getAccountInfo( + multisigPda + ); + const [multisigAccountPostExecution] = Multisig.fromAccountInfo( + multisigAccountInfoPostExecution! + ); + // The rentCollector should be updated. + assert.strictEqual( + multisigAccountPostExecution.rentCollector?.toBase58(), + vaultPda.toBase58() + ); + // The stale transaction index should NOT be updated and remain 0. + assert.strictEqual( + multisigAccountPostExecution.staleTransactionIndex.toString(), + "0" + ); + // multisig space should be reallocated: increased by at least 32 bytes of new rent_collector. + assert.ok( + multisigAccountInfoPostExecution!.data.length >= + multisigAccountInfoPreExecution!.data.length + 32 + ); + }); + + it("unset `rent_collector` for the controlled multisig", async () => { + const signature = await multisig.rpc.multisigSetRentCollector({ + connection, + multisigPda, + feePayer: configAuthority, + configAuthority: configAuthority.publicKey, + newRentCollector: null, + rentPayer: configAuthority.publicKey, + programId, + signers: [configAuthority], + }); + await connection.confirmTransaction(signature, "confirmed"); + + // Make sure the rent_collector was unset correctly. + const multisigAccount = await Multisig.fromAccountAddress( + connection, + multisigPda + ); + assert.strictEqual(multisigAccount.rentCollector, null); + }); +}); diff --git a/tests/suites/multisig-sdk.ts b/tests/suites/multisig-sdk.ts index c28a2471..61425fab 100644 --- a/tests/suites/multisig-sdk.ts +++ b/tests/suites/multisig-sdk.ts @@ -25,6 +25,7 @@ const { Permission, Permissions } = multisig.types; const programId = getTestProgramId(); +import "./instructions/multisigSetRentCollector"; import "./instructions/configTransactionExecute"; import "./instructions/configTransactionAccountsClose"; import "./instructions/vaultBatchTransactionAccountClose"; From 20a1df8a5e0bd0ebe56735860bf077110aab406a Mon Sep 17 00:00:00 2001 From: Vladimir Guguiev <1524432+vovacodes@users.noreply.github.com> Date: Tue, 28 Nov 2023 23:25:11 +0100 Subject: [PATCH 7/7] feat(rent-reclamation): add member check to batch_accounts_close --- .../src/instructions/batch_create.rs | 2 + .../transaction_accounts_close.rs | 12 ++++++ sdk/multisig/idl/squads_multisig_program.json | 8 ++++ .../instructions/batchAccountsClose.ts | 7 ++++ .../src/instructions/batchAccountsClose.ts | 3 ++ sdk/multisig/src/rpc/batchAccountsClose.ts | 5 ++- .../src/transactions/batchAccountsClose.ts | 3 ++ .../suites/instructions/batchAccountsClose.ts | 37 ++++++++++++++++++- 8 files changed, 75 insertions(+), 2 deletions(-) diff --git a/programs/squads_multisig_program/src/instructions/batch_create.rs b/programs/squads_multisig_program/src/instructions/batch_create.rs index 61c29b4e..3afce40d 100644 --- a/programs/squads_multisig_program/src/instructions/batch_create.rs +++ b/programs/squads_multisig_program/src/instructions/batch_create.rs @@ -91,6 +91,8 @@ impl BatchCreate<'_> { batch.size = 0; batch.executed_transaction_index = 0; + batch.invariant()?; + // Updated last transaction index in the multisig account. multisig.transaction_index = index; 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 d835fd1a..e2a5fa7a 100644 --- a/programs/squads_multisig_program/src/instructions/transaction_accounts_close.rs +++ b/programs/squads_multisig_program/src/instructions/transaction_accounts_close.rs @@ -403,6 +403,9 @@ pub struct BatchAccountsClose<'info> { )] pub multisig: Account<'info, Multisig>, + /// Member of the multisig. + pub member: Signer<'info>, + #[account(mut, close = rent_collector)] pub proposal: Account<'info, Proposal>, @@ -436,6 +439,15 @@ impl BatchAccountsClose<'_> { .key(); //endregion + //region member + // Has to be a member of the `multisig`. + // This is checked to prevent potential attackers from closing the `Batch` and `Proposal` + // accounts before all `VaultBatchTransaction`s are closed. + require!( + multisig.is_member(self.member.key()).is_some(), + MultisigError::NotAMember + ); + //region rent_collector // Has to match the `multisig.rent_collector`. require_keys_eq!( diff --git a/sdk/multisig/idl/squads_multisig_program.json b/sdk/multisig/idl/squads_multisig_program.json index 0a5f736e..9f79ccd6 100644 --- a/sdk/multisig/idl/squads_multisig_program.json +++ b/sdk/multisig/idl/squads_multisig_program.json @@ -1208,6 +1208,14 @@ "isMut": false, "isSigner": false }, + { + "name": "member", + "isMut": false, + "isSigner": true, + "docs": [ + "Member of the multisig." + ] + }, { "name": "proposal", "isMut": true, diff --git a/sdk/multisig/src/generated/instructions/batchAccountsClose.ts b/sdk/multisig/src/generated/instructions/batchAccountsClose.ts index 3625cb41..cd5f6a27 100644 --- a/sdk/multisig/src/generated/instructions/batchAccountsClose.ts +++ b/sdk/multisig/src/generated/instructions/batchAccountsClose.ts @@ -23,6 +23,7 @@ export const batchAccountsCloseStruct = new beet.BeetArgsStruct<{ * Accounts required by the _batchAccountsClose_ instruction * * @property [] multisig + * @property [**signer**] member * @property [_writable_] proposal * @property [_writable_] batch * @property [_writable_] rentCollector @@ -32,6 +33,7 @@ export const batchAccountsCloseStruct = new beet.BeetArgsStruct<{ */ export type BatchAccountsCloseInstructionAccounts = { multisig: web3.PublicKey + member: web3.PublicKey proposal: web3.PublicKey batch: web3.PublicKey rentCollector: web3.PublicKey @@ -64,6 +66,11 @@ export function createBatchAccountsCloseInstruction( isWritable: false, isSigner: false, }, + { + pubkey: accounts.member, + isWritable: false, + isSigner: true, + }, { pubkey: accounts.proposal, isWritable: true, diff --git a/sdk/multisig/src/instructions/batchAccountsClose.ts b/sdk/multisig/src/instructions/batchAccountsClose.ts index a11a2830..3a07aa3c 100644 --- a/sdk/multisig/src/instructions/batchAccountsClose.ts +++ b/sdk/multisig/src/instructions/batchAccountsClose.ts @@ -14,11 +14,13 @@ import { getProposalPda, getTransactionPda } from "../pda"; */ export function batchAccountsClose({ multisigPda, + member, rentCollector, batchIndex, programId = PROGRAM_ID, }: { multisigPda: PublicKey; + member: PublicKey; rentCollector: PublicKey; batchIndex: bigint; programId?: PublicKey; @@ -37,6 +39,7 @@ export function batchAccountsClose({ return createBatchAccountsCloseInstruction( { multisig: multisigPda, + member, rentCollector, proposal: proposalPda, batch: batchPda, diff --git a/sdk/multisig/src/rpc/batchAccountsClose.ts b/sdk/multisig/src/rpc/batchAccountsClose.ts index 22d16e11..98eba18e 100644 --- a/sdk/multisig/src/rpc/batchAccountsClose.ts +++ b/sdk/multisig/src/rpc/batchAccountsClose.ts @@ -22,6 +22,7 @@ export async function batchAccountsClose({ connection, feePayer, multisigPda, + member, rentCollector, batchIndex, sendOptions, @@ -30,6 +31,7 @@ export async function batchAccountsClose({ connection: Connection; feePayer: Signer; multisigPda: PublicKey; + member: Signer; rentCollector: PublicKey; batchIndex: bigint; sendOptions?: SendOptions; @@ -40,13 +42,14 @@ export async function batchAccountsClose({ const tx = transactions.batchAccountsClose({ blockhash, feePayer: feePayer.publicKey, + member: member.publicKey, rentCollector, batchIndex, multisigPda, programId, }); - tx.sign([feePayer]); + tx.sign([feePayer, member]); try { return await connection.sendTransaction(tx, sendOptions); diff --git a/sdk/multisig/src/transactions/batchAccountsClose.ts b/sdk/multisig/src/transactions/batchAccountsClose.ts index fe48a990..f116ab8d 100644 --- a/sdk/multisig/src/transactions/batchAccountsClose.ts +++ b/sdk/multisig/src/transactions/batchAccountsClose.ts @@ -9,6 +9,7 @@ export function batchAccountsClose({ blockhash, feePayer, multisigPda, + member, rentCollector, batchIndex, programId, @@ -16,6 +17,7 @@ export function batchAccountsClose({ blockhash: string; feePayer: PublicKey; multisigPda: PublicKey; + member: PublicKey; rentCollector: PublicKey; batchIndex: bigint; programId?: PublicKey; @@ -26,6 +28,7 @@ export function batchAccountsClose({ instructions: [ instructions.batchAccountsClose({ multisigPda, + member, rentCollector, batchIndex, programId, diff --git a/tests/suites/instructions/batchAccountsClose.ts b/tests/suites/instructions/batchAccountsClose.ts index 4d949008..6b47155c 100644 --- a/tests/suites/instructions/batchAccountsClose.ts +++ b/tests/suites/instructions/batchAccountsClose.ts @@ -160,6 +160,7 @@ describe("Instructions / batch_accounts_close", () => { connection, feePayer: members.almighty, multisigPda, + member: members.almighty, rentCollector: Keypair.generate().publicKey, batchIndex, programId, @@ -168,6 +169,31 @@ describe("Instructions / batch_accounts_close", () => { ); }); + it("error: not a member", async () => { + const batchIndex = testMultisig.rejectedBatchIndex; + + const fakeMember = Keypair.generate(); + + const multisigAccount = await Multisig.fromAccountAddress( + connection, + multisigPda + ); + + await assert.rejects( + () => + multisig.rpc.batchAccountsClose({ + connection, + feePayer: members.almighty, + multisigPda, + member: fakeMember, + rentCollector: multisigAccount.rentCollector!, + batchIndex, + programId, + }), + /Provided pubkey is not a member of multisig/ + ); + }); + it("error: invalid rent_collector", async () => { const batchIndex = testMultisig.rejectedBatchIndex; @@ -179,6 +205,7 @@ describe("Instructions / batch_accounts_close", () => { connection, feePayer: members.almighty, multisigPda, + member: members.almighty, rentCollector: fakeRentCollector, batchIndex, programId, @@ -269,6 +296,7 @@ describe("Instructions / batch_accounts_close", () => { const ix = multisig.generated.createBatchAccountsCloseInstruction( { multisig: multisigPda, + member: members.almighty.publicKey, rentCollector: vaultPda, proposal: multisig.getProposalPda({ multisigPda: otherMultisig, @@ -292,7 +320,7 @@ describe("Instructions / batch_accounts_close", () => { instructions: [ix], }).compileToV0Message(); const tx = new VersionedTransaction(message); - tx.sign([feePayer]); + tx.sign([feePayer, members.almighty]); await assert.rejects( () => @@ -317,6 +345,7 @@ describe("Instructions / batch_accounts_close", () => { connection, feePayer: members.almighty, multisigPda, + member: members.almighty, rentCollector: multisigAccount.rentCollector!, batchIndex, programId, @@ -339,6 +368,7 @@ describe("Instructions / batch_accounts_close", () => { connection, feePayer: members.almighty, multisigPda, + member: members.almighty, rentCollector: multisigAccount.rentCollector!, batchIndex, programId, @@ -361,6 +391,7 @@ describe("Instructions / batch_accounts_close", () => { connection, feePayer: members.almighty, multisigPda, + member: members.almighty, rentCollector: multisigAccount.rentCollector!, batchIndex, programId, @@ -381,6 +412,7 @@ describe("Instructions / batch_accounts_close", () => { connection, feePayer: members.almighty, multisigPda, + member: members.almighty, rentCollector: multisigAccount.rentCollector!, batchIndex, programId, @@ -423,6 +455,7 @@ describe("Instructions / batch_accounts_close", () => { connection, feePayer: members.almighty, multisigPda, + member: members.almighty, rentCollector: multisigAccount.rentCollector!, batchIndex, programId, @@ -442,6 +475,7 @@ describe("Instructions / batch_accounts_close", () => { connection, feePayer: members.almighty, multisigPda, + member: members.almighty, rentCollector: multisigAccount.rentCollector!, batchIndex, programId, @@ -461,6 +495,7 @@ describe("Instructions / batch_accounts_close", () => { connection, feePayer: members.almighty, multisigPda, + member: members.almighty, rentCollector: multisigAccount.rentCollector!, batchIndex, programId,