diff --git a/programs/futarchy/src/error.rs b/programs/futarchy/src/error.rs index d822ac67..43de38b3 100644 --- a/programs/futarchy/src/error.rs +++ b/programs/futarchy/src/error.rs @@ -90,4 +90,6 @@ pub enum FutarchyError { InvalidSpendingLimitMint, #[msg("No active optimistic proposal")] NoActiveOptimisticProposal, + #[msg("Address lookup tables referenced by the vault transaction must be frozen")] + UnfrozenAddressLookupTable, } diff --git a/programs/futarchy/src/instructions/initialize_proposal.rs b/programs/futarchy/src/instructions/initialize_proposal.rs index a59736a5..8879f853 100644 --- a/programs/futarchy/src/instructions/initialize_proposal.rs +++ b/programs/futarchy/src/instructions/initialize_proposal.rs @@ -13,6 +13,17 @@ pub struct InitializeProposal<'info> { pub proposal: Box>, pub squads_proposal: Box>, pub squads_multisig: Box>, + #[account( + seeds = [ + squads_multisig_program::SEED_PREFIX, + squads_multisig.key().as_ref(), + squads_multisig_program::SEED_TRANSACTION, + squads_proposal.transaction_index.to_le_bytes().as_ref(), + ], + bump, + seeds::program = squads_multisig_program::ID, + )] + pub squads_vault_transaction: Box>, #[account(mut, has_one = squads_multisig)] pub dao: Box>, #[account( @@ -35,8 +46,8 @@ pub struct InitializeProposal<'info> { pub system_program: Program<'info, System>, } -impl InitializeProposal<'_> { - pub fn validate(&self) -> Result<()> { +impl<'info, 'c: 'info> InitializeProposal<'info> { + pub fn validate(&self, remaining_accounts: &[AccountInfo<'info>]) -> Result<()> { // If we're trying to challenge an optimistic proposal that has already passed due to age, we should error // In the case of an already-optimistically-passed proposal, the optimistic proposal can be cleared // from the DAO state by finalizing the optimistic proposal (finalize_optimistic_proposal) @@ -72,13 +83,17 @@ impl InitializeProposal<'_> { self.squads_multisig.stale_transaction_index ); + // Any address lookup table the vault transaction references must be frozen, so the + // addresses the market evaluates can't change between approval and execution + validate_address_lookup_tables(&self.squads_vault_transaction.message, remaining_accounts)?; + // Should never be the case because the oracle is the proposal account, and you can't re-initialize a proposal assert!(!self.question.is_resolved()); Ok(()) } - pub fn handle(ctx: Context) -> Result<()> { + pub fn handle(ctx: Context<'_, '_, 'c, 'info, Self>) -> Result<()> { let Self { base_vault, quote_vault, @@ -86,6 +101,7 @@ impl InitializeProposal<'_> { proposal, squads_proposal, squads_multisig: _, + squads_vault_transaction: _, dao, proposer, payer: _, diff --git a/programs/futarchy/src/instructions/launch_proposal.rs b/programs/futarchy/src/instructions/launch_proposal.rs index 7816215c..daa97a9d 100644 --- a/programs/futarchy/src/instructions/launch_proposal.rs +++ b/programs/futarchy/src/instructions/launch_proposal.rs @@ -31,13 +31,24 @@ pub struct LaunchProposal<'info> { pub squads_multisig: Account<'info, squads_multisig_program::Multisig>, #[account(owner = squads_multisig_program::ID)] pub squads_proposal: Account<'info, squads_multisig_program::Proposal>, + #[account( + seeds = [ + squads_multisig_program::SEED_PREFIX, + squads_multisig.key().as_ref(), + squads_multisig_program::SEED_TRANSACTION, + squads_proposal.transaction_index.to_le_bytes().as_ref(), + ], + bump, + seeds::program = squads_multisig_program::ID, + )] + pub squads_vault_transaction: Box>, pub system_program: Program<'info, System>, pub token_program: Program<'info, Token>, pub associated_token_program: Program<'info, AssociatedToken>, } -impl LaunchProposal<'_> { - pub fn validate(&self) -> Result<()> { +impl<'info, 'c: 'info> LaunchProposal<'info> { + pub fn validate(&self, remaining_accounts: &[AccountInfo<'info>]) -> Result<()> { msg!("proposal state: {:?}", self.proposal.state); require!( matches!(self.proposal.state, ProposalState::Draft { .. }), @@ -91,10 +102,14 @@ impl LaunchProposal<'_> { self.squads_multisig.stale_transaction_index ); + // Any address lookup table the vault transaction references must be frozen, so the + // addresses the market evaluates can't change between approval and execution. + validate_address_lookup_tables(&self.squads_vault_transaction.message, remaining_accounts)?; + Ok(()) } - pub fn handle(ctx: Context) -> Result<()> { + pub fn handle(ctx: Context<'_, '_, 'c, 'info, Self>) -> Result<()> { let Self { proposal, dao, @@ -114,6 +129,7 @@ impl LaunchProposal<'_> { amm_fail_quote_vault: _, squads_multisig: _, squads_proposal: _, + squads_vault_transaction: _, system_program: _, token_program: _, associated_token_program: _, diff --git a/programs/futarchy/src/lib.rs b/programs/futarchy/src/lib.rs index 7b5dc5e5..c3b1d6da 100644 --- a/programs/futarchy/src/lib.rs +++ b/programs/futarchy/src/lib.rs @@ -69,8 +69,10 @@ pub mod futarchy { InitializeDao::handle(ctx, params) } - #[access_control(ctx.accounts.validate())] - pub fn initialize_proposal(ctx: Context) -> Result<()> { + #[access_control(ctx.accounts.validate(ctx.remaining_accounts))] + pub fn initialize_proposal<'c: 'info, 'info>( + ctx: Context<'_, '_, 'c, 'info, InitializeProposal<'info>>, + ) -> Result<()> { InitializeProposal::handle(ctx) } @@ -90,8 +92,10 @@ pub mod futarchy { UnstakeFromProposal::handle(ctx, params) } - #[access_control(ctx.accounts.validate())] - pub fn launch_proposal(ctx: Context) -> Result<()> { + #[access_control(ctx.accounts.validate(ctx.remaining_accounts))] + pub fn launch_proposal<'c: 'info, 'info>( + ctx: Context<'_, '_, 'c, 'info, LaunchProposal<'info>>, + ) -> Result<()> { LaunchProposal::handle(ctx) } diff --git a/programs/futarchy/src/squads.rs b/programs/futarchy/src/squads.rs index 9a56b9a2..1d228c64 100644 --- a/programs/futarchy/src/squads.rs +++ b/programs/futarchy/src/squads.rs @@ -1,9 +1,69 @@ use anchor_lang::prelude::*; +use anchor_lang::solana_program::address_lookup_table::{self, state::AddressLookupTable}; use std::collections::BTreeMap; use crate::FutarchyError; +/// Validates that every Address Lookup Table referenced by a vault transaction message is +/// frozen (`authority` permanently `None`, so its contents can never change) and that every +/// index the message references already exists in the table. `remaining_accounts` must hold +/// exactly one ALT account per `message.address_table_lookups` entry, in the same order — +/// the same convention Squads' own `vault_transaction_execute` uses. +pub fn validate_address_lookup_tables<'info>( + message: &squads_multisig_program::VaultTransactionMessage, + remaining_accounts: &[AccountInfo<'info>], +) -> Result<()> { + require_eq!( + remaining_accounts.len(), + message.address_table_lookups.len(), + FutarchyError::InvalidTransaction + ); + + for (lookup, alt_account_info) in message + .address_table_lookups + .iter() + .zip(remaining_accounts.iter()) + { + require_keys_eq!( + *alt_account_info.key, + lookup.account_key, + FutarchyError::InvalidTransaction + ); + require_keys_eq!( + *alt_account_info.owner, + address_lookup_table::program::ID, + FutarchyError::InvalidTransaction + ); + + let alt_data = alt_account_info.try_borrow_data()?; + let alt_state = AddressLookupTable::deserialize(&alt_data) + .map_err(|_| FutarchyError::InvalidTransaction)?; + + require!( + alt_state.meta.authority.is_none(), + FutarchyError::UnfrozenAddressLookupTable + ); + + // A frozen table's length is final: an out-of-range index can never be filled, + // so the proposal could pass its market but never execute. Reject it upfront. + if let Some(max_index) = lookup + .writable_indexes + .iter() + .chain(lookup.readonly_indexes.iter()) + .max() + { + require_gt!( + alt_state.addresses.len(), + *max_index as usize, + FutarchyError::InvalidTransaction + ); + } + } + + Ok(()) +} + /// Compiles a Solana instruction into a Squads TransactionMessage format. /// This is necessary because Solana's Message::serialize() uses a different header format /// (num_readonly_signed_accounts, num_readonly_unsigned_accounts) than Squads expects diff --git a/sdk/src/futarchy/v0.6/FutarchyClient.ts b/sdk/src/futarchy/v0.6/FutarchyClient.ts index 2468db64..b49a385b 100644 --- a/sdk/src/futarchy/v0.6/FutarchyClient.ts +++ b/sdk/src/futarchy/v0.6/FutarchyClient.ts @@ -276,18 +276,57 @@ export class FutarchyClient { }); } + /** + * Fetches the Squads vault transaction linked to a Squads proposal and returns its + * address along with the address lookup table accounts its message references. The + * lookup tables must be passed as remaining accounts to `initializeProposal` and + * `launchProposal`, which verify each one is frozen. + */ + async getSquadsVaultTransactionAccounts(squadsProposal: PublicKey): Promise<{ + squadsVaultTransaction: PublicKey; + lookupTableKeys: PublicKey[]; + }> { + const squadsProposalAccount = + await multisig.accounts.Proposal.fromAccountAddress( + this.provider.connection, + squadsProposal, + ); + + const [squadsVaultTransaction] = multisig.getTransactionPda({ + multisigPda: squadsProposalAccount.multisig, + index: BigInt(squadsProposalAccount.transactionIndex.toString()), + }); + + const vaultTransactionAccount = + await multisig.accounts.VaultTransaction.fromAccountAddress( + this.provider.connection, + squadsVaultTransaction, + ); + + const lookupTableKeys = + vaultTransactionAccount.message.addressTableLookups.map( + (lookup) => lookup.accountKey, + ); + + return { squadsVaultTransaction, lookupTableKeys }; + } + launchProposalIx({ proposal, dao, baseMint, quoteMint, squadsProposal, + squadsVaultTransaction, + lookupTables = [], }: { proposal: PublicKey; dao: PublicKey; baseMint: PublicKey; quoteMint: PublicKey; squadsProposal: PublicKey; + squadsVaultTransaction: PublicKey; + lookupTables?: PublicKey[]; }) { const { baseVault, @@ -333,8 +372,16 @@ export class FutarchyClient { ), squadsMultisig, squadsProposal, + squadsVaultTransaction, payer: this.provider.publicKey, }) + .remainingAccounts( + lookupTables.map((pubkey) => ({ + pubkey, + isSigner: false, + isWritable: false, + })), + ) .preInstructions([ ComputeBudgetProgram.setComputeUnitLimit({ units: 300_000 }), ]); @@ -590,7 +637,11 @@ export class FutarchyClient { instructions: TransactionInstruction[]; transactionIndex: bigint; payer?: PublicKey; - }): { tx: Transaction; squadsProposal: PublicKey } { + }): { + tx: Transaction; + squadsProposal: PublicKey; + squadsVaultTransaction: PublicKey; + } { const multisigPda = multisig.getMultisigPda({ createKey: dao })[0]; const transactionMessage = new TransactionMessage({ @@ -621,9 +672,14 @@ export class FutarchyClient { transactionIndex: transactionIndex, }); + const [squadsVaultTransaction] = multisig.getTransactionPda({ + multisigPda, + index: transactionIndex, + }); + const tx = new Transaction().add(vaultTxCreate, proposalCreate); - return { tx, squadsProposal }; + return { tx, squadsProposal, squadsVaultTransaction }; } async initializeProposal( @@ -657,12 +713,17 @@ export class FutarchyClient { ) .rpc(); + const { squadsVaultTransaction, lookupTableKeys } = + await this.getSquadsVaultTransactionAccounts(squadsProposal); + await this.initializeProposalIx( squadsProposal, dao, storedDao.baseMint, storedDao.quoteMint, question, + squadsVaultTransaction, + lookupTableKeys, ) .preInstructions([ ComputeBudgetProgram.setComputeUnitLimit({ units: 300_000 }), @@ -678,6 +739,8 @@ export class FutarchyClient { baseMint: PublicKey, quoteMint: PublicKey, question: PublicKey, + squadsVaultTransaction: PublicKey, + lookupTables: PublicKey[] = [], proposer: PublicKey = this.provider.publicKey, ) { let [proposal] = getProposalAddr(this.futarchy.programId, squadsProposal); @@ -703,12 +766,20 @@ export class FutarchyClient { question, proposal, squadsProposal, + squadsVaultTransaction, dao, baseVault, quoteVault, proposer, squadsMultisig, }) + .remainingAccounts( + lookupTables.map((pubkey) => ({ + pubkey, + isSigner: false, + isWritable: false, + })), + ) .preInstructions([ createAssociatedTokenAccountIdempotentInstruction( this.provider.publicKey, diff --git a/sdk/src/futarchy/v0.6/types/futarchy.ts b/sdk/src/futarchy/v0.6/types/futarchy.ts index 9af98c32..a6d2c5aa 100644 --- a/sdk/src/futarchy/v0.6/types/futarchy.ts +++ b/sdk/src/futarchy/v0.6/types/futarchy.ts @@ -123,6 +123,11 @@ export type Futarchy = { isMut: false; isSigner: false; }, + { + name: "squadsVaultTransaction"; + isMut: false; + isSigner: false; + }, { name: "dao"; isMut: true; @@ -390,6 +395,11 @@ export type Futarchy = { isMut: false; isSigner: false; }, + { + name: "squadsVaultTransaction"; + isMut: false; + isSigner: false; + }, { name: "systemProgram"; isMut: false; @@ -3801,6 +3811,11 @@ export type Futarchy = { name: "NoActiveOptimisticProposal"; msg: "No active optimistic proposal"; }, + { + code: 6043; + name: "UnfrozenAddressLookupTable"; + msg: "Address lookup tables referenced by the vault transaction must be frozen"; + }, ]; }; @@ -3929,6 +3944,11 @@ export const IDL: Futarchy = { isMut: false, isSigner: false, }, + { + name: "squadsVaultTransaction", + isMut: false, + isSigner: false, + }, { name: "dao", isMut: true, @@ -4196,6 +4216,11 @@ export const IDL: Futarchy = { isMut: false, isSigner: false, }, + { + name: "squadsVaultTransaction", + isMut: false, + isSigner: false, + }, { name: "systemProgram", isMut: false, @@ -7607,5 +7632,10 @@ export const IDL: Futarchy = { name: "NoActiveOptimisticProposal", msg: "No active optimistic proposal", }, + { + code: 6043, + name: "UnfrozenAddressLookupTable", + msg: "Address lookup tables referenced by the vault transaction must be frozen", + }, ], }; diff --git a/tests/futarchy/integration/futarchyAmm.test.ts b/tests/futarchy/integration/futarchyAmm.test.ts index b74c7ede..88d70141 100644 --- a/tests/futarchy/integration/futarchyAmm.test.ts +++ b/tests/futarchy/integration/futarchyAmm.test.ts @@ -214,6 +214,11 @@ export default function suite() { const proposalAccount = await this.futarchy.getProposal(proposal); + const { squadsVaultTransaction } = + await this.futarchy.getSquadsVaultTransactionAccounts( + proposalAccount.squadsProposal, + ); + await this.futarchy .launchProposalIx({ proposal, @@ -221,6 +226,7 @@ export default function suite() { baseMint: META, quoteMint: USDC, squadsProposal: proposalAccount.squadsProposal, + squadsVaultTransaction, }) .rpc(); diff --git a/tests/futarchy/unit/adminCancelProposal.test.ts b/tests/futarchy/unit/adminCancelProposal.test.ts index cea66318..95ff8537 100644 --- a/tests/futarchy/unit/adminCancelProposal.test.ts +++ b/tests/futarchy/unit/adminCancelProposal.test.ts @@ -117,6 +117,9 @@ export default function suite() { proposal = await this.futarchy.initializeProposal(dao, squadsProposalPda); + const { squadsVaultTransaction } = + await this.futarchy.getSquadsVaultTransactionAccounts(squadsProposalPda); + await this.futarchy .launchProposalIx({ proposal, @@ -124,6 +127,7 @@ export default function suite() { baseMint: META, quoteMint: USDC, squadsProposal: squadsProposalPda, + squadsVaultTransaction, }) .rpc(); }); diff --git a/tests/futarchy/unit/adminRemoveProposal.test.ts b/tests/futarchy/unit/adminRemoveProposal.test.ts index 565313bf..6ae10b1a 100644 --- a/tests/futarchy/unit/adminRemoveProposal.test.ts +++ b/tests/futarchy/unit/adminRemoveProposal.test.ts @@ -226,6 +226,9 @@ export default function suite() { }); // Launch the proposal to move it to Pending state + const { squadsVaultTransaction } = + await this.futarchy.getSquadsVaultTransactionAccounts(squadsProposalPda); + await this.futarchy .launchProposalIx({ proposal, @@ -233,6 +236,7 @@ export default function suite() { baseMint: META, quoteMint: USDC, squadsProposal: squadsProposalPda, + squadsVaultTransaction, }) .rpc(); diff --git a/tests/futarchy/unit/executeMultisigProposalApproval.test.ts b/tests/futarchy/unit/executeMultisigProposalApproval.test.ts index 233118d1..26e8d93c 100644 --- a/tests/futarchy/unit/executeMultisigProposalApproval.test.ts +++ b/tests/futarchy/unit/executeMultisigProposalApproval.test.ts @@ -226,6 +226,9 @@ export default function suite() { // Now launch the futarchy proposal to push the AMM out of Spot. const storedDao = await this.futarchy.getDao(dao); + const { squadsVaultTransaction } = + await this.futarchy.getSquadsVaultTransactionAccounts(proposalPda); + await this.futarchy .launchProposalIx({ proposal, @@ -233,6 +236,7 @@ export default function suite() { baseMint: storedDao.baseMint, quoteMint: storedDao.quoteMint, squadsProposal: proposalPda, + squadsVaultTransaction, }) .rpc(); diff --git a/tests/futarchy/unit/executeSpendingLimitChange.test.ts b/tests/futarchy/unit/executeSpendingLimitChange.test.ts index da68fc85..50fd3bef 100644 --- a/tests/futarchy/unit/executeSpendingLimitChange.test.ts +++ b/tests/futarchy/unit/executeSpendingLimitChange.test.ts @@ -127,6 +127,7 @@ export default function suite() { minOutputAmount: new BN(0), }) .preInstructions([ + ComputeBudgetProgram.setComputeUnitLimit({ units: 400_000 }), ComputeBudgetProgram.setComputeUnitPrice({ microLamports: i }), ]) .rpc(); @@ -414,6 +415,7 @@ export default function suite() { minOutputAmount: new BN(0), }) .preInstructions([ + ComputeBudgetProgram.setComputeUnitLimit({ units: 400_000 }), ComputeBudgetProgram.setComputeUnitPrice({ microLamports: i }), ]) .rpc(); @@ -541,6 +543,7 @@ export default function suite() { minOutputAmount: new BN(0), }) .preInstructions([ + ComputeBudgetProgram.setComputeUnitLimit({ units: 400_000 }), ComputeBudgetProgram.setComputeUnitPrice({ microLamports: i }), ]) .rpc(); diff --git a/tests/futarchy/unit/finalizeProposal.test.ts b/tests/futarchy/unit/finalizeProposal.test.ts index 231c3c9e..4f800ac7 100644 --- a/tests/futarchy/unit/finalizeProposal.test.ts +++ b/tests/futarchy/unit/finalizeProposal.test.ts @@ -119,6 +119,9 @@ export default function suite() { // Now initialize the futarchy proposal proposal = await this.futarchy.initializeProposal(dao, squadsProposalPda); + const { squadsVaultTransaction } = + await this.futarchy.getSquadsVaultTransactionAccounts(squadsProposalPda); + await this.futarchy .launchProposalIx({ proposal, @@ -126,6 +129,7 @@ export default function suite() { baseMint: META, quoteMint: USDC, squadsProposal: squadsProposalPda, + squadsVaultTransaction, }) .rpc(); }); @@ -200,6 +204,7 @@ export default function suite() { minOutputAmount: new BN(0), }) .preInstructions([ + ComputeBudgetProgram.setComputeUnitLimit({ units: 400_000 }), ComputeBudgetProgram.setComputeUnitPrice({ microLamports: i }), ]) .rpc(); @@ -347,6 +352,7 @@ export default function suite() { minOutputAmount: new BN(0), }) .preInstructions([ + ComputeBudgetProgram.setComputeUnitLimit({ units: 400_000 }), ComputeBudgetProgram.setComputeUnitPrice({ microLamports: i }), ]) .rpc(); @@ -504,6 +510,9 @@ export default function suite() { }) .rpc(); + const { squadsVaultTransaction } = + await this.futarchy.getSquadsVaultTransactionAccounts(squadsProposalPda); + await this.futarchy .launchProposalIx({ proposal: teamSponsoredProposal, @@ -511,6 +520,7 @@ export default function suite() { baseMint: META, quoteMint: USDC, squadsProposal: squadsProposalPda, + squadsVaultTransaction, }) .rpc(); @@ -543,6 +553,9 @@ export default function suite() { minOutputAmount: new BN(0), }) .preInstructions([ + // The swap sits right at the 200k default compute limit; the unit price keeps + // each crank transaction unique. + ComputeBudgetProgram.setComputeUnitLimit({ units: 400_000 }), ComputeBudgetProgram.setComputeUnitPrice({ microLamports: i }), ]) .rpc(); diff --git a/tests/futarchy/unit/initializeProposal.test.ts b/tests/futarchy/unit/initializeProposal.test.ts index ec3bea39..54853dc5 100644 --- a/tests/futarchy/unit/initializeProposal.test.ts +++ b/tests/futarchy/unit/initializeProposal.test.ts @@ -5,12 +5,19 @@ import { } from "@metadaoproject/programs"; import { ComputeBudgetProgram, + Keypair, PublicKey, + SystemProgram, Transaction, TransactionMessage, } from "@solana/web3.js"; import BN from "bn.js"; -import { expectError, setOptimisticGovernanceEnabled } from "../../utils.js"; +import { + addLookupsToVaultTransaction, + expectError, + setLookupTableAccount, + setOptimisticGovernanceEnabled, +} from "../../utils.js"; import { assert } from "chai"; import * as multisig from "@sqds/multisig"; const { Permissions, Permission } = multisig.types; @@ -75,6 +82,60 @@ export default function suite() { await setOptimisticGovernanceEnabled(this, dao, true); }); + async function createSquadsProposal( + context: any, + daoKey: PublicKey, + ): Promise<{ squadsProposal: PublicKey; squadsVaultTransaction: PublicKey }> { + const multisigPda = multisig.getMultisigPda({ createKey: daoKey })[0]; + + const transactionMessage = new TransactionMessage({ + payerKey: context.payer.publicKey, + recentBlockhash: (await context.banksClient.getLatestBlockhash())[0], + instructions: [ + SystemProgram.transfer({ + fromPubkey: context.payer.publicKey, + toPubkey: context.payer.publicKey, + lamports: 1, + }), + ], + }); + + const tx = new Transaction().add( + multisig.instructions.vaultTransactionCreate({ + multisigPda, + transactionIndex: 1n, + creator: PERMISSIONLESS_ACCOUNT.publicKey, + rentPayer: context.payer.publicKey, + vaultIndex: 0, + ephemeralSigners: 0, + transactionMessage, + }), + multisig.instructions.proposalCreate({ + multisigPda, + transactionIndex: 1n, + creator: PERMISSIONLESS_ACCOUNT.publicKey, + rentPayer: context.payer.publicKey, + }), + ); + + tx.recentBlockhash = (await context.banksClient.getLatestBlockhash())[0]; + tx.feePayer = context.payer.publicKey; + tx.sign(context.payer, PERMISSIONLESS_ACCOUNT); + + await context.banksClient.processTransaction(tx); + + const [squadsProposal] = multisig.getProposalPda({ + multisigPda, + transactionIndex: 1n, + }); + const [squadsVaultTransaction] = multisig.getTransactionPda({ + multisigPda, + index: 1n, + }); + + return { squadsProposal, squadsVaultTransaction }; + } + it("should initialize a proposal", async function () { // Create a simple instruction for the proposal const updateDaoIx = await this.futarchy @@ -195,4 +256,48 @@ export default function suite() { .initializeProposal(dao, daoAccount.optimisticProposal.squadsProposal) .then(callbacks[0], callbacks[1]); }); + + it("rejects a vault transaction referencing an unfrozen lookup table", async function () { + const { squadsProposal, squadsVaultTransaction } = + await createSquadsProposal(this, dao); + + const lookupTable = Keypair.generate().publicKey; + setLookupTableAccount(this, lookupTable, this.payer.publicKey, [ + Keypair.generate().publicKey, + ]); + await addLookupsToVaultTransaction(this, squadsVaultTransaction, [ + { accountKey: lookupTable, writableIndexes: [0], readonlyIndexes: [] }, + ]); + + const callbacks = expectError( + "UnfrozenAddressLookupTable", + "initialize_proposal accepted an unfrozen lookup table", + ); + + await this.futarchy + .initializeProposal(dao, squadsProposal) + .then(callbacks[0], callbacks[1]); + }); + + it("rejects a frozen lookup table when the message references an index that doesn't exist", async function () { + const { squadsProposal, squadsVaultTransaction } = + await createSquadsProposal(this, dao); + + const lookupTable = Keypair.generate().publicKey; + setLookupTableAccount(this, lookupTable, null, [ + Keypair.generate().publicKey, + ]); + await addLookupsToVaultTransaction(this, squadsVaultTransaction, [ + { accountKey: lookupTable, writableIndexes: [0], readonlyIndexes: [5] }, + ]); + + const callbacks = expectError( + "InvalidTransaction", + "initialize_proposal accepted a lookup index that is out of range", + ); + + await this.futarchy + .initializeProposal(dao, squadsProposal) + .then(callbacks[0], callbacks[1]); + }); } diff --git a/tests/futarchy/unit/launchProposal.test.ts b/tests/futarchy/unit/launchProposal.test.ts index a2a06198..6c72824c 100644 --- a/tests/futarchy/unit/launchProposal.test.ts +++ b/tests/futarchy/unit/launchProposal.test.ts @@ -12,7 +12,12 @@ import { TransactionMessage, } from "@solana/web3.js"; import BN from "bn.js"; -import { expectError, setOptimisticGovernanceEnabled } from "../../utils.js"; +import { + addLookupsToVaultTransaction, + expectError, + setLookupTableAccount, + setOptimisticGovernanceEnabled, +} from "../../utils.js"; import { assert } from "chai"; import * as multisig from "@sqds/multisig"; @@ -96,12 +101,12 @@ export default function suite() { } /** - * Helper function to initialize a proposal for a DAO + * Helper function to create a Squads proposal (with its vault transaction) for a DAO */ - async function initializeProposal( + async function createSquadsProposal( context: any, dao: PublicKey, - ): Promise<{ proposal: PublicKey; squadsProposal: PublicKey }> { + ): Promise<{ squadsProposal: PublicKey; squadsVaultTransaction: PublicKey }> { const updateDaoIx = await context.futarchy .updateDaoIx({ dao, @@ -149,6 +154,11 @@ export default function suite() { transactionIndex: 1n, }); + const [squadsVaultTransaction] = multisig.getTransactionPda({ + multisigPda, + index: 1n, + }); + const tx = new Transaction().add(vaultTxCreate, proposalCreateIx); tx.recentBlockhash = (await context.banksClient.getLatestBlockhash())[0]; tx.feePayer = context.payer.publicKey; @@ -156,6 +166,18 @@ export default function suite() { await context.banksClient.processTransaction(tx); + return { squadsProposal, squadsVaultTransaction }; + } + + /** + * Helper function to initialize a proposal for a DAO + */ + async function initializeProposal( + context: any, + dao: PublicKey, + ): Promise<{ proposal: PublicKey; squadsProposal: PublicKey }> { + const { squadsProposal } = await createSquadsProposal(context, dao); + const proposal = await context.futarchy.initializeProposal( dao, squadsProposal, @@ -204,6 +226,9 @@ export default function suite() { .rpc(); // Launch proposal without staking anything - should succeed because it's team-sponsored + const { squadsVaultTransaction } = + await this.futarchy.getSquadsVaultTransactionAccounts(squadsProposal); + await this.futarchy .launchProposalIx({ proposal, @@ -211,6 +236,7 @@ export default function suite() { baseMint: META, quoteMint: USDC, squadsProposal, + squadsVaultTransaction, }) .rpc(); @@ -263,6 +289,9 @@ export default function suite() { .rpc(); // Launch should succeed + const { squadsVaultTransaction } = + await this.futarchy.getSquadsVaultTransactionAccounts(squadsProposal); + await this.futarchy .launchProposalIx({ proposal, @@ -270,6 +299,7 @@ export default function suite() { baseMint: META, quoteMint: USDC, squadsProposal, + squadsVaultTransaction, }) .rpc(); @@ -320,6 +350,9 @@ export default function suite() { .rpc(); // Launch should succeed at exact threshold + const { squadsVaultTransaction } = + await this.futarchy.getSquadsVaultTransactionAccounts(squadsProposal); + await this.futarchy .launchProposalIx({ proposal, @@ -327,6 +360,7 @@ export default function suite() { baseMint: META, quoteMint: USDC, squadsProposal, + squadsVaultTransaction, }) .rpc(); @@ -395,6 +429,9 @@ export default function suite() { this.context.setAccount(dao, daoAccountInfo); // Launch the proposal + const { squadsVaultTransaction } = + await this.futarchy.getSquadsVaultTransactionAccounts(squadsProposal); + await this.futarchy .launchProposalIx({ proposal, @@ -402,6 +439,7 @@ export default function suite() { baseMint: META, quoteMint: USDC, squadsProposal, + squadsVaultTransaction, }) .rpc(); @@ -456,6 +494,9 @@ export default function suite() { "Launch should fail when stake is below threshold", ); + const { squadsVaultTransaction } = + await this.futarchy.getSquadsVaultTransactionAccounts(squadsProposal); + await this.futarchy .launchProposalIx({ proposal, @@ -463,6 +504,7 @@ export default function suite() { baseMint: META, quoteMint: USDC, squadsProposal, + squadsVaultTransaction, }) .rpc() .then(callbacks[0], callbacks[1]); @@ -542,6 +584,9 @@ export default function suite() { }) .rpc(); + const { squadsVaultTransaction } = + await this.futarchy.getSquadsVaultTransactionAccounts(squadsProposal); + await this.futarchy .launchProposalIx({ proposal, @@ -549,6 +594,7 @@ export default function suite() { baseMint: META, quoteMint: USDC, squadsProposal, + squadsVaultTransaction, }) .rpc(); @@ -646,6 +692,70 @@ export default function suite() { "Optimistic proposal has already passed", ); + const { squadsVaultTransaction } = + await this.futarchy.getSquadsVaultTransactionAccounts(squadsProposal); + + await this.futarchy + .launchProposalIx({ + proposal, + dao, + baseMint: META, + quoteMint: USDC, + squadsProposal, + squadsVaultTransaction, + }) + .rpc() + .then(callbacks[0], callbacks[1]); + }); + + it("rejects a vault transaction referencing an unfrozen lookup table on an already-initialized proposal", async function () { + const dao = await createDaoWithStakeThreshold( + this, + META, + USDC, + new BN(0), + this.payer, + ); + + await this.futarchy + .provideLiquidityIx({ + dao, + baseMint: META, + quoteMint: USDC, + quoteAmount: new BN(100_000 * 10 ** 6), + maxBaseAmount: new BN(100_000 * 10 ** 6), + minLiquidity: new BN(0), + positionAuthority: this.payer.publicKey, + liquidityProvider: this.payer.publicKey, + }) + .preInstructions([ + ComputeBudgetProgram.setComputeUnitLimit({ units: 300_000 }), + ]) + .rpc(); + + const { squadsProposal, squadsVaultTransaction } = + await createSquadsProposal(this, dao); + + const proposal = await this.futarchy.initializeProposal( + dao, + squadsProposal, + ); + + // Simulate a draft that predates this validation: the lookup table appears in the + // stored message only after initialize_proposal has already run without checking it. + const lookupTable = Keypair.generate().publicKey; + setLookupTableAccount(this, lookupTable, this.payer.publicKey, [ + Keypair.generate().publicKey, + ]); + await addLookupsToVaultTransaction(this, squadsVaultTransaction, [ + { accountKey: lookupTable, writableIndexes: [0], readonlyIndexes: [] }, + ]); + + const callbacks = expectError( + "UnfrozenAddressLookupTable", + "launch_proposal accepted an unfrozen lookup table", + ); + await this.futarchy .launchProposalIx({ proposal, @@ -653,8 +763,68 @@ export default function suite() { baseMint: META, quoteMint: USDC, squadsProposal, + squadsVaultTransaction, + lookupTables: [lookupTable], }) .rpc() .then(callbacks[0], callbacks[1]); }); + + it("launches a proposal whose vault transaction references a frozen, in-bounds lookup table", async function () { + const dao = await createDaoWithStakeThreshold( + this, + META, + USDC, + new BN(0), + this.payer, + ); + + await this.futarchy + .provideLiquidityIx({ + dao, + baseMint: META, + quoteMint: USDC, + quoteAmount: new BN(100_000 * 10 ** 6), + maxBaseAmount: new BN(100_000 * 10 ** 6), + minLiquidity: new BN(0), + positionAuthority: this.payer.publicKey, + liquidityProvider: this.payer.publicKey, + }) + .preInstructions([ + ComputeBudgetProgram.setComputeUnitLimit({ units: 300_000 }), + ]) + .rpc(); + + const { squadsProposal, squadsVaultTransaction } = + await createSquadsProposal(this, dao); + + const lookupTable = Keypair.generate().publicKey; + setLookupTableAccount(this, lookupTable, null, [ + Keypair.generate().publicKey, + Keypair.generate().publicKey, + ]); + await addLookupsToVaultTransaction(this, squadsVaultTransaction, [ + { accountKey: lookupTable, writableIndexes: [0], readonlyIndexes: [1] }, + ]); + + const proposal = await this.futarchy.initializeProposal( + dao, + squadsProposal, + ); + + await this.futarchy + .launchProposalIx({ + proposal, + dao, + baseMint: META, + quoteMint: USDC, + squadsProposal, + squadsVaultTransaction, + lookupTables: [lookupTable], + }) + .rpc(); + + const storedProposal = await this.futarchy.getProposal(proposal); + assert.exists(storedProposal.state.pending); + }); } diff --git a/tests/futarchy/unit/unstakeFromProposal.test.ts b/tests/futarchy/unit/unstakeFromProposal.test.ts index 96a31a72..bf6ac483 100644 --- a/tests/futarchy/unit/unstakeFromProposal.test.ts +++ b/tests/futarchy/unit/unstakeFromProposal.test.ts @@ -165,6 +165,9 @@ export default function suite() { }) .rpc(); + const { squadsVaultTransaction } = + await this.futarchy.getSquadsVaultTransactionAccounts(squadsProposalPda); + await this.futarchy .launchProposalIx({ proposal, @@ -172,6 +175,7 @@ export default function suite() { baseMint: META, quoteMint: USDC, squadsProposal: squadsProposalPda, + squadsVaultTransaction, }) .rpc(); @@ -210,6 +214,9 @@ export default function suite() { }) .rpc(); + const { squadsVaultTransaction } = + await this.futarchy.getSquadsVaultTransactionAccounts(squadsProposalPda); + await this.futarchy .launchProposalIx({ proposal, @@ -217,6 +224,7 @@ export default function suite() { baseMint: META, quoteMint: USDC, squadsProposal: squadsProposalPda, + squadsVaultTransaction, }) .rpc(); @@ -258,6 +266,9 @@ export default function suite() { // Use .instruction() for launch so we don't pull in its own // ComputeBudget preInstruction — Solana rejects transactions with // duplicate compute-budget instructions. + const { squadsVaultTransaction } = + await this.futarchy.getSquadsVaultTransactionAccounts(squadsProposalPda); + const launchIx = await this.futarchy .launchProposalIx({ proposal, @@ -265,6 +276,7 @@ export default function suite() { baseMint: META, quoteMint: USDC, squadsProposal: squadsProposalPda, + squadsVaultTransaction, }) .instruction(); diff --git a/tests/futarchy/unit/updateDao.test.ts b/tests/futarchy/unit/updateDao.test.ts index 50b8fc00..db8109d1 100644 --- a/tests/futarchy/unit/updateDao.test.ts +++ b/tests/futarchy/unit/updateDao.test.ts @@ -175,8 +175,18 @@ export default function suite() { ) .rpc(); + const { squadsVaultTransaction } = + await this.futarchy.getSquadsVaultTransactionAccounts(squadsProposalPda); + await this.futarchy - .initializeProposalIx(squadsProposalPda, dao, META, USDC, question) + .initializeProposalIx( + squadsProposalPda, + dao, + META, + USDC, + question, + squadsVaultTransaction, + ) .preInstructions([ ComputeBudgetProgram.setComputeUnitLimit({ units: 300_000 }), ]) @@ -198,6 +208,7 @@ export default function suite() { baseMint: META, quoteMint: USDC, squadsProposal: squadsProposalPda, + squadsVaultTransaction, }) .rpc(); @@ -237,6 +248,7 @@ export default function suite() { payer: this.payer.publicKey, }) .preInstructions([ + ComputeBudgetProgram.setComputeUnitLimit({ units: 400_000 }), ComputeBudgetProgram.setComputeUnitPrice({ microLamports: i }), ]) .rpc(); @@ -331,6 +343,9 @@ export default function suite() { ) .rpc(); + const { squadsVaultTransaction: squadsVaultTransaction2 } = + await this.futarchy.getSquadsVaultTransactionAccounts(squadsProposalPda2); + await this.futarchy .initializeProposalIx( squadsProposalPda2, @@ -338,6 +353,7 @@ export default function suite() { META, USDC, proposalBPdas.question, + squadsVaultTransaction2, ) .preInstructions([ ComputeBudgetProgram.setComputeUnitLimit({ units: 300_000 }), @@ -352,6 +368,7 @@ export default function suite() { baseMint: META, quoteMint: USDC, squadsProposal: squadsProposalPda2, + squadsVaultTransaction: squadsVaultTransaction2, }) .rpc(); @@ -476,8 +493,18 @@ export default function suite() { ) .rpc(); + const { squadsVaultTransaction } = + await this.futarchy.getSquadsVaultTransactionAccounts(squadsProposalPda); + await this.futarchy - .initializeProposalIx(squadsProposalPda, dao, META, USDC, question) + .initializeProposalIx( + squadsProposalPda, + dao, + META, + USDC, + question, + squadsVaultTransaction, + ) .preInstructions([ ComputeBudgetProgram.setComputeUnitLimit({ units: 300_000 }), ]) @@ -499,6 +526,7 @@ export default function suite() { baseMint: META, quoteMint: USDC, squadsProposal: squadsProposalPda, + squadsVaultTransaction, }) .rpc(); @@ -533,6 +561,7 @@ export default function suite() { payer: this.payer.publicKey, }) .preInstructions([ + ComputeBudgetProgram.setComputeUnitLimit({ units: 400_000 }), ComputeBudgetProgram.setComputeUnitPrice({ microLamports: i }), ]) .rpc(); diff --git a/tests/gatedMint/utils.ts b/tests/gatedMint/utils.ts index 16cf81bd..e5e8e258 100644 --- a/tests/gatedMint/utils.ts +++ b/tests/gatedMint/utils.ts @@ -1,4 +1,5 @@ import { + ComputeBudgetProgram, PublicKey, Keypair, Transaction, @@ -83,6 +84,8 @@ export async function setupGatedMint( return { mint, gatedMintConfig, admin }; } +let whitelistTxNonce = 0; + export async function whitelistUser( gatedMintClient: GatedMintClient, mint: PublicKey, @@ -109,6 +112,11 @@ export async function whitelistUser( user, payer: payer.publicKey, }) + .preInstructions([ + ComputeBudgetProgram.setComputeUnitPrice({ + microLamports: whitelistTxNonce++, + }), + ]) .signers(signers) .rpc(); diff --git a/tests/integration/fullLaunch.test.ts b/tests/integration/fullLaunch.test.ts index b2914587..bf717fbb 100644 --- a/tests/integration/fullLaunch.test.ts +++ b/tests/integration/fullLaunch.test.ts @@ -461,6 +461,9 @@ export default async function suite() { .rpc(); // Launch the proposal first + const { squadsVaultTransaction } = + await this.futarchy.getSquadsVaultTransactionAccounts(squadsProposalPda); + await this.futarchy .launchProposalIx({ proposal, @@ -468,6 +471,7 @@ export default async function suite() { baseMint: META, quoteMint: MAINNET_USDC, squadsProposal: squadsProposalPda, + squadsVaultTransaction, }) .rpc(); diff --git a/tests/integration/fullLaunch_v7.test.ts b/tests/integration/fullLaunch_v7.test.ts index e236370d..d44f3503 100644 --- a/tests/integration/fullLaunch_v7.test.ts +++ b/tests/integration/fullLaunch_v7.test.ts @@ -506,6 +506,9 @@ export default async function suite() { .rpc(); // Launch the proposal first + const { squadsVaultTransaction } = + await this.futarchy.getSquadsVaultTransactionAccounts(squadsProposalPda); + await this.futarchy .launchProposalIx({ proposal, @@ -513,6 +516,7 @@ export default async function suite() { baseMint: META, quoteMint: MAINNET_USDC, squadsProposal: squadsProposalPda, + squadsVaultTransaction, }) .rpc(); diff --git a/tests/main.test.ts b/tests/main.test.ts index 7c4d6307..a111015b 100644 --- a/tests/main.test.ts +++ b/tests/main.test.ts @@ -159,6 +159,7 @@ export interface TestContext { baseVault: PublicKey; quoteVault: PublicKey; squadsProposal: PublicKey; + squadsVaultTransaction: PublicKey; }>; initializeAndLaunchProposal: ({ dao, @@ -172,6 +173,7 @@ export interface TestContext { baseVault: PublicKey; quoteVault: PublicKey; squadsProposal: PublicKey; + squadsVaultTransaction: PublicKey; }>; advanceBySlots: (slots: bigint) => Promise; advanceBySeconds: (seconds: number) => Promise; @@ -375,6 +377,10 @@ before(async function () { ); }; + // Two mintTo calls with the same mint, recipient, and amount produce byte-identical + // transactions, which get rejected as duplicates when they land in the same blockhash + // window; an incrementing compute budget keeps every mintTo transaction unique. + let mintToTxNonce = 0; this.mintTo = async ( mint: PublicKey, to: PublicKey, @@ -385,6 +391,11 @@ before(async function () { const tx = new Transaction(); + tx.add( + ComputeBudgetProgram.setComputeUnitLimit({ + units: 200_000 + mintToTxNonce++, + }), + ); tx.add( token.createAssociatedTokenAccountIdempotentInstruction( this.payer.publicKey, @@ -575,15 +586,19 @@ before(async function () { baseVault: PublicKey; quoteVault: PublicKey; squadsProposal: PublicKey; + squadsVaultTransaction: PublicKey; }> => { const storedDao = await this.futarchy.getDao(dao); - const { tx: squadsProposalCreateTx, squadsProposal } = - this.futarchy.squadsProposalCreateTx({ - dao, - instructions, - transactionIndex: 1n, - }); + const { + tx: squadsProposalCreateTx, + squadsProposal, + squadsVaultTransaction, + } = this.futarchy.squadsProposalCreateTx({ + dao, + instructions, + transactionIndex: 1n, + }); squadsProposalCreateTx.recentBlockhash = ( await this.banksClient.getLatestBlockhash() @@ -591,7 +606,7 @@ before(async function () { squadsProposalCreateTx.feePayer = this.payer.publicKey; squadsProposalCreateTx.sign(this.payer, PERMISSIONLESS_ACCOUNT); - this.banksClient.processTransaction(squadsProposalCreateTx); + await this.banksClient.processTransaction(squadsProposalCreateTx); let [proposal] = getProposalAddrV2({ squadsProposal }); @@ -628,13 +643,21 @@ before(async function () { storedDao.baseMint, storedDao.quoteMint, question, + squadsVaultTransaction, ) .preInstructions([ ComputeBudgetProgram.setComputeUnitLimit({ units: 300_000 }), ]) .rpc(); - return { proposal, question, baseVault, quoteVault, squadsProposal }; + return { + proposal, + question, + baseVault, + quoteVault, + squadsProposal, + squadsVaultTransaction, + }; }; this.initializeAndLaunchProposal = async ({ @@ -649,9 +672,16 @@ before(async function () { baseVault: PublicKey; quoteVault: PublicKey; squadsProposal: PublicKey; + squadsVaultTransaction: PublicKey; }> => { - const { proposal, question, baseVault, quoteVault, squadsProposal } = - await this.initializeProposal({ dao, instructions }); + const { + proposal, + question, + baseVault, + quoteVault, + squadsProposal, + squadsVaultTransaction, + } = await this.initializeProposal({ dao, instructions }); const storedDao = await this.futarchy.getDao(dao); await this.futarchy .launchProposalIx({ @@ -660,10 +690,18 @@ before(async function () { baseMint: storedDao.baseMint, quoteMint: storedDao.quoteMint, squadsProposal, + squadsVaultTransaction, }) .rpc(); - return { proposal, question, baseVault, quoteVault, squadsProposal }; + return { + proposal, + question, + baseVault, + quoteVault, + squadsProposal, + squadsVaultTransaction, + }; }; this.setupBasicPerformancePackage = async ({ diff --git a/tests/performancePackageV2/unit/completeUnlock.test.ts b/tests/performancePackageV2/unit/completeUnlock.test.ts index 45d1b40d..dcf23871 100644 --- a/tests/performancePackageV2/unit/completeUnlock.test.ts +++ b/tests/performancePackageV2/unit/completeUnlock.test.ts @@ -543,6 +543,9 @@ export default function suite() { recipient: recipient.publicKey, signer: authority.publicKey, }) + .preInstructions([ + ComputeBudgetProgram.setComputeUnitPrice({ microLamports: 1 }), + ]) .signers([authority]) .rpc(); @@ -657,6 +660,9 @@ export default function suite() { signer: recipient.publicKey, dao, }) + .preInstructions([ + ComputeBudgetProgram.setComputeUnitPrice({ microLamports: 1 }), + ]) .signers([recipient]) .rpc(); @@ -822,6 +828,9 @@ export default function suite() { recipient: recipient.publicKey, signer: authority.publicKey, }) + .preInstructions([ + ComputeBudgetProgram.setComputeUnitPrice({ microLamports: 1 }), + ]) .signers([authority]) .rpc(); diff --git a/tests/utils.ts b/tests/utils.ts index 4792adfe..78177c61 100644 --- a/tests/utils.ts +++ b/tests/utils.ts @@ -11,6 +11,7 @@ import { } from "@solana/web3.js"; import { TestContext } from "./main.test.js"; import { getDaoAddr, PriceMath } from "@metadaoproject/programs"; +import * as multisig from "@sqds/multisig"; export const TEN_SECONDS_IN_SLOTS = 25n; export const ONE_MINUTE_IN_SLOTS = TEN_SECONDS_IN_SLOTS * 6n; @@ -88,6 +89,69 @@ export async function setOptimisticGovernanceEnabled( context.context.setAccount(dao, daoBanksAccount); } +// Writes an address lookup table account directly: the bincode-serialized +// ProgramState::LookupTable(LookupTableMeta) header padded to 56 bytes, followed by the +// raw table addresses. authority === null makes the table frozen. +export function setLookupTableAccount( + context: TestContext, + address: PublicKey, + authority: PublicKey | null, + addresses: PublicKey[], +) { + const meta = Buffer.alloc(56); + meta.writeUInt32LE(1, 0); + meta.writeBigUInt64LE(0xffffffffffffffffn, 4); + meta.writeBigUInt64LE(0n, 12); + meta.writeUInt8(0, 20); + if (authority !== null) { + meta.writeUInt8(1, 21); + authority.toBuffer().copy(meta, 22); + } + + context.context.setAccount(address, { + lamports: 1_000_000_000, + data: Buffer.concat([meta, ...addresses.map((a) => a.toBuffer())]), + owner: AddressLookupTableProgram.programId, + executable: false, + }); +} + +// The Squads SDK only compiles lookups from real on-chain tables, so tests rewrite a +// stored vault transaction message directly to reference arbitrary tables and indexes. +export async function addLookupsToVaultTransaction( + context: TestContext, + squadsVaultTransaction: PublicKey, + lookups: { + accountKey: PublicKey; + writableIndexes: number[]; + readonlyIndexes: number[]; + }[], +) { + const vtAccount = await multisig.accounts.VaultTransaction.fromAccountAddress( + context.squadsConnection, + squadsVaultTransaction, + ); + + const modifiedVt = multisig.accounts.VaultTransaction.fromArgs({ + ...vtAccount, + message: { + ...vtAccount.message, + addressTableLookups: lookups.map((lookup) => ({ + accountKey: lookup.accountKey, + writableIndexes: Uint8Array.from(lookup.writableIndexes), + readonlyIndexes: Uint8Array.from(lookup.readonlyIndexes), + })), + }, + }); + const [serialized] = modifiedVt.serialize(); + + const vtBanksAccount = await context.banksClient.getAccount( + squadsVaultTransaction, + ); + vtBanksAccount.data = serialized; + context.context.setAccount(squadsVaultTransaction, vtBanksAccount); +} + /** * Creates a lookup table for all unique accounts in a transaction * @param transaction - The transaction to create a lookup table for