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..65ff45f 100644 --- a/programs/squads_smart_account_program/src/events/account_events.rs +++ b/programs/squads_smart_account_program/src/events/account_events.rs @@ -165,3 +165,9 @@ pub struct SettingsChangePolicyEvent { pub settings: Settings, pub changes: Vec, } + +#[derive(BorshSerialize, BorshDeserialize)] +pub struct SetAccountIndexEvent { + pub settings: Settings, + pub settings_pubkey: 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..0773067 100644 --- a/programs/squads_smart_account_program/src/events/mod.rs +++ b/programs/squads_smart_account_program/src/events/mod.rs @@ -22,6 +22,7 @@ pub enum SmartAccountEvent { SynchronousTransactionEventV2(SynchronousTransactionEventV2), SettingsChangePolicyEvent(SettingsChangePolicyEvent), PolicyEvent(PolicyEvent), + SetAccountIndexEvent(SetAccountIndexEvent), } pub struct LogAuthorityInfo<'info> { pub authority: AccountInfo<'info>, diff --git a/programs/squads_smart_account_program/src/instructions/mod.rs b/programs/squads_smart_account_program/src/instructions/mod.rs index 07a1f8f..0f5b707 100644 --- a/programs/squads_smart_account_program/src/instructions/mod.rs +++ b/programs/squads_smart_account_program/src/instructions/mod.rs @@ -24,6 +24,7 @@ pub use transaction_execute::*; pub use transaction_execute_sync::*; pub use transaction_execute_sync_legacy::*; pub use use_spending_limit::*; +pub use set_account_index::*; mod activate_proposal; mod authority_settings_transaction_execute; @@ -51,3 +52,4 @@ mod transaction_execute; mod transaction_execute_sync; mod transaction_execute_sync_legacy; mod use_spending_limit; +mod set_account_index; diff --git a/programs/squads_smart_account_program/src/instructions/set_account_index.rs b/programs/squads_smart_account_program/src/instructions/set_account_index.rs new file mode 100644 index 0000000..00274a0 --- /dev/null +++ b/programs/squads_smart_account_program/src/instructions/set_account_index.rs @@ -0,0 +1,73 @@ +use anchor_lang::prelude::*; +use anchor_lang::solana_program::pubkey; + +use crate::{ + errors::SmartAccountError, + events::*, + program::SquadsSmartAccountProgram, + state::{Settings, SEED_PREFIX, SEED_SETTINGS, get_settings_signer_seeds}, +}; + +pub const FREE_ACCOUNT_MAX_INDEX: u8 = 249; + +#[cfg(not(feature = "testing"))] +const PAYMASTER: Pubkey = pubkey!("7kEydiJ9en86ESNpwpZ45khxX8usgjUmWxbSTzkSbXkR"); + +#[cfg(feature = "testing")] +const PAYMASTER: Pubkey = pubkey!("BHpoHAaFDFPwDP47mcpy2R4fXX66NsUe8y8RFkfrEMNG"); + +#[derive(AnchorSerialize, AnchorDeserialize)] +pub struct SetAccountIndexArgs { + pub new_index: u8, +} + +#[derive(Accounts)] +pub struct SetAccountIndex<'info> { + #[account( + mut, + seeds = [ + SEED_PREFIX, + SEED_SETTINGS, + &settings.seed.to_le_bytes(), + ], + bump = settings.bump, + )] + pub settings: Account<'info, Settings>, + + #[account( + address = PAYMASTER @ SmartAccountError::Unauthorized + )] + pub paymaster: Signer<'info>, + + pub program: Program<'info, SquadsSmartAccountProgram>, +} + +impl SetAccountIndex<'_> { + fn validate(&self, args: &SetAccountIndexArgs) -> Result<()> { + require!( + args.new_index <= FREE_ACCOUNT_MAX_INDEX, + SmartAccountError::InvalidInstructionArgs + ); + Ok(()) + } + + #[access_control(ctx.accounts.validate(&args))] + pub fn set_account_index(ctx: Context, args: SetAccountIndexArgs) -> Result<()> { + let settings = &mut ctx.accounts.settings; + settings.account_utilization = args.new_index; + + let event = SetAccountIndexEvent { + settings: Settings::try_from_slice(&settings.try_to_vec()?)?, + settings_pubkey: settings.key(), + }; + 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::SetAccountIndexEvent(event).log(&log_authority_info)?; + + Ok(()) + } +} diff --git a/programs/squads_smart_account_program/src/lib.rs b/programs/squads_smart_account_program/src/lib.rs index 5c36d5d..5ce5353 100644 --- a/programs/squads_smart_account_program/src/lib.rs +++ b/programs/squads_smart_account_program/src/lib.rs @@ -340,4 +340,10 @@ pub mod squads_smart_account_program { ) -> Result<()> { LogEvent::log_event(ctx, args) } + + /// Set the account utilization index for a smart account. + /// Callable only by the paymaster key. + pub fn set_account_index(ctx: Context, args: SetAccountIndexArgs) -> Result<()> { + SetAccountIndex::set_account_index(ctx, args) + } } diff --git a/sdk/smart-account/idl/squads_smart_account_program.json b/sdk/smart-account/idl/squads_smart_account_program.json index 3382873..9319868 100644 --- a/sdk/smart-account/idl/squads_smart_account_program.json +++ b/sdk/smart-account/idl/squads_smart_account_program.json @@ -1858,6 +1858,38 @@ } } ] + }, + { + "name": "setAccountIndex", + "docs": [ + "Set the account utilization index for a smart account.", + "Callable only by the paymaster key." + ], + "accounts": [ + { + "name": "settings", + "isMut": true, + "isSigner": false + }, + { + "name": "paymaster", + "isMut": false, + "isSigner": true + }, + { + "name": "program", + "isMut": false, + "isSigner": false + } + ], + "args": [ + { + "name": "args", + "type": { + "defined": "SetAccountIndexArgs" + } + } + ] } ], "accounts": [ @@ -3114,6 +3146,18 @@ ] } }, + { + "name": "SetAccountIndexArgs", + "type": { + "kind": "struct", + "fields": [ + { + "name": "newIndex", + "type": "u8" + } + ] + } + }, { "name": "CreateSettingsTransactionArgs", "type": { diff --git a/sdk/smart-account/src/generated/instructions/index.ts b/sdk/smart-account/src/generated/instructions/index.ts index f7c85e5..d115d46 100644 --- a/sdk/smart-account/src/generated/instructions/index.ts +++ b/sdk/smart-account/src/generated/instructions/index.ts @@ -30,6 +30,7 @@ export * from './logEvent' export * from './rejectProposal' export * from './removeSignerAsAuthority' export * from './removeSpendingLimitAsAuthority' +export * from './setAccountIndex' export * from './setArchivalAuthorityAsAuthority' export * from './setNewSettingsAuthorityAsAuthority' export * from './setProgramConfigAuthority' diff --git a/sdk/smart-account/src/generated/instructions/setAccountIndex.ts b/sdk/smart-account/src/generated/instructions/setAccountIndex.ts new file mode 100644 index 0000000..3fe2709 --- /dev/null +++ b/sdk/smart-account/src/generated/instructions/setAccountIndex.ts @@ -0,0 +1,109 @@ +/** + * This code was GENERATED using the solita package. + * Please DO NOT EDIT THIS FILE, instead rerun solita to update it or write a wrapper to add functionality. + * + * See: https://github.com/metaplex-foundation/solita + */ + +import * as beet from '@metaplex-foundation/beet' +import * as web3 from '@solana/web3.js' +import { + SetAccountIndexArgs, + setAccountIndexArgsBeet, +} from '../types/SetAccountIndexArgs' + +/** + * @category Instructions + * @category SetAccountIndex + * @category generated + */ +export type SetAccountIndexInstructionArgs = { + args: SetAccountIndexArgs +} +/** + * @category Instructions + * @category SetAccountIndex + * @category generated + */ +export const setAccountIndexStruct = new beet.BeetArgsStruct< + SetAccountIndexInstructionArgs & { + instructionDiscriminator: number[] /* size: 8 */ + } +>( + [ + ['instructionDiscriminator', beet.uniformFixedSizeArray(beet.u8, 8)], + ['args', setAccountIndexArgsBeet], + ], + 'SetAccountIndexInstructionArgs' +) +/** + * Accounts required by the _setAccountIndex_ instruction + * + * @property [_writable_] settings + * @property [**signer**] paymaster + * @property [] program + * @category Instructions + * @category SetAccountIndex + * @category generated + */ +export type SetAccountIndexInstructionAccounts = { + settings: web3.PublicKey + paymaster: web3.PublicKey + program: web3.PublicKey + anchorRemainingAccounts?: web3.AccountMeta[] +} + +export const setAccountIndexInstructionDiscriminator = [ + 203, 233, 20, 64, 189, 188, 245, 67, +] + +/** + * Creates a _SetAccountIndex_ instruction. + * + * @param accounts that will be accessed while the instruction is processed + * @param args to provide as instruction data to the program + * + * @category Instructions + * @category SetAccountIndex + * @category generated + */ +export function createSetAccountIndexInstruction( + accounts: SetAccountIndexInstructionAccounts, + args: SetAccountIndexInstructionArgs, + programId = new web3.PublicKey('SMRTzfY6DfH5ik3TKiyLFfXexV8uSG3d2UksSCYdunG') +) { + const [data] = setAccountIndexStruct.serialize({ + instructionDiscriminator: setAccountIndexInstructionDiscriminator, + ...args, + }) + const keys: web3.AccountMeta[] = [ + { + pubkey: accounts.settings, + isWritable: true, + isSigner: false, + }, + { + pubkey: accounts.paymaster, + 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/types/SetAccountIndexArgs.ts b/sdk/smart-account/src/generated/types/SetAccountIndexArgs.ts new file mode 100644 index 0000000..e59217a --- /dev/null +++ b/sdk/smart-account/src/generated/types/SetAccountIndexArgs.ts @@ -0,0 +1,21 @@ +/** + * This code was GENERATED using the solita package. + * Please DO NOT EDIT THIS FILE, instead rerun solita to update it or write a wrapper to add functionality. + * + * See: https://github.com/metaplex-foundation/solita + */ + +import * as beet from '@metaplex-foundation/beet' +export type SetAccountIndexArgs = { + newIndex: number +} + +/** + * @category userTypes + * @category generated + */ +export const setAccountIndexArgsBeet = + new beet.BeetArgsStruct( + [['newIndex', beet.u8]], + 'SetAccountIndexArgs' + ) diff --git a/sdk/smart-account/src/generated/types/index.ts b/sdk/smart-account/src/generated/types/index.ts index 0580c4d..be3e88e 100644 --- a/sdk/smart-account/src/generated/types/index.ts +++ b/sdk/smart-account/src/generated/types/index.ts @@ -53,6 +53,7 @@ export * from './ProposalStatus' export * from './QuantityConstraints' export * from './RemoveSignerArgs' export * from './RemoveSpendingLimitArgs' +export * from './SetAccountIndexArgs' export * from './SetArchivalAuthorityArgs' export * from './SetNewSettingsAuthorityArgs' export * from './SetTimeLockArgs' diff --git a/tests/fixtures/paymaster-test.json b/tests/fixtures/paymaster-test.json new file mode 100644 index 0000000..f26f293 --- /dev/null +++ b/tests/fixtures/paymaster-test.json @@ -0,0 +1 @@ +[49,87,162,112,58,101,205,198,205,159,92,228,246,195,192,216,22,27,112,168,116,13,3,97,109,53,41,19,44,236,23,172,152,228,53,29,45,72,43,205,72,221,144,142,193,123,101,110,114,17,216,206,37,240,242,174,168,26,164,195,235,182,43,57] \ No newline at end of file diff --git a/tests/suites/examples/set-account-index.ts b/tests/suites/examples/set-account-index.ts new file mode 100644 index 0000000..90c98d3 --- /dev/null +++ b/tests/suites/examples/set-account-index.ts @@ -0,0 +1,158 @@ +import { + Keypair, + TransactionMessage, + VersionedTransaction, +} from "@solana/web3.js"; +import * as smartAccount from "@sqds/smart-account"; +import assert from "assert"; +import { readFileSync } from "fs"; +import path from "path"; +import { + createAutonomousMultisig, + createLocalhostConnection, + generateSmartAccountSigners, + getNextAccountIndex, + getTestProgramId, + TestMembers, +} from "../../utils"; + +const programId = getTestProgramId(); +const { Settings } = smartAccount.accounts; + +function getTestPaymaster(): Keypair { + return Keypair.fromSecretKey( + Buffer.from( + JSON.parse( + readFileSync( + path.join(__dirname, "../../fixtures/paymaster-test.json"), + "utf-8" + ) + ) + ) + ); +} + +describe("Examples / Set Account Index", () => { + const connection = createLocalhostConnection(); + + let members: TestMembers; + let paymaster: Keypair; + + before(async () => { + members = await generateSmartAccountSigners(connection); + paymaster = getTestPaymaster(); + + // Fund the paymaster + const sig = await connection.requestAirdrop( + paymaster.publicKey, + 1_000_000_000 + ); + await connection.confirmTransaction(sig); + }); + + it("paymaster can set account index", async () => { + const accountIndex = await getNextAccountIndex(connection, programId); + + const [settingsPda] = await createAutonomousMultisig({ + connection, + members, + threshold: 1, + timeLock: 0, + programId, + accountIndex, + }); + + // Verify initial account_utilization is 0 + let settingsAccount = await Settings.fromAccountAddress( + connection, + settingsPda + ); + assert.strictEqual(settingsAccount.accountUtilization, 0); + + // Set account index to 5 + const ix = smartAccount.generated.createSetAccountIndexInstruction( + { + settings: settingsPda, + paymaster: paymaster.publicKey, + program: programId, + }, + { + args: { newIndex: 5 }, + }, + programId + ); + + const message = new TransactionMessage({ + payerKey: paymaster.publicKey, + recentBlockhash: (await connection.getLatestBlockhash()).blockhash, + instructions: [ix], + }).compileToV0Message(); + + const tx = new VersionedTransaction(message); + tx.sign([paymaster]); + + const signature = await connection.sendRawTransaction(tx.serialize(), { + skipPreflight: true, + }); + await connection.confirmTransaction(signature); + + // Verify account_utilization is now 5 + settingsAccount = await Settings.fromAccountAddress( + connection, + settingsPda + ); + assert.strictEqual(settingsAccount.accountUtilization, 5); + }); + + it("non-paymaster cannot set account index", async () => { + const accountIndex = await getNextAccountIndex(connection, programId); + + const [settingsPda] = await createAutonomousMultisig({ + connection, + members, + threshold: 1, + timeLock: 0, + programId, + accountIndex, + }); + + // Try to set account index with a random keypair (not the paymaster) + const fakeSigner = members.almighty; + + const ix = smartAccount.generated.createSetAccountIndexInstruction( + { + settings: settingsPda, + paymaster: fakeSigner.publicKey, + program: programId, + }, + { + args: { newIndex: 5 }, + }, + programId + ); + + const message = new TransactionMessage({ + payerKey: fakeSigner.publicKey, + recentBlockhash: (await connection.getLatestBlockhash()).blockhash, + instructions: [ix], + }).compileToV0Message(); + + const tx = new VersionedTransaction(message); + tx.sign([fakeSigner]); + + await assert.rejects( + () => + connection + .sendRawTransaction(tx.serialize()) + .catch(smartAccount.errors.translateAndThrowAnchorError), + /Unauthorized/ + ); + + // Verify account_utilization is still 0 + const settingsAccount = await Settings.fromAccountAddress( + connection, + settingsPda + ); + assert.strictEqual(settingsAccount.accountUtilization, 0); + }); +});