diff --git a/programs/squads_smart_account_program/src/errors.rs b/programs/squads_smart_account_program/src/errors.rs index cf177a8..592a116 100644 --- a/programs/squads_smart_account_program/src/errors.rs +++ b/programs/squads_smart_account_program/src/errors.rs @@ -290,4 +290,12 @@ pub enum SmartAccountError { PolicyExpirationViolationHashExpired, #[msg("Policy expiration violation: timestamp has expired")] PolicyExpirationViolationTimestampExpired, + + // =============================================== + // Account Index Errors + // =============================================== + #[msg("Account index is locked, must increment_account_index first")] + AccountIndexLocked, + #[msg("Cannot exceed maximum free account index (250)")] + MaxAccountIndexReached, } diff --git a/programs/squads_smart_account_program/src/events/account_events.rs b/programs/squads_smart_account_program/src/events/account_events.rs index 8d689f1..cc85080 100644 --- a/programs/squads_smart_account_program/src/events/account_events.rs +++ b/programs/squads_smart_account_program/src/events/account_events.rs @@ -12,6 +12,12 @@ pub struct CreateSmartAccountEvent { pub new_settings_content: Settings, } +#[derive(BorshSerialize, BorshDeserialize)] +pub struct IncrementAccountIndexEvent { + pub settings_pubkey: Pubkey, + pub settings_state: Settings, +} + #[derive(BorshSerialize, BorshDeserialize)] pub struct SynchronousTransactionEventV2 { pub consensus_account: Pubkey, diff --git a/programs/squads_smart_account_program/src/events/mod.rs b/programs/squads_smart_account_program/src/events/mod.rs index d4f260e..b7bb79b 100644 --- a/programs/squads_smart_account_program/src/events/mod.rs +++ b/programs/squads_smart_account_program/src/events/mod.rs @@ -10,6 +10,7 @@ pub use account_events::*; #[derive(BorshSerialize, BorshDeserialize)] pub enum SmartAccountEvent { CreateSmartAccountEvent(CreateSmartAccountEvent), + IncrementAccountIndexEvent(IncrementAccountIndexEvent), SynchronousTransactionEvent(SynchronousTransactionEvent), SynchronousSettingsTransactionEvent(SynchronousSettingsTransactionEvent), AddSpendingLimitEvent(AddSpendingLimitEvent), diff --git a/programs/squads_smart_account_program/src/instructions/batch_create.rs b/programs/squads_smart_account_program/src/instructions/batch_create.rs index 8a3c262..54c4e1f 100644 --- a/programs/squads_smart_account_program/src/instructions/batch_create.rs +++ b/programs/squads_smart_account_program/src/instructions/batch_create.rs @@ -45,13 +45,15 @@ pub struct CreateBatch<'info> { } impl CreateBatch<'_> { - fn validate(&self) -> Result<()> { + fn validate(&self, args: &CreateBatchArgs) -> Result<()> { let Self { settings, creator, .. } = self; + settings.validate_account_index_unlocked(args.account_index)?; + // creator require!( settings.is_signer(creator.key()).is_some(), @@ -66,7 +68,7 @@ impl CreateBatch<'_> { } /// Create a new batch. - #[access_control(ctx.accounts.validate())] + #[access_control(ctx.accounts.validate(&args))] pub fn create_batch(ctx: Context, args: CreateBatchArgs) -> Result<()> { let settings = &mut ctx.accounts.settings; let creator = &mut ctx.accounts.creator; diff --git a/programs/squads_smart_account_program/src/instructions/increment_account_index.rs b/programs/squads_smart_account_program/src/instructions/increment_account_index.rs new file mode 100644 index 0000000..9f46789 --- /dev/null +++ b/programs/squads_smart_account_program/src/instructions/increment_account_index.rs @@ -0,0 +1,78 @@ +use anchor_lang::prelude::*; + +use crate::{ + errors::SmartAccountError, + events::{IncrementAccountIndexEvent, LogAuthorityInfo, SmartAccountEvent}, + interface::consensus_trait::Consensus, + program::SquadsSmartAccountProgram, + state::{ + get_settings_signer_seeds, Permission, Settings, FREE_ACCOUNT_MAX_INDEX, SEED_PREFIX, + SEED_SETTINGS, + }, +}; + +#[derive(Accounts)] +pub struct IncrementAccountIndex<'info> { + #[account( + mut, + seeds = [ + SEED_PREFIX, + SEED_SETTINGS, + &settings.seed.to_le_bytes(), + ], + bump = settings.bump, + )] + pub settings: Account<'info, Settings>, + + pub signer: Signer<'info>, + + pub program: Program<'info, SquadsSmartAccountProgram>, +} + +impl IncrementAccountIndex<'_> { + fn validate(&self) -> Result<()> { + let settings = &self.settings; + let signer_key = self.signer.key(); + + // Signer must be a member of the smart account + let signer_index = settings + .is_signer(signer_key) + .ok_or(SmartAccountError::NotASigner)?; + + // Permission: Initiate OR Vote OR Execute (mask & 7 != 0) + let permissions = settings.signers[signer_index].permissions; + require!( + permissions.has(Permission::Initiate) + || permissions.has(Permission::Vote) + || permissions.has(Permission::Execute), + SmartAccountError::Unauthorized + ); + + // Cannot exceed free account range + require!( + settings.account_utilization < FREE_ACCOUNT_MAX_INDEX, + SmartAccountError::MaxAccountIndexReached + ); + + Ok(()) + } + + #[access_control(ctx.accounts.validate())] + pub fn increment_account_index(ctx: Context) -> Result<()> { + let settings = &mut ctx.accounts.settings; + settings.increment_account_utilization_index(); + + let event = IncrementAccountIndexEvent { + settings_pubkey: settings.key(), + settings_state: settings.clone().into_inner(), + }; + let log_authority_info = LogAuthorityInfo { + authority: settings.to_account_info(), + authority_seeds: get_settings_signer_seeds(settings.seed), + bump: settings.bump, + program: ctx.accounts.program.to_account_info(), + }; + SmartAccountEvent::IncrementAccountIndexEvent(event).log(&log_authority_info)?; + Ok(()) + } +} diff --git a/programs/squads_smart_account_program/src/instructions/mod.rs b/programs/squads_smart_account_program/src/instructions/mod.rs index 07a1f8f..e9274b9 100644 --- a/programs/squads_smart_account_program/src/instructions/mod.rs +++ b/programs/squads_smart_account_program/src/instructions/mod.rs @@ -1,4 +1,5 @@ pub use activate_proposal::*; +pub use increment_account_index::*; pub use authority_settings_transaction_execute::*; pub use authority_spending_limit_add::*; pub use authority_spending_limit_remove::*; @@ -26,6 +27,7 @@ pub use transaction_execute_sync_legacy::*; pub use use_spending_limit::*; mod activate_proposal; +mod increment_account_index; mod authority_settings_transaction_execute; mod authority_spending_limit_add; mod authority_spending_limit_remove; diff --git a/programs/squads_smart_account_program/src/instructions/transaction_create.rs b/programs/squads_smart_account_program/src/instructions/transaction_create.rs index c7b7a35..42a1ba9 100644 --- a/programs/squads_smart_account_program/src/instructions/transaction_create.rs +++ b/programs/squads_smart_account_program/src/instructions/transaction_create.rs @@ -81,10 +81,19 @@ impl<'info> CreateTransaction<'info> { // Validate the transaction payload match consensus_account.account_type() { ConsensusAccountType::Settings => { - assert!(matches!( - args, - CreateTransactionArgs::TransactionPayload { .. } - )); + match args { + CreateTransactionArgs::TransactionPayload(TransactionPayload { + account_index, + .. + }) => { + // Validate the account index is unlocked + let settings = consensus_account.read_only_settings()?; + settings.validate_account_index_unlocked(*account_index)?; + } + _ => { + return Err(SmartAccountError::InvalidTransactionMessage.into()); + } + } } ConsensusAccountType::Policy => { let policy = consensus_account.read_only_policy()?; diff --git a/programs/squads_smart_account_program/src/instructions/transaction_execute_sync.rs b/programs/squads_smart_account_program/src/instructions/transaction_execute_sync.rs index a4bdf9f..56ec9a0 100644 --- a/programs/squads_smart_account_program/src/instructions/transaction_execute_sync.rs +++ b/programs/squads_smart_account_program/src/instructions/transaction_execute_sync.rs @@ -71,6 +71,12 @@ impl<'info> SyncTransaction<'info> { // Check that the consensus account is active (policy) consensus_account.is_active(&remaining_accounts[args.num_signers as usize..])?; + // Validate account index is unlocked for Settings-based transactions + if consensus_account.account_type() == ConsensusAccountType::Settings { + let settings = consensus_account.read_only_settings()?; + settings.validate_account_index_unlocked(args.account_index)?; + } + // Validate policy payload if necessary if consensus_account.account_type() == ConsensusAccountType::Policy { let policy = consensus_account.read_only_policy()?; diff --git a/programs/squads_smart_account_program/src/instructions/transaction_execute_sync_legacy.rs b/programs/squads_smart_account_program/src/instructions/transaction_execute_sync_legacy.rs index 7a573de..ab189b8 100644 --- a/programs/squads_smart_account_program/src/instructions/transaction_execute_sync_legacy.rs +++ b/programs/squads_smart_account_program/src/instructions/transaction_execute_sync_legacy.rs @@ -45,6 +45,11 @@ impl LegacySyncTransaction<'_> { remaining_accounts: &[AccountInfo], ) -> Result<()> { let Self { consensus_account, .. } = self; + + // Validate account index is unlocked + let settings = consensus_account.read_only_settings()?; + settings.validate_account_index_unlocked(args.account_index)?; + validate_synchronous_consensus(&consensus_account, args.num_signers, remaining_accounts) } #[access_control(ctx.accounts.validate(&args, &ctx.remaining_accounts))] diff --git a/programs/squads_smart_account_program/src/lib.rs b/programs/squads_smart_account_program/src/lib.rs index 5c36d5d..277de92 100644 --- a/programs/squads_smart_account_program/src/lib.rs +++ b/programs/squads_smart_account_program/src/lib.rs @@ -340,4 +340,11 @@ pub mod squads_smart_account_program { ) -> Result<()> { LogEvent::log_event(ctx, args) } + + /// Increment the account utilization index, unlocking the next vault index. + /// Callable by any signer with Initiate, Vote, or Execute permissions. + // Future: consider decrement instruction for account index management + pub fn increment_account_index(ctx: Context) -> Result<()> { + IncrementAccountIndex::increment_account_index(ctx) + } } diff --git a/programs/squads_smart_account_program/src/state/policies/policy_core/payloads.rs b/programs/squads_smart_account_program/src/state/policies/policy_core/payloads.rs index 74289c9..6c23f74 100644 --- a/programs/squads_smart_account_program/src/state/policies/policy_core/payloads.rs +++ b/programs/squads_smart_account_program/src/state/policies/policy_core/payloads.rs @@ -4,7 +4,7 @@ use crate::{ state::policies::implementations::InternalFundTransferPayload, InternalFundTransferPolicyCreationPayload, ProgramInteractionPayload, ProgramInteractionPolicyCreationPayload, ProgramInteractionPolicyCreationPayloadLegacy, - SettingsChangePayload, SettingsChangePolicyCreationPayload, SpendingLimitPayload, + Settings, SettingsChangePayload, SettingsChangePolicyCreationPayload, SpendingLimitPayload, SpendingLimitPolicyCreationPayload, }; @@ -33,6 +33,23 @@ impl PolicyCreationPayload { PolicyCreationPayload::ProgramInteraction(payload) => payload.policy_state_size(), } } + + /// Validates that all account indices used by this policy are unlocked. + /// SettingsChange policies don't use vault accounts and always pass validation. + pub fn validate_account_indices(&self, settings: &Settings) -> Result<()> { + let indices: Vec = match self { + PolicyCreationPayload::SpendingLimit(p) => vec![p.source_account_index], + PolicyCreationPayload::ProgramInteraction(p) => vec![p.account_index], + PolicyCreationPayload::LegacyProgramInteraction(p) => vec![p.account_index], + PolicyCreationPayload::InternalFundTransfer(p) => { + let mut indices = p.source_account_indices.clone(); + indices.extend(&p.destination_account_indices); + indices + } + PolicyCreationPayload::SettingsChange(_) => vec![], + }; + settings.validate_account_indices_unlocked(&indices) + } } /// Unified enum for all policy execution payloads diff --git a/programs/squads_smart_account_program/src/state/settings.rs b/programs/squads_smart_account_program/src/state/settings.rs index ed10fd8..34645cf 100644 --- a/programs/squads_smart_account_program/src/state/settings.rs +++ b/programs/squads_smart_account_program/src/state/settings.rs @@ -18,6 +18,11 @@ use crate::{ }; pub const MAX_TIME_LOCK: u32 = 3 * 30 * 24 * 60 * 60; // 3 months +// Account index constants +// Free accounts: 0-249 (250 accounts) +// Reserved accounts: 250-255 (6 accounts) - bypass index validation +pub const FREE_ACCOUNT_MAX_INDEX: u8 = 249; + #[account] pub struct Settings { /// An integer that is used seed the settings PDA. Its incremented by 1 @@ -428,6 +433,9 @@ impl Settings { start_timestamp, expiration_args, } => { + // Validate that all account indices used by the policy are unlocked + policy_creation_payload.validate_account_indices(self)?; + // Increment the policy seed if it exists, otherwise set it to // 1 (First policy is being created) let next_policy_seed = if let Some(policy_seed) = self.policy_seed { @@ -570,6 +578,9 @@ impl Settings { policy_update_payload, expiration_args, } => { + // Validate that all account indices used by the policy are unlocked + policy_update_payload.validate_account_indices(self)?; + // Find the policy account let policy_info = remaining_accounts .iter() @@ -723,9 +734,31 @@ impl Settings { Ok(()) } - pub fn increment_account_utilization(&mut self) { + pub fn increment_account_utilization_index(&mut self) { self.account_utilization = self.account_utilization.checked_add(1).unwrap(); } + + /// Validates that the given account index is unlocked. + /// Reserved accounts (250-255) bypass this check. + pub fn validate_account_index_unlocked(&self, index: u8) -> Result<()> { + // Reserved accounts (250-255) bypass the check + if index > FREE_ACCOUNT_MAX_INDEX { + return Ok(()); + } + require!( + index <= self.account_utilization, + SmartAccountError::AccountIndexLocked + ); + Ok(()) + } + + /// Validates that all given account indices are unlocked. + pub fn validate_account_indices_unlocked(&self, indices: &[u8]) -> Result<()> { + for index in indices { + self.validate_account_index_unlocked(*index)?; + } + Ok(()) + } } #[derive(AnchorDeserialize, AnchorSerialize, InitSpace, Eq, PartialEq, Clone)] diff --git a/sdk/smart-account/idl/squads_smart_account_program.json b/sdk/smart-account/idl/squads_smart_account_program.json index a20ccdd..db3acba 100644 --- a/sdk/smart-account/idl/squads_smart_account_program.json +++ b/sdk/smart-account/idl/squads_smart_account_program.json @@ -1858,6 +1858,31 @@ } } ] + }, + { + "name": "incrementAccountIndex", + "docs": [ + "Increment the account utilization index, unlocking the next vault index.", + "Callable by any signer with Initiate, Vote, or Execute permissions." + ], + "accounts": [ + { + "name": "settings", + "isMut": true, + "isSigner": false + }, + { + "name": "signer", + "isMut": false, + "isSigner": true + }, + { + "name": "program", + "isMut": false, + "isSigner": false + } + ], + "args": [] } ], "accounts": [ @@ -6359,12 +6384,22 @@ "code": 6128, "name": "PolicyExpirationViolationTimestampExpired", "msg": "Policy expiration violation: timestamp has expired" + }, + { + "code": 6129, + "name": "AccountIndexLocked", + "msg": "Account index is locked, must increment_account_index first" + }, + { + "code": 6130, + "name": "MaxAccountIndexReached", + "msg": "Cannot exceed maximum free account index (250)" } ], "metadata": { "address": "SMRTzfY6DfH5ik3TKiyLFfXexV8uSG3d2UksSCYdunG", "origin": "anchor", - "binaryVersion": "0.29.0", + "binaryVersion": "0.32.1", "libVersion": "=0.29.0" } } \ No newline at end of file diff --git a/sdk/smart-account/scripts/fix-smallvec.js b/sdk/smart-account/scripts/fix-smallvec.js index f3a42f1..b1f4236 100644 --- a/sdk/smart-account/scripts/fix-smallvec.js +++ b/sdk/smart-account/scripts/fix-smallvec.js @@ -87,7 +87,8 @@ function processFile(filePath) { } if (fileName === 'CompiledHook.ts') { - content = content.replace(/\['instructionData', beet\.bytes\]/g, "['instructionData', smallArray(beet.u8, beet.u8)]"); + // instruction_data: SmallVec (note: u16 length prefix) + content = content.replace(/\['instructionData', beet\.bytes\]/g, "['instructionData', smallArray(beet.u16, beet.u8)]"); content = content.replace(/instructionData: Uint8Array/g, 'instructionData: number[]'); } diff --git a/sdk/smart-account/src/generated/errors/index.ts b/sdk/smart-account/src/generated/errors/index.ts index 472abd9..6cd9cf3 100644 --- a/sdk/smart-account/src/generated/errors/index.ts +++ b/sdk/smart-account/src/generated/errors/index.ts @@ -3428,6 +3428,52 @@ createErrorFromNameLookup.set( () => new PolicyExpirationViolationTimestampExpiredError() ) +/** + * AccountIndexLocked: 'Account index is locked, must increment_account_index first' + * + * @category Errors + * @category generated + */ +export class AccountIndexLockedError extends Error { + readonly code: number = 0x17f1 + readonly name: string = 'AccountIndexLocked' + constructor() { + super('Account index is locked, must increment_account_index first') + if (typeof Error.captureStackTrace === 'function') { + Error.captureStackTrace(this, AccountIndexLockedError) + } + } +} + +createErrorFromCodeLookup.set(0x17f1, () => new AccountIndexLockedError()) +createErrorFromNameLookup.set( + 'AccountIndexLocked', + () => new AccountIndexLockedError() +) + +/** + * MaxAccountIndexReached: 'Cannot exceed maximum free account index (250)' + * + * @category Errors + * @category generated + */ +export class MaxAccountIndexReachedError extends Error { + readonly code: number = 0x17f2 + readonly name: string = 'MaxAccountIndexReached' + constructor() { + super('Cannot exceed maximum free account index (250)') + if (typeof Error.captureStackTrace === 'function') { + Error.captureStackTrace(this, MaxAccountIndexReachedError) + } + } +} + +createErrorFromCodeLookup.set(0x17f2, () => new MaxAccountIndexReachedError()) +createErrorFromNameLookup.set( + 'MaxAccountIndexReached', + () => new MaxAccountIndexReachedError() +) + /** * Attempts to resolve a custom program error from the provided error code. * @category Errors diff --git a/sdk/smart-account/src/generated/instructions/incrementAccountIndex.ts b/sdk/smart-account/src/generated/instructions/incrementAccountIndex.ts new file mode 100644 index 0000000..c12c6b2 --- /dev/null +++ b/sdk/smart-account/src/generated/instructions/incrementAccountIndex.ts @@ -0,0 +1,88 @@ +/** + * This code was GENERATED using the solita package. + * Please DO NOT EDIT THIS FILE, instead rerun solita to update it or write a wrapper to add functionality. + * + * See: https://github.com/metaplex-foundation/solita + */ + +import * as beet from '@metaplex-foundation/beet' +import * as web3 from '@solana/web3.js' + +/** + * @category Instructions + * @category IncrementAccountIndex + * @category generated + */ +export const incrementAccountIndexStruct = new beet.BeetArgsStruct<{ + instructionDiscriminator: number[] /* size: 8 */ +}>( + [['instructionDiscriminator', beet.uniformFixedSizeArray(beet.u8, 8)]], + 'IncrementAccountIndexInstructionArgs' +) +/** + * Accounts required by the _incrementAccountIndex_ instruction + * + * @property [_writable_] settings + * @property [**signer**] signer + * @property [] program + * @category Instructions + * @category IncrementAccountIndex + * @category generated + */ +export type IncrementAccountIndexInstructionAccounts = { + settings: web3.PublicKey + signer: web3.PublicKey + program: web3.PublicKey + anchorRemainingAccounts?: web3.AccountMeta[] +} + +export const incrementAccountIndexInstructionDiscriminator = [ + 212, 170, 222, 71, 21, 131, 117, 220, +] + +/** + * Creates a _IncrementAccountIndex_ instruction. + * + * @param accounts that will be accessed while the instruction is processed + * @category Instructions + * @category IncrementAccountIndex + * @category generated + */ +export function createIncrementAccountIndexInstruction( + accounts: IncrementAccountIndexInstructionAccounts, + programId = new web3.PublicKey('SMRTzfY6DfH5ik3TKiyLFfXexV8uSG3d2UksSCYdunG') +) { + const [data] = incrementAccountIndexStruct.serialize({ + instructionDiscriminator: incrementAccountIndexInstructionDiscriminator, + }) + const keys: web3.AccountMeta[] = [ + { + pubkey: accounts.settings, + isWritable: true, + isSigner: false, + }, + { + pubkey: accounts.signer, + isWritable: false, + isSigner: true, + }, + { + pubkey: accounts.program, + isWritable: false, + isSigner: false, + }, + ] + + if (accounts.anchorRemainingAccounts != null) { + for (const acc of accounts.anchorRemainingAccounts) { + keys.push(acc) + } + } + + const ix = new web3.TransactionInstruction({ + programId, + keys, + data, + }) + return ix +} diff --git a/sdk/smart-account/src/generated/instructions/index.ts b/sdk/smart-account/src/generated/instructions/index.ts index f7c85e5..187f9f9 100644 --- a/sdk/smart-account/src/generated/instructions/index.ts +++ b/sdk/smart-account/src/generated/instructions/index.ts @@ -25,6 +25,7 @@ export * from './executeTransaction' export * from './executeTransactionSync' export * from './executeTransactionSyncV2' export * from './extendTransactionBuffer' +export * from './incrementAccountIndex' export * from './initializeProgramConfig' export * from './logEvent' export * from './rejectProposal' diff --git a/sdk/smart-account/src/generated/types/CompiledHook.ts b/sdk/smart-account/src/generated/types/CompiledHook.ts index 5fef632..c44bfb2 100644 --- a/sdk/smart-account/src/generated/types/CompiledHook.ts +++ b/sdk/smart-account/src/generated/types/CompiledHook.ts @@ -27,7 +27,7 @@ export const compiledHookBeet = new beet.FixableBeetArgsStruct( [ ['numExtraAccounts', beet.u8], ['accountConstraints', smallArray(beet.u8, compiledAccountConstraintBeet)], - ['instructionData', smallArray(beet.u8, beet.u8)], + ['instructionData', smallArray(beet.u16, beet.u8)], ['programIdIndex', beet.u8], ['passInnerInstructions', beet.bool], ], diff --git a/tests/index.ts b/tests/index.ts index 406bcc7..f77847f 100644 --- a/tests/index.ts +++ b/tests/index.ts @@ -17,11 +17,12 @@ import "./suites/program-config-init"; // import "./suites/instructions/transactionBufferExtend"; // import "./suites/instructions/batchTransactionAccountClose"; // import "./suites/instructions/transactionAccountsClose"; -// import "./suites/instructions/transactionCreateFromBuffer"; -// import "./suites/instructions/transactionSynchronous"; +import "./suites/instructions/transactionCreateFromBuffer"; +import "./suites/instructions/transactionSynchronous"; +import "./suites/instructions/incrementAccountIndex"; // import "./suites/instructions/logEvent"; -// import "./suites/instructions/policyCreation"; -// import "./suites/instructions/policyUpdate"; +import "./suites/instructions/policyCreation"; +import "./suites/instructions/policyUpdate"; // import "./suites/instructions/removePolicy"; // import "./suites/instructions/policyExpiration"; // import "./suites/instructions/settingsChangePolicy"; diff --git a/tests/suites/instructions/incrementAccountIndex.ts b/tests/suites/instructions/incrementAccountIndex.ts new file mode 100644 index 0000000..396bda3 --- /dev/null +++ b/tests/suites/instructions/incrementAccountIndex.ts @@ -0,0 +1,353 @@ +import { + Keypair, + LAMPORTS_PER_SOL, + PublicKey, + TransactionMessage, + VersionedTransaction, +} from "@solana/web3.js"; +import * as smartAccount from "@sqds/smart-account"; +import assert from "assert"; +import { + createAutonomousSmartAccountV2, + createLocalhostConnection, + generateSmartAccountSigners, + getNextAccountIndex, + getTestProgramId, + TestMembers, +} from "../../utils"; + +const { Settings } = smartAccount.accounts; +const programId = getTestProgramId(); +const connection = createLocalhostConnection(); + +function createIncrementAccountIndexInstruction( + settingsPda: PublicKey, + signer: PublicKey, + programId: PublicKey +) { + return smartAccount.generated.createIncrementAccountIndexInstruction( + { settings: settingsPda, signer, program: programId }, + programId + ); +} + +describe("Instructions / increment_account_index", () => { + let members: TestMembers; + + before(async () => { + members = await generateSmartAccountSigners(connection); + }); + + it("increment_account_index successfully", async () => { + const accountIndex = await getNextAccountIndex(connection, programId); + const [settingsPda] = await createAutonomousSmartAccountV2({ + connection, + accountIndex, + members, + threshold: 2, + timeLock: 0, + rentCollector: null, + programId, + }); + + // Check initial account_utilization is 0 + let settingsAccount = await Settings.fromAccountAddress( + connection, + settingsPda + ); + assert.strictEqual(settingsAccount.accountUtilization, 0); + + // Increment the account index + const incrementIx = createIncrementAccountIndexInstruction( + settingsPda, + members.almighty.publicKey, + programId + ); + + const message = new TransactionMessage({ + payerKey: members.almighty.publicKey, + recentBlockhash: (await connection.getLatestBlockhash()).blockhash, + instructions: [incrementIx], + }).compileToV0Message(); + + const tx = new VersionedTransaction(message); + tx.sign([members.almighty]); + + const signature = await connection.sendRawTransaction(tx.serialize()); + await connection.confirmTransaction(signature); + + // Verify account_utilization is now 1 + settingsAccount = await Settings.fromAccountAddress(connection, settingsPda); + assert.strictEqual(settingsAccount.accountUtilization, 1); + }); + + it("increment multiple times", async () => { + const accountIndex = await getNextAccountIndex(connection, programId); + const [settingsPda] = await createAutonomousSmartAccountV2({ + connection, + accountIndex, + members, + threshold: 2, + timeLock: 0, + rentCollector: null, + programId, + }); + + // Increment 3 times + for (let i = 0; i < 3; i++) { + const incrementIx = createIncrementAccountIndexInstruction( + settingsPda, + members.almighty.publicKey, + programId + ); + + const message = new TransactionMessage({ + payerKey: members.almighty.publicKey, + recentBlockhash: (await connection.getLatestBlockhash()).blockhash, + instructions: [incrementIx], + }).compileToV0Message(); + + const tx = new VersionedTransaction(message); + tx.sign([members.almighty]); + + await connection.confirmTransaction( + await connection.sendRawTransaction(tx.serialize()) + ); + } + + const settingsAccount = await Settings.fromAccountAddress( + connection, + settingsPda + ); + assert.strictEqual(settingsAccount.accountUtilization, 3); + }); + + it("error: non-signer cannot increment", async () => { + const accountIndex = await getNextAccountIndex(connection, programId); + const [settingsPda] = await createAutonomousSmartAccountV2({ + connection, + accountIndex, + members, + threshold: 2, + timeLock: 0, + rentCollector: null, + programId, + }); + + // Create a random keypair that is not a signer on the smart account + const nonSigner = Keypair.generate(); + await connection.confirmTransaction( + await connection.requestAirdrop(nonSigner.publicKey, LAMPORTS_PER_SOL) + ); + + const incrementIx = createIncrementAccountIndexInstruction( + settingsPda, + nonSigner.publicKey, + programId + ); + + const message = new TransactionMessage({ + payerKey: nonSigner.publicKey, + recentBlockhash: (await connection.getLatestBlockhash()).blockhash, + instructions: [incrementIx], + }).compileToV0Message(); + + const tx = new VersionedTransaction(message); + tx.sign([nonSigner]); + + await assert.rejects( + async () => { + await connection + .sendRawTransaction(tx.serialize()) + .catch(smartAccount.errors.translateAndThrowAnchorError); + }, + /NotASigner/ + ); + }); + + it("proposer can increment (has Initiate permission)", async () => { + const accountIndex = await getNextAccountIndex(connection, programId); + const [settingsPda] = await createAutonomousSmartAccountV2({ + connection, + accountIndex, + members, + threshold: 2, + timeLock: 0, + rentCollector: null, + programId, + }); + + // Proposer only has Initiate permission + const incrementIx = createIncrementAccountIndexInstruction( + settingsPda, + members.proposer.publicKey, + programId + ); + + const message = new TransactionMessage({ + payerKey: members.proposer.publicKey, + recentBlockhash: (await connection.getLatestBlockhash()).blockhash, + instructions: [incrementIx], + }).compileToV0Message(); + + const tx = new VersionedTransaction(message); + tx.sign([members.proposer]); + + const signature = await connection.sendRawTransaction(tx.serialize()); + await connection.confirmTransaction(signature); + + const settingsAccount = await Settings.fromAccountAddress( + connection, + settingsPda + ); + assert.strictEqual(settingsAccount.accountUtilization, 1); + }); + + it("voter can increment (has Vote permission)", async () => { + const accountIndex = await getNextAccountIndex(connection, programId); + const [settingsPda] = await createAutonomousSmartAccountV2({ + connection, + accountIndex, + members, + threshold: 2, + timeLock: 0, + rentCollector: null, + programId, + }); + + // Voter only has Vote permission + const incrementIx = createIncrementAccountIndexInstruction( + settingsPda, + members.voter.publicKey, + programId + ); + + const message = new TransactionMessage({ + payerKey: members.voter.publicKey, + recentBlockhash: (await connection.getLatestBlockhash()).blockhash, + instructions: [incrementIx], + }).compileToV0Message(); + + const tx = new VersionedTransaction(message); + tx.sign([members.voter]); + + const signature = await connection.sendRawTransaction(tx.serialize()); + await connection.confirmTransaction(signature); + + const settingsAccount = await Settings.fromAccountAddress( + connection, + settingsPda + ); + assert.strictEqual(settingsAccount.accountUtilization, 1); + }); + + it("executor can increment (has Execute permission)", async () => { + const accountIndex = await getNextAccountIndex(connection, programId); + const [settingsPda] = await createAutonomousSmartAccountV2({ + connection, + accountIndex, + members, + threshold: 2, + timeLock: 0, + rentCollector: null, + programId, + }); + + // Executor only has Execute permission + const incrementIx = createIncrementAccountIndexInstruction( + settingsPda, + members.executor.publicKey, + programId + ); + + const message = new TransactionMessage({ + payerKey: members.executor.publicKey, + recentBlockhash: (await connection.getLatestBlockhash()).blockhash, + instructions: [incrementIx], + }).compileToV0Message(); + + const tx = new VersionedTransaction(message); + tx.sign([members.executor]); + + const signature = await connection.sendRawTransaction(tx.serialize()); + await connection.confirmTransaction(signature); + + const settingsAccount = await Settings.fromAccountAddress( + connection, + settingsPda + ); + assert.strictEqual(settingsAccount.accountUtilization, 1); + }); + + it("error: cannot increment beyond max index (250)", async () => { + const accountIndex = await getNextAccountIndex(connection, programId); + const [settingsPda] = await createAutonomousSmartAccountV2({ + connection, + accountIndex, + members, + threshold: 1, + timeLock: 0, + rentCollector: null, + programId, + }); + + // Batch increment instructions to reach 250 efficiently + // ~20 instructions per tx to stay within limits + // Note: yes this is stupid but we can't mock the account_utilization + // to be at 250 already so we need to batch send. + const BATCH_SIZE = 20; + const TARGET = 250; + + for (let i = 0; i < TARGET; i += BATCH_SIZE) { + const count = Math.min(BATCH_SIZE, TARGET - i); + const instructions = Array.from({ length: count }, () => + createIncrementAccountIndexInstruction( + settingsPda, + members.almighty.publicKey, + programId + ) + ); + + const message = new TransactionMessage({ + payerKey: members.almighty.publicKey, + recentBlockhash: (await connection.getLatestBlockhash()).blockhash, + instructions, + }).compileToV0Message(); + + const tx = new VersionedTransaction(message); + tx.sign([members.almighty]); + await connection.confirmTransaction( + await connection.sendRawTransaction(tx.serialize()) + ); + } + + // Verify we're at 250 + let settings = await Settings.fromAccountAddress(connection, settingsPda); + assert.strictEqual(settings.accountUtilization, 250); + + // Now try to increment to 251 - should fail + const incrementIx = createIncrementAccountIndexInstruction( + settingsPda, + members.almighty.publicKey, + programId + ); + + const message = new TransactionMessage({ + payerKey: members.almighty.publicKey, + recentBlockhash: (await connection.getLatestBlockhash()).blockhash, + instructions: [incrementIx], + }).compileToV0Message(); + + const tx = new VersionedTransaction(message); + tx.sign([members.almighty]); + + await assert.rejects( + async () => { + await connection + .sendRawTransaction(tx.serialize()) + .catch(smartAccount.errors.translateAndThrowAnchorError); + }, + /MaxAccountIndexReached/ + ); + }); +}); diff --git a/tests/suites/instructions/policyCreation.ts b/tests/suites/instructions/policyCreation.ts index 19a30ac..bd0ca1d 100644 --- a/tests/suites/instructions/policyCreation.ts +++ b/tests/suites/instructions/policyCreation.ts @@ -31,6 +31,24 @@ describe("Flows / Policy Creation", () => { }) )[0]; + // Increment account_utilization to unlock indices 1, 2, 3 (test uses 0-3) + for (let i = 0; i < 3; i++) { + const ix = smartAccount.generated.createIncrementAccountIndexInstruction( + { settings: settingsPda, signer: members.almighty.publicKey, program: programId }, + programId + ); + const msg = new web3.TransactionMessage({ + payerKey: members.almighty.publicKey, + recentBlockhash: (await connection.getLatestBlockhash()).blockhash, + instructions: [ix], + }).compileToV0Message(); + const tx = new web3.VersionedTransaction(msg); + tx.sign([members.almighty]); + await connection.confirmTransaction( + await connection.sendRawTransaction(tx.serialize()) + ); + } + // Use seed 1 for the first policy on this smart account const policySeed = 1; @@ -151,7 +169,7 @@ describe("Flows / Policy Creation", () => { const policyCreationPayload: smartAccount.generated.PolicyCreationPayload = { - __kind: "ProgramInteraction", + __kind: "LegacyProgramInteraction", fields: [ { accountIndex: 0, // Apply to account index 0 @@ -509,4 +527,115 @@ describe("Flows / Policy Creation", () => { ); assert.strictEqual(policyAccount.threshold, 1); }); + + it("error: create policy with locked account index", async () => { + // Create new autonomous smart account - account_utilization starts at 0 + const settingsPda = ( + await createAutonomousMultisig({ + connection, + members, + threshold: 1, + timeLock: 0, + programId, + }) + )[0]; + + const policySeed = 1; + + // Try to create a SpendingLimit policy targeting account index 1 (locked) + const policyCreationPayload: smartAccount.generated.PolicyCreationPayload = + { + __kind: "SpendingLimit", + fields: [ + { + mint: web3.PublicKey.default, + sourceAccountIndex: 1, // Index 1 is locked (account_utilization = 0) + timeConstraints: { + start: 0, + expiration: null, + period: { __kind: "OneTime" }, + accumulateUnused: false, + }, + quantityConstraints: { + maxPerPeriod: 1000000, + maxPerUse: 1000000, + enforceExactQuantity: false, + }, + usageState: null, + destinations: [], + }, + ], + }; + + const transactionIndex = BigInt(1); + + const [policyPda] = smartAccount.getPolicyPda({ + settingsPda, + policySeed, + programId, + }); + + // Create settings transaction with PolicyCreate action + let signature = await smartAccount.rpc.createSettingsTransaction({ + connection, + feePayer: members.proposer, + settingsPda, + transactionIndex, + creator: members.proposer.publicKey, + actions: [ + { + __kind: "PolicyCreate", + seed: policySeed, + policyCreationPayload, + signers: [{ key: members.voter.publicKey, permissions: { mask: 7 } }], + threshold: 1, + timeLock: 0, + startTimestamp: null, + expirationArgs: null, + }, + ], + programId, + }); + await connection.confirmTransaction(signature); + + // Create and approve proposal + signature = await smartAccount.rpc.createProposal({ + connection, + feePayer: members.proposer, + settingsPda, + transactionIndex, + creator: members.proposer, + programId, + }); + await connection.confirmTransaction(signature); + + signature = await smartAccount.rpc.approveProposal({ + connection, + feePayer: members.voter, + settingsPda, + transactionIndex, + signer: members.voter, + programId, + }); + await connection.confirmTransaction(signature); + + // Execute should fail with AccountIndexLocked + await assert.rejects( + async () => { + await smartAccount.rpc + .executeSettingsTransaction({ + connection, + feePayer: members.almighty, + settingsPda, + transactionIndex, + signer: members.almighty, + rentPayer: members.almighty, + policies: [policyPda], + programId, + }) + .catch(smartAccount.errors.translateAndThrowAnchorError); + }, + /AccountIndexLocked/ + ); + }); }); diff --git a/tests/suites/instructions/policyUpdate.ts b/tests/suites/instructions/policyUpdate.ts index a25d69c..c6ecf4a 100644 --- a/tests/suites/instructions/policyUpdate.ts +++ b/tests/suites/instructions/policyUpdate.ts @@ -32,6 +32,24 @@ describe("Flows / Policy Update", () => { }) )[0]; + // Increment account_utilization to unlock indices 1, 2, 3 (test uses 0-3) + for (let i = 0; i < 3; i++) { + const ix = smartAccount.generated.createIncrementAccountIndexInstruction( + { settings: settingsPda, signer: members.almighty.publicKey, program: programId }, + programId + ); + const msg = new web3.TransactionMessage({ + payerKey: members.almighty.publicKey, + recentBlockhash: (await connection.getLatestBlockhash()).blockhash, + instructions: [ix], + }).compileToV0Message(); + const tx = new web3.VersionedTransaction(msg); + tx.sign([members.almighty]); + await connection.confirmTransaction( + await connection.sendRawTransaction(tx.serialize()) + ); + } + // Use seed 1 for the first policy on this smart account const policySeed = 1; @@ -218,4 +236,169 @@ describe("Flows / Policy Update", () => { members.voter.publicKey.toString() ); }); + + it("error: update policy with locked account index", async () => { + // Create new autonomous smart account - account_utilization starts at 0 + const settingsPda = ( + await createAutonomousMultisig({ + connection, + members, + threshold: 1, + timeLock: 0, + programId, + }) + )[0]; + + const policySeed = 1; + + // First create a valid policy with account index 0 (which is unlocked) + const policyCreationPayload: smartAccount.generated.PolicyCreationPayload = + { + __kind: "InternalFundTransfer", + fields: [ + { + sourceAccountIndices: new Uint8Array([0]), + destinationAccountIndices: new Uint8Array([0]), + allowedMints: [web3.PublicKey.default], + }, + ], + }; + + let transactionIndex = BigInt(1); + + const [policyPda] = smartAccount.getPolicyPda({ + settingsPda, + policySeed, + programId, + }); + + // Create, propose, approve, and execute the policy creation + let signature = await smartAccount.rpc.createSettingsTransaction({ + connection, + feePayer: members.proposer, + settingsPda, + transactionIndex, + creator: members.proposer.publicKey, + actions: [ + { + __kind: "PolicyCreate", + seed: policySeed, + policyCreationPayload, + signers: [{ key: members.voter.publicKey, permissions: { mask: 7 } }], + threshold: 1, + timeLock: 0, + startTimestamp: null, + expirationArgs: null, + }, + ], + programId, + }); + await connection.confirmTransaction(signature); + + signature = await smartAccount.rpc.createProposal({ + connection, + feePayer: members.proposer, + settingsPda, + transactionIndex, + creator: members.proposer, + programId, + }); + await connection.confirmTransaction(signature); + + signature = await smartAccount.rpc.approveProposal({ + connection, + feePayer: members.voter, + settingsPda, + transactionIndex, + signer: members.voter, + programId, + }); + await connection.confirmTransaction(signature); + + signature = await smartAccount.rpc.executeSettingsTransaction({ + connection, + feePayer: members.almighty, + settingsPda, + transactionIndex, + signer: members.almighty, + rentPayer: members.almighty, + policies: [policyPda], + programId, + }); + await connection.confirmTransaction(signature); + + // Now try to update the policy to use locked account indices + transactionIndex = BigInt(2); + + const policyUpdatePayload: smartAccount.generated.PolicyCreationPayload = { + __kind: "InternalFundTransfer", + fields: [ + { + sourceAccountIndices: new Uint8Array([0, 5]), // Index 5 is locked + destinationAccountIndices: new Uint8Array([0]), + allowedMints: [web3.PublicKey.default], + }, + ], + }; + + signature = await smartAccount.rpc.createSettingsTransaction({ + connection, + feePayer: members.proposer, + settingsPda, + transactionIndex, + creator: members.proposer.publicKey, + actions: [ + { + __kind: "PolicyUpdate", + policy: policyPda, + policyUpdatePayload, + signers: [{ key: members.voter.publicKey, permissions: { mask: 7 } }], + threshold: 1, + timeLock: 0, + expirationArgs: null, + }, + ], + programId, + }); + await connection.confirmTransaction(signature); + + signature = await smartAccount.rpc.createProposal({ + connection, + feePayer: members.proposer, + settingsPda, + transactionIndex, + creator: members.proposer, + programId, + }); + await connection.confirmTransaction(signature); + + signature = await smartAccount.rpc.approveProposal({ + connection, + feePayer: members.voter, + settingsPda, + transactionIndex, + signer: members.voter, + programId, + }); + await connection.confirmTransaction(signature); + + // Execute should fail with AccountIndexLocked + await assert.rejects( + async () => { + await smartAccount.rpc + .executeSettingsTransaction({ + connection, + feePayer: members.almighty, + settingsPda, + transactionIndex, + signer: members.almighty, + rentPayer: members.almighty, + policies: [policyPda], + programId, + }) + .catch(smartAccount.errors.translateAndThrowAnchorError); + }, + /AccountIndexLocked/ + ); + }); }); diff --git a/tests/suites/instructions/transactionCreateFromBuffer.ts b/tests/suites/instructions/transactionCreateFromBuffer.ts index 052f204..4b48f7b 100644 --- a/tests/suites/instructions/transactionCreateFromBuffer.ts +++ b/tests/suites/instructions/transactionCreateFromBuffer.ts @@ -519,4 +519,147 @@ describe("Instructions / transaction_create_from_buffer", () => { assert.match(logs, /Access violation in heap section at address/); }); + + it("error: create transaction with locked account index", async () => { + // Create a fresh smart account for this test + const accountIndex = await getNextAccountIndex(connection, programId); + const [testSettingsPda] = await createAutonomousSmartAccountV2({ + connection, + accountIndex, + members, + threshold: 1, + timeLock: 0, + rentCollector: null, + programId, + }); + const [testVaultPda] = smartAccount.getSmartAccountPda({ + settingsPda: testSettingsPda, + accountIndex: 0, + programId, + }); + + const transactionIndex = 1n; + const bufferIndex = 0; + + const testIx = await createTestTransferInstruction( + testVaultPda, + Keypair.generate().publicKey, + 0.1 * LAMPORTS_PER_SOL + ); + + const testTransferMessage = new TransactionMessage({ + payerKey: testVaultPda, + recentBlockhash: (await connection.getLatestBlockhash()).blockhash, + instructions: [testIx], + }); + + const messageBuffer = + smartAccount.utils.transactionMessageToMultisigTransactionMessageBytes({ + message: testTransferMessage, + addressLookupTableAccounts: [], + smartAccountPda: testVaultPda, + }); + + const [transactionBuffer] = PublicKey.findProgramAddressSync( + [ + Buffer.from("smart_account"), + testSettingsPda.toBuffer(), + Buffer.from("transaction_buffer"), + members.proposer.publicKey.toBuffer(), + Uint8Array.from([bufferIndex]), + ], + programId + ); + + const messageHash = crypto + .createHash("sha256") + .update(messageBuffer.transactionMessageBytes) + .digest(); + + // Create buffer with locked account_index 1 + const createBufferIx = + smartAccount.generated.createCreateTransactionBufferInstruction( + { + consensusAccount: testSettingsPda, + transactionBuffer, + creator: members.proposer.publicKey, + rentPayer: members.proposer.publicKey, + systemProgram: SystemProgram.programId, + }, + { + args: { + bufferIndex, + accountIndex: 1, // Locked - account_utilization starts at 0 + finalBufferHash: Array.from(messageHash), + finalBufferSize: messageBuffer.transactionMessageBytes.byteLength, + buffer: messageBuffer.transactionMessageBytes, + } as CreateTransactionBufferArgs, + } as CreateTransactionBufferInstructionArgs, + programId + ); + + const createBufferMsg = new TransactionMessage({ + payerKey: members.proposer.publicKey, + recentBlockhash: (await connection.getLatestBlockhash()).blockhash, + instructions: [createBufferIx], + }).compileToV0Message(); + + const createBufferTx = new VersionedTransaction(createBufferMsg); + createBufferTx.sign([members.proposer]); + await connection.confirmTransaction( + await connection.sendTransaction(createBufferTx, { skipPreflight: true }) + ); + + // Now try to create transaction from buffer - should fail + const [transactionPda] = smartAccount.getTransactionPda({ + settingsPda: testSettingsPda, + transactionIndex, + programId, + }); + + const createFromBufferIx = + smartAccount.generated.createCreateTransactionFromBufferInstruction( + { + transactionCreateItemConsensusAccount: testSettingsPda, + transactionCreateItemTransaction: transactionPda, + transactionCreateItemCreator: members.proposer.publicKey, + transactionCreateItemRentPayer: members.proposer.publicKey, + transactionCreateItemSystemProgram: SystemProgram.programId, + transactionCreateItemProgram: programId, + transactionBuffer, + creator: members.proposer.publicKey, + }, + { + args: { + __kind: "TransactionPayload", + fields: [ + { + accountIndex: 1, // Locked index + ephemeralSigners: 0, + transactionMessage: new Uint8Array([0, 0, 0, 0, 0, 0]), + memo: null, + }, + ], + }, + }, + programId + ); + + const createFromBufferMsg = new TransactionMessage({ + payerKey: members.proposer.publicKey, + recentBlockhash: (await connection.getLatestBlockhash()).blockhash, + instructions: [createFromBufferIx], + }).compileToV0Message(); + + const createFromBufferTx = new VersionedTransaction(createFromBufferMsg); + createFromBufferTx.sign([members.proposer]); + + await assert.rejects( + () => + connection + .sendTransaction(createFromBufferTx) + .catch(smartAccount.errors.translateAndThrowAnchorError), + /AccountIndexLocked/ + ); + }); }); diff --git a/tests/suites/instructions/transactionSynchronous.ts b/tests/suites/instructions/transactionSynchronous.ts index 84db682..8c6d93d 100644 --- a/tests/suites/instructions/transactionSynchronous.ts +++ b/tests/suites/instructions/transactionSynchronous.ts @@ -827,4 +827,142 @@ describe("Instructions / transaction_execute_sync", () => { ); }, /DuplicateSigner/); }); + + it("error: sync transaction with locked account index", async () => { + const accountIndex = await getNextAccountIndex(connection, programId); + const [settingsPda] = await createAutonomousSmartAccountV2({ + connection, + accountIndex, + members, + threshold: 2, + timeLock: 0, + rentCollector: null, + programId, + }); + + // account_utilization starts at 0, so vault index 1 should fail + const [vaultPda] = smartAccount.getSmartAccountPda({ + settingsPda, + accountIndex: 1, // This index is locked + programId, + }); + + await connection.confirmTransaction( + await connection.requestAirdrop(vaultPda, 2 * LAMPORTS_PER_SOL) + ); + + const receiver = Keypair.generate(); + const transferInstruction = SystemProgram.transfer({ + fromPubkey: vaultPda, + toPubkey: receiver.publicKey, + lamports: LAMPORTS_PER_SOL, + }); + + const { instructions, accounts: instruction_accounts } = + smartAccount.utils.instructionsToSynchronousTransactionDetails({ + vaultPda, + members: [ + members.proposer.publicKey, + members.voter.publicKey, + members.almighty.publicKey, + ], + transaction_instructions: [transferInstruction], + }); + + const synchronousTransactionInstruction = + smartAccount.instructions.executeTransactionSync({ + settingsPda, + numSigners: 3, + accountIndex: 1, // Locked index + instructions, + instruction_accounts, + programId, + }); + + const message = new TransactionMessage({ + payerKey: members.almighty.publicKey, + recentBlockhash: (await connection.getLatestBlockhash()).blockhash, + instructions: [synchronousTransactionInstruction], + }).compileToV0Message(); + + const transaction = new VersionedTransaction(message); + transaction.sign([members.proposer, members.voter, members.almighty]); + + await assert.rejects( + async () => { + await connection + .sendRawTransaction(transaction.serialize()) + .catch(smartAccount.errors.translateAndThrowAnchorError); + }, + /AccountIndexLocked/ + ); + }); + + it("reserved account index (251) bypasses validation", async () => { + const accountIndex = await getNextAccountIndex(connection, programId); + const [settingsPda] = await createAutonomousSmartAccountV2({ + connection, + accountIndex, + members, + threshold: 2, + timeLock: 0, + rentCollector: null, + programId, + }); + + // Reserved account index 251 should bypass validation even with account_utilization = 0 + const [vaultPda] = smartAccount.getSmartAccountPda({ + settingsPda, + accountIndex: 251, // Reserved index - should bypass validation + programId, + }); + + await connection.confirmTransaction( + await connection.requestAirdrop(vaultPda, 2 * LAMPORTS_PER_SOL) + ); + + const receiver = Keypair.generate(); + const transferInstruction = SystemProgram.transfer({ + fromPubkey: vaultPda, + toPubkey: receiver.publicKey, + lamports: LAMPORTS_PER_SOL, + }); + + const { instructions, accounts: instruction_accounts } = + smartAccount.utils.instructionsToSynchronousTransactionDetails({ + vaultPda, + members: [ + members.proposer.publicKey, + members.voter.publicKey, + members.almighty.publicKey, + ], + transaction_instructions: [transferInstruction], + }); + + const synchronousTransactionInstruction = + smartAccount.instructions.executeTransactionSync({ + settingsPda, + numSigners: 3, + accountIndex: 251, // Reserved index + instructions, + instruction_accounts, + programId, + }); + + const message = new TransactionMessage({ + payerKey: members.almighty.publicKey, + recentBlockhash: (await connection.getLatestBlockhash()).blockhash, + instructions: [synchronousTransactionInstruction], + }).compileToV0Message(); + + const transaction = new VersionedTransaction(message); + transaction.sign([members.proposer, members.voter, members.almighty]); + + const signature = await connection.sendRawTransaction(transaction.serialize()); + await connection.confirmTransaction(signature); + + // Verify the transfer succeeded + const receiverBalance = await connection.getBalance(receiver.publicKey); + assert.strictEqual(receiverBalance, LAMPORTS_PER_SOL); + }); });