diff --git a/sdk/multisig/src/actions/common/base.ts b/sdk/multisig/src/actions/common/base.ts new file mode 100644 index 00000000..f2bbc152 --- /dev/null +++ b/sdk/multisig/src/actions/common/base.ts @@ -0,0 +1,289 @@ +import { + Connection, + Keypair, + PublicKey, + TransactionInstruction, + TransactionMessage, + TransactionSignature, + VersionedTransaction, +} from "@solana/web3.js"; +import { + BaseBuilderArgs, + BuildResult, + BuildTransactionSettings, + SendSettings, +} from "./types"; + +export abstract class BaseBuilder< + T extends BuildResult, + U extends BaseBuilderArgs = BaseBuilderArgs +> { + public createKey?: Keypair; + protected connection: Connection; + protected instructions: TransactionInstruction[] = []; + protected creator: PublicKey = PublicKey.default; + protected buildPromise: Promise; + protected args: Omit; + private built: boolean = false; + // Use this as an indicator to clear all instructions? + private sent: boolean = false; + + constructor(args: U, options: { generateCreateKey?: boolean } = {}) { + this.connection = args.connection; + this.creator = args.creator; + this.args = this.extractAdditionalArgs(args); + if (options.generateCreateKey) { + this.createKey = Keypair.generate(); + } + this.buildPromise = this.initializeBuild(); + } + + private async initializeBuild(): Promise { + await this.build(); + this.built = true; + } + + protected async ensureBuilt(): Promise { + if (!this.built) { + await this.buildPromise; + } + } + + private extractAdditionalArgs(args: U): Omit { + const { connection, creator, ...additionalArgs } = args; + return additionalArgs; + } + + protected abstract build(): Promise; + + /** + * Fetches built instructions. Will always contain at least one instruction corresponding to + * the builder you are using, unless cleared after sending. + * @returns `Promise` - An array of built instructions. + */ + async getInstructions(): Promise { + await this.ensureBuilt(); + return this.instructions; + } + + /** + * Creates a `VersionedTransaction` containing the corresponding instruction(s). + * + * @args {@link BuildTransactionSettings} - **(Optional)** Address Lookup Table accounts, signers, a custom fee-payer to add to the transaction. + * @returns `VersionedTransaction` + * + * @example + * // Get pre-built transaction from builder instance. + * const builder = createMultisig({ + * // ... args + * }); + * const transaction = await builder.transaction(); + * @example + * // Run chained async method to return the + * // transaction all in one go. + * const transaction = await createMultisig({ + * // ... args + * }).transaction(); + */ + async transaction( + settings?: BuildTransactionSettings + ): Promise { + await this.ensureBuilt(); + const message = new TransactionMessage({ + payerKey: settings?.feePayer?.publicKey ?? this.creator, + recentBlockhash: (await this.connection.getLatestBlockhash()).blockhash, + instructions: [...this.instructions], + }).compileToV0Message(settings?.addressLookupTableAccounts); + + const tx = new VersionedTransaction(message); + if (settings?.feePayer) { + tx.sign([settings?.feePayer]); + } + if (settings?.signers) { + tx.sign([...settings?.signers]); + } + return tx; + } + + /** + * Builds a transaction with the corresponding instruction(s), and sends it. + * + * **NOTE: Not wallet-adapter compatible.** + * + * @args {@link SendSettings} - Optional pre/post instructions, fee payer, and send options. + * @returns `TransactionSignature` + * @example + * const builder = createMultisig({ + * // ... args + * }); + * const signature = await builder.send(); + * @example + * const builder = createMultisig({ + * // ... args + * }); + * + * // With settings + * const signature = await builder.send({ + * preInstructions: [...preInstructions], + * postInstructions: [...postInstructions], + * feePayer: someKeypair, + * options: { skipPreflight: true }, + * }); + */ + async send(settings?: SendSettings): Promise { + await this.ensureBuilt(); + const instructions = [...this.instructions]; + if (settings?.preInstructions) { + instructions.unshift(...settings.preInstructions); + } + if (settings?.postInstructions) { + instructions.push(...settings.postInstructions); + } + const message = new TransactionMessage({ + payerKey: settings?.feePayer?.publicKey ?? this.creator, + recentBlockhash: (await this.connection.getLatestBlockhash()).blockhash, + instructions: [...instructions], + }).compileToV0Message(settings?.addressLookupTableAccounts); + + const tx = new VersionedTransaction(message); + if (settings?.feePayer) { + tx.sign([settings.feePayer]); + } + if (settings?.signers) { + tx.sign([...settings.signers]); + } + const signature = await this.connection.sendTransaction( + tx, + settings?.options + ); + this.sent = true; + + if (settings?.clearInstructions) { + this.instructions = []; + } + + return signature; + } + + /** + * Builds a transaction with the corresponding instruction(s), sends it, and confirms the transaction. + * + * **NOTE: Not wallet-adapter compatible.** + * + * @args {@link SendSettings} - Optional pre/post instructions, fee payer keypair, and send options. + * @returns `TransactionSignature` + * @example + * const builder = createMultisig({ + * // ... args + * }); + * const signature = await builder.sendAndConfirm(); + * @example + * const builder = createMultisig({ + * // ... args + * }); + * + * // With settings + * const signature = await builder.sendAndConfirm({ + * preInstructions: [...preInstructions], + * postInstructions: [...postInstructions], + * feePayer: someKeypair, + * options: { skipPreflight: true }, + * }); + */ + async sendAndConfirm(settings?: SendSettings): Promise { + await this.ensureBuilt(); + const instructions = [...this.instructions]; + if (settings?.preInstructions) { + instructions.unshift(...settings.preInstructions); + } + if (settings?.postInstructions) { + instructions.push(...settings.postInstructions); + } + const message = new TransactionMessage({ + payerKey: settings?.feePayer?.publicKey ?? this.creator, + recentBlockhash: (await this.connection.getLatestBlockhash()).blockhash, + instructions: [...instructions], + }).compileToV0Message(settings?.addressLookupTableAccounts); + + const tx = new VersionedTransaction(message); + if (settings?.feePayer) { + tx.sign([settings.feePayer]); + } + if (settings?.signers) { + tx.sign([...settings.signers]); + } + const signature = await this.connection.sendTransaction( + tx, + settings?.options + ); + + let commitment = settings?.options?.preflightCommitment; + + let sent = false; + const maxAttempts = 10; + const delayMs = 1000; + for (let attempt = 0; attempt < maxAttempts && !sent; attempt++) { + const status = await this.connection.getSignatureStatus(signature); + if (status?.value?.confirmationStatus === commitment || "confirmed") { + await new Promise((resolve) => setTimeout(resolve, delayMs)); + sent = true; + } else { + await new Promise((resolve) => setTimeout(resolve, delayMs)); + } + } + + if (!sent) { + throw new Error( + "Transaction was not confirmed within the expected timeframe" + ); + } + + if (settings?.clearInstructions) { + this.instructions = []; + } + + return signature; + } + + /** + * We build a message with the corresponding instruction(s), you give us a callback + * for post-processing, sending, and confirming. + * + * @args `callback` - Async function with `TransactionMessage` as argument, and `TransactionSignature` as return value. + * @returns `TransactionSignature` + * + * @example + * const txBuilder = createVaultTransaction({ + * connection, + * creator: creator, + * message: message + * multisig: multisig, + * vaultIndex: 0, + * }); + * + * await txBuilder + * .withProposal() + * .withApproval() + * .withExecute(); + * + * const signature = await txBuilder.customSend( + * // Callback with transaction message, and your function. + * async (msg) => await customSender(msg, connection) + * ); + */ + async customSend( + callback: (args: TransactionMessage) => Promise + ): Promise { + await this.ensureBuilt(); + const message = new TransactionMessage({ + payerKey: this.creator, + recentBlockhash: (await this.connection.getLatestBlockhash()).blockhash, + instructions: [...this.instructions], + }); + + const signature = await callback(message); + this.sent = true; + + return signature; + } +} diff --git a/sdk/multisig/src/actions/common/baseTransaction.ts b/sdk/multisig/src/actions/common/baseTransaction.ts new file mode 100644 index 00000000..6d94a89f --- /dev/null +++ b/sdk/multisig/src/actions/common/baseTransaction.ts @@ -0,0 +1,71 @@ +import { PublicKey } from "@solana/web3.js"; +import { TransactionBuildResult, TransactionBuilderArgs } from "./types"; +import { PROGRAM_ID, Proposal } from "../../generated"; +import { getProposalPda, getTransactionPda } from "../../pda"; +import { BaseBuilder } from "./base"; + +export abstract class BaseTransactionBuilder< + T extends TransactionBuildResult, + U extends TransactionBuilderArgs +> extends BaseBuilder { + public index: number = 1; + public vaultIndex: number = 0; + + constructor(args: U) { + super(args); + } + + async getIndex(): Promise { + await this.ensureBuilt(); + return this.index; + } + + /** + * Fetches the `PublicKey` of the corresponding account for the transaction being built. + * + * @returns `PublicKey` + */ + async getTransactionKey(): Promise { + await this.ensureBuilt(); + const index = this.index; + const [transactionPda] = getTransactionPda({ + multisigPda: this.args.multisig, + index: BigInt(index ?? 1), + programId: this.args.programId ?? PROGRAM_ID, + }); + + return transactionPda; + } + + /** + * Fetches the `PublicKey` of the corresponding {@link Proposal} account for the transaction being built. + * + * @returns `PublicKey` + */ + getProposalKey(): PublicKey { + const index = this.index; + const [proposalPda] = getProposalPda({ + multisigPda: this.args.multisig, + transactionIndex: BigInt(index ?? 1), + programId: this.args.programId ?? PROGRAM_ID, + }); + + return proposalPda; + } + + /** + * Fetches and deserializes the {@link Proposal} account after it is built and sent. + * @args `key` - The public key of the `Proposal` account. + * @returns `Proposal` - Deserialized `Proposal` account data. + */ + async getProposalAccount(key: PublicKey) { + return this.buildPromise.then(async () => { + const propAccount = await Proposal.fromAccountAddress( + this.connection, + key + ); + + return propAccount; + }); + } +} diff --git a/sdk/multisig/src/actions/common/multisig.ts b/sdk/multisig/src/actions/common/multisig.ts new file mode 100644 index 00000000..1db42c18 --- /dev/null +++ b/sdk/multisig/src/actions/common/multisig.ts @@ -0,0 +1,50 @@ +import { Keypair } from "@solana/web3.js"; +import { instructions } from "../.."; +import { PROGRAM_ID, ProgramConfig } from "../../generated"; +import { getMultisigPda, getProgramConfigPda } from "../../pda"; +import { CreateMultisigActionArgs, CreateMultisigResult } from "./types"; + +export async function createMultisigCore( + args: CreateMultisigActionArgs, + createKey: Keypair +): Promise { + const { + connection, + creator, + threshold, + members, + timeLock = 0, + configAuthority, + rentCollector, + programId = PROGRAM_ID, + } = args; + + const [multisigPda] = getMultisigPda({ + createKey: createKey.publicKey, + programId, + }); + const programConfigPda = getProgramConfigPda({ programId })[0]; + + const programConfig = await ProgramConfig.fromAccountAddress( + connection, + programConfigPda + ); + + const ix = instructions.multisigCreateV2({ + creator, + threshold, + members, + multisigPda: multisigPda, + treasury: programConfig.treasury, + createKey: createKey.publicKey, + timeLock: timeLock ?? 0, + rentCollector: rentCollector ?? null, + configAuthority: configAuthority ?? null, + programId: programId ?? PROGRAM_ID, + }); + + return { + instructions: [ix], + multisigKey: multisigPda, + }; +} diff --git a/sdk/multisig/src/actions/common/proposal.ts b/sdk/multisig/src/actions/common/proposal.ts new file mode 100644 index 00000000..e0b742c7 --- /dev/null +++ b/sdk/multisig/src/actions/common/proposal.ts @@ -0,0 +1,63 @@ +import { instructions } from "../.."; +import { PROGRAM_ID } from "../../generated"; +import { + CreateProposalActionArgs, + ProposalResult, + VoteActionArgs, +} from "./types"; + +export function createProposalCore( + args: CreateProposalActionArgs +): ProposalResult { + const { + multisig, + creator, + transactionIndex, + rentPayer, + isDraft = false, + programId = PROGRAM_ID, + } = args; + + const ix = instructions.proposalCreate({ + multisigPda: multisig, + transactionIndex: BigInt(transactionIndex), + creator: creator, + isDraft, + rentPayer, + programId: programId, + }); + + return { + instruction: ix, + }; +} + +export function createApprovalCore(args: VoteActionArgs): ProposalResult { + const { multisig, member, transactionIndex, programId = PROGRAM_ID } = args; + + const ix = instructions.proposalApprove({ + multisigPda: multisig, + member: member, + transactionIndex: BigInt(transactionIndex), + programId: programId, + }); + + return { + instruction: ix, + }; +} + +export function createRejectionCore(args: VoteActionArgs): ProposalResult { + const { multisig, member, transactionIndex, programId = PROGRAM_ID } = args; + + const ix = instructions.proposalReject({ + multisigPda: multisig, + member: member, + transactionIndex: BigInt(transactionIndex), + programId: programId, + }); + + return { + instruction: ix, + }; +} diff --git a/sdk/multisig/src/actions/common/transaction.ts b/sdk/multisig/src/actions/common/transaction.ts new file mode 100644 index 00000000..be7ad91f --- /dev/null +++ b/sdk/multisig/src/actions/common/transaction.ts @@ -0,0 +1,235 @@ +import { PROGRAM_ID, accounts, instructions } from "../.."; +import { + BatchAddTransactionActionArgs, + BatchAddTransactionResult, + CreateBatchActionArgs, + CreateBatchResult, + CreateConfigTransactionActionArgs, + CreateConfigTransactionResult, + CreateVaultTransactionActionArgs, + CreateVaultTransactionResult, + ExecuteBatchActionArgs, + ExecuteBatchResult, + ExecuteConfigTransactionActionArgs, + ExecuteConfigTransactionResult, + ExecuteVaultTransactionActionArgs, + ExecuteVaultTransactionResult, + ProposalResult, + ReclaimRentActionArgs, +} from "./types"; + +//region VaultTransaction +export async function createVaultTransactionCore( + args: CreateVaultTransactionActionArgs, + transactionIndex?: number +): Promise { + const { + connection, + multisig, + creator, + message, + vaultIndex = 0, + ephemeralSigners = 0, + rentPayer = creator, + memo, + programId = PROGRAM_ID, + } = args; + + const multisigInfo = await accounts.Multisig.fromAccountAddress( + connection, + multisig + ); + let index; + if (transactionIndex) { + index = BigInt(transactionIndex); + } else { + const currentTransactionIndex = Number(multisigInfo.transactionIndex); + index = BigInt(currentTransactionIndex + 1); + } + + const ix = instructions.vaultTransactionCreate({ + multisigPda: multisig, + transactionIndex: index, + creator: creator, + vaultIndex: vaultIndex, + ephemeralSigners: ephemeralSigners, + transactionMessage: message, + memo: memo, + rentPayer, + programId: programId, + }); + + return { instructions: [ix], index: Number(index) }; +} + +export async function executeVaultTransactionCore( + args: ExecuteVaultTransactionActionArgs +): Promise { + const { connection, multisig, index, member, programId = PROGRAM_ID } = args; + const ix = await instructions.vaultTransactionExecute({ + connection, + multisigPda: multisig, + member: member, + transactionIndex: BigInt(index), + programId: programId, + }); + + return { + ...ix, + }; +} + +export async function reclaimRentCore( + args: ReclaimRentActionArgs +): Promise { + const { connection, multisig, index, programId = PROGRAM_ID } = args; + const multisigInfo = await accounts.Multisig.fromAccountAddress( + connection, + multisig + ); + + if (!multisigInfo.rentCollector) { + throw new Error("No rent collector found in Multisig config."); + } + + const ix = instructions.vaultTransactionAccountsClose({ + multisigPda: multisig, + rentCollector: multisigInfo.rentCollector, + transactionIndex: BigInt(index), + programId: programId, + }); + + return { + instruction: ix, + }; +} +//endregion + +//region ConfigTransaction +export async function createConfigTransactionCore( + args: CreateConfigTransactionActionArgs +): Promise { + const { + connection, + multisig, + creator, + actions, + rentPayer = creator, + memo, + programId = PROGRAM_ID, + } = args; + + const multisigInfo = await accounts.Multisig.fromAccountAddress( + connection, + multisig + ); + + const currentTransactionIndex = Number(multisigInfo.transactionIndex); + const index = BigInt(currentTransactionIndex + 1); + + const ix = instructions.configTransactionCreate({ + multisigPda: multisig, + transactionIndex: index, + creator: creator, + actions, + memo: memo, + rentPayer, + programId: programId, + }); + + return { instructions: [ix], index: Number(index) }; +} + +export async function executeConfigTransactionCore( + args: ExecuteConfigTransactionActionArgs +): Promise { + const { multisig, index, member, programId = PROGRAM_ID } = args; + const ix = instructions.configTransactionExecute({ + multisigPda: multisig, + member: member, + transactionIndex: BigInt(index), + programId: programId, + }); + + return { instruction: ix }; +} +//endregion + +//region Batch +export async function createBatchCore( + args: CreateBatchActionArgs +): Promise { + const { + connection, + multisig, + creator, + vaultIndex = 0, + rentPayer = creator, + memo, + programId = PROGRAM_ID, + } = args; + + const multisigInfo = await accounts.Multisig.fromAccountAddress( + connection, + multisig + ); + + const currentTransactionIndex = Number(multisigInfo.transactionIndex); + const index = BigInt(currentTransactionIndex + 1); + + const ix = instructions.batchCreate({ + multisigPda: multisig, + batchIndex: index, + creator: creator, + vaultIndex: vaultIndex, + memo: memo, + rentPayer, + programId: programId, + }); + + return { instructions: [ix], index: Number(index) }; +} + +export async function addBatchTransactionCore( + args: BatchAddTransactionActionArgs +): Promise { + const { + multisig, + globalIndex, + innerIndex, + message, + vaultIndex, + ephemeralSigners, + member, + programId = PROGRAM_ID, + } = args; + const ix = instructions.batchAddTransaction({ + multisigPda: multisig, + member: member, + batchIndex: BigInt(globalIndex), + transactionIndex: innerIndex, + transactionMessage: message, + vaultIndex: vaultIndex ?? 0, + ephemeralSigners: ephemeralSigners ?? 0, + programId: programId, + }); + + return { instruction: ix }; +} + +export async function executeBatchTransactionCore( + args: ExecuteBatchActionArgs +): Promise { + const { connection, multisig, index, member, programId = PROGRAM_ID } = args; + const ix = instructions.batchExecuteTransaction({ + connection, + multisigPda: multisig, + member: member, + batchIndex: BigInt(index), + transactionIndex: index, + programId: programId, + }); + + return { ...ix }; +} +//endregion diff --git a/sdk/multisig/src/actions/common/types.ts b/sdk/multisig/src/actions/common/types.ts new file mode 100644 index 00000000..68ea5b2d --- /dev/null +++ b/sdk/multisig/src/actions/common/types.ts @@ -0,0 +1,463 @@ +import { + AddressLookupTableAccount, + Connection, + PublicKey, + SendOptions, + Signer, + TransactionInstruction, + TransactionMessage, +} from "@solana/web3.js"; +import { ConfigAction, Member } from "../../generated"; + +//region BaseBuilder +export interface BaseBuilderArgs { + /** The connection to an SVM network cluster */ + connection: Connection; + /** The public key of the creator */ + creator: PublicKey; +} + +export interface BuildResult { + instructions: TransactionInstruction[]; +} + +export interface SendSettings { + /** (Optional) Clear all current instructions after sending, so subsequent actions can be done with the same builder. */ + clearInstructions?: boolean; + /** (Optional) Extra instructions to prepend before specified builder instructions. */ + preInstructions?: TransactionInstruction[]; + /** (Optional) Extra instructions to append after specified builder instructions. */ + postInstructions?: TransactionInstruction[]; + /** (Optional) Any address lookup table accounts to be added to the transaction. */ + addressLookupTableAccounts?: AddressLookupTableAccount[]; + /** (Optional) Fee paying signer keypair. Sufficient if only one signer is needed */ + feePayer?: Signer; + /** (Optional) Array of multiple signing keypairs. Used for if multiple signers are needed. */ + signers?: Signer[]; + /** (Optional) `SendOptions` object from web3.js. Defaults to `{ preflightCommitment: "finalized" }` */ + options?: SendOptions; +} + +export interface BuildTransactionSettings { + /** **(Optional)** Any address lookup table accounts to be added to the transaction. */ + addressLookupTableAccounts?: AddressLookupTableAccount[]; + /** **(Optional)** Fee paying signer keypair. Sufficient if only one signer is needed */ + feePayer?: Signer; + /** **(Optional)** Array of multiple signing keypairs. Used for if multiple signers are needed. */ + signers?: Signer[]; +} +//endregion + +//region BaseTransactionBuilder +export interface TransactionBuilderArgs extends BaseBuilderArgs { + multisig: PublicKey; + programId?: PublicKey; +} + +export interface TransactionBuildResult extends BuildResult { + index: number; +} +//endregion + +//region Multisig +export interface CreateMultisigActionArgs extends BaseBuilderArgs { + /** The number of approvals required to approve transactions */ + threshold: number; + /** The list of members in the multisig, with their associated permissions */ + members: Member[]; + /** Optional time lock in seconds */ + timeLock?: number; + /** Optional config authority key that can override consensus for ConfigTransactions */ + configAuthority?: PublicKey; + /** Optional rent collector where completed transaction rent will go after reclaim */ + rentCollector?: PublicKey; + /** Optional program ID (defaults to Solana mainnet-beta/devnet Program ID) */ + programId?: PublicKey; +} + +export interface CreateMultisigResult extends BuildResult { + multisigKey: PublicKey; +} +//endregion + +//region Proposals +export interface CreateProposalActionArgs { + /** The public key of the multisig config account */ + multisig: PublicKey; + /** The public key of the creator */ + creator: PublicKey; + /** Transaction index of the resulting Proposal */ + transactionIndex: number; + /** The public key of the fee payer, defaults to the creator */ + rentPayer?: PublicKey; + /** Whether the proposal should be initialized with status `Draft`. */ + isDraft?: boolean; + /** Optional program ID (defaults to Solana mainnet-beta/devnet Program ID) */ + programId?: PublicKey; +} + +export interface VoteActionArgs { + /** The public key of the multisig config account */ + multisig: PublicKey; + /** The public key of the approving member */ + member: PublicKey; + /** Transaction index of the resulting Proposal */ + transactionIndex: number; + /** Optional program ID (defaults to Solana mainnet-beta/devnet Program ID) */ + programId?: PublicKey; +} + +export interface ProposalResult { + /** `proposalCreate` instruction */ + instruction: TransactionInstruction; +} +//endregion + +//region VaultTransaction +export interface CreateVaultTransactionActionArgs { + /** The connection to an SVM network cluster */ + connection: Connection; + /** The public key of the multisig config account */ + multisig: PublicKey; + /** The public key of the creator */ + creator: PublicKey; + /** Transaction message containing the instructions to execute */ + message: TransactionMessage; + /** (Optional) Index of the transaction to build. If omitted, this will be fetched from the multisig account. */ + transactionIndex?: number; + /** (Optional) Index of the vault to target. Defaults to 0 */ + vaultIndex?: number; + /** (Optional) Specify a number of ephemeral signers to include. + * Useful if the underlying transaction requires more than one signer. + */ + ephemeralSigners?: number; + /** (Optional) The public key of the fee payer, defaults to the creator */ + rentPayer?: PublicKey; + /** (Optional) UTF-8 Memo for indexing purposes */ + memo?: string; + /** (Optional) Squads Program ID (defaults to Solana mainnet-beta/devnet Program ID) */ + programId?: PublicKey; +} + +export interface CreateVaultTransactionResult extends BuildResult { + /** Transaction index of the resulting VaultTransaction */ + index: number; +} + +export interface ExecuteVaultTransactionActionArgs { + /** The connection to an SVM network cluster */ + connection: Connection; + /** The public key of the multisig config account */ + multisig: PublicKey; + /** Member who is executing the transaction */ + member: PublicKey; + /** Transaction index of the VaultTransaction to execute */ + index: number; + /** Optional memo for indexing purposes */ + memo?: string; + /** Optional program ID (defaults to Solana mainnet-beta/devnet Program ID) */ + programId?: PublicKey; +} + +export interface ExecuteVaultTransactionResult { + /** `vaultTransactionExecute` instruction */ + instruction: TransactionInstruction; + /** AddressLookupTableAccounts for the transaction */ + lookupTableAccounts: AddressLookupTableAccount[]; +} + +export interface ReclaimRentActionArgs { + /** The connection to an SVM network cluster */ + connection: Connection; + /** The public key of the multisig config account */ + multisig: PublicKey; + /** Transaction index of the VaultTransaction to execute */ + index: number; + /** Optional memo for indexing purposes */ + memo?: string; + /** Optional program ID (defaults to Solana mainnet-beta/devnet Program ID) */ + programId?: PublicKey; +} +//endregion + +//region ConfigTransaction +export interface CreateConfigTransactionActionArgs { + /** The connection to an SVM network cluster */ + connection: Connection; + /** The public key of the multisig config account */ + multisig: PublicKey; + /** The public key of the creator */ + creator: PublicKey; + /** Transaction message containing the instructions to execute */ + actions: ConfigAction[]; + /** The public key of the fee payer, defaults to the creator */ + rentPayer?: PublicKey; + /** Optional memo for indexing purposes */ + memo?: string; + /** Optional program ID (defaults to Solana mainnet-beta/devnet Program ID) */ + programId?: PublicKey; +} + +export interface CreateConfigTransactionResult { + /** `configTransactionCreate` instruction */ + instructions: TransactionInstruction[]; + /** Transaction index of the resulting ConfigTransaction */ + index: number; +} + +export interface ExecuteConfigTransactionActionArgs { + /** The public key of the multisig config account */ + multisig: PublicKey; + /** Member who is executing the transaction */ + member: PublicKey; + /** Transaction index of the ConfigTransaction to execute */ + index: number; + /** Optional memo for indexing purposes */ + memo?: string; + /** Optional program ID (defaults to Solana mainnet-beta/devnet Program ID) */ + programId?: PublicKey; +} + +export interface ExecuteConfigTransactionResult { + /** `configTransactionExecute` instruction */ + instruction: TransactionInstruction; +} + +export const ConfigActions = { + AddMember: (newMember: Member) => [ + { + __kind: "AddMember", + newMember, + }, + ], + RemoveMember: (oldMember: PublicKey) => { + return { + __kind: "RemoveMember", + oldMember, + } as ConfigAction; + }, + ChangeThreshold: (newThreshold: number) => { + return { + __kind: "ChangeThreshold", + newThreshold, + } as ConfigAction; + }, + SetTimeLock: (newTimeLock: number) => { + return { + __kind: "SetTimeLock", + newTimeLock, + } as ConfigAction; + }, + AddSpendingLimit: (spendingLimit: SpendingLimit) => { + return { + __kind: "AddSpendingLimit", + ...spendingLimit, + } as ConfigAction; + }, + RemoveSpendingLimit: (spendingLimit: PublicKey) => { + return { + __kind: "RemoveSpendingLimit", + spendingLimit, + } as ConfigAction; + }, + SetRentCollector: (rentCollector: PublicKey) => { + return { + __kind: "SetRentCollector", + newRentCollector: rentCollector, + } as ConfigAction; + }, +} as const; + +export interface SpendingLimit { + createKey: PublicKey; + vaultIndex: number; + mint: PublicKey; + amount: number; + period: number; + members: PublicKey[]; + destinations: PublicKey[]; +} +//endregion + +//region Batch +export interface CreateBatchActionArgs extends BaseBuilderArgs { + /** The public key of the multisig config account */ + multisig: PublicKey; + /** Index of the vault to target. Defaults to 0 */ + vaultIndex?: number; + /** The public key of the fee payer, defaults to the creator */ + rentPayer?: PublicKey; + /** Optional memo for indexing purposes */ + memo?: string; + /** Optional program ID (defaults to Solana mainnet-beta/devnet Program ID) */ + programId?: PublicKey; +} + +export interface CreateBatchResult extends BuildResult { + /** Transaction index of the resulting VaultTransaction */ + index: number; +} + +export interface BatchAddTransactionActionArgs { + /** The public key of the multisig config account */ + multisig: PublicKey; + /** Member who is executing the transaction */ + member: PublicKey; + /** Transaction index of the Batch created. */ + globalIndex: number; + /** Local transaction index of a transaction inside of the Batch. */ + innerIndex: number; + /** Transaction message containing the instructions to execute */ + message: TransactionMessage; + /** Index of the vault to target. Defaults to 0 */ + vaultIndex?: number; + /** Specify a number of ephemeral signers to include. + * Useful if the underlying transaction requires more than one signer. + */ + ephemeralSigners?: number; + /** Optional memo for indexing purposes */ + memo?: string; + /** Optional program ID (defaults to Solana mainnet-beta/devnet Program ID) */ + programId?: PublicKey; +} + +export interface BatchAddTransactionResult { + /** `batchAddTransaction` instruction */ + instruction: TransactionInstruction; +} + +export interface ExecuteBatchActionArgs { + /** The connection to an SVM network cluster */ + connection: Connection; + /** The public key of the multisig config account */ + multisig: PublicKey; + /** Member who is executing the transaction */ + member: PublicKey; + /** Transaction index of the VaultTransaction to execute */ + index: number; + /** Optional memo for indexing purposes */ + memo?: string; + /** Optional program ID (defaults to Solana mainnet-beta/devnet Program ID) */ + programId?: PublicKey; +} + +export interface ExecuteBatchResult { + /** `vaultTransactionExecute` instruction */ + instruction: TransactionInstruction; + /** AddressLookupTableAccounts for the transaction */ + lookupTableAccounts: AddressLookupTableAccount[]; +} +//endregion + +//region Methods +export type Methods = { + [K in keyof MethodProgression]: T extends K ? MethodProgression[K] : never; +}[keyof MethodProgression]; + +export type BatchMethods = { + [K in keyof BatchMethodProgression]: T extends K + ? BatchMethodProgression[K] + : never; +}[keyof BatchMethodProgression]; + +type BaseMethodKeys = + | "getInstructions" + | "transaction" + | "send" + | "sendAndConfirm" + | "customSend"; + +type BaseSendKeys = "send" | "sendAndConfirm" | "customSend"; + +// TODO: Split between sync and async getters. +type TransactionGetKeys = + | "getIndex" + | "getInstructions" + | "getTransactionKey" + | "getProposalKey" + | "getTransactionAccount" + | "getProposalAccount"; + +type TransactionActionKeys = + | "withProposal" + | "withApproval" + | "withRejection" + | "withExecute"; + +// TODO: Split between sync and async getters. +type BatchGetKeys = + | "getInstructions" + | "getBatchKey" + | "getBatchTransactionKey" + | "getAllBatchTransactionKeys" + | "getBatchAccount"; + +type BatchActionKeys = "addTransaction" | TransactionActionKeys; + +type MethodProgression = { + // Senders + send: never; + sendAndConfirm: never; + customSend: never; + // Transaction Actions + withProposal: + | "withApproval" + | "withRejection" + | BaseSendKeys + | TransactionGetKeys; + withApproval: + | "withExecute" + | "withRejection" + | BaseSendKeys + | TransactionGetKeys; + withRejection: + | "withExecute" + | "withApproval" + | BaseSendKeys + | TransactionGetKeys; + withExecute: BaseSendKeys | TransactionGetKeys; + reclaimRent: BaseSendKeys | TransactionGetKeys; + // Synchronous Getters + getInstructions: BaseMethodKeys | BaseSendKeys; + getIndex: + | BaseMethodKeys + | TransactionActionKeys + | TransactionGetKeys + | BatchActionKeys + | BatchGetKeys; + getTransactionKey: + | BaseMethodKeys + | TransactionActionKeys + | TransactionGetKeys + | BatchActionKeys + | BatchGetKeys; + getProposalKey: + | BaseMethodKeys + | TransactionActionKeys + | TransactionGetKeys + | BatchActionKeys + | BatchGetKeys; + // Asynchronous Getters + getTransactionAccount: never; + getProposalAccount: never; +}; + +type BatchMethodProgression = { + send: never; + sendAndConfirm: never; + customSend: never; + withProposal: "withApproval" | "withRejection" | BaseSendKeys; + withApproval: "withExecute" | "withRejection" | BaseSendKeys | BatchGetKeys; + withRejection: "withExecute" | "withApproval" | BaseSendKeys; + withExecute: BaseSendKeys; + getBatchKey: + | BaseMethodKeys + | TransactionActionKeys + | TransactionGetKeys + | BatchActionKeys + | BatchGetKeys; + getBatchTransactionKey: BatchActionKeys | BatchGetKeys; + getBatchAccount: never; + addTransaction: never; +}; +//endregion diff --git a/sdk/multisig/src/actions/createBatch.ts b/sdk/multisig/src/actions/createBatch.ts new file mode 100644 index 00000000..4f21abbc --- /dev/null +++ b/sdk/multisig/src/actions/createBatch.ts @@ -0,0 +1,356 @@ +import { + Connection, + PublicKey, + TransactionInstruction, + TransactionMessage, +} from "@solana/web3.js"; +import { getBatchTransactionPda, getTransactionPda } from ".."; +import { Batch, PROGRAM_ID, VaultBatchTransaction } from "../generated"; +import { + addBatchTransactionCore, + createBatchCore, + executeBatchTransactionCore, +} from "./common/transaction"; +import { + BatchMethods, + CreateBatchActionArgs, + CreateBatchResult, +} from "./common/types"; +import { BaseTransactionBuilder } from "./common/baseTransaction"; +import { + createApprovalCore, + createProposalCore, + createRejectionCore, +} from "./common/proposal"; + +/** + * Builds an instruction to create a new {@link Batch}, + * with the option to chain additional methods for adding transactions, adding proposals, voting, building transactions, and sending. + * + * @args {@link CreateBatchActionArgs} + * @returns - {@link BatchBuilder} or if awaited {@link CreateBatchResult} + * + * @example + * const batchBuilder = createBatch({ + * connection, + * creator: creator, + * multisig: multisigPda, + * // Can also include vaultIndex, rentPayer, programId, and memo. + * }); + * + * // Chain proposal creations, and votes + * await batchBuilder.withProposal({ isDraft: true }); + * await batchBuilder.withApproval(); + * + * // Get instructions and the computed transaction indexes. + * const instructions = batchBuilder.getInstructions(); + * const index = batchBuilder.getIndex(); + * const innerIndex = batchBuilder.getInnerIndex(); + * + * @example + * // Run the builder async to get the result immediately. + * const result = await createBatch({ + * connection, + * creator: creator, + * multisig: multisigPda, + * }); + * + * @example + * // Using the `transaction()` method: + * const transaction = await createBatch({ + * // ... args + * }).transaction(); + * + * @example + * // Using the `send()` or `sendAndConfirm()` methods: + * const signature = await createBatch({ + * // ... args + * }).sendAndConfirm({ + * // Options for fee-payer, pre/post instructions, and signers. + * signers: [signer1, signer2], + * options: { skipPreflight: true }, + * }); + * + * @throws Will throw an error if required parameters are missing or invalid. + * + * @since 2.1.4 + */ +export function createBatch(args: CreateBatchActionArgs): BatchBuilder { + return new BatchBuilder(args); +} + +class BatchBuilder extends BaseTransactionBuilder< + CreateBatchResult, + CreateBatchActionArgs +> { + public instructions: TransactionInstruction[] = []; + public index: number = 1; + public innerIndex: number = 1; + + constructor(args: CreateBatchActionArgs) { + super(args); + } + + protected async build() { + const { + multisig, + vaultIndex = 0, + rentPayer = this.creator, + memo, + programId = PROGRAM_ID, + } = this.args; + const result = await createBatchCore({ + connection: this.connection, + multisig, + creator: this.creator, + vaultIndex, + rentPayer, + memo, + programId, + }); + + this.instructions = [...result.instructions]; + this.index = result.index; + } + + /** + * Fetches the current index of transactions inside of the {@link Batch} account. + * @returns `Promise` + */ + async getInnerIndex(): Promise { + this.ensureBuilt(); + + return this.innerIndex; + } + + /** + * Fetches the PublicKey of the built {@link Batch} account. + * @returns `Promise` - PublicKey of the `Batch` account. + */ + async getBatchKey(): Promise { + this.ensureBuilt(); + const index = this.index; + const [batchPda] = getTransactionPda({ + multisigPda: this.args.multisig, + index: BigInt(index ?? 1), + programId: this.args.programId ?? PROGRAM_ID, + }); + + return batchPda; + } + + /** + * Fetches the PublicKey of a transaction inside of the built {@link Batch} account. + * @args `innerIndex` - Number denoting the index of the transaction inside of the batch. + * @returns `Promise` - PublicKey of the `VaultBatchTransaction` account. + */ + async getBatchTransactionKey(innerIndex?: number): Promise { + this.ensureBuilt(); + const index = this.index; + const [batchPda] = getBatchTransactionPda({ + multisigPda: this.args.multisig, + batchIndex: BigInt(index ?? 1), + transactionIndex: innerIndex ?? this.innerIndex, + programId: this.args.programId ?? PROGRAM_ID, + }); + + return batchPda; + } + + /** + * Fetches and returns an array of PublicKeys for all transactions added to the batch. + * @returns `Promise` + */ + async getAllBatchTransactionKeys(): Promise { + this.ensureBuilt(); + const index = this.index; + const transactions = []; + for (let i = 1; i <= this.innerIndex; i++) { + const [batchPda] = getBatchTransactionPda({ + multisigPda: this.args.multisig, + batchIndex: BigInt(index ?? 1), + transactionIndex: i, + programId: this.args.programId ?? PROGRAM_ID, + }); + + transactions.push(batchPda); + } + + return transactions; + } + + /** + * Fetches and deserializes the {@link Batch} account after it is built and sent. + * @args `key` - The public key of the `Batch` account. + * @returns `Batch` - Deserialized `Batch` account data. + */ + async getBatchAccount( + key: PublicKey + ): Promise>> { + this.ensureBuilt(); + const batchAccount = await Batch.fromAccountAddress(this.connection, key); + + return batchAccount; + } + + /** + * Fetches and deserializes a {@link VaultBatchTransaction} account after it is added to the `Batch`. + * @args `key` - The public key of the `Batch` account. + * @returns `VaultBatchTransaction` - Deserialized `VaultBatchTransaction` account data. + */ + async getBatchTransactionAccount( + key: PublicKey + ): Promise>> { + this.ensureBuilt(); + const batchTxAccount = await VaultBatchTransaction.fromAccountAddress( + this.connection, + key + ); + + return batchTxAccount; + } + + /** + * Pushes a `batchAddTransaction` instruction to the builder. Increments the batch's inner index. + * @args `{ message: TransactionMessage, member?: PublicKey, ephemeralSigners?: number }` - Specify the `TransactionMessage` to add to the batch, the member conducting the action, and the number of ephemeral signers to include. + * @returns `VersionedTransaction` with the `vaultTransactionCreate` instruction. + */ + async addTransaction({ + message, + member, + ephemeralSigners, + }: { + message: TransactionMessage; + member?: PublicKey; + ephemeralSigners?: number; + }): Promise>> { + this.ensureBuilt(); + const { instruction } = await addBatchTransactionCore({ + multisig: this.args.multisig, + member: member ?? this.creator, + globalIndex: this.index, + innerIndex: this.innerIndex, + message, + vaultIndex: this.vaultIndex, + ephemeralSigners: ephemeralSigners ?? 0, + programId: this.args.programId, + }); + + this.instructions.push(instruction); + + this.innerIndex++; + + return this; + } + + /** + * Pushes a `proposalCreate` instruction to the builder. + * @args `isDraft` - **(Optional)** Whether the proposal is a draft or not, defaults to `false`. + */ + async withProposal({ isDraft }: { isDraft?: boolean } = {}): Promise< + Pick> + > { + this.ensureBuilt(); + const { instruction } = createProposalCore({ + multisig: this.args.multisig, + creator: this.creator, + transactionIndex: this.index, + programId: this.args.programId, + isDraft: isDraft ?? false, + }); + + this.instructions.push(instruction); + + return this; + } + + /** + * Pushes a `proposalApprove` instruction to the builder. + * @args `member` - **(Optional)** Specify the approving member, will default to the creator. + */ + withApproval({ member }: { member?: PublicKey } = {}): Pick< + BatchBuilder, + BatchMethods<"withApproval"> + > { + const { instruction } = createApprovalCore({ + multisig: this.args.multisig, + member: member ?? this.creator, + transactionIndex: this.index, + programId: this.args.programId, + }); + + this.instructions.push(instruction); + + return this; + } + + /** + * Pushes a `proposalReject` instruction to the builder. + * @args `member` - **(Optional)** Specify the rejecting member, will default to the creator. + */ + withRejection({ member }: { member?: PublicKey } = {}): Pick< + BatchBuilder, + BatchMethods<"withRejection"> + > { + const { instruction } = createRejectionCore({ + multisig: this.args.multisig, + member: member ?? this.creator, + transactionIndex: this.index, + programId: this.args.programId, + }); + + this.instructions.push(instruction); + + return this; + } + + /** + * Pushes a `vaultTransactionExecute` instruction to the builder. + * @args `member` - **(Optional)** Specify the executing member, will default to the creator. + */ + async withExecute({ member }: { member?: PublicKey } = {}): Promise< + Pick> + > { + await this.ensureBuilt(); + const { instruction } = await executeBatchTransactionCore({ + connection: this.connection, + multisig: this.args.multisig, + member: member ?? this.creator, + index: this.index, + programId: this.args.programId, + }); + + this.instructions.push(instruction); + + return this; + } +} + +/** + * Attempts to fetch and deserialize the {@link Batch} account, and returns a boolean indicating if it was successful. + * @args `connection: Connection, key: PublicKey` - Specify a cluster connection, and the `PublicKey` of the `Batch` account. + */ +export async function isBatch(connection: Connection, key: PublicKey) { + try { + await Batch.fromAccountAddress(connection, key); + return true; + } catch (err) { + return false; + } +} + +/** + * Attempts to fetch and deserialize the {@link VaultBatchTransaction} account, and returns a boolean indicating if it was successful. + * @args `connection: Connection, key: PublicKey` - Specify a cluster connection, and the `PublicKey` of the `VaultBatchTransaction` account. + */ +export async function isBatchTransaction( + connection: Connection, + key: PublicKey +) { + try { + await VaultBatchTransaction.fromAccountAddress(connection, key); + return true; + } catch (err) { + return false; + } +} diff --git a/sdk/multisig/src/actions/createConfigTransaction.ts b/sdk/multisig/src/actions/createConfigTransaction.ts new file mode 100644 index 00000000..78911857 --- /dev/null +++ b/sdk/multisig/src/actions/createConfigTransaction.ts @@ -0,0 +1,219 @@ +import { Connection, PublicKey, TransactionInstruction } from "@solana/web3.js"; +import { ConfigTransaction } from "../generated"; +import { + createConfigTransactionCore, + executeConfigTransactionCore, +} from "./common/transaction"; +import { BaseTransactionBuilder } from "./common/baseTransaction"; +import { + CreateConfigTransactionActionArgs, + CreateConfigTransactionResult, + Methods, +} from "./common/types"; +import { + createApprovalCore, + createProposalCore, + createRejectionCore, +} from "./common/proposal"; + +/** + * Builds an instruction to create a new {@link ConfigTransaction}, + * with the option to chain additional methods for adding proposals, voting, building transactions, and sending. + * + * @args {@link CreateConfigTransactionActionArgs} + * @returns - {@link ConfigTransactionBuilder} or if awaited {@link CreateConfigTransactionResult} + * + * @example + * const configBuilder = createConfigTransaction({ + * connection, + * multisig: multisigPda, + * creator: creator, + * actions: [ConfigActions.SetTimeLock(100)], + * // Can also include rentPayer, programId, and memo. + * }); + * + * // Chain proposal creations, and votes + * await configBuilder.withProposal(); + * await configBuilder.withApproval(); + * + * // Get instructions and the computed transaction index. + * const instructions = configBuilder.getInstructions(); + * const index = configBuilder.getIndex(); + * + * @example + * // Run the builder async to get the result immediately. + * const result = await createConfigTransaction({ + * connection, + * multisig: multisigPda, + * creator: creator, + * actions: [ConfigActions.SetTimeLock(100)], + * }); + * + * @example + * // Using the `transaction()` method: + * const transaction = await createConfigTransaction({ + * // ... args + * }).transaction(); + * + * @example + * // Using the `send()` or `sendAndConfirm()` methods: + * const signature = await createConfigTransaction({ + * // ... args + * }).sendAndConfirm({ + * // Options for fee-payer, pre/post instructions, and signers. + * signers: [signer1, signer2], + * options: { skipPreflight: true }, + * }); + * + * @throws Will throw an error if required parameters are missing or invalid. + * + * @since 2.1.4 + */ +export function createConfigTransaction( + args: CreateConfigTransactionActionArgs +): ConfigTransactionBuilder { + return new ConfigTransactionBuilder(args); +} + +class ConfigTransactionBuilder extends BaseTransactionBuilder< + CreateConfigTransactionResult, + CreateConfigTransactionActionArgs +> { + public instructions: TransactionInstruction[] = []; + public index: number = 1; + + constructor(args: CreateConfigTransactionActionArgs) { + super(args); + } + + protected async build() { + const { multisig, actions, rentPayer, memo, programId } = this.args; + const result = await createConfigTransactionCore({ + connection: this.connection, + multisig, + creator: this.creator, + actions, + rentPayer, + memo, + programId, + }); + + this.instructions = [...result.instructions]; + this.index = result.index; + } + + /** + * Fetches deserialized account data for the corresponding {@link ConfigTransaction} account after it is built and sent. + * + * @returns `ConfigTransaction` + */ + async getTransactionAccount(key: PublicKey): Promise { + this.ensureBuilt(); + const txAccount = await ConfigTransaction.fromAccountAddress( + this.connection, + key + ); + + return txAccount; + } + + /** + * Pushes a `proposalCreate` instruction to the builder. + * @args `isDraft` - **(Optional)** Whether the proposal is a draft or not, defaults to `false`. + */ + async withProposal({ isDraft }: { isDraft?: boolean } = {}): Promise< + Pick> + > { + await this.ensureBuilt(); + const { instruction } = createProposalCore({ + multisig: this.args.multisig, + creator: this.creator, + transactionIndex: this.index, + isDraft, + programId: this.args.programId, + }); + + this.instructions.push(instruction); + + return this; + } + + /** + * Pushes a `proposalApprove` instruction to the builder. + * @args `member` - **(Optional)** Specify the approving member, will default to the creator. + */ + withApproval({ member }: { member?: PublicKey } = {}): Pick< + ConfigTransactionBuilder, + Methods<"withApproval"> + > { + const { instruction } = createApprovalCore({ + multisig: this.args.multisig, + member: member ?? this.creator, + transactionIndex: this.index, + programId: this.args.programId, + }); + + this.instructions.push(instruction); + + return this; + } + + /** + * Pushes a `proposalReject` instruction to the builder. + * @args `member` - **(Optional)** Specify the rejecting member, will default to the creator. + */ + withRejection({ member }: { member?: PublicKey } = {}): Pick< + ConfigTransactionBuilder, + Methods<"withRejection"> + > { + const { instruction } = createRejectionCore({ + multisig: this.args.multisig, + member: member ?? this.creator, + transactionIndex: this.index, + programId: this.args.programId, + }); + + this.instructions.push(instruction); + + return this; + } + + /** + * Pushes a `vaultTransactionExecute` instruction to the builder. + * @args `member` - **(Optional)** Specify the executing member, will default to the creator. + */ + async withExecute({ member }: { member?: PublicKey } = {}): Promise< + Pick> + > { + const { instruction } = await executeConfigTransactionCore({ + multisig: this.args.multisig, + member: member ?? this.creator, + index: this.index, + programId: this.args.programId, + }); + + this.instructions.push(instruction); + + return this; + } + + async reclaimRent() { + // TODO + } +} + +/** + * Attempts to fetch and deserialize the {@link ConfigTransaction} account, and returns a boolean indicating if it was successful. + * @args `connection: Connection, key: PublicKey` - Specify a cluster connection, and the `PublicKey` of the `ConfigTransaction` account. + */ +export async function isConfigTransaction( + connection: Connection, + key: PublicKey +) { + try { + await ConfigTransaction.fromAccountAddress(connection, key); + return true; + } catch (err) { + return false; + } +} diff --git a/sdk/multisig/src/actions/createMultisig.ts b/sdk/multisig/src/actions/createMultisig.ts new file mode 100644 index 00000000..a3d389c8 --- /dev/null +++ b/sdk/multisig/src/actions/createMultisig.ts @@ -0,0 +1,275 @@ +import { + Connection, + Keypair, + PublicKey, + SendOptions, + Signer, + TransactionInstruction, + VersionedTransaction, +} from "@solana/web3.js"; +import { Multisig, PROGRAM_ID } from "../generated"; +import { createMultisigCore } from "./common/multisig"; +import { + BuildTransactionSettings, + CreateMultisigActionArgs, + CreateMultisigResult, + SendSettings, +} from "./common/types"; +import { BaseBuilder } from "./common/base"; + +/** + * Builds an instruction to create a new {@link Multisig}, + * with the option to chain additional methods for building transactions, and sending. + * + * @args {@link CreateMultisigActionArgs} + * @returns - {@link MultisigBuilder} or if awaited {@link CreateMultisigResult} + * + * @example + * const builder = createMultisig({ + * connection, + * creator: creator, + * threshold: 1, + * members: createMembers([ + * { + * key: creator, permissions: SquadPermissions.All + * }, + * ]), + * // Can also include timeLock, configAuthority, rentCollector, and programId. + * }); + * + * // Get the built instructions and the generated createKey. + * const instructions = builder.getInstructions(); + * const createKey = builder.getCreateKey(); + * + * @example + * // Run the builder async to get the result immediately. + * const result = await createMultisig({ + * connection, + * creator: creator, + * threshold: 1, + * members: createMembers([ + * { + * key: creator, permissions: SquadPermissions.All + * }, + * ]), + * }); + * + * @example + * // Using the `transaction()` method: + * const transaction = await createMultisig({ + * // ... args + * }).transaction(); + * + * @example + * // Using the `send()` or `sendAndConfirm()` methods: + * const signature = await createMultisig({ + * // ... args + * }).sendAndConfirm({ + * // Options for fee-payer, pre/post instructions, and signers. + * signers: [signer1, signer2], + * options: { skipPreflight: true }, + * }); + * + * @throws Will throw an error if required parameters are missing or invalid. + * + * @since 2.1.4 + */ +export function createMultisig( + args: CreateMultisigActionArgs +): MultisigBuilder { + return new MultisigBuilder(args); +} + +class MultisigBuilder extends BaseBuilder< + CreateMultisigResult, + CreateMultisigActionArgs +> { + public instructions: TransactionInstruction[] = []; + public multisigKey: PublicKey = PublicKey.default; + + constructor(args: CreateMultisigActionArgs) { + super(args, { generateCreateKey: true }); + } + + protected async build(): Promise { + const { + threshold, + members, + timeLock = 0, + configAuthority, + rentCollector, + programId = PROGRAM_ID, + } = this.args; + const result = await createMultisigCore( + { + connection: this.connection, + creator: this.creator, + threshold, + members, + timeLock, + configAuthority, + rentCollector, + programId, + }, + this.createKey! + ); + + this.instructions = [...result.instructions]; + this.multisigKey = result.multisigKey; + } + + /** + * Fetches the generated `createKey` used to generate the {@link Multisig} PDA. + * @returns `Keypair` + */ + async getCreateKey(): Promise { + await this.ensureBuilt(); + return this.createKey!; + } + + /** + * Fetches the generated {@link Multisig} PDA. + * @returns `PublicKey` + */ + async getMultisigKey(): Promise { + await this.ensureBuilt(); + return this.multisigKey; + } + + /** + * Fetches deserialized account data for the corresponding {@link Multisig} account after it is built and sent. + * @returns `Multisig` + */ + async getMultisigAccount(key: PublicKey) { + await this.ensureBuilt(); + const multisigAccount = await Multisig.fromAccountAddress( + this.connection, + key + ); + + return multisigAccount; + } + + /** + * Creates a `VersionedTransaction` containing the corresponding instruction(s), and signs it with the generated `createKey`. + * + * @args {@link BuildTransactionSettings} - **(Optional)** Address Lookup Table accounts, signers, a custom fee-payer to add to the transaction. + * @returns `VersionedTransaction` + * + * @example + * // Get pre-built transaction from builder instance. + * const builder = createMultisig({ + * // ... args + * }); + * const transaction = await builder.transaction(); + * @example + * // Run chained async method to return the + * // transaction all in one go. + * const transaction = await createMultisig({ + * // ... args + * }).transaction(); + */ + async transaction( + settings?: BuildTransactionSettings + ): Promise { + await this.ensureBuilt(); + if (settings?.signers) { + settings.signers.push(this.createKey!); + } else { + settings = { + signers: [this.createKey!], + ...settings, + }; + } + return await super.transaction(settings); + } + + /** + * Builds a transaction with the corresponding instruction(s), signs it with the generated `createKey`, and sends it. + * + * **NOTE: Not wallet-adapter compatible.** + * + * @args {@link SendSettings} - Optional pre/post instructions, fee payer, and send options. + * @returns `TransactionSignature` + * @example + * const builder = createMultisig({ + * // ... args + * }); + * const signature = await builder.send(); + * @example + * const builder = createMultisig({ + * // ... args + * }); + * + * // With settings + * const signature = await builder.send({ + * preInstructions: [...preInstructions], + * postInstructions: [...postInstructions], + * feePayer: someKeypair, + * options: { skipPreflight: true }, + * }); + */ + async send(settings?: SendSettings): Promise { + await this.ensureBuilt(); + if (settings?.signers) { + settings.signers.push(this.createKey!); + } else { + settings = { + signers: [this.createKey!], + ...settings, + }; + } + return await super.send(settings); + } + + /** + * Builds a transaction with the corresponding instruction(s), signs it with the generated `createKey`, sends it, and confirms the transaction. + * + * **NOTE: Not wallet-adapter compatible.** + * + * @args {@link SendSettings} - Optional pre/post instructions, fee payer keypair, and send options. + * @returns `TransactionSignature` + * @example + * const builder = createMultisig({ + * // ... args + * }); + * const signature = await builder.sendAndConfirm(); + * @example + * const builder = createMultisig({ + * // ... args + * }); + * + * // With settings + * const signature = await builder.sendAndConfirm({ + * preInstructions: [...preInstructions], + * postInstructions: [...postInstructions], + * feePayer: someKeypair, + * options: { skipPreflight: true }, + * }); + */ + async sendAndConfirm(settings?: SendSettings): Promise { + await this.ensureBuilt(); + if (settings?.signers) { + settings.signers.push(this.createKey!); + } else { + settings = { + signers: [this.createKey!], + ...settings, + }; + } + return await super.sendAndConfirm(settings); + } +} + +/** + * Attempts to fetch and deserialize the {@link Multisig} account, and returns a boolean indicating if it was successful. + * @args `connection: Connection, key: PublicKey` - Specify a cluster connection, and the `PublicKey` of the `Multisig` account. + */ +export async function isMultisig(connection: Connection, key: PublicKey) { + try { + await Multisig.fromAccountAddress(connection, key); + return true; + } catch (err) { + return false; + } +} diff --git a/sdk/multisig/src/actions/createVaultTransaction.ts b/sdk/multisig/src/actions/createVaultTransaction.ts new file mode 100644 index 00000000..4454bb67 --- /dev/null +++ b/sdk/multisig/src/actions/createVaultTransaction.ts @@ -0,0 +1,254 @@ +import { + Connection, + PublicKey, + TransactionInstruction, + AddressLookupTableAccount, +} from "@solana/web3.js"; +import { PROGRAM_ID, VaultTransaction } from "../generated"; +import { + createVaultTransactionCore, + executeVaultTransactionCore, + reclaimRentCore, +} from "./common/transaction"; +import { + CreateVaultTransactionActionArgs, + CreateVaultTransactionResult, + Methods, +} from "./common/types"; +import { BaseTransactionBuilder } from "./common/baseTransaction"; +import { + createApprovalCore, + createProposalCore, + createRejectionCore, +} from "./common/proposal"; + +/** + * Builds an instruction to create a new {@link VaultTransaction}, + * with the option to chain additional methods for adding proposals, voting, building transactions, and sending. + * + * @args {@link CreateVaultTransactionActionArgs} + * @returns - {@link VaultTransactionBuilder} or if awaited {@link CreateVaultTransactionResult} + * + * @example + * const txBuilder = createVaultTransaction({ + * connection, + * multisig: multisigPda, + * creator: creator, + * message: message, + * // Can also include ephemeral signers, vaultIndex, + * // rentPayer, programId, and memo. + * }); + * + * // Chain proposal creations, and votes + * await txBuilder.withProposal(); + * await txBuilder.withApproval(); + * + * // Get the built instructions and the computed transaction index. + * const instructions = txBuilder.getInstructions(); + * const index = txBuilder.getIndex(); + * + * @example + * // Run the builder async to get the result immediately. + * const result = await createVaultTransaction({ + * connection, + * multisig: multisigPda, + * creator: creator, + * message: message, + * }); + * + * @example + * // Using the `transaction()` method: + * const transaction = await createVaultTransaction({ + * // ... args + * }).transaction(); + * + * @example + * // Using the `send()` or `sendAndConfirm()` methods: + * const signature = await createVaultTransaction({ + * // ... args + * }).sendAndConfirm({ + * // Options for fee-payer, pre/post instructions, and signers. + * signers: [signer1, signer2], + * options: { skipPreflight: true }, + * }); + * + * @throws Will throw an error if required parameters are missing or invalid. + * + * @since 2.1.4 + */ +export function createVaultTransaction( + args: CreateVaultTransactionActionArgs +): VaultTransactionBuilder { + return new VaultTransactionBuilder(args); +} + +class VaultTransactionBuilder extends BaseTransactionBuilder< + CreateVaultTransactionResult, + CreateVaultTransactionActionArgs +> { + public instructions: TransactionInstruction[] = []; + public addressLookupTableAccounts: AddressLookupTableAccount[] = []; + static index: number; + + constructor(args: CreateVaultTransactionActionArgs) { + super(args); + } + + protected async build() { + const { + multisig, + message, + vaultIndex = 0, + ephemeralSigners = 0, + rentPayer = this.creator, + memo, + programId = PROGRAM_ID, + } = this.args; + const result = await createVaultTransactionCore( + { + connection: this.connection, + multisig, + creator: this.creator, + message, + vaultIndex, + ephemeralSigners, + rentPayer, + memo, + programId, + }, + this.index + ); + + this.instructions = [...result.instructions]; + this.index = result.index; + } + + /** + * Fetches deserialized account data for the corresponding {@link VaultTransaction} account after it is built and sent. + * + * @returns `VaultTransaction` + */ + async getTransactionAccount(key: PublicKey) { + this.ensureBuilt(); + const txAccount = await VaultTransaction.fromAccountAddress( + this.connection, + key + ); + + return txAccount; + } + + /** + * Pushes a `proposalCreate` instruction to the builder. + * @args `isDraft` - **(Optional)** Whether the proposal is a draft or not, defaults to `false`. + */ + async withProposal({ isDraft }: { isDraft?: boolean } = {}): Promise< + Pick> + > { + await this.ensureBuilt(); + const { instruction } = createProposalCore({ + multisig: this.args.multisig, + creator: this.creator, + transactionIndex: this.index, + programId: this.args.programId, + isDraft, + }); + + this.instructions.push(instruction); + + return this; + } + + /** + * Pushes a `proposalApprove` instruction to the builder. + * @args `member` - **(Optional)** Specify the approving member, will default to the creator. + */ + withApproval({ member }: { member?: PublicKey } = {}): Pick< + VaultTransactionBuilder, + Methods<"withApproval"> + > { + const { instruction } = createApprovalCore({ + multisig: this.args.multisig, + member: member ?? this.creator, + transactionIndex: this.index, + programId: this.args.programId, + }); + + this.instructions.push(instruction); + + return this; + } + + /** + * Pushes a `proposalReject` instruction to the builder. + * @args `member` - **(Optional)** Specify the rejecting member, will default to the creator. + */ + withRejection({ member }: { member?: PublicKey } = {}): Pick< + VaultTransactionBuilder, + Methods<"withRejection"> + > { + const { instruction } = createRejectionCore({ + multisig: this.args.multisig, + member: member ?? this.creator, + transactionIndex: this.index, + programId: this.args.programId, + }); + + this.instructions.push(instruction); + + return this; + } + + /** + * Pushes a `vaultTransactionExecute` instruction to the builder. + * @args `member` - **(Optional)** Specify the executing member, will default to the creator. + */ + async withExecute({ member }: { member?: PublicKey } = {}): Promise< + Pick> + > { + await this.ensureBuilt(); + const { instruction, lookupTableAccounts } = + await executeVaultTransactionCore({ + connection: this.connection, + multisig: this.args.multisig, + member: member ?? this.creator, + index: this.index, + programId: this.args.programId, + }); + + this.instructions.push(instruction); + this.addressLookupTableAccounts.push(...lookupTableAccounts); + + return this; + } + + async reclaimRent(): Promise< + Pick> + > { + const { instruction } = await reclaimRentCore({ + connection: this.connection, + multisig: this.args.multisig, + index: this.index, + programId: this.args.programId, + }); + + this.instructions.push(instruction); + return this; + } +} + +/** + * Attempts to fetch and deserialize the {@link VaultTransaction} account, and returns a boolean indicating if it was successful. + * @args `connection: Connection, key: PublicKey` - Specify a cluster connection, and the `PublicKey` of the `VaultTransaction` account. + */ +export async function isVaultTransaction( + connection: Connection, + key: PublicKey +) { + try { + await VaultTransaction.fromAccountAddress(connection, key); + return true; + } catch (err) { + return false; + } +} diff --git a/sdk/multisig/src/actions/index.ts b/sdk/multisig/src/actions/index.ts new file mode 100644 index 00000000..5c4e2144 --- /dev/null +++ b/sdk/multisig/src/actions/index.ts @@ -0,0 +1,7 @@ +export * from "./createMultisig"; +export * from "./createVaultTransaction"; +export * from "./createConfigTransaction"; +export * from "./createBatch"; +export * from "./members"; + +export { ConfigActions } from "./common/types"; diff --git a/sdk/multisig/src/actions/members.ts b/sdk/multisig/src/actions/members.ts new file mode 100644 index 00000000..1576b494 --- /dev/null +++ b/sdk/multisig/src/actions/members.ts @@ -0,0 +1,33 @@ +import { PublicKey } from "@solana/web3.js"; +import { Member, Permission, Permissions } from "../types"; + +export enum SquadPermissions { + Proposer = 1, + Voter = 2, + Executor = 4, + ProposerAndVoter = 3, + VoterAndExecutor = 5, + ProposerAndExecutor = 6, + All = 7, +} + +export function createMember(member: { + key: PublicKey; + permissions: SquadPermissions; +}) { + return { + key: member.key, + permissions: Permissions.fromMask(member.permissions) as Permissions, + } as Member; +} + +export function createMembers( + members: { key: PublicKey; permissions: SquadPermissions }[] +) { + return members.map((member) => { + return { + key: member.key, + permissions: Permissions.fromMask(member.permissions) as Permissions, + }; + }) as Member[]; +} diff --git a/sdk/multisig/src/index.ts b/sdk/multisig/src/index.ts index 5364731d..dcaaefef 100644 --- a/sdk/multisig/src/index.ts +++ b/sdk/multisig/src/index.ts @@ -12,6 +12,8 @@ export * as rpc from "./rpc"; export * as transactions from "./transactions"; /** Instructions for the multisig program. */ export * as instructions from "./instructions/index.js"; +/** Builders and chainable actions for the multisig program. */ +export * from "./actions/index.js"; /** Additional types */ export * as types from "./types.js"; /** Utils for the multisig program. */ diff --git a/sdk/multisig/src/types.ts b/sdk/multisig/src/types.ts index baa18812..5c5606a7 100644 --- a/sdk/multisig/src/types.ts +++ b/sdk/multisig/src/types.ts @@ -39,6 +39,10 @@ export class Permissions implements IPermissions { ); } + static fromMask(mask: number) { + return new Permissions(mask); + } + static all() { return new Permissions( Object.values(Permission).reduce( diff --git a/tests/index.ts b/tests/index.ts index 5ea50433..58e43ced 100644 --- a/tests/index.ts +++ b/tests/index.ts @@ -23,5 +23,4 @@ import "./suites/multisig-sdk"; // // Uncomment to enable the heapTest instruction testing // //import "./suites/instructions/heapTest"; -// import "./suites/examples/custom-heap"; - +// import "./suites/examples/custom-heap"; \ No newline at end of file diff --git a/tests/suites/examples/actions.ts b/tests/suites/examples/actions.ts new file mode 100644 index 00000000..2d9593cb --- /dev/null +++ b/tests/suites/examples/actions.ts @@ -0,0 +1,650 @@ +import * as multisig from "@sqds/multisig"; +import { + PublicKey, + TransactionMessage, + Keypair, + Connection, + LAMPORTS_PER_SOL, + TransactionInstruction, +} from "@solana/web3.js"; +import { + createLocalhostConnection, + createTestTransferInstruction, + generateFundedKeypair, + generateMultisigMembers, + getTestProgramId, + TestMembers, +} from "../../utils"; +import { + createMultisig, + createVaultTransaction, + createConfigTransaction, + createBatch, + ConfigActions, + createMembers, + isVaultTransaction, + isConfigTransaction, + isMultisig, + isBatch, +} from "@sqds/multisig"; +import assert from "assert"; +import { SquadPermissions } from "@sqds/multisig"; + +const programId = getTestProgramId(); + +const getLogs = async ( + connection: Connection, + signature: string +): Promise => { + const tx = await connection.getTransaction(signature, { + commitment: "confirmed", + maxSupportedTransactionVersion: 0, + }); + return tx?.meta?.logMessages ?? null; +}; + +describe("Examples / End2End Actions", () => { + const connection = createLocalhostConnection(); + + let multisigPda: PublicKey = PublicKey.default; + + let transactionPda: PublicKey; + let configTransactionPda: PublicKey; + let batchPda: PublicKey; + let batchTxPda: PublicKey; + + let members: TestMembers; + let outsider: Keypair; + + before(async () => { + outsider = await generateFundedKeypair(connection); + members = await generateMultisigMembers(connection); + }); + + //region Multisig + it("should create a multisig", async () => { + const builder = createMultisig({ + connection, + creator: members.almighty.publicKey, + members: createMembers([ + { key: members.almighty.publicKey, permissions: SquadPermissions.All }, + ]), + threshold: 1, + programId, + }); + + const signature = await builder.sendAndConfirm({ + signers: [members.almighty], + }); + + const [vaultPda] = multisig.getVaultPda({ + multisigPda: multisigPda, + index: 0, + }); + await connection.requestAirdrop(vaultPda, 10 * LAMPORTS_PER_SOL); + + assert.ok(signature); + }); + + it("should create a multi-member multisig", async () => { + const builder = createMultisig({ + connection, + creator: members.almighty.publicKey, + members: createMembers([ + { key: members.almighty.publicKey, permissions: SquadPermissions.All }, + { + key: members.proposer.publicKey, + permissions: SquadPermissions.Proposer, + }, + { key: members.voter.publicKey, permissions: SquadPermissions.Voter }, + { + key: members.executor.publicKey, + permissions: SquadPermissions.Executor, + }, + ]), + threshold: 1, + programId, + }); + + multisigPda = await builder.getMultisigKey(); + + const signature = await builder.sendAndConfirm({ + signers: [members.almighty], + }); + + assert.ok(signature); + }); + + it("should get multisig account", async () => { + const builder = createMultisig({ + connection, + creator: members.almighty.publicKey, + members: createMembers([ + { key: members.almighty.publicKey, permissions: SquadPermissions.All }, + ]), + threshold: 1, + programId, + }); + + let multisigKey = await builder.getMultisigKey(); + + const signature = await builder.sendAndConfirm({ + signers: [members.almighty], + }); + + assert.ok(signature); + + const account = await builder.getMultisigAccount(multisigKey); + + assert.ok(account instanceof multisig.accounts.Multisig); + }); + //endregion + + //region Vault Transactions + it("should create a vault transaction", async () => { + const [vaultPda] = multisig.getVaultPda({ + multisigPda: multisigPda, + index: 0, + }); + + await connection.requestAirdrop(vaultPda, 10 * LAMPORTS_PER_SOL); + + const message = new TransactionMessage({ + payerKey: vaultPda, + recentBlockhash: (await connection.getLatestBlockhash()).blockhash, + instructions: [ + createTestTransferInstruction(vaultPda, outsider.publicKey), + ], + }); + + const txBuilder = createVaultTransaction({ + connection, + multisig: multisigPda, + creator: members.almighty.publicKey, + message, + programId, + }); + + const signature = await txBuilder.sendAndConfirm({ + feePayer: members.almighty, + }); + + assert.ok(signature); + }); + + it("should create a vault transaction w/ proposal", async () => { + const [vaultPda] = multisig.getVaultPda({ + multisigPda: multisigPda, + index: 0, + }); + + const message = new TransactionMessage({ + payerKey: vaultPda, + recentBlockhash: (await connection.getLatestBlockhash()).blockhash, + instructions: [ + createTestTransferInstruction(vaultPda, outsider.publicKey), + ], + }); + + const txBuilder = createVaultTransaction({ + connection, + multisig: multisigPda, + creator: members.almighty.publicKey, + message, + programId, + }); + + await txBuilder.withProposal(); + + transactionPda = await txBuilder.getTransactionKey(); + + const signature = await txBuilder.sendAndConfirm({ + feePayer: members.almighty, + }); + + assert.ok(signature); + }); + + it("should create a vault transaction w/ proposal & approve", async () => { + const [vaultPda] = multisig.getVaultPda({ + multisigPda: multisigPda, + index: 0, + }); + + const message = new TransactionMessage({ + payerKey: vaultPda, + recentBlockhash: (await connection.getLatestBlockhash()).blockhash, + instructions: [ + createTestTransferInstruction(vaultPda, outsider.publicKey), + ], + }); + + const txBuilder = createVaultTransaction({ + connection, + multisig: multisigPda, + creator: members.almighty.publicKey, + message, + programId, + }); + + await txBuilder.withProposal(); + txBuilder.withApproval({ member: members.almighty.publicKey }); + + const signature = await txBuilder.sendAndConfirm({ + feePayer: members.almighty, + }); + + assert.ok(signature); + }); + + it("should create a vault transaction w/ proposal & reject", async () => { + const [vaultPda] = multisig.getVaultPda({ + multisigPda: multisigPda, + index: 0, + }); + + const message = new TransactionMessage({ + payerKey: vaultPda, + recentBlockhash: (await connection.getLatestBlockhash()).blockhash, + instructions: [ + createTestTransferInstruction(vaultPda, outsider.publicKey), + ], + }); + + const txBuilder = createVaultTransaction({ + connection, + multisig: multisigPda, + creator: members.almighty.publicKey, + message, + programId, + }); + + await txBuilder.withProposal(); + txBuilder.withRejection({ member: members.almighty.publicKey }); + + transactionPda = await txBuilder.getTransactionKey(); + + const signature = await txBuilder.sendAndConfirm({ + feePayer: members.almighty, + }); + + assert.ok(signature); + }); + + it("should get vault transaction account", async () => { + const [vaultPda] = multisig.getVaultPda({ + multisigPda: multisigPda, + index: 0, + }); + + const message = new TransactionMessage({ + payerKey: vaultPda, + recentBlockhash: (await connection.getLatestBlockhash()).blockhash, + instructions: [ + createTestTransferInstruction(vaultPda, outsider.publicKey), + ], + }); + + const txBuilder = createVaultTransaction({ + connection, + multisig: multisigPda, + creator: members.almighty.publicKey, + message, + programId, + }); + + const transactionKey = await txBuilder.getTransactionKey(); + + const signature = await txBuilder.sendAndConfirm({ + feePayer: members.almighty, + }); + + assert.ok(signature); + + const account = await txBuilder.getTransactionAccount(transactionKey); + + assert.ok(account instanceof multisig.accounts.VaultTransaction); + }); + //endregion + + //region Config Transactions + it("should create a config transaction", async () => { + const configBuilder = createConfigTransaction({ + connection, + multisig: multisigPda, + creator: members.proposer.publicKey, + actions: [ConfigActions.SetTimeLock(10)], + programId, + }); + + const signature = await configBuilder.sendAndConfirm({ + signers: [members.proposer], + }); + + assert.ok(signature); + }); + + it("should create a config transaction w/ multiple actions", async () => { + const configBuilder = createConfigTransaction({ + connection, + multisig: multisigPda, + creator: members.proposer.publicKey, + actions: [ + ConfigActions.SetTimeLock(300), + ConfigActions.SetRentCollector(members.almighty.publicKey), + ], + programId, + }); + + const signature = await configBuilder.sendAndConfirm({ + signers: [members.proposer], + }); + + assert.ok(signature); + }); + + it("should create a config transaction w/ proposal", async () => { + const configBuilder = createConfigTransaction({ + connection, + multisig: multisigPda, + creator: members.proposer.publicKey, + actions: [ + ConfigActions.SetTimeLock(300), + ConfigActions.SetRentCollector(members.almighty.publicKey), + ], + programId, + }); + + configTransactionPda = await configBuilder.getTransactionKey(); + + await configBuilder.withProposal(); + + const signature = await configBuilder.sendAndConfirm({ + signers: [members.proposer], + }); + + assert.ok(signature); + }); + + it("should create a config transaction w/ proposal & approve", async () => { + const configBuilder = createConfigTransaction({ + connection, + multisig: multisigPda, + creator: members.almighty.publicKey, + actions: [ + ConfigActions.SetTimeLock(300), + ConfigActions.SetRentCollector(members.almighty.publicKey), + ], + programId, + }); + + await configBuilder.withProposal(); + configBuilder.withApproval({ member: members.almighty.publicKey }); + + const signature = await configBuilder.sendAndConfirm({ + signers: [members.almighty], + }); + + assert.ok(signature); + }); + + it("should create a config transaction w/ proposal & reject", async () => { + const configBuilder = createConfigTransaction({ + connection, + multisig: multisigPda, + creator: members.almighty.publicKey, + actions: [ + ConfigActions.SetTimeLock(300), + ConfigActions.SetRentCollector(members.almighty.publicKey), + ], + programId, + }); + + await configBuilder.withProposal(); + configBuilder.withRejection({ member: members.almighty.publicKey }); + + const signature = await configBuilder.sendAndConfirm({ + signers: [members.almighty], + }); + + assert.ok(signature); + }); + + it("should get config transaction account", async () => { + const configBuilder = createConfigTransaction({ + connection, + multisig: multisigPda, + creator: members.proposer.publicKey, + actions: [ConfigActions.SetTimeLock(10)], + programId, + }); + + const signature = await configBuilder.sendAndConfirm({ + signers: [members.proposer], + }); + + assert.ok(signature); + + const account = await configBuilder.getTransactionAccount( + configTransactionPda + ); + + assert.ok(account instanceof multisig.accounts.ConfigTransaction); + }); + //endregion + + //region Batches + it("should create a batch", async () => { + const batchBuilder = createBatch({ + connection, + creator: members.almighty.publicKey, + multisig: multisigPda, + programId, + }); + + const signature = await batchBuilder.sendAndConfirm({ + feePayer: members.almighty, + }); + + assert.ok(signature); + }); + + it("should create a batch with proposal", async () => { + const batchBuilder = createBatch({ + connection, + creator: members.almighty.publicKey, + multisig: multisigPda, + programId, + }); + + batchPda = await batchBuilder.getBatchKey(); + + await batchBuilder.withProposal(); + + const signature = await batchBuilder.sendAndConfirm({ + feePayer: members.almighty, + }); + + assert.ok(signature); + }); + + it("should create a batch with proposal & approval", async () => { + const batchBuilder = createBatch({ + connection, + creator: members.almighty.publicKey, + multisig: multisigPda, + programId, + }); + + await batchBuilder.withProposal(); + batchBuilder.withApproval({ member: members.almighty.publicKey }); + + const signature = await batchBuilder.sendAndConfirm({ + feePayer: members.almighty, + }); + + assert.ok(signature); + }); + + it("should create a batch with proposal & reject", async () => { + const batchBuilder = createBatch({ + connection, + creator: members.almighty.publicKey, + multisig: multisigPda, + programId, + }); + + await batchBuilder.withProposal(); + batchBuilder.withRejection({ member: members.almighty.publicKey }); + + const signature = await batchBuilder.sendAndConfirm({ + feePayer: members.almighty, + }); + + assert.ok(signature); + }); + + it("should create a batch & add a transaction", async () => { + const [vaultPda] = multisig.getVaultPda({ + multisigPda: multisigPda, + index: 0, + }); + + const message = new TransactionMessage({ + payerKey: vaultPda, + recentBlockhash: (await connection.getLatestBlockhash()).blockhash, + instructions: [ + createTestTransferInstruction(vaultPda, outsider.publicKey), + ], + }); + + const batchBuilder = createBatch({ + connection, + creator: members.almighty.publicKey, + multisig: multisigPda, + programId, + }); + + await batchBuilder.withProposal({ isDraft: true }); + await batchBuilder.addTransaction({ + message, + member: members.almighty.publicKey, + }); + + const innerIndex = await batchBuilder.getInnerIndex(); + batchTxPda = await batchBuilder.getBatchTransactionKey(innerIndex - 1); + + const signature = await batchBuilder.sendAndConfirm({ + feePayer: members.almighty, + }); + + assert.ok(signature); + }); + + it("should get batch account", async () => { + const batchBuilder = createBatch({ + connection, + creator: members.almighty.publicKey, + multisig: multisigPda, + programId, + }); + + const signature = await batchBuilder.sendAndConfirm({ + feePayer: members.almighty, + }); + + assert.ok(signature); + + const account = await batchBuilder.getBatchAccount(batchPda); + + assert.ok(account instanceof multisig.accounts.Batch); + }); + //endregion + + //region Account checks + it("is this a multisig?", async () => { + const get = await isMultisig(connection, multisigPda); + + assert.ok(get); + }); + + it("is this a vault transaction?", async () => { + const get = await isVaultTransaction(connection, transactionPda); + + assert.ok(get); + }); + + it("is this a config transaction?", async () => { + const get = await isConfigTransaction(connection, configTransactionPda); + + assert.ok(get); + }); + + it("is this a batch?", async () => { + const get = await isBatch(connection, batchPda); + + assert.ok(get); + }); + + /* + // WIP + it("is this a batch transaction?", async () => { + const get = await isBatchTransaction(connection, batchTxPda); + + assert.ok(get); + }); + */ + //endregion + + //region Complete actions + it("should create, vote on & execute a vault transaction", async () => { + const message = new TransactionMessage({ + payerKey: members.almighty.publicKey, + recentBlockhash: (await connection.getLatestBlockhash()).blockhash, + instructions: [ + new TransactionInstruction({ + keys: [ + { + pubkey: members.almighty.publicKey, + isSigner: true, + isWritable: true, + }, + ], + data: Buffer.from("Hello from the action builder!", "utf-8"), + programId: new PublicKey( + "MemoSq4gqABAXKb96qnH8TysNcWxMyWCqXgDLGmfcHr" + ), + }), + ], + }); + + const txBuilder = createVaultTransaction({ + connection, + multisig: multisigPda, + creator: members.almighty.publicKey, + message, + programId, + }); + + await txBuilder.withProposal(); + txBuilder.withApproval({ member: members.almighty.publicKey }); + + const signature = await txBuilder.sendAndConfirm({ + signers: [members.almighty], + clearInstructions: true, + options: { preflightCommitment: "finalized" }, + }); + + assert.ok(signature); + + await txBuilder.withExecute({ member: members.almighty.publicKey }); + + const signature2 = await txBuilder.sendAndConfirm({ + signers: [members.almighty], + addressLookupTableAccounts: txBuilder.addressLookupTableAccounts, + options: { skipPreflight: true }, + }); + + assert.ok(signature2); + }); +});