diff --git a/interface/src/lib.rs b/interface/src/lib.rs index 19207559..55c63d14 100644 --- a/interface/src/lib.rs +++ b/interface/src/lib.rs @@ -16,9 +16,10 @@ use swig::actions::{ pub use swig_compact_instructions::*; use swig_state_x::{ action::{ - all::All, manage_authority::ManageAuthority, program::Program, program_scope::ProgramScope, - sol_limit::SolLimit, sol_recurring_limit::SolRecurringLimit, stake_all::StakeAll, - stake_limit::StakeLimit, stake_recurring_limit::StakeRecurringLimit, + all::All, authorization_lock::AuthorizationLock, manage_authority::ManageAuthority, + manage_authorization_lock::ManageAuthorizationLock, program::Program, + program_scope::ProgramScope, sol_limit::SolLimit, sol_recurring_limit::SolRecurringLimit, + stake_all::StakeAll, stake_limit::StakeLimit, stake_recurring_limit::StakeRecurringLimit, sub_account::SubAccount, token_limit::TokenLimit, token_recurring_limit::TokenRecurringLimit, Action, Permission, }, @@ -43,6 +44,8 @@ pub enum ClientAction { StakeLimit(StakeLimit), StakeRecurringLimit(StakeRecurringLimit), StakeAll(StakeAll), + AuthorizationLock(AuthorizationLock), + ManageAuthorizationLock(ManageAuthorizationLock), } impl ClientAction { @@ -66,6 +69,13 @@ impl ClientAction { (Permission::StakeRecurringLimit, StakeRecurringLimit::LEN) }, ClientAction::StakeAll(_) => (Permission::StakeAll, StakeAll::LEN), + ClientAction::AuthorizationLock(_) => { + (Permission::AuthorizationLock, AuthorizationLock::LEN) + }, + ClientAction::ManageAuthorizationLock(_) => ( + Permission::ManageAuthorizationLock, + ManageAuthorizationLock::LEN, + ), }; let offset = data.len() as u32; let header = Action::new( @@ -90,6 +100,8 @@ impl ClientAction { ClientAction::StakeLimit(action) => action.into_bytes(), ClientAction::StakeRecurringLimit(action) => action.into_bytes(), ClientAction::StakeAll(action) => action.into_bytes(), + ClientAction::AuthorizationLock(action) => action.into_bytes(), + ClientAction::ManageAuthorizationLock(action) => action.into_bytes(), }; data.extend_from_slice( bytes_res.map_err(|e| anyhow::anyhow!("Failed to serialize action {:?}", e))?, diff --git a/program/src/actions/add_actions_to_role_v1.rs b/program/src/actions/add_actions_to_role_v1.rs new file mode 100644 index 00000000..19950bc6 --- /dev/null +++ b/program/src/actions/add_actions_to_role_v1.rs @@ -0,0 +1,385 @@ +/// Module for adding actions to an existing role in a Swig wallet. +/// This module implements the functionality to append additional actions to +/// a role's existing action set. +use no_padding::NoPadding; +use pinocchio::{ + account_info::AccountInfo, + msg, + program_error::ProgramError, + sysvars::{clock::Clock, rent::Rent, Sysvar}, + ProgramResult, +}; +use pinocchio_system::instructions::Transfer; +use swig_assertions::{check_bytes_match, check_self_owned}; +use swig_state_x::{ + action::{ + all::All, manage_authority::ManageAuthority, + manage_authorization_lock::ManageAuthorizationLock, Action, ActionLoader, Permission, + }, + role::Position, + swig::{Swig, SwigBuilder}, + Discriminator, IntoBytes, SwigAuthenticateError, Transmutable, TransmutableMut, +}; + +use crate::{ + error::SwigError, + instruction::{ + accounts::{AddActionsToRoleV1Accounts, Context}, + SwigInstruction, + }, +}; + +/// Struct representing the complete add actions to role instruction data. +/// +/// # Fields +/// * `args` - The add actions to role arguments +/// * `data_payload` - Raw data payload +/// * `authority_payload` - Authority-specific payload data +/// * `actions` - Actions data to be added +pub struct AddActionsToRoleV1<'a> { + pub args: &'a AddActionsToRoleV1Args, + data_payload: &'a [u8], + authority_payload: &'a [u8], + actions: &'a [u8], +} + +/// Arguments for adding actions to an existing role in a Swig wallet. +/// +/// # Fields +/// * `instruction` - The instruction type identifier +/// * `actions_data_len` - Length of the actions data +/// * `num_actions` - Number of actions to add +/// * `_padding` - Padding bytes for alignment +/// * `target_role_id` - ID of the role to add actions to +/// * `acting_role_id` - ID of the role performing the addition +#[repr(C, align(8))] +#[derive(Debug, NoPadding)] +pub struct AddActionsToRoleV1Args { + pub instruction: SwigInstruction, + pub actions_data_len: u16, + pub num_actions: u8, + _padding: [u8; 3], + pub target_role_id: u32, + pub acting_role_id: u32, +} + +impl Transmutable for AddActionsToRoleV1Args { + const LEN: usize = core::mem::size_of::(); +} + +impl AddActionsToRoleV1Args { + /// Creates a new instance of AddActionsToRoleV1Args. + /// + /// # Arguments + /// * `acting_role_id` - ID of the role performing the addition + /// * `target_role_id` - ID of the role to add actions to + /// * `actions_data_len` - Length of the actions data + /// * `num_actions` - Number of actions to add + pub fn new( + acting_role_id: u32, + target_role_id: u32, + actions_data_len: u16, + num_actions: u8, + ) -> Self { + Self { + instruction: SwigInstruction::AddActionsToRoleV1, + actions_data_len, + num_actions, + _padding: [0; 3], + target_role_id, + acting_role_id, + } + } +} + +impl IntoBytes for AddActionsToRoleV1Args { + fn into_bytes(&self) -> Result<&[u8], ProgramError> { + Ok(unsafe { core::slice::from_raw_parts(self as *const Self as *const u8, Self::LEN) }) + } +} + +impl<'a> AddActionsToRoleV1<'a> { + /// Parses the instruction data bytes into an AddActionsToRoleV1 instance. + /// + /// # Arguments + /// * `data` - Raw instruction data bytes + /// + /// # Returns + /// * `Result` - Parsed instruction or error + pub fn from_instruction_bytes(data: &'a [u8]) -> Result { + if data.len() < AddActionsToRoleV1Args::LEN { + return Err(SwigError::InvalidSwigAddActionsToRoleInstructionDataTooShort.into()); + } + + let (inst, rest) = data.split_at(AddActionsToRoleV1Args::LEN); + let args = unsafe { AddActionsToRoleV1Args::load_unchecked(inst)? }; + let (actions_payload, authority_payload) = rest.split_at(args.actions_data_len as usize); + + Ok(Self { + args, + authority_payload, + actions: actions_payload, + data_payload: &data[..AddActionsToRoleV1Args::LEN + args.actions_data_len as usize], + }) + } +} + +/// Adds actions to an existing role in a Swig wallet. +/// +/// This function handles the complete flow of adding actions to a role: +/// 1. Validates the acting role's permissions +/// 2. Authenticates the request +/// 3. Finds the target role +/// 4. Allocates space for the new actions +/// 5. Appends the actions to the role's existing action set +/// +/// # Arguments +/// * `ctx` - The account context for adding actions to role +/// * `add` - Raw add actions to role instruction data +/// * `all_accounts` - All accounts involved in the operation +/// +/// # Returns +/// * `ProgramResult` - Success or error status +pub fn add_actions_to_role_v1( + ctx: Context, + add: &[u8], + all_accounts: &[AccountInfo], +) -> ProgramResult { + check_self_owned(ctx.accounts.swig, SwigError::OwnerMismatchSwigAccount)?; + check_bytes_match( + ctx.accounts.system_program.key(), + &pinocchio_system::ID, + 32, + SwigError::InvalidSystemProgram, + )?; + + let add_actions_to_role_v1 = AddActionsToRoleV1::from_instruction_bytes(add).map_err(|e| { + msg!("AddActionsToRoleV1 Args Error: {:?}", e); + ProgramError::InvalidInstructionData + })?; + + if add_actions_to_role_v1.args.num_actions == 0 { + return Err(SwigError::InvalidAuthorityMustHaveAtLeastOneAction.into()); + } + + let swig_account_data = unsafe { ctx.accounts.swig.borrow_mut_data_unchecked() }; + let swig_data_len = swig_account_data.len(); + + // Find and validate the target role, calculate space needed + let (new_reserved_lamports, target_role_boundary, target_role_offset) = { + if swig_account_data[0] != Discriminator::SwigAccount as u8 { + return Err(SwigError::InvalidSwigAccountDiscriminator.into()); + } + + let (swig_header, swig_roles) = + unsafe { swig_account_data.split_at_mut_unchecked(Swig::LEN) }; + let swig = unsafe { Swig::load_mut_unchecked(swig_header)? }; + + // Find the acting role + let acting_role = + Swig::get_mut_role(add_actions_to_role_v1.args.acting_role_id, swig_roles)?; + if acting_role.is_none() { + return Err(SwigError::InvalidAuthorityNotFoundByRoleId.into()); + } + let acting_role = acting_role.unwrap(); + + // Authenticate the caller + let clock = Clock::get()?; + let slot = clock.slot; + + if acting_role.authority.session_based() { + acting_role.authority.authenticate_session( + all_accounts, + add_actions_to_role_v1.authority_payload, + add_actions_to_role_v1.data_payload, + slot, + )?; + } else { + acting_role.authority.authenticate( + all_accounts, + add_actions_to_role_v1.authority_payload, + add_actions_to_role_v1.data_payload, + slot, + )?; + } + + // Check permissions + let all = acting_role.get_action::(&[])?; + let manage_authority = acting_role.get_action::(&[])?; + + if all.is_none() && manage_authority.is_none() { + return Err(SwigAuthenticateError::PermissionDeniedToManageAuthority.into()); + } + + // Check if any actions being added are AuthorizationLock and verify permission + let mut action_cursor = 0; + for _i in 0..add_actions_to_role_v1.args.num_actions { + let header = + &add_actions_to_role_v1.actions[action_cursor..action_cursor + Action::LEN]; + let action_header = unsafe { Action::load_unchecked(header)? }; + + if action_header.permission()? == Permission::AuthorizationLock { + let manage_auth_lock = acting_role.get_action::(&[])?; + if all.is_none() && manage_auth_lock.is_none() { + return Err(SwigAuthenticateError::PermissionDeniedToManageAuthority.into()); + } + } + + action_cursor += Action::LEN + action_header.length() as usize; + } + + // Find the target role + let mut cursor = 0; + let mut target_found = false; + let mut target_role_offset = 0; + let mut target_role_boundary = 0; + + for _i in 0..swig.roles { + let position = + unsafe { Position::load_unchecked(&swig_roles[cursor..cursor + Position::LEN])? }; + + if position.id() == add_actions_to_role_v1.args.target_role_id { + target_found = true; + target_role_offset = cursor; + target_role_boundary = position.boundary() as usize; + break; + } + + cursor = position.boundary() as usize; + } + + if !target_found { + return Err(SwigError::InvalidAuthorityNotFoundByRoleId.into()); + } + + // Calculate new account size + let additional_size = add_actions_to_role_v1.actions.len(); + let account_size = core::alloc::Layout::from_size_align( + swig_data_len + additional_size, + core::mem::size_of::(), + ) + .map_err(|_| SwigError::InvalidAlignment)? + .pad_to_align() + .size(); + + ctx.accounts.swig.realloc(account_size, false)?; + + let cost = Rent::get()? + .minimum_balance(account_size) + .checked_sub(swig.reserved_lamports) + .unwrap_or_default(); + + if cost > 0 { + Transfer { + from: ctx.accounts.payer, + to: ctx.accounts.swig, + lamports: cost, + } + .invoke()?; + } + + ( + swig.reserved_lamports + cost, + target_role_boundary, + target_role_offset, + ) + }; + + // Now modify the account data + let swig_account_data = unsafe { ctx.accounts.swig.borrow_mut_data_unchecked() }; + let mut swig_builder = SwigBuilder::new_from_bytes(swig_account_data)?; + swig_builder.swig.reserved_lamports = new_reserved_lamports; + + // Calculate the new data size and shift existing data + let additional_size = add_actions_to_role_v1.actions.len(); + let shift_from = target_role_boundary; + let shift_to = target_role_boundary + additional_size; + + // Calculate how much data we have after the target role + let old_role_buffer_len = swig_data_len - Swig::LEN; + let data_to_shift = old_role_buffer_len.saturating_sub(shift_from); + + if data_to_shift > 0 && shift_from < old_role_buffer_len { + // Shift data to make room for new actions + swig_builder + .role_buffer + .copy_within(shift_from..shift_from + data_to_shift, shift_to); + } + + // Update all role boundaries that come after the target role + let mut cursor = 0; + for i in 0..swig_builder.swig.roles { + let position = unsafe { + Position::load_mut_unchecked( + &mut swig_builder.role_buffer[cursor..cursor + Position::LEN], + )? + }; + + let old_boundary = position.boundary as usize; + + if cursor == target_role_offset { + // This is the target role, update its num_actions and boundary + position.num_actions += add_actions_to_role_v1.args.num_actions as u16; + position.boundary += additional_size as u32; + } else if cursor > target_role_offset { + // This role comes after the target, update its boundary + position.boundary += additional_size as u32; + } + + // Move to next role using the original boundary + cursor = if old_boundary <= target_role_boundary { + old_boundary + } else { + old_boundary + additional_size + }; + } + + // Add the new actions to the target role + let mut action_cursor = 0; + let mut insert_cursor = target_role_boundary; + + for _i in 0..add_actions_to_role_v1.args.num_actions { + let header = &add_actions_to_role_v1.actions[action_cursor..action_cursor + Action::LEN]; + let action_header = unsafe { Action::load_unchecked(header)? }; + action_cursor += Action::LEN; + + let action_slice = &add_actions_to_role_v1.actions + [action_cursor..action_cursor + action_header.length() as usize]; + action_cursor += action_header.length() as usize; + + if ActionLoader::validate_layout(action_header.permission()?, action_slice)? { + // Copy action header + swig_builder.role_buffer[insert_cursor..insert_cursor + Action::LEN] + .copy_from_slice(header); + + // Fix boundary: position where next action starts within actions buffer + // This should match the pattern used in add_role method + let actions_start = target_role_offset + + Position::LEN + + unsafe { + Position::load_unchecked( + &swig_builder.role_buffer + [target_role_offset..target_role_offset + Position::LEN], + )? + .authority_length() as usize + }; + let current_action_pos_in_actions = insert_cursor - actions_start; + let next_action_pos_in_actions = + current_action_pos_in_actions + Action::LEN + action_header.length() as usize; + swig_builder.role_buffer[insert_cursor + 4..insert_cursor + 8] + .copy_from_slice(&(next_action_pos_in_actions as u32).to_le_bytes()); + + insert_cursor += Action::LEN; + + // Copy action data + swig_builder.role_buffer + [insert_cursor..insert_cursor + action_header.length() as usize] + .copy_from_slice(action_slice); + insert_cursor += action_header.length() as usize; + } else { + return Err(ProgramError::InvalidAccountData); + } + } + + Ok(()) +} diff --git a/program/src/actions/add_authority_v1.rs b/program/src/actions/add_authority_v1.rs index ef360af4..9d422376 100644 --- a/program/src/actions/add_authority_v1.rs +++ b/program/src/actions/add_authority_v1.rs @@ -12,7 +12,10 @@ use pinocchio::{ use pinocchio_system::instructions::Transfer; use swig_assertions::{check_bytes_match, check_self_owned}; use swig_state_x::{ - action::{all::All, manage_authority::ManageAuthority}, + action::{ + all::All, manage_authority::ManageAuthority, + manage_authorization_lock::ManageAuthorizationLock, Action, Permission, + }, authority::{authority_type_to_length, AuthorityType}, role::Position, swig::{Swig, SwigBuilder}, @@ -207,10 +210,29 @@ pub fn add_authority_v1( } let all = acting_role.get_action::(&[])?; let manage_authority = acting_role.get_action::(&[])?; + let manage_auth_lock = acting_role.get_action::(&[])?; if all.is_none() && manage_authority.is_none() { return Err(SwigAuthenticateError::PermissionDeniedToManageAuthority.into()); } + + // Check if any actions being added are AuthorizationLock and verify permission + let has_all = all.is_some(); + let has_manage_auth_lock = manage_auth_lock.is_some(); + + let mut action_cursor = 0; + for _i in 0..add_authority_v1.args.num_actions { + let header = &add_authority_v1.actions[action_cursor..action_cursor + Action::LEN]; + let action_header = unsafe { Action::load_unchecked(header)? }; + + if action_header.permission()? == Permission::AuthorizationLock { + if !has_all && !has_manage_auth_lock { + return Err(SwigAuthenticateError::PermissionDeniedToManageAuthority.into()); + } + } + + action_cursor += Action::LEN + action_header.length() as usize; + } let new_authority_length = authority_type_to_length(&new_authority_type)?; let role_size = Position::LEN + new_authority_length + add_authority_v1.actions.len(); diff --git a/program/src/actions/mod.rs b/program/src/actions/mod.rs index 6d7ef273..bef5a421 100644 --- a/program/src/actions/mod.rs +++ b/program/src/actions/mod.rs @@ -5,10 +5,12 @@ //! instruction type and handles the validation and execution of that //! instruction's business logic. +pub mod add_actions_to_role_v1; pub mod add_authority_v1; pub mod create_session_v1; pub mod create_sub_account_v1; pub mod create_v1; +pub mod remove_actions_from_role_v1; pub mod remove_authority_v1; pub mod sign_v1; pub mod sub_account_sign_v1; @@ -19,19 +21,21 @@ use num_enum::FromPrimitive; use pinocchio::{account_info::AccountInfo, msg, program_error::ProgramError, ProgramResult}; use self::{ - add_authority_v1::*, create_session_v1::*, create_sub_account_v1::*, create_v1::*, - remove_authority_v1::*, sign_v1::*, sub_account_sign_v1::*, toggle_sub_account_v1::*, - withdraw_from_sub_account_v1::*, + add_actions_to_role_v1::*, add_authority_v1::*, create_session_v1::*, create_sub_account_v1::*, + create_v1::*, remove_actions_from_role_v1::*, remove_authority_v1::*, sign_v1::*, + sub_account_sign_v1::*, toggle_sub_account_v1::*, withdraw_from_sub_account_v1::*, }; use crate::{ instruction::{ accounts::{ - AddAuthorityV1Accounts, CreateSessionV1Accounts, CreateSubAccountV1Accounts, - CreateV1Accounts, RemoveAuthorityV1Accounts, SignV1Accounts, SubAccountSignV1Accounts, + AddActionsToRoleV1Accounts, AddAuthorityV1Accounts, CreateSessionV1Accounts, + CreateSubAccountV1Accounts, CreateV1Accounts, RemoveActionsFromRoleV1Accounts, + RemoveAuthorityV1Accounts, SignV1Accounts, SubAccountSignV1Accounts, ToggleSubAccountV1Accounts, WithdrawFromSubAccountV1Accounts, }, SwigInstruction, }, + util::AuthorizationLockCache, AccountClassification, }; @@ -45,6 +49,7 @@ use crate::{ /// * `accounts` - List of accounts involved in the instruction /// * `account_classification` - Classification of each account's type and role /// * `data` - Raw instruction data +/// * `authorization_lock_cache` - Optional cache of authorization locks /// /// # Returns /// * `ProgramResult` - Success or error status @@ -53,6 +58,7 @@ pub fn process_action( accounts: &[AccountInfo], account_classification: &[AccountClassification], data: &[u8], + authorization_lock_cache: Option<&AuthorizationLockCache>, ) -> ProgramResult { if data.len() < 2 { return Err(ProgramError::InvalidInstructionData); @@ -61,7 +67,12 @@ pub fn process_action( let ix = SwigInstruction::from_primitive(discriminator); match ix { SwigInstruction::CreateV1 => process_create_v1(accounts, data), - SwigInstruction::SignV1 => process_sign_v1(accounts, account_classification, data), + SwigInstruction::SignV1 => process_sign_v1( + accounts, + account_classification, + data, + authorization_lock_cache, + ), SwigInstruction::AddAuthorityV1 => process_add_authority_v1(accounts, data), SwigInstruction::RemoveAuthorityV1 => process_remove_authority_v1(accounts, data), SwigInstruction::CreateSessionV1 => process_create_session_v1(accounts, data), @@ -73,6 +84,10 @@ pub fn process_action( process_sub_account_sign_v1(accounts, account_classification, data) }, SwigInstruction::ToggleSubAccountV1 => process_toggle_sub_account_v1(accounts, data), + SwigInstruction::AddActionsToRoleV1 => process_add_actions_to_role_v1(accounts, data), + SwigInstruction::RemoveActionsFromRoleV1 => { + process_remove_actions_from_role_v1(accounts, data) + }, } } @@ -91,9 +106,16 @@ fn process_sign_v1( accounts: &[AccountInfo], account_classification: &[AccountClassification], data: &[u8], + authorization_lock_cache: Option<&AuthorizationLockCache>, ) -> ProgramResult { let account_ctx = SignV1Accounts::context(accounts)?; - sign_v1(account_ctx, accounts, data, account_classification) + sign_v1( + account_ctx, + accounts, + data, + account_classification, + authorization_lock_cache, + ) } /// Processes an AddAuthorityV1 instruction. @@ -159,3 +181,19 @@ fn process_toggle_sub_account_v1(accounts: &[AccountInfo], data: &[u8]) -> Progr let account_ctx = ToggleSubAccountV1Accounts::context(accounts)?; toggle_sub_account_v1(account_ctx, data, accounts) } + +/// Processes an AddActionsToRoleV1 instruction. +/// +/// Adds actions to an existing role with specified permissions. +fn process_add_actions_to_role_v1(accounts: &[AccountInfo], data: &[u8]) -> ProgramResult { + let account_ctx = AddActionsToRoleV1Accounts::context(accounts)?; + add_actions_to_role_v1(account_ctx, data, accounts) +} + +/// Processes a RemoveActionsFromRoleV1 instruction. +/// +/// Removes actions from an existing role by their indices. +fn process_remove_actions_from_role_v1(accounts: &[AccountInfo], data: &[u8]) -> ProgramResult { + let account_ctx = RemoveActionsFromRoleV1Accounts::context(accounts)?; + remove_actions_from_role_v1(account_ctx, data, accounts) +} diff --git a/program/src/actions/remove_actions_from_role_v1.rs b/program/src/actions/remove_actions_from_role_v1.rs new file mode 100644 index 00000000..a957c9b3 --- /dev/null +++ b/program/src/actions/remove_actions_from_role_v1.rs @@ -0,0 +1,471 @@ +/// Module for removing actions from an existing role in a Swig wallet. +/// This module implements the functionality to remove specific actions from +/// a role's action set by their indices. +use no_padding::NoPadding; +use pinocchio::{ + account_info::AccountInfo, + msg, + program_error::ProgramError, + sysvars::{clock::Clock, rent::Rent, Sysvar}, + ProgramResult, +}; +use swig_assertions::{check_bytes_match, check_self_owned}; +use swig_state_x::{ + action::{ + all::All, authorization_lock::AuthorizationLock, manage_authority::ManageAuthority, + manage_authorization_lock::ManageAuthorizationLock, Action, Permission, + }, + role::Position, + swig::{Swig, SwigBuilder}, + Discriminator, IntoBytes, SwigAuthenticateError, Transmutable, TransmutableMut, +}; + +use crate::{ + error::SwigError, + instruction::{ + accounts::{Context, RemoveActionsFromRoleV1Accounts}, + SwigInstruction, + }, +}; + +/// Struct representing the complete remove actions from role instruction data. +/// +/// # Fields +/// * `args` - The remove actions from role arguments +/// * `data_payload` - Raw data payload +/// * `authority_payload` - Authority-specific payload data +/// * `action_indices` - Indices of actions to remove +pub struct RemoveActionsFromRoleV1<'a> { + pub args: &'a RemoveActionsFromRoleV1Args, + data_payload: &'a [u8], + authority_payload: &'a [u8], + action_indices: &'a [u8], +} + +/// Arguments for removing actions from an existing role in a Swig wallet. +/// +/// # Fields +/// * `instruction` - The instruction type identifier +/// * `indices_count` - Number of indices to remove +/// * `_padding` - Padding bytes for alignment +/// * `target_role_id` - ID of the role to remove actions from +/// * `acting_role_id` - ID of the role performing the removal +#[repr(C, align(8))] +#[derive(Debug, NoPadding)] +pub struct RemoveActionsFromRoleV1Args { + pub instruction: SwigInstruction, + pub indices_count: u16, + _padding: [u8; 4], + pub target_role_id: u32, + pub acting_role_id: u32, +} + +impl Transmutable for RemoveActionsFromRoleV1Args { + const LEN: usize = core::mem::size_of::(); +} + +impl RemoveActionsFromRoleV1Args { + /// Creates a new instance of RemoveActionsFromRoleV1Args. + /// + /// # Arguments + /// * `acting_role_id` - ID of the role performing the removal + /// * `target_role_id` - ID of the role to remove actions from + /// * `indices_count` - Number of indices to remove + pub fn new(acting_role_id: u32, target_role_id: u32, indices_count: u16) -> Self { + Self { + instruction: SwigInstruction::RemoveActionsFromRoleV1, + indices_count, + _padding: [0; 4], + target_role_id, + acting_role_id, + } + } +} + +impl IntoBytes for RemoveActionsFromRoleV1Args { + fn into_bytes(&self) -> Result<&[u8], ProgramError> { + Ok(unsafe { core::slice::from_raw_parts(self as *const Self as *const u8, Self::LEN) }) + } +} + +impl<'a> RemoveActionsFromRoleV1<'a> { + /// Parses the instruction data bytes into a RemoveActionsFromRoleV1 + /// instance. + /// + /// # Arguments + /// * `data` - Raw instruction data bytes + /// + /// # Returns + /// * `Result` - Parsed instruction or error + pub fn from_instruction_bytes(data: &'a [u8]) -> Result { + if data.len() < RemoveActionsFromRoleV1Args::LEN { + return Err(SwigError::InvalidSwigRemoveActionsFromRoleInstructionDataTooShort.into()); + } + + let (inst, rest) = data.split_at(RemoveActionsFromRoleV1Args::LEN); + let args = unsafe { RemoveActionsFromRoleV1Args::load_unchecked(inst)? }; + + // Each index is a u16, so we need indices_count * 2 bytes + let indices_len = args.indices_count as usize * 2; + if rest.len() < indices_len { + return Err(SwigError::InvalidSwigRemoveActionsFromRoleInstructionDataTooShort.into()); + } + + let (action_indices, authority_payload) = rest.split_at(indices_len); + + Ok(Self { + args, + authority_payload, + action_indices, + data_payload: &data[..RemoveActionsFromRoleV1Args::LEN + indices_len], + }) + } + + /// Extracts the action indices from the raw bytes. + /// + /// # Returns + /// * `Vec` - Vector of action indices + pub fn get_indices(&self) -> Vec { + let mut indices = Vec::with_capacity(self.args.indices_count as usize); + for i in 0..self.args.indices_count as usize { + let start = i * 2; + let bytes = [self.action_indices[start], self.action_indices[start + 1]]; + indices.push(u16::from_le_bytes(bytes)); + } + indices + } +} + +/// Removes actions from an existing role in a Swig wallet. +/// +/// This function handles the complete flow of removing actions from a role: +/// 1. Validates the acting role's permissions +/// 2. Authenticates the request +/// 3. Finds the target role +/// 4. Validates the action indices +/// 5. Removes the specified actions +/// 6. Updates role boundaries and shrinks the account +/// +/// # Arguments +/// * `ctx` - The account context for removing actions from role +/// * `remove` - Raw remove actions from role instruction data +/// * `all_accounts` - All accounts involved in the operation +/// +/// # Returns +/// * `ProgramResult` - Success or error status +pub fn remove_actions_from_role_v1( + ctx: Context, + remove: &[u8], + all_accounts: &[AccountInfo], +) -> ProgramResult { + check_self_owned(ctx.accounts.swig, SwigError::OwnerMismatchSwigAccount)?; + check_bytes_match( + ctx.accounts.system_program.key(), + &pinocchio_system::ID, + 32, + SwigError::InvalidSystemProgram, + )?; + + let remove_actions_from_role_v1 = RemoveActionsFromRoleV1::from_instruction_bytes(remove) + .map_err(|e| { + msg!("RemoveActionsFromRoleV1 Args Error: {:?}", e); + ProgramError::InvalidInstructionData + })?; + + if remove_actions_from_role_v1.args.indices_count == 0 { + return Err(SwigError::InvalidActionIndicesEmpty.into()); + } + + let mut indices = remove_actions_from_role_v1.get_indices(); + // Sort indices in descending order to avoid shifting issues when removing + indices.sort_by(|a, b| b.cmp(a)); + + // Check for duplicates + for i in 1..indices.len() { + if indices[i] == indices[i - 1] { + return Err(SwigError::InvalidActionIndicesDuplicate.into()); + } + } + + let swig_account_data = unsafe { ctx.accounts.swig.borrow_mut_data_unchecked() }; + let swig_data_len = swig_account_data.len(); + + // Find and validate the target role + let (target_role_offset, target_role_boundary, total_removal_size) = { + if swig_account_data[0] != Discriminator::SwigAccount as u8 { + return Err(SwigError::InvalidSwigAccountDiscriminator.into()); + } + + let (swig_header, swig_roles) = + unsafe { swig_account_data.split_at_mut_unchecked(Swig::LEN) }; + let swig = unsafe { Swig::load_mut_unchecked(swig_header)? }; + + // Find the acting role + let acting_role = + Swig::get_mut_role(remove_actions_from_role_v1.args.acting_role_id, swig_roles)?; + if acting_role.is_none() { + return Err(SwigError::InvalidAuthorityNotFoundByRoleId.into()); + } + let acting_role = acting_role.unwrap(); + + // Authenticate the caller + let clock = Clock::get()?; + let slot = clock.slot; + + if acting_role.authority.session_based() { + acting_role.authority.authenticate_session( + all_accounts, + remove_actions_from_role_v1.authority_payload, + remove_actions_from_role_v1.data_payload, + slot, + )?; + } else { + acting_role.authority.authenticate( + all_accounts, + remove_actions_from_role_v1.authority_payload, + remove_actions_from_role_v1.data_payload, + slot, + )?; + } + + // Check permissions and extract permission flags + let all = acting_role.get_action::(&[])?; + let manage_authority = acting_role.get_action::(&[])?; + let manage_auth_lock = acting_role.get_action::(&[])?; + + if all.is_none() && manage_authority.is_none() { + return Err(SwigAuthenticateError::PermissionDeniedToManageAuthority.into()); + } + + let has_all = all.is_some(); + let has_manage_auth_lock = manage_auth_lock.is_some(); + + // Find the target role + let mut cursor = 0; + let mut target_found = false; + let mut target_role_offset = 0; + let mut target_role_boundary = 0; + + for _i in 0..swig.roles { + let position = + unsafe { Position::load_unchecked(&swig_roles[cursor..cursor + Position::LEN])? }; + + if position.id() == remove_actions_from_role_v1.args.target_role_id { + target_found = true; + target_role_offset = cursor; + target_role_boundary = position.boundary() as usize; + break; + } + + cursor = position.boundary() as usize; + } + + if !target_found { + return Err(SwigError::InvalidAuthorityNotFoundByRoleId.into()); + } + + // Get the target role to validate indices and calculate removal size + let target_position = unsafe { + Position::load_unchecked( + &swig_roles[target_role_offset..target_role_offset + Position::LEN], + )? + }; + + // Validate we're not removing all actions + if indices.len() >= target_position.num_actions() as usize { + return Err(SwigError::InvalidAuthorityMustHaveAtLeastOneAction.into()); + } + + // Parse actions to validate indices and calculate removal sizes + let actions_start = + target_role_offset + Position::LEN + target_position.authority_length() as usize; + let actions_data = &swig_roles[actions_start..target_role_boundary]; + + let mut action_cursor = 0; + let mut action_index = 0; + let mut total_removal_size = 0; + + while action_cursor < actions_data.len() { + let action = unsafe { + Action::load_unchecked(&actions_data[action_cursor..action_cursor + Action::LEN])? + }; + + // Check if this action index is in our removal list + if indices.contains(&action_index) { + // Check if this action is AuthorizationLock and verify permission + if action.permission()? == Permission::AuthorizationLock { + if !has_all && !has_manage_auth_lock { + return Err(SwigAuthenticateError::PermissionDeniedToManageAuthority.into()); + } + + // Additional validation: check that the acting role ID matches the creator role ID + let action_data_start = action_cursor + Action::LEN; + let action_data = &actions_data[action_data_start..action.boundary() as usize]; + + if action_data.len() >= AuthorizationLock::LEN { + let authorization_lock = + unsafe { AuthorizationLock::load_unchecked(action_data)? }; + + // Verify that the acting role ID matches the creator role ID + if authorization_lock.creator_role_id + != remove_actions_from_role_v1.args.acting_role_id + { + return Err( + SwigAuthenticateError::PermissionDeniedToManageAuthority.into() + ); + } + } + } + + let action_total_size = action.boundary() as usize - action_cursor; + total_removal_size += action_total_size; + } + + action_cursor = action.boundary() as usize; + action_index += 1; + } + + // Validate all indices were valid + if indices.iter().any(|&idx| idx >= action_index) { + return Err(SwigError::InvalidActionIndexOutOfBounds.into()); + } + + (target_role_offset, target_role_boundary, total_removal_size) + }; + + // Now modify the account data + #[cfg(test)] + println!("Starting account data modification phase"); + let swig_account_data = unsafe { ctx.accounts.swig.borrow_mut_data_unchecked() }; + let mut swig_builder = SwigBuilder::new_from_bytes(swig_account_data)?; + + // Get the target position and actions data + let (actions_start, actions_end, authority_length) = { + let target_position = unsafe { + Position::load_unchecked( + &swig_builder.role_buffer[target_role_offset..target_role_offset + Position::LEN], + )? + }; + let actions_start = + target_role_offset + Position::LEN + target_position.authority_length() as usize; + let actions_end = target_position.boundary() as usize; + let authority_length = target_position.authority_length() as usize; + (actions_start, actions_end, authority_length) + }; + + // Parse all existing actions and keep only those NOT in the removal indices + let mut kept_actions = Vec::new(); + let mut action_cursor = 0; // Relative to actions_start + let mut action_index = 0; + + while actions_start + action_cursor < actions_end { + let action = unsafe { + Action::load_unchecked( + &swig_builder.role_buffer + [actions_start + action_cursor..actions_start + action_cursor + Action::LEN], + )? + }; + + let next_action_start = action.boundary() as usize; + let action_size = next_action_start - action_cursor; + + // If this action should be kept (not removed) + if !indices.contains(&action_index) { + // Copy the complete action data (header + data) + let action_data = swig_builder.role_buffer + [actions_start + action_cursor..actions_start + action_cursor + action_size] + .to_vec(); + kept_actions.push(action_data); + } + + action_cursor = next_action_start; + action_index += 1; + } + + // Now rebuild the actions section from scratch + let mut write_cursor = actions_start; + let mut actions_size = 0; + + for (i, action_data) in kept_actions.iter().enumerate() { + // Copy the action data + let action_len = action_data.len(); + swig_builder.role_buffer[write_cursor..write_cursor + action_len] + .copy_from_slice(action_data); + + // Update the boundary in the action header to be correct + // Boundary is relative to actions_start + let next_pos = write_cursor + action_len - actions_start; + swig_builder.role_buffer[write_cursor + 4..write_cursor + 8] + .copy_from_slice(&(next_pos as u32).to_le_bytes()); + + write_cursor += action_len; + actions_size += action_len; + } + + // Update role position + let position = unsafe { + Position::load_mut_unchecked( + &mut swig_builder.role_buffer[target_role_offset..target_role_offset + Position::LEN], + )? + }; + position.num_actions = kept_actions.len() as u16; + position.boundary = + (target_role_offset + Position::LEN + authority_length + actions_size) as u32; + + // Shift data after the role + let old_actions_end = actions_end; + let new_actions_end = actions_start + actions_size; + let bytes_after = swig_builder.role_buffer.len() - old_actions_end; + + if bytes_after > 0 { + swig_builder.role_buffer.copy_within( + old_actions_end..old_actions_end + bytes_after, + new_actions_end, + ); + } + + // Update boundaries of roles that come after + let shrinkage = old_actions_end - new_actions_end; + let mut cursor = new_actions_end; + while cursor < swig_builder.role_buffer.len() - shrinkage { + if cursor + Position::LEN > swig_builder.role_buffer.len() - shrinkage { + break; + } + + let pos = unsafe { + Position::load_mut_unchecked( + &mut swig_builder.role_buffer[cursor..cursor + Position::LEN], + )? + }; + + if cursor > target_role_offset { + pos.boundary -= shrinkage as u32; + } + + cursor = pos.boundary() as usize; + } + + // Clear unused bytes + let new_data_end = swig_builder.role_buffer.len() - shrinkage; + swig_builder.role_buffer[new_data_end..].fill(0); + + // Use the total_removal_size calculated during validation + let total_removed_bytes = total_removal_size; + + // Shrink the account + let new_account_size = core::alloc::Layout::from_size_align( + swig_data_len - total_removed_bytes, + core::mem::size_of::(), + ) + .map_err(|_| SwigError::InvalidAlignment)? + .pad_to_align() + .size(); + + ctx.accounts.swig.realloc(new_account_size, false)?; + + // Update reserved lamports + let new_reserved_lamports = Rent::get()?.minimum_balance(new_account_size); + swig_builder.swig.reserved_lamports = new_reserved_lamports; + + Ok(()) +} diff --git a/program/src/actions/sign_v1.rs b/program/src/actions/sign_v1.rs index 62a50f12..df9c7543 100644 --- a/program/src/actions/sign_v1.rs +++ b/program/src/actions/sign_v1.rs @@ -137,6 +137,7 @@ impl<'a> SignV1<'a> { /// * `all_accounts` - All accounts involved in the transaction /// * `data` - Raw signing instruction data /// * `account_classifiers` - Classifications for involved accounts +/// * `authorization_lock_cache` - Optional cache of authorization locks /// /// # Returns /// * `ProgramResult` - Success or error status @@ -146,6 +147,7 @@ pub fn sign_v1( all_accounts: &[AccountInfo], data: &[u8], account_classifiers: &[AccountClassification], + authorization_lock_cache: Option<&crate::util::AuthorizationLockCache>, ) -> ProgramResult { check_stack_height(1, SwigError::Cpi)?; // KEEP remove since we enfoce swig is owned in lib.rs @@ -210,8 +212,40 @@ pub fn sign_v1( return Err(SwigError::InstructionExecutionError.into()); } } + let actions = role.actions; + if RoleMut::get_action_mut::(actions, &[])?.is_some() { + // Even with All permission, authorization locks must be enforced + if let Some(auth_cache) = authorization_lock_cache { + for (index, account) in account_classifiers.iter().enumerate() { + if let AccountClassification::SwigTokenAccount { balance } = account { + let data = + unsafe { &all_accounts.get_unchecked(index).borrow_data_unchecked() }; + let mint = unsafe { data.get_unchecked(0..32) }; + let current_token_balance = u64::from_le_bytes(unsafe { + data.get_unchecked(64..72) + .try_into() + .map_err(|_| ProgramError::InvalidAccountData)? + }); + + // Check for outgoing transfers + if balance > ¤t_token_balance { + let transfer_amount = balance - current_token_balance; + auth_cache.check_authorization_locks( + mint, + balance, + transfer_amount, + slot, + )?; + } + } + } + } + // Clean up expired authorization locks after successful execution + if let Some(auth_cache) = authorization_lock_cache { + auth_cache.remove_expired_locks(swig_roles)?; + } return Ok(()); } else { for (index, account) in account_classifiers.iter().enumerate() { @@ -267,7 +301,21 @@ pub fn sign_v1( .into(), ); } + + // Check authorization locks for outgoing token transfers if balance > ¤t_token_balance { + let transfer_amount = balance - current_token_balance; + + // Check authorization locks if cache is available + if let Some(auth_cache) = authorization_lock_cache { + auth_cache.check_authorization_locks( + mint, + balance, + transfer_amount, + slot, + )?; + } + let mut matched = false; let diff = balance - current_token_balance; @@ -412,5 +460,10 @@ pub fn sign_v1( } } + // Clean up expired authorization locks after successful execution + if let Some(auth_cache) = authorization_lock_cache { + auth_cache.remove_expired_locks(swig_roles)?; + } + Ok(()) } diff --git a/program/src/actions/withdraw_from_sub_account_v1.rs b/program/src/actions/withdraw_from_sub_account_v1.rs index 5d2e59a3..66c9e184 100644 --- a/program/src/actions/withdraw_from_sub_account_v1.rs +++ b/program/src/actions/withdraw_from_sub_account_v1.rs @@ -191,7 +191,6 @@ pub fn withdraw_from_sub_account_v1( amount, token_program: token_account_program_owner, }; - msg!("amount: {}", amount); let role_id_bytes = sub_account.role_id.to_le_bytes(); let bump_byte = [sub_account.bump]; let seeds = sub_account_signer(&swig.id, &role_id_bytes, &bump_byte); diff --git a/program/src/error.rs b/program/src/error.rs index afe4aff8..9955558c 100644 --- a/program/src/error.rs +++ b/program/src/error.rs @@ -105,6 +105,16 @@ pub enum SwigError { InvalidSwigTokenAccountOwner, /// Invalid program scope balance field configuration InvalidProgramScopeBalanceFields, + /// Add actions to role instruction data is too short + InvalidSwigAddActionsToRoleInstructionDataTooShort, + /// Remove actions from role instruction data is too short + InvalidSwigRemoveActionsFromRoleInstructionDataTooShort, + /// Action indices list is empty + InvalidActionIndicesEmpty, + /// Duplicate action indices provided + InvalidActionIndicesDuplicate, + /// Action index is out of bounds + InvalidActionIndexOutOfBounds, } /// Implements conversion from SwigError to ProgramError. diff --git a/program/src/instruction.rs b/program/src/instruction.rs index c39bcf43..b720f4c8 100644 --- a/program/src/instruction.rs +++ b/program/src/instruction.rs @@ -128,4 +128,26 @@ pub enum SwigInstruction { #[account(1, signer, name="payer", desc="the payer")] #[account(2, writable, name="sub_account", desc="the sub account to toggle enabled state")] ToggleSubAccountV1 = 10, + + /// Adds actions to an existing role. + /// + /// Required accounts: + /// 1. `[writable]` Swig wallet account + /// 2. `[writable, signer]` Payer account + /// 3. System program account + #[account(0, writable, name="swig", desc="the swig smart wallet")] + #[account(1, writable, signer, name="payer", desc="the payer")] + #[account(2, name="system_program", desc="the system program")] + AddActionsToRoleV1 = 11, + + /// Removes actions from an existing role. + /// + /// Required accounts: + /// 1. `[writable]` Swig wallet account + /// 2. `[writable, signer]` Payer account + /// 3. System program account + #[account(0, writable, name="swig", desc="the swig smart wallet")] + #[account(1, writable, signer, name="payer", desc="the payer")] + #[account(2, name="system_program", desc="the system program")] + RemoveActionsFromRoleV1 = 12, } diff --git a/program/src/lib.rs b/program/src/lib.rs index 325070a5..ab0e22ac 100644 --- a/program/src/lib.rs +++ b/program/src/lib.rs @@ -34,7 +34,7 @@ use swig_state_x::{ swig::{Swig, SwigWithRoles}, AccountClassification, Discriminator, StakeAccountState, Transmutable, }; -use util::{read_program_scope_account_balance, ProgramScopeCache}; +use util::{read_program_scope_account_balance, AuthorizationLockCache, ProgramScopeCache}; /// Program ID for the Swig wallet program declare_id!("swigDk8JezhiAVde8k6NMwxpZfgGm2NNuMe1KYCmUjP"); @@ -117,22 +117,26 @@ unsafe fn execute( index = 1; } - // Create program scope cache if first account is a valid Swig account - let program_scope_cache = if index > 0 { + // Create program scope cache and authorization lock cache if first account is a + // valid Swig account + let (program_scope_cache, authorization_lock_cache) = if index > 0 { let first_account = accounts[0].assume_init_ref(); if first_account.owner() == &crate::ID { let data = first_account.borrow_data_unchecked(); if data.len() >= Swig::LEN && *data.get_unchecked(0) == Discriminator::SwigAccount as u8 { - ProgramScopeCache::load_from_swig(data) + ( + ProgramScopeCache::load_from_swig(data), + AuthorizationLockCache::new(data).ok(), + ) } else { - None + (None, None) } } else { - None + (None, None) } } else { - None + (None, None) }; // Process remaining accounts using the cache @@ -161,6 +165,7 @@ unsafe fn execute( core::slice::from_raw_parts(accounts.as_ptr() as _, index), core::slice::from_raw_parts(account_classification.as_ptr() as _, index), instruction, + authorization_lock_cache.as_ref(), )?; Ok(()) } diff --git a/program/src/util/mod.rs b/program/src/util/mod.rs index fda032d5..32571431 100644 --- a/program/src/util/mod.rs +++ b/program/src/util/mod.rs @@ -6,25 +6,29 @@ //! - Token transfer operations //! The utilities are optimized for performance and safety. -use std::mem::MaybeUninit; +use std::{collections::HashMap, mem::MaybeUninit}; use pinocchio::{ account_info::AccountInfo, cpi::invoke_signed, instruction::{AccountMeta, Instruction, Signer}, + msg, program_error::ProgramError, pubkey::Pubkey, + sysvars::{clock::Clock, Sysvar}, ProgramResult, }; use swig_state_x::{ action::{ + authorization_lock::AuthorizationLock, program_scope::{NumericType, ProgramScope}, Action, Permission, }, constants::PROGRAM_SCOPE_BYTE_SIZE, read_numeric_field, + role::{Position, RoleMut}, swig::{Swig, SwigWithRoles}, - Transmutable, + Transmutable, TransmutableMut, }; use crate::error::SwigError; @@ -158,6 +162,261 @@ impl ProgramScopeCache { } } +/// Cache for authorization lock information to optimize lookups. +/// +/// This struct maintains a mapping of token mints to their authorization locks +/// across all roles in the Swig wallet. Authorization locks apply to the entire +/// wallet regardless of which authority is signing. +pub struct AuthorizationLockCache { + /// Maps token mint to authorization lock data from any role + locks: Vec<([u8; 32], AuthorizationLock)>, + /// Expired locks with their location information (role_id, cursor_start, + /// cursor_end) + expired_locks: Vec<(u32, usize, usize)>, +} + +impl AuthorizationLockCache { + /// Creates a new authorization lock cache by scanning all roles for + /// authorization locks Filters out any locks that have expired before + /// the current slot + pub fn new(swig_roles: &[u8]) -> Result { + let clock = Clock::get()?; + let current_slot = clock.slot; + let mut lock_map: HashMap<[u8; 32], (u64, u64)> = HashMap::new(); // mint -> (total_locked_amount, latest_expiry_slot) + let mut expired_locks = Vec::new(); + + if swig_roles.len() < Swig::LEN { + return Ok(Self { + locks: Vec::new(), + expired_locks: Vec::new(), + }); + } + + let swig_with_roles = SwigWithRoles::from_bytes(swig_roles) + .map_err(|_| SwigError::InvalidSwigAccountDiscriminator)?; + + // Iterate through all roles and their authorization locks + for role_id in 0..swig_with_roles.state.role_counter { + if let Ok(Some(role)) = swig_with_roles.get_role(role_id) { + let mut cursor = 0; + while cursor < role.actions.len() { + if cursor + Action::LEN > role.actions.len() { + break; + } + + // Load the action header + if let Ok(action_header) = unsafe { + Action::load_unchecked(&role.actions[cursor..cursor + Action::LEN]) + } { + let action_start = cursor; + cursor += Action::LEN; + + let action_len = action_header.length() as usize; + if cursor + action_len > role.actions.len() { + break; + } + + // Try to load as AuthorizationLock + if action_header.permission().ok() == Some(Permission::AuthorizationLock) { + let action_data = &role.actions[cursor..cursor + action_len]; + if action_data.len() == AuthorizationLock::LEN { + if let Ok(auth_lock) = + unsafe { AuthorizationLock::load_unchecked(action_data) } + { + if auth_lock.is_expired(current_slot) { + // Cache expired lock location + expired_locks.push(( + role_id, + action_start, + cursor + action_len, + )); + } else { + // Only include locks that haven't expired + // Combine locks for the same token mint + lock_map + .entry(auth_lock.token_mint) + .and_modify(|(amount, expiry)| { + *amount += auth_lock.locked_amount; + *expiry = (*expiry).max(auth_lock.expiry_slot); + }) + .or_insert(( + auth_lock.locked_amount, + auth_lock.expiry_slot, + )); + } + } + } + } + + cursor += action_len; + } else { + break; + } + } + } + } + + // Convert HashMap to Vec of combined locks + let locks = lock_map + .into_iter() + .map(|(mint, (total_amount, latest_expiry))| { + let combined_lock = AuthorizationLock::new(mint, total_amount, latest_expiry, 0); + (mint, combined_lock) + }) + .collect(); + + Ok(Self { + locks, + expired_locks, + }) + } + + /// Checks if any authorization locks would prevent the given transfer + pub fn check_authorization_locks( + &self, + mint: &[u8], + current_balance: &u64, + transfer_amount: u64, + current_slot: u64, + ) -> Result<(), ProgramError> { + for lock in &self.locks { + // Skip expired locks (should already be filtered out during cache creation) + if lock.1.is_expired(current_slot) { + continue; + } + + // Check if this lock applies to the mint being transferred + if lock.0 == mint { + // Check if the transfer would violate the authorization lock + if let Err(e) = + lock.1 + .check_authorization(current_balance, transfer_amount, current_slot) + { + return Err(e); + } + } + } + Ok(()) + } + + /// Removes expired authorization locks from the Swig account data + /// + /// This method uses the cached expired lock locations to efficiently remove + /// expired authorization locks from the role data. It processes removals in + /// reverse order to maintain cursor validity. + /// + /// # Arguments + /// * `swig_roles` - Mutable reference to the roles data portion of the Swig + /// account + /// + /// # Returns + /// * `ProgramResult` - Success or error status + pub fn remove_expired_locks(&self, swig_roles: &mut [u8]) -> ProgramResult { + use swig_state_x::{role::Position, swig::Swig, TransmutableMut}; + + if self.expired_locks.is_empty() { + return Ok(()); + } + + // Group expired locks by role_id + let mut locks_by_role: HashMap> = HashMap::new(); + for &(role_id, start, end) in &self.expired_locks { + locks_by_role.entry(role_id).or_default().push((start, end)); + } + + // First pass: collect role information + let mut role_info: Vec<(u32, usize, usize, usize, usize, Vec<(usize, usize)>)> = Vec::new(); + + for (role_id, mut lock_positions) in locks_by_role { + // Sort positions in reverse order (largest start position first) + lock_positions.sort_by(|a, b| b.0.cmp(&a.0)); + + // Find the role's position in the buffer + let mut cursor = 0; + + for _ in 0..100 { + // Reasonable upper bound to prevent infinite loops + if cursor + Position::LEN > swig_roles.len() { + break; + } + + let position = unsafe { + Position::load_unchecked(&swig_roles[cursor..cursor + Position::LEN])? + }; + + if position.id() == role_id { + let auth_length = position.authority_length() as usize; + let actions_start = cursor + Position::LEN + auth_length; + let actions_end = position.boundary() as usize; + + if actions_start < actions_end && actions_end <= swig_roles.len() { + role_info.push(( + role_id, + cursor, + actions_start, + actions_end, + auth_length, + lock_positions, + )); + } + break; + } + + cursor = position.boundary() as usize; + } + } + + // Second pass: perform the actual removals + for (role_id, offset, actions_start, actions_end, auth_length, lock_positions) in role_info + { + // Calculate total bytes to remove and count + let mut total_removed_bytes = 0; + let mut removed_count = 0u16; + + for (lock_start, lock_end) in &lock_positions { + let relative_start = actions_start + lock_start; + let relative_end = actions_start + lock_end; + let lock_size = relative_end - relative_start; + + if relative_end <= actions_end { + // Shift remaining data to fill the gap + let copy_start = relative_end; + let copy_end = actions_end - total_removed_bytes; + let copy_dest = relative_start; + + if copy_start < copy_end { + // Use a temporary buffer to avoid overlapping copy issues + let remaining_data = swig_roles[copy_start..copy_end].to_vec(); + swig_roles[copy_dest..copy_dest + remaining_data.len()] + .copy_from_slice(&remaining_data); + } + + total_removed_bytes += lock_size; + removed_count += 1; + } + } + + if removed_count > 0 { + // Update the position metadata + let position = unsafe { + Position::load_mut_unchecked(&mut swig_roles[offset..offset + Position::LEN])? + }; + + position.num_actions = position.num_actions.saturating_sub(removed_count); + position.boundary = (position.boundary() as usize - total_removed_bytes) as u32; + + // Clear the now-unused space at the end + let new_actions_end = actions_end - total_removed_bytes; + if new_actions_end < actions_end { + swig_roles[new_actions_end..actions_end].fill(0); + } + } + } + + Ok(()) + } +} + /// Reads a numeric balance from an account's data based on a `ProgramScope` /// configuration. /// diff --git a/program/tests/add_actions_to_role_test.rs b/program/tests/add_actions_to_role_test.rs new file mode 100644 index 00000000..15aff206 --- /dev/null +++ b/program/tests/add_actions_to_role_test.rs @@ -0,0 +1,301 @@ +#![cfg(not(feature = "program_scope_test"))] + +mod common; + +use common::*; +use solana_sdk::{ + compute_budget::ComputeBudgetInstruction, + message::{v0, VersionedMessage}, + pubkey::Pubkey, + signature::Keypair, + signer::Signer, + transaction::VersionedTransaction, +}; +use swig::actions::add_actions_to_role_v1::AddActionsToRoleV1Args; +use swig_interface::{swig, AuthorityConfig, ClientAction}; +use swig_state_x::{ + action::{ + all::All, manage_authority::ManageAuthority, program::Program, sol_limit::SolLimit, Action, + Permission, + }, + authority::AuthorityType, + swig::{swig_account_seeds, SwigWithRoles}, + IntoBytes, Transmutable, +}; + +fn build_add_actions_to_role_ix_data( + acting_role_id: u32, + target_role_id: u32, + actions: Vec, +) -> Vec { + // Build actions data + let mut actions_data = Vec::new(); + let mut num_actions = 0u8; + + for action in actions { + match action { + ClientAction::All(all) => { + let action_header = Action::client_new(Permission::All, All::LEN as u16); + actions_data.extend_from_slice(action_header.into_bytes().unwrap()); + actions_data.extend_from_slice(all.into_bytes().unwrap()); + num_actions += 1; + }, + ClientAction::ManageAuthority(manage) => { + let action_header = + Action::client_new(Permission::ManageAuthority, ManageAuthority::LEN as u16); + actions_data.extend_from_slice(action_header.into_bytes().unwrap()); + actions_data.extend_from_slice(manage.into_bytes().unwrap()); + num_actions += 1; + }, + ClientAction::SolLimit(sol_limit) => { + let action_header = Action::client_new(Permission::SolLimit, SolLimit::LEN as u16); + actions_data.extend_from_slice(action_header.into_bytes().unwrap()); + actions_data.extend_from_slice(sol_limit.into_bytes().unwrap()); + num_actions += 1; + }, + ClientAction::Program(program) => { + let action_header = Action::client_new(Permission::Program, Program::LEN as u16); + actions_data.extend_from_slice(action_header.into_bytes().unwrap()); + actions_data.extend_from_slice(program.into_bytes().unwrap()); + num_actions += 1; + }, + _ => panic!("Unsupported action type"), + } + } + + let args = AddActionsToRoleV1Args::new( + acting_role_id, + target_role_id, + actions_data.len() as u16, + num_actions, + ); + + let mut ix_data = Vec::new(); + ix_data.extend_from_slice(args.into_bytes().unwrap()); + ix_data.extend_from_slice(&actions_data); + // Add authority payload for Ed25519 - single byte indicating the account index + // of the authority that needs to sign + ix_data.push(3); // Authority will be at index 3 + + ix_data +} + +#[test_log::test] +fn test_add_actions_to_role_success() { + let mut context = setup_test_context().unwrap(); + let swig_authority = Keypair::new(); + + context + .svm + .airdrop(&swig_authority.pubkey(), 10_000_000_000) + .unwrap(); + + let id = rand::random::<[u8; 32]>(); + let (swig_pubkey, _) = create_swig_ed25519(&mut context, &swig_authority, id).unwrap(); + + // Create a second authority with limited permissions + let second_authority = Keypair::new(); + context + .svm + .airdrop(&second_authority.pubkey(), 10_000_000_000) + .unwrap(); + + // Add second authority with just SolLimit permission + add_authority_with_ed25519_root( + &mut context, + &swig_pubkey, + &swig_authority, + AuthorityConfig { + authority_type: AuthorityType::Ed25519, + authority: second_authority.pubkey().as_ref(), + }, + vec![ClientAction::SolLimit(SolLimit { amount: 1_000_000 })], + ) + .unwrap(); + + // Now add more actions to the second role using the first authority + let new_actions = vec![ + ClientAction::Program(Program { + program_id: [4; 32], + }), + ClientAction::SolLimit(SolLimit { amount: 5_000_000 }), + ]; + + let ix_data = build_add_actions_to_role_ix_data(0, 1, new_actions); + + let accounts = vec![ + solana_sdk::instruction::AccountMeta::new(swig_pubkey, false), + solana_sdk::instruction::AccountMeta::new(swig_authority.pubkey(), true), + solana_sdk::instruction::AccountMeta::new_readonly(pinocchio_system::ID.into(), false), + solana_sdk::instruction::AccountMeta::new_readonly(swig_authority.pubkey(), true), /* Authority signer */ + ]; + + let ix = solana_sdk::instruction::Instruction { + program_id: program_id(), + accounts, + data: ix_data, + }; + + let msg = v0::Message::try_compile( + &swig_authority.pubkey(), + &[ + ComputeBudgetInstruction::set_compute_unit_limit(10000000), + ix, + ], + &[], + context.svm.latest_blockhash(), + ) + .unwrap(); + + let tx = VersionedTransaction::try_new( + VersionedMessage::V0(msg), + &[swig_authority.insecure_clone()], + ) + .unwrap(); + + let result = context.svm.send_transaction(tx); + assert!( + result.is_ok(), + "Failed to add actions to role: {:?}", + result + ); + + // Verify the role now has the additional actions + let swig_account = context.svm.get_account(&swig_pubkey).unwrap(); + let swig_state = SwigWithRoles::from_bytes(&swig_account.data).unwrap(); + + let role = swig_state.get_role(1).unwrap().unwrap(); + + // Should have 3 actions now (original SolLimit + Program + new SolLimit) + assert_eq!(role.position.num_actions(), 3); + + // Double check with get_all_actions + let all_actions = role.get_all_actions().unwrap(); + assert_eq!( + all_actions.len(), + 3, + "Should have exactly 3 actions, but got {}", + all_actions.len() + ); +} + +#[test_log::test] +fn test_add_actions_to_role_no_permission() { + let mut context = setup_test_context().unwrap(); + let swig_authority = Keypair::new(); + + context + .svm + .airdrop(&swig_authority.pubkey(), 10_000_000_000) + .unwrap(); + + let id = rand::random::<[u8; 32]>(); + let (swig_pubkey, _) = create_swig_ed25519(&mut context, &swig_authority, id).unwrap(); + + // Create a second authority without ManageAuthority permission + let second_authority = Keypair::new(); + context + .svm + .airdrop(&second_authority.pubkey(), 10_000_000_000) + .unwrap(); + + add_authority_with_ed25519_root( + &mut context, + &swig_pubkey, + &swig_authority, + AuthorityConfig { + authority_type: AuthorityType::Ed25519, + authority: second_authority.pubkey().as_ref(), + }, + vec![ClientAction::SolLimit(SolLimit { amount: 1_000_000 })], // No ManageAuthority or All + ) + .unwrap(); + + // Try to add actions using the second authority which doesn't have permission + let new_actions = vec![ClientAction::Program(Program { + program_id: [4; 32], + })]; + + let ix_data = build_add_actions_to_role_ix_data(1, 0, new_actions); // Use role 1 (second_authority) to modify role 0 + + let accounts = vec![ + solana_sdk::instruction::AccountMeta::new(swig_pubkey, false), + solana_sdk::instruction::AccountMeta::new(second_authority.pubkey(), true), + solana_sdk::instruction::AccountMeta::new_readonly(pinocchio_system::ID.into(), false), + solana_sdk::instruction::AccountMeta::new_readonly(second_authority.pubkey(), true), /* Authority signer */ + ]; + + let ix = solana_sdk::instruction::Instruction { + program_id: program_id(), + accounts, + data: ix_data, + }; + + let msg = v0::Message::try_compile( + &second_authority.pubkey(), + &[ix], + &[], + context.svm.latest_blockhash(), + ) + .unwrap(); + + let tx = VersionedTransaction::try_new( + VersionedMessage::V0(msg), + &[second_authority.insecure_clone()], + ) + .unwrap(); + + let result = context.svm.send_transaction(tx); + assert!(result.is_err(), "Should fail without proper permissions"); +} + +#[test_log::test] +fn test_add_actions_to_role_invalid_target() { + let mut context = setup_test_context().unwrap(); + let swig_authority = Keypair::new(); + + context + .svm + .airdrop(&swig_authority.pubkey(), 10_000_000_000) + .unwrap(); + + let id = rand::random::<[u8; 32]>(); + let (swig_pubkey, _) = create_swig_ed25519(&mut context, &swig_authority, id).unwrap(); + + // Try to add actions to non-existent role + let new_actions = vec![ClientAction::Program(Program { + program_id: [4; 32], + })]; + + let ix_data = build_add_actions_to_role_ix_data(0, 999, new_actions); // Invalid target role ID + + let accounts = vec![ + solana_sdk::instruction::AccountMeta::new(swig_pubkey, false), + solana_sdk::instruction::AccountMeta::new(swig_authority.pubkey(), true), + solana_sdk::instruction::AccountMeta::new_readonly(pinocchio_system::ID.into(), false), + solana_sdk::instruction::AccountMeta::new_readonly(swig_authority.pubkey(), true), /* Authority signer */ + ]; + + let ix = solana_sdk::instruction::Instruction { + program_id: program_id(), + accounts, + data: ix_data, + }; + + let msg = v0::Message::try_compile( + &swig_authority.pubkey(), + &[ix], + &[], + context.svm.latest_blockhash(), + ) + .unwrap(); + + let tx = VersionedTransaction::try_new( + VersionedMessage::V0(msg), + &[swig_authority.insecure_clone()], + ) + .unwrap(); + + let result = context.svm.send_transaction(tx); + assert!(result.is_err(), "Should fail with non-existent target role"); +} diff --git a/program/tests/authorization_lock_test.rs b/program/tests/authorization_lock_test.rs new file mode 100644 index 00000000..e64fe01e --- /dev/null +++ b/program/tests/authorization_lock_test.rs @@ -0,0 +1,1072 @@ +#![cfg(not(feature = "program_scope_test"))] +// This feature flag ensures these tests are only run when the +// "program_scope_test" feature is not enabled. This allows us to isolate +// and run only program_scope tests or only the regular tests. + +mod common; +use common::*; +use litesvm_token::spl_token::{self, instruction::TokenInstruction}; +use solana_sdk::{ + instruction::{AccountMeta, Instruction, InstructionError}, + message::{v0, VersionedMessage}, + program_pack::Pack, + pubkey::Pubkey, + signature::Keypair, + signer::Signer, + transaction::{TransactionError, VersionedTransaction}, +}; +use swig_interface::{AuthorityConfig, ClientAction}; +use swig_state_x::{ + action::{ + all::All, authorization_lock::AuthorizationLock, token_limit::TokenLimit, Action, + Permission, + }, + authority::AuthorityType, + swig::{swig_account_seeds, SwigWithRoles}, + Transmutable, +}; + +#[test_log::test] +fn test_authorization_lock_prevents_transfer_below_locked_amount() { + let mut context = setup_test_context().unwrap(); + let swig_authority = Keypair::new(); + let recipient = Keypair::new(); + context + .svm + .airdrop(&recipient.pubkey(), 10_000_000_000) + .unwrap(); + context + .svm + .airdrop(&swig_authority.pubkey(), 10_000_000_000) + .unwrap(); + + let id = rand::random::<[u8; 32]>(); + let swig = Pubkey::find_program_address(&swig_account_seeds(&id), &program_id()).0; + + // Setup token infrastructure + let mint_pubkey = setup_mint(&mut context.svm, &context.default_payer).unwrap(); + let mint_pubkey_2 = setup_mint(&mut context.svm, &context.default_payer).unwrap(); + let swig_ata = setup_ata( + &mut context.svm, + &mint_pubkey, + &swig, + &context.default_payer, + ) + .unwrap(); + let recipient_ata = setup_ata( + &mut context.svm, + &mint_pubkey, + &recipient.pubkey(), + &context.default_payer, + ) + .unwrap(); + + // Mint 1000 tokens to swig account + mint_to( + &mut context.svm, + &mint_pubkey, + &context.default_payer, + &swig_ata, + 1000, + ) + .unwrap(); + + let swig_create_txn = create_swig_ed25519(&mut context, &swig_authority, id); + assert!(swig_create_txn.is_ok()); + + let second_authority = Keypair::new(); + context + .svm + .airdrop(&second_authority.pubkey(), 10_000_000_000) + .unwrap(); + + // Create multiple authorization locks for the same token mint that should be + // combined First lock: 300 tokens until slot 200 + let auth_lock_1 = AuthorizationLock::new(mint_pubkey.to_bytes(), 300, 200, 1); + // Second lock: 200 tokens until slot 150 + let auth_lock_2 = AuthorizationLock::new(mint_pubkey.to_bytes(), 200, 150, 1); + // Combined they should lock 500 tokens total until slot 200 (latest expiry) + + add_authority_with_ed25519_root( + &mut context, + &swig, + &swig_authority, + AuthorityConfig { + authority_type: AuthorityType::Ed25519, + authority: second_authority.pubkey().as_ref(), + }, + vec![ + ClientAction::TokenLimit(TokenLimit { + token_mint: mint_pubkey.to_bytes(), + current_amount: 1000, + }), // Give permission to transfer tokens + ClientAction::TokenLimit(TokenLimit { + token_mint: mint_pubkey_2.to_bytes(), + current_amount: 1000, + }), // Give permission to transfer tokens + ClientAction::AuthorizationLock(auth_lock_1), + ClientAction::AuthorizationLock(auth_lock_2), + ], + ) + .unwrap(); + + context.svm.warp_to_slot(100); // Before expiry + + // Try to transfer 600 tokens (would leave 400, below combined locked amount of + // 500) This should fail because the combined authorization locks require + // 500 tokens minimum + let token_ix = Instruction { + program_id: spl_token::id(), + accounts: vec![ + AccountMeta::new(swig_ata, false), + AccountMeta::new(recipient_ata, false), + AccountMeta::new(swig, false), + ], + data: TokenInstruction::Transfer { amount: 600 }.pack(), + }; + + let sign_ix = swig_interface::SignInstruction::new_ed25519( + swig, + second_authority.pubkey(), + second_authority.pubkey(), + token_ix, + 1, + ) + .unwrap(); + + let transfer_message = v0::Message::try_compile( + &second_authority.pubkey(), + &[sign_ix], + &[], + context.svm.latest_blockhash(), + ) + .unwrap(); + + let transfer_tx = + VersionedTransaction::try_new(VersionedMessage::V0(transfer_message), &[&second_authority]) + .unwrap(); + + let res = context.svm.send_transaction(transfer_tx); + + if !res.is_err() { + println!("{}", res.clone().unwrap().pretty_logs()); + } + assert!(res.is_err()); + + let full_res = res.unwrap_err(); + + println!("{}", full_res.meta.pretty_logs()); + + // Should fail with authorization lock violation error (3021) + if let TransactionError::InstructionError(_, InstructionError::Custom(err_code)) = full_res.err + { + assert_eq!( + err_code, 3021, + "Should fail with authorization lock violation" + ); + } else { + panic!("Expected authorization lock violation error"); + } + + // Verify no tokens were transferred + let swig_token_account = context.svm.get_account(&swig_ata).unwrap(); + let swig_balance = spl_token::state::Account::unpack(&swig_token_account.data).unwrap(); + assert_eq!(swig_balance.amount, 1000); +} + +#[test_log::test] +fn test_authorization_lock_allows_transfer_above_locked_amount() { + let mut context = setup_test_context().unwrap(); + let swig_authority = Keypair::new(); + let recipient = Keypair::new(); + context + .svm + .airdrop(&recipient.pubkey(), 10_000_000_000) + .unwrap(); + context + .svm + .airdrop(&swig_authority.pubkey(), 10_000_000_000) + .unwrap(); + + let id = rand::random::<[u8; 32]>(); + let swig = Pubkey::find_program_address(&swig_account_seeds(&id), &program_id()).0; + + // Setup token infrastructure + let mint_pubkey = setup_mint(&mut context.svm, &context.default_payer).unwrap(); + let swig_ata = setup_ata( + &mut context.svm, + &mint_pubkey, + &swig, + &context.default_payer, + ) + .unwrap(); + let recipient_ata = setup_ata( + &mut context.svm, + &mint_pubkey, + &recipient.pubkey(), + &context.default_payer, + ) + .unwrap(); + + // Mint 1000 tokens to swig account + mint_to( + &mut context.svm, + &mint_pubkey, + &context.default_payer, + &swig_ata, + 1000, + ) + .unwrap(); + + let swig_create_txn = create_swig_ed25519(&mut context, &swig_authority, id); + assert!(swig_create_txn.is_ok()); + + let second_authority = Keypair::new(); + context + .svm + .airdrop(&second_authority.pubkey(), 10_000_000_000) + .unwrap(); + + // Create authorization lock that requires minimum 500 tokens, expires at slot + // 200 + let auth_lock = AuthorizationLock::new(mint_pubkey.to_bytes(), 500, 200, 1); + + add_authority_with_ed25519_root( + &mut context, + &swig, + &swig_authority, + AuthorityConfig { + authority_type: AuthorityType::Ed25519, + authority: second_authority.pubkey().as_ref(), + }, + vec![ + ClientAction::TokenLimit(TokenLimit { + token_mint: mint_pubkey.to_bytes(), + current_amount: 1000, + }), // Give permission to transfer tokens + ClientAction::AuthorizationLock(auth_lock), + ], + ) + .unwrap(); + + context.svm.warp_to_slot(100); // Before expiry + + // Try to transfer 400 tokens (would leave 600, above locked amount of 500) + let token_ix = Instruction { + program_id: spl_token::id(), + accounts: vec![ + AccountMeta::new(swig_ata, false), + AccountMeta::new(recipient_ata, false), + AccountMeta::new(swig, false), + ], + data: TokenInstruction::Transfer { amount: 400 }.pack(), + }; + + let sign_ix = swig_interface::SignInstruction::new_ed25519( + swig, + second_authority.pubkey(), + second_authority.pubkey(), + token_ix, + 1, + ) + .unwrap(); + + let transfer_message = v0::Message::try_compile( + &second_authority.pubkey(), + &[sign_ix], + &[], + context.svm.latest_blockhash(), + ) + .unwrap(); + + let transfer_tx = + VersionedTransaction::try_new(VersionedMessage::V0(transfer_message), &[&second_authority]) + .unwrap(); + + let res = context.svm.send_transaction(transfer_tx); + if res.is_err() { + println!("{}", res.clone().unwrap().pretty_logs()); + } + assert!(res.is_ok()); + + // Verify tokens were transferred correctly + let swig_token_account = context.svm.get_account(&swig_ata).unwrap(); + let swig_balance = spl_token::state::Account::unpack(&swig_token_account.data).unwrap(); + assert_eq!(swig_balance.amount, 600); // 1000 - 400 + + let recipient_token_account = context.svm.get_account(&recipient_ata).unwrap(); + let recipient_balance = + spl_token::state::Account::unpack(&recipient_token_account.data).unwrap(); + assert_eq!(recipient_balance.amount, 400); +} + +#[test_log::test] +fn test_authorization_lock_expires_allows_all_transfers() { + let mut context = setup_test_context().unwrap(); + let swig_authority = Keypair::new(); + let recipient = Keypair::new(); + context + .svm + .airdrop(&recipient.pubkey(), 10_000_000_000) + .unwrap(); + context + .svm + .airdrop(&swig_authority.pubkey(), 10_000_000_000) + .unwrap(); + + let id = rand::random::<[u8; 32]>(); + let swig = Pubkey::find_program_address(&swig_account_seeds(&id), &program_id()).0; + + // Setup token infrastructure + let mint_pubkey = setup_mint(&mut context.svm, &context.default_payer).unwrap(); + let swig_ata = setup_ata( + &mut context.svm, + &mint_pubkey, + &swig, + &context.default_payer, + ) + .unwrap(); + let recipient_ata = setup_ata( + &mut context.svm, + &mint_pubkey, + &recipient.pubkey(), + &context.default_payer, + ) + .unwrap(); + + // Mint 1000 tokens to swig account + mint_to( + &mut context.svm, + &mint_pubkey, + &context.default_payer, + &swig_ata, + 1000, + ) + .unwrap(); + + let swig_create_txn = create_swig_ed25519(&mut context, &swig_authority, id); + assert!(swig_create_txn.is_ok()); + + let second_authority = Keypair::new(); + context + .svm + .airdrop(&second_authority.pubkey(), 10_000_000_000) + .unwrap(); + + // Create authorization lock that requires minimum 500 tokens, expires at slot + // 200 + let auth_lock = AuthorizationLock::new(mint_pubkey.to_bytes(), 500, 200, 1); + + add_authority_with_ed25519_root( + &mut context, + &swig, + &swig_authority, + AuthorityConfig { + authority_type: AuthorityType::Ed25519, + authority: second_authority.pubkey().as_ref(), + }, + vec![ + ClientAction::TokenLimit(TokenLimit { + token_mint: mint_pubkey.to_bytes(), + current_amount: 1000, + }), // Give permission to transfer tokens + ClientAction::AuthorizationLock(auth_lock), + ], + ) + .unwrap(); + + context.svm.warp_to_slot(250); // After expiry (200) + + // Try to transfer 900 tokens (would leave 100, below locked amount of 500) + // This should succeed because the lock has expired + let token_ix = Instruction { + program_id: spl_token::id(), + accounts: vec![ + AccountMeta::new(swig_ata, false), + AccountMeta::new(recipient_ata, false), + AccountMeta::new(swig, false), + ], + data: TokenInstruction::Transfer { amount: 900 }.pack(), + }; + + let sign_ix = swig_interface::SignInstruction::new_ed25519( + swig, + second_authority.pubkey(), + second_authority.pubkey(), + token_ix, + 1, + ) + .unwrap(); + + let transfer_message = v0::Message::try_compile( + &second_authority.pubkey(), + &[sign_ix], + &[], + context.svm.latest_blockhash(), + ) + .unwrap(); + + let transfer_tx = + VersionedTransaction::try_new(VersionedMessage::V0(transfer_message), &[&second_authority]) + .unwrap(); + + let res = context.svm.send_transaction(transfer_tx); + + if res.is_err() { + println!("{}", res.clone().unwrap().pretty_logs()); + } + assert!(res.is_ok()); + + // Verify tokens were transferred correctly (lock expired, so transfer allowed) + let swig_token_account = context.svm.get_account(&swig_ata).unwrap(); + let swig_balance = spl_token::state::Account::unpack(&swig_token_account.data).unwrap(); + assert_eq!(swig_balance.amount, 100); // 1000 - 900 + + let recipient_token_account = context.svm.get_account(&recipient_ata).unwrap(); + let recipient_balance = + spl_token::state::Account::unpack(&recipient_token_account.data).unwrap(); + assert_eq!(recipient_balance.amount, 900); +} + +#[test_log::test] +fn test_authorization_lock_with_all_permission_cant_bypass_lock() { + let mut context = setup_test_context().unwrap(); + let swig_authority = Keypair::new(); + let recipient = Keypair::new(); + context + .svm + .airdrop(&recipient.pubkey(), 10_000_000_000) + .unwrap(); + context + .svm + .airdrop(&swig_authority.pubkey(), 10_000_000_000) + .unwrap(); + + let id = rand::random::<[u8; 32]>(); + let swig = Pubkey::find_program_address(&swig_account_seeds(&id), &program_id()).0; + + // Setup token infrastructure + let mint_pubkey = setup_mint(&mut context.svm, &context.default_payer).unwrap(); + let swig_ata = setup_ata( + &mut context.svm, + &mint_pubkey, + &swig, + &context.default_payer, + ) + .unwrap(); + let recipient_ata = setup_ata( + &mut context.svm, + &mint_pubkey, + &recipient.pubkey(), + &context.default_payer, + ) + .unwrap(); + + // Mint 1000 tokens to swig account + mint_to( + &mut context.svm, + &mint_pubkey, + &context.default_payer, + &swig_ata, + 1000, + ) + .unwrap(); + + let swig_create_txn = create_swig_ed25519(&mut context, &swig_authority, id); + assert!(swig_create_txn.is_ok()); + + let second_authority = Keypair::new(); + context + .svm + .airdrop(&second_authority.pubkey(), 10_000_000_000) + .unwrap(); + + // Add authorization lock to a different role + let third_authority = Keypair::new(); + context + .svm + .airdrop(&third_authority.pubkey(), 10_000_000_000) + .unwrap(); + + let auth_lock = AuthorizationLock::new(mint_pubkey.to_bytes(), 500, 200, 1); + add_authority_with_ed25519_root( + &mut context, + &swig, + &swig_authority, + AuthorityConfig { + authority_type: AuthorityType::Ed25519, + authority: second_authority.pubkey().as_ref(), + }, + vec![ClientAction::All(All)], + ) + .unwrap(); + add_authority_with_ed25519_root( + &mut context, + &swig, + &swig_authority, + AuthorityConfig { + authority_type: AuthorityType::Ed25519, + authority: third_authority.pubkey().as_ref(), + }, + vec![ + ClientAction::AuthorizationLock(auth_lock), /* Only authorization lock, no transfer + * permissions */ + ], + ) + .unwrap(); + + context.svm.warp_to_slot(100); // Before expiry + + // Try to transfer 900 tokens using All permission (should succeed despite lock) + let token_ix = Instruction { + program_id: spl_token::id(), + accounts: vec![ + AccountMeta::new(swig_ata, false), + AccountMeta::new(recipient_ata, false), + AccountMeta::new(swig, false), + ], + data: TokenInstruction::Transfer { amount: 900 }.pack(), + }; + + let sign_ix = swig_interface::SignInstruction::new_ed25519( + swig, + second_authority.pubkey(), + second_authority.pubkey(), + token_ix, + 1, // Authority with All permission + ) + .unwrap(); + + let transfer_message = v0::Message::try_compile( + &second_authority.pubkey(), + &[sign_ix], + &[], + context.svm.latest_blockhash(), + ) + .unwrap(); + + let transfer_tx = + VersionedTransaction::try_new(VersionedMessage::V0(transfer_message), &[&second_authority]) + .unwrap(); + + let res = context.svm.send_transaction(transfer_tx); + assert!(res.is_err()); + + let full_res = res.unwrap_err(); + + println!("{}", full_res.meta.pretty_logs()); + + // Should fail with authorization lock violation error (3021) + if let TransactionError::InstructionError(_, InstructionError::Custom(err_code)) = full_res.err + { + assert_eq!( + err_code, 3021, + "Should fail with authorization lock violation" + ); + } else { + panic!("Expected authorization lock violation error"); + } +} + +#[test_log::test] +fn test_multiple_authorization_locks_combine_for_same_token() { + let mut context = setup_test_context().unwrap(); + let swig_authority = Keypair::new(); + let recipient = Keypair::new(); + context + .svm + .airdrop(&recipient.pubkey(), 10_000_000_000) + .unwrap(); + context + .svm + .airdrop(&swig_authority.pubkey(), 10_000_000_000) + .unwrap(); + + let id = rand::random::<[u8; 32]>(); + let swig = Pubkey::find_program_address(&swig_account_seeds(&id), &program_id()).0; + + // Setup token infrastructure + let mint_pubkey = setup_mint(&mut context.svm, &context.default_payer).unwrap(); + let swig_ata = setup_ata( + &mut context.svm, + &mint_pubkey, + &swig, + &context.default_payer, + ) + .unwrap(); + let recipient_ata = setup_ata( + &mut context.svm, + &mint_pubkey, + &recipient.pubkey(), + &context.default_payer, + ) + .unwrap(); + + // Mint 1000 tokens to swig account + mint_to( + &mut context.svm, + &mint_pubkey, + &context.default_payer, + &swig_ata, + 1000, + ) + .unwrap(); + + let swig_create_txn = create_swig_ed25519(&mut context, &swig_authority, id); + assert!(swig_create_txn.is_ok()); + + let second_authority = Keypair::new(); + context + .svm + .airdrop(&second_authority.pubkey(), 10_000_000_000) + .unwrap(); + + // Create three authorization locks for the same token mint: + // Lock 1: 100 tokens until slot 150 (expires first) + // Lock 2: 200 tokens until slot 200 + // Lock 3: 50 tokens until slot 300 (expires last) + // Total combined: 350 tokens until slot 300 + let auth_lock_1 = AuthorizationLock::new(mint_pubkey.to_bytes(), 100, 150, 1); + let auth_lock_2 = AuthorizationLock::new(mint_pubkey.to_bytes(), 200, 150, 1); + let auth_lock_3 = AuthorizationLock::new(mint_pubkey.to_bytes(), 50, 150, 1); + + add_authority_with_ed25519_root( + &mut context, + &swig, + &swig_authority, + AuthorityConfig { + authority_type: AuthorityType::Ed25519, + authority: second_authority.pubkey().as_ref(), + }, + vec![ + ClientAction::TokenLimit(TokenLimit { + token_mint: mint_pubkey.to_bytes(), + current_amount: 1000, + }), // Give permission to transfer tokens + ClientAction::AuthorizationLock(auth_lock_1), + ClientAction::AuthorizationLock(auth_lock_2), + ClientAction::AuthorizationLock(auth_lock_3), + ], + ) + .unwrap(); + + context.svm.warp_to_slot(100); // Before any expiry + + // Try to transfer 700 tokens (would leave 300, below combined locked amount of + // 350) + let token_ix = Instruction { + program_id: spl_token::id(), + accounts: vec![ + AccountMeta::new(swig_ata, false), + AccountMeta::new(recipient_ata, false), + AccountMeta::new(swig, false), + ], + data: TokenInstruction::Transfer { amount: 700 }.pack(), + }; + + let sign_ix = swig_interface::SignInstruction::new_ed25519( + swig, + second_authority.pubkey(), + second_authority.pubkey(), + token_ix, + 1, + ) + .unwrap(); + + let transfer_message = v0::Message::try_compile( + &second_authority.pubkey(), + &[sign_ix], + &[], + context.svm.latest_blockhash(), + ) + .unwrap(); + + let transfer_tx = + VersionedTransaction::try_new(VersionedMessage::V0(transfer_message), &[&second_authority]) + .unwrap(); + + let res = context.svm.send_transaction(transfer_tx); + assert!(res.is_err()); + // println!("{}", res.clone().unwrap().pretty_logs()); + + // Should fail with authorization lock violation - combined locks require 350 + // tokens minimum + let full_res = res.unwrap_err(); + + println!("{}", full_res.meta.pretty_logs()); + assert_eq!( + full_res.err, + TransactionError::InstructionError(0, InstructionError::Custom(3021)) + ); + + // Now try a transfer that should succeed (leaves 400 tokens, above 350 minimum) + context.svm.warp_to_slot(100); // Still before any expiry + + let token_ix_2 = Instruction { + program_id: spl_token::id(), + accounts: vec![ + AccountMeta::new(swig_ata, false), + AccountMeta::new(recipient_ata, false), + AccountMeta::new(swig, false), + ], + data: TokenInstruction::Transfer { amount: 600 }.pack(), + }; + + let sign_ix_2 = swig_interface::SignInstruction::new_ed25519( + swig, + second_authority.pubkey(), + second_authority.pubkey(), + token_ix_2, + 1, + ) + .unwrap(); + + let transfer_message_2 = v0::Message::try_compile( + &second_authority.pubkey(), + &[sign_ix_2], + &[], + context.svm.latest_blockhash(), + ) + .unwrap(); + + let transfer_tx_2 = VersionedTransaction::try_new( + VersionedMessage::V0(transfer_message_2), + &[&second_authority], + ) + .unwrap(); + + let res_2 = context.svm.send_transaction(transfer_tx_2); + if res_2.is_err() { + println!("{}", res_2.clone().unwrap_err().meta.pretty_logs()); + } + assert!(res_2.is_ok()); + + // Verify the transfer succeeded (1000 - 600 = 400 remaining) + let swig_token_account = context.svm.get_account(&swig_ata).unwrap(); + let swig_balance = spl_token::state::Account::unpack(&swig_token_account.data).unwrap(); + assert_eq!(swig_balance.amount, 400); +} + +#[test_log::test] +fn test_expired_authorization_locks_automatically_removed() { + let mut context = setup_test_context().unwrap(); + let swig_authority = Keypair::new(); + let recipient = Keypair::new(); + context + .svm + .airdrop(&recipient.pubkey(), 10_000_000_000) + .unwrap(); + context + .svm + .airdrop(&swig_authority.pubkey(), 10_000_000_000) + .unwrap(); + + let id = rand::random::<[u8; 32]>(); + let swig = Pubkey::find_program_address(&swig_account_seeds(&id), &program_id()).0; + + // Setup token infrastructure + let mint_pubkey = setup_mint(&mut context.svm, &context.default_payer).unwrap(); + let swig_ata = setup_ata( + &mut context.svm, + &mint_pubkey, + &swig, + &context.default_payer, + ) + .unwrap(); + let recipient_ata = setup_ata( + &mut context.svm, + &mint_pubkey, + &recipient.pubkey(), + &context.default_payer, + ) + .unwrap(); + + // Mint 1000 tokens to swig account + mint_to( + &mut context.svm, + &mint_pubkey, + &context.default_payer, + &swig_ata, + 1000, + ) + .unwrap(); + + let swig_create_txn = create_swig_ed25519(&mut context, &swig_authority, id); + assert!(swig_create_txn.is_ok()); + + let second_authority = Keypair::new(); + context + .svm + .airdrop(&second_authority.pubkey(), 10_000_000_000) + .unwrap(); + + // Create authorization lock that requires minimum 500 tokens, expires at slot + // 200 + let auth_lock = AuthorizationLock::new(mint_pubkey.to_bytes(), 500, 200, 1); + + add_authority_with_ed25519_root( + &mut context, + &swig, + &swig_authority, + AuthorityConfig { + authority_type: AuthorityType::Ed25519, + authority: second_authority.pubkey().as_ref(), + }, + vec![ + ClientAction::TokenLimit(TokenLimit { + token_mint: mint_pubkey.to_bytes(), + current_amount: 1000, + }), // Give permission to transfer tokens + ClientAction::AuthorizationLock(auth_lock), + ], + ) + .unwrap(); + + // First verify the lock works before expiry + context.svm.warp_to_slot(100); // Before expiry + + // Try to transfer 600 tokens (would leave 400, below locked amount of 500) + // This should fail because of the authorization lock + let token_ix = Instruction { + program_id: spl_token::id(), + accounts: vec![ + AccountMeta::new(swig_ata, false), + AccountMeta::new(recipient_ata, false), + AccountMeta::new(swig, false), + ], + data: TokenInstruction::Transfer { amount: 600 }.pack(), + }; + + let sign_ix = swig_interface::SignInstruction::new_ed25519( + swig, + second_authority.pubkey(), + second_authority.pubkey(), + token_ix.clone(), + 1, + ) + .unwrap(); + + let transfer_message = v0::Message::try_compile( + &second_authority.pubkey(), + &[sign_ix], + &[], + context.svm.latest_blockhash(), + ) + .unwrap(); + + let transfer_tx = + VersionedTransaction::try_new(VersionedMessage::V0(transfer_message), &[&second_authority]) + .unwrap(); + + let res = context.svm.send_transaction(transfer_tx); + assert!( + res.is_err(), + "Transfer should fail due to authorization lock" + ); + + // Should fail with authorization lock violation error (3021) + if let TransactionError::InstructionError(_, InstructionError::Custom(err_code)) = + res.unwrap_err().err + { + assert_eq!( + err_code, 3021, + "Should fail with authorization lock violation" + ); + } else { + panic!("Expected authorization lock violation error"); + } + + let swig_account_data = context.svm.get_account(&swig).unwrap(); + let swig_with_roles = SwigWithRoles::from_bytes(&swig_account_data.data).unwrap(); + + // Count authorization lock actions in role 1 (the second authority that had the + // lock) + let mut auth_lock_count = 0; + if let Ok(Some(role)) = swig_with_roles.get_role(1) { + let mut cursor = 0; + while cursor < role.actions.len() { + if cursor + Action::LEN > role.actions.len() { + break; + } + + if let Ok(action_header) = + unsafe { Action::load_unchecked(&role.actions[cursor..cursor + Action::LEN]) } + { + cursor += Action::LEN; + let action_len = action_header.length() as usize; + + if cursor + action_len > role.actions.len() { + break; + } + + println!( + "action_header.permission(): {:?}", + action_header.permission() + ); + // Check if this is an authorization lock + if action_header.permission().ok() == Some(Permission::AuthorizationLock) { + auth_lock_count += 1; + } + + cursor += action_len; + } else { + break; + } + } + } + + // Now advance time past the lock's expiry + context.svm.warp_to_slot(250); // After expiry (200) + + // Try the same transfer again - it should now succeed because the expired lock + // should be automatically removed + let sign_ix = swig_interface::SignInstruction::new_ed25519( + swig, + second_authority.pubkey(), + second_authority.pubkey(), + token_ix, + 1, + ) + .unwrap(); + + context.svm.expire_blockhash(); + + let transfer_message = v0::Message::try_compile( + &second_authority.pubkey(), + &[sign_ix], + &[], + context.svm.latest_blockhash(), + ) + .unwrap(); + + let transfer_tx = + VersionedTransaction::try_new(VersionedMessage::V0(transfer_message), &[&second_authority]) + .unwrap(); + + let res = context.svm.send_transaction(transfer_tx); + if res.is_err() { + println!("{}", res.clone().unwrap_err().meta.pretty_logs()); + } + + println!("{}", res.clone().unwrap().pretty_logs()); + assert!( + res.is_ok(), + "Transfer should succeed after lock expiry - expired lock should be automatically removed" + ); + + // CRITICAL: Verify that the authorization lock was actually removed from the + // swig account data after expiry + let swig_account_data = context.svm.get_account(&swig).unwrap(); + let swig_with_roles = SwigWithRoles::from_bytes(&swig_account_data.data).unwrap(); + + // Count authorization lock actions in role 1 (the second authority that had the + // lock) + let mut auth_lock_count = 0; + if let Ok(Some(role)) = swig_with_roles.get_role(1) { + let mut cursor = 0; + while cursor < role.actions.len() { + if cursor + Action::LEN > role.actions.len() { + break; + } + + if let Ok(action_header) = + unsafe { Action::load_unchecked(&role.actions[cursor..cursor + Action::LEN]) } + { + cursor += Action::LEN; + let action_len = action_header.length() as usize; + + if cursor + action_len > role.actions.len() { + break; + } + + println!( + "action_header.permission(): {:?}", + action_header.permission() + ); + // Check if this is an authorization lock + if action_header.permission().ok() == Some(Permission::AuthorizationLock) { + auth_lock_count += 1; + } + + cursor += action_len; + } else { + break; + } + } + } + + // The authorization lock should have been removed, so count should be 0 + assert_eq!( + auth_lock_count, 0, + "Authorization lock should have been removed from the account data after expiry" + ); + + // Also verify that the role's action count decreased + if let Ok(Some(role)) = swig_with_roles.get_role(1) { + // Role should now have only 1 action (TokenLimit), the AuthorizationLock should + // be gone + assert_eq!( + role.position.num_actions(), + 1, + "Role should have 1 action after expired lock removal (only TokenLimit should remain)" + ); + } + + println!( + "✅ Verified that expired authorization lock was physically removed from swig account data" + ); + + // Additional verification: Test a second transfer that would have failed with + // the lock + let token_ix_2 = Instruction { + program_id: spl_token::id(), + accounts: vec![ + AccountMeta::new(swig_ata, false), + AccountMeta::new(recipient_ata, false), + AccountMeta::new(swig, false), + ], + data: TokenInstruction::Transfer { amount: 350 }.pack(), + }; + + let sign_ix_2 = swig_interface::SignInstruction::new_ed25519( + swig, + second_authority.pubkey(), + second_authority.pubkey(), + token_ix_2, + 1, + ) + .unwrap(); + + let transfer_message_2 = v0::Message::try_compile( + &second_authority.pubkey(), + &[sign_ix_2], + &[], + context.svm.latest_blockhash(), + ) + .unwrap(); + + let transfer_tx_2 = VersionedTransaction::try_new( + VersionedMessage::V0(transfer_message_2), + &[&second_authority], + ) + .unwrap(); + + let res_2 = context.svm.send_transaction(transfer_tx_2); + if res_2.is_err() { + println!("{}", res_2.clone().unwrap_err().meta.pretty_logs()); + } + + assert!( + res_2.is_ok(), + "Second transfer should also succeed since expired lock was removed" + ); + + // Final balance verification: should have 50 tokens left (400 - 350) + let final_token_account = context.svm.get_account(&swig_ata).unwrap(); + let final_balance = spl_token::state::Account::unpack(&final_token_account.data).unwrap(); + assert_eq!(final_balance.amount, 50); + + println!( + "✅ All verifications passed - expired authorization lock functionality works correctly" + ); +} diff --git a/program/tests/remove_actions_from_role_test.rs b/program/tests/remove_actions_from_role_test.rs new file mode 100644 index 00000000..cd7a521e --- /dev/null +++ b/program/tests/remove_actions_from_role_test.rs @@ -0,0 +1,706 @@ +#![cfg(not(feature = "program_scope_test"))] + +mod common; + +use common::*; +use solana_sdk::{ + compute_budget::ComputeBudgetInstruction, + message::{v0, VersionedMessage}, + pubkey::Pubkey, + signature::Keypair, + signer::Signer, + transaction::VersionedTransaction, +}; +use swig::actions::remove_actions_from_role_v1::RemoveActionsFromRoleV1Args; +use swig_interface::{swig, AuthorityConfig, ClientAction}; +use swig_state_x::{ + action::{ + all::All, manage_authority::ManageAuthority, program::Program, sol_limit::SolLimit, Action, + Permission, + }, + authority::AuthorityType, + swig::{swig_account_seeds, SwigWithRoles}, + IntoBytes, Transmutable, +}; + +fn build_remove_actions_from_role_ix_data( + acting_role_id: u32, + target_role_id: u32, + indices: Vec, +) -> Vec { + let args = + RemoveActionsFromRoleV1Args::new(acting_role_id, target_role_id, indices.len() as u16); + + let mut ix_data = Vec::new(); + ix_data.extend_from_slice(args.into_bytes().unwrap()); + + // Add action indices as u16 little-endian bytes + for index in indices { + ix_data.extend_from_slice(&index.to_le_bytes()); + } + + // Add authority payload for Ed25519 - single byte indicating the account index + // of the authority that needs to sign + ix_data.push(3); // Authority will be at index 3 + + ix_data +} + +#[test_log::test] +fn test_remove_actions_from_role_success() { + let mut context = setup_test_context().unwrap(); + let swig_authority = Keypair::new(); + + context + .svm + .airdrop(&swig_authority.pubkey(), 10_000_000_000) + .unwrap(); + + let id = rand::random::<[u8; 32]>(); + let (swig_pubkey, _) = create_swig_ed25519(&mut context, &swig_authority, id).unwrap(); + + // Create a second authority with multiple actions + let second_authority = Keypair::new(); + context + .svm + .airdrop(&second_authority.pubkey(), 10_000_000_000) + .unwrap(); + + // Add second authority with multiple actions: SolLimit, Program, another + // SolLimit + add_authority_with_ed25519_root( + &mut context, + &swig_pubkey, + &swig_authority, + AuthorityConfig { + authority_type: AuthorityType::Ed25519, + authority: second_authority.pubkey().as_ref(), + }, + vec![ + ClientAction::SolLimit(SolLimit { amount: 1_000_000 }), + ClientAction::Program(Program { + program_id: [4; 32], + }), + ClientAction::SolLimit(SolLimit { amount: 5_000_000 }), + ], + ) + .unwrap(); + + // Verify the role initially has 3 actions + let swig_account = context.svm.get_account(&swig_pubkey).unwrap(); + let swig_state = SwigWithRoles::from_bytes(&swig_account.data).unwrap(); + let role = swig_state.get_role(1).unwrap().unwrap(); + assert_eq!(role.position.num_actions(), 3); + let all_actions = role.get_all_actions().unwrap(); + assert_eq!(all_actions.len(), 3); + + // Remove the middle action (index 1, which is the Program action) + let remove_indices = vec![1]; // Remove Program action + let ix_data = build_remove_actions_from_role_ix_data(0, 1, remove_indices); + + let accounts = vec![ + solana_sdk::instruction::AccountMeta::new(swig_pubkey, false), + solana_sdk::instruction::AccountMeta::new(swig_authority.pubkey(), true), + solana_sdk::instruction::AccountMeta::new_readonly(pinocchio_system::ID.into(), false), + solana_sdk::instruction::AccountMeta::new_readonly(swig_authority.pubkey(), true), /* Authority signer */ + ]; + + let ix = solana_sdk::instruction::Instruction { + program_id: program_id(), + accounts, + data: ix_data, + }; + + let msg = v0::Message::try_compile( + &swig_authority.pubkey(), + &[ + ComputeBudgetInstruction::set_compute_unit_limit(10000000), + ix, + ], + &[], + context.svm.latest_blockhash(), + ) + .unwrap(); + + let tx = VersionedTransaction::try_new( + VersionedMessage::V0(msg), + &[swig_authority.insecure_clone()], + ) + .unwrap(); + + let result = context.svm.send_transaction(tx); + assert!( + result.is_ok(), + "Failed to remove actions from role: {:?}", + result + ); + + // Verify the role now has 2 actions (SolLimit and SolLimit) + let swig_account = context.svm.get_account(&swig_pubkey).unwrap(); + let swig_state = SwigWithRoles::from_bytes(&swig_account.data).unwrap(); + let role = swig_state.get_role(1).unwrap().unwrap(); + + // Should have 2 actions now (two SolLimit actions) + assert_eq!(role.position.num_actions(), 2); + + // Verify with get_all_actions + let all_actions = role.get_all_actions().unwrap(); + assert_eq!( + all_actions.len(), + 2, + "Should have exactly 2 actions after removal" + ); + + // Verify the remaining actions are both SolLimit actions + for action in all_actions.iter() { + assert_eq!(action.permission().unwrap(), Permission::SolLimit); + } +} + +#[test_log::test] +fn test_remove_multiple_actions_from_role_success() { + let mut context = setup_test_context().unwrap(); + let swig_authority = Keypair::new(); + + context + .svm + .airdrop(&swig_authority.pubkey(), 10_000_000_000) + .unwrap(); + + let id = rand::random::<[u8; 32]>(); + let (swig_pubkey, _) = create_swig_ed25519(&mut context, &swig_authority, id).unwrap(); + + // Create a second authority with multiple actions + let second_authority = Keypair::new(); + context + .svm + .airdrop(&second_authority.pubkey(), 10_000_000_000) + .unwrap(); + + // Add second authority with 4 actions: SolLimit, Program, SolLimit, Program + add_authority_with_ed25519_root( + &mut context, + &swig_pubkey, + &swig_authority, + AuthorityConfig { + authority_type: AuthorityType::Ed25519, + authority: second_authority.pubkey().as_ref(), + }, + vec![ + ClientAction::SolLimit(SolLimit { amount: 1_000_000 }), + ClientAction::Program(Program { + program_id: [4; 32], + }), + ClientAction::SolLimit(SolLimit { amount: 5_000_000 }), + ClientAction::Program(Program { + program_id: [5; 32], + }), + ], + ) + .unwrap(); + + // Verify the role initially has 4 actions + let swig_account = context.svm.get_account(&swig_pubkey).unwrap(); + let swig_state = SwigWithRoles::from_bytes(&swig_account.data).unwrap(); + let role = swig_state.get_role(1).unwrap().unwrap(); + assert_eq!(role.position.num_actions(), 4); + + // Debug: Check what actions we have before removal + let all_actions_before = role.get_all_actions().unwrap(); + println!("Before removal: {} actions", all_actions_before.len()); + for (i, action) in all_actions_before.iter().enumerate() { + println!("Action {}: {:?}", i, action.permission()); + } + + // Remove multiple actions (indices 1 and 3, both Program actions) + let remove_indices = vec![1, 3]; // Remove both Program actions + let ix_data = build_remove_actions_from_role_ix_data(0, 1, remove_indices); + + let accounts = vec![ + solana_sdk::instruction::AccountMeta::new(swig_pubkey, false), + solana_sdk::instruction::AccountMeta::new(swig_authority.pubkey(), true), + solana_sdk::instruction::AccountMeta::new_readonly(pinocchio_system::ID.into(), false), + solana_sdk::instruction::AccountMeta::new_readonly(swig_authority.pubkey(), true), /* Authority signer */ + ]; + + let ix = solana_sdk::instruction::Instruction { + program_id: program_id(), + accounts, + data: ix_data, + }; + + let msg = v0::Message::try_compile( + &swig_authority.pubkey(), + &[ + ComputeBudgetInstruction::set_compute_unit_limit(10000000), + ix, + ], + &[], + context.svm.latest_blockhash(), + ) + .unwrap(); + + let tx = VersionedTransaction::try_new( + VersionedMessage::V0(msg), + &[swig_authority.insecure_clone()], + ) + .unwrap(); + + let result = context.svm.send_transaction(tx); + assert!( + result.is_ok(), + "Failed to remove multiple actions from role: {:?}", + result + ); + + // Verify the role now has 2 actions (both SolLimit actions) + let swig_account = context.svm.get_account(&swig_pubkey).unwrap(); + let swig_state = SwigWithRoles::from_bytes(&swig_account.data).unwrap(); + let role = swig_state.get_role(1).unwrap().unwrap(); + + // Debug: Check what actions we have after removal + println!( + "After removal: position.num_actions() = {}", + role.position.num_actions() + ); + let all_actions = role.get_all_actions().unwrap(); + println!( + "After removal: get_all_actions().len() = {}", + all_actions.len() + ); + for (i, action) in all_actions.iter().enumerate() { + println!( + "Action {}: {:?}, boundary: {}", + i, + action.permission(), + action.boundary() + ); + } + + // Manual debug parsing to see what's in the actions data + println!("\nDebug: Manual action parsing after removal:"); + let actions_data = role.actions; + println!("Total actions data length: {}", actions_data.len()); + let mut cursor = 0; + let mut idx = 0; + while cursor < actions_data.len() && idx < 5 { + // Limit to 5 to avoid infinite loop + if cursor + 8 > actions_data.len() { + println!("Not enough data for action header at cursor {}", cursor); + break; + } + let action_type = u16::from_le_bytes([actions_data[cursor], actions_data[cursor + 1]]); + let action_len = u16::from_le_bytes([actions_data[cursor + 2], actions_data[cursor + 3]]); + let action_boundary = u32::from_le_bytes([ + actions_data[cursor + 4], + actions_data[cursor + 5], + actions_data[cursor + 6], + actions_data[cursor + 7], + ]); + println!( + "Action {}: type={}, len={}, boundary={}, cursor={}", + idx, action_type, action_len, action_boundary, cursor + ); + + if action_boundary as usize <= cursor || action_boundary as usize > actions_data.len() { + println!( + "Invalid boundary - would go to {} but actions_data.len() is {}", + action_boundary, + actions_data.len() + ); + break; + } + + cursor = action_boundary as usize; + idx += 1; + } + + // Should have 2 actions now (both SolLimit actions) + assert_eq!(role.position.num_actions(), 2); + + // Verify with get_all_actions + assert_eq!( + all_actions.len(), + 2, + "Should have exactly 2 actions after removal" + ); + + // Verify the remaining actions are both SolLimit actions + for action in all_actions.iter() { + assert_eq!(action.permission().unwrap(), Permission::SolLimit); + } +} + +#[test_log::test] +fn test_remove_actions_from_role_no_permission() { + let mut context = setup_test_context().unwrap(); + let swig_authority = Keypair::new(); + + context + .svm + .airdrop(&swig_authority.pubkey(), 10_000_000_000) + .unwrap(); + + let id = rand::random::<[u8; 32]>(); + let (swig_pubkey, _) = create_swig_ed25519(&mut context, &swig_authority, id).unwrap(); + + // Create a second authority without ManageAuthority permission + let second_authority = Keypair::new(); + context + .svm + .airdrop(&second_authority.pubkey(), 10_000_000_000) + .unwrap(); + + add_authority_with_ed25519_root( + &mut context, + &swig_pubkey, + &swig_authority, + AuthorityConfig { + authority_type: AuthorityType::Ed25519, + authority: second_authority.pubkey().as_ref(), + }, + vec![ClientAction::SolLimit(SolLimit { amount: 1_000_000 })], // No ManageAuthority or All + ) + .unwrap(); + + // Try to remove actions using the second authority which doesn't have + // permission + let remove_indices = vec![0]; // Try to remove first action + let ix_data = build_remove_actions_from_role_ix_data(1, 0, remove_indices); // Use role 1 (second_authority) to modify role 0 + + let accounts = vec![ + solana_sdk::instruction::AccountMeta::new(swig_pubkey, false), + solana_sdk::instruction::AccountMeta::new(second_authority.pubkey(), true), + solana_sdk::instruction::AccountMeta::new_readonly(pinocchio_system::ID.into(), false), + solana_sdk::instruction::AccountMeta::new_readonly(second_authority.pubkey(), true), /* Authority signer */ + ]; + + let ix = solana_sdk::instruction::Instruction { + program_id: program_id(), + accounts, + data: ix_data, + }; + + let msg = v0::Message::try_compile( + &second_authority.pubkey(), + &[ix], + &[], + context.svm.latest_blockhash(), + ) + .unwrap(); + + let tx = VersionedTransaction::try_new( + VersionedMessage::V0(msg), + &[second_authority.insecure_clone()], + ) + .unwrap(); + + let result = context.svm.send_transaction(tx); + assert!(result.is_err(), "Should fail without proper permissions"); +} + +#[test_log::test] +fn test_remove_actions_from_role_invalid_target() { + let mut context = setup_test_context().unwrap(); + let swig_authority = Keypair::new(); + + context + .svm + .airdrop(&swig_authority.pubkey(), 10_000_000_000) + .unwrap(); + + let id = rand::random::<[u8; 32]>(); + let (swig_pubkey, _) = create_swig_ed25519(&mut context, &swig_authority, id).unwrap(); + + // Try to remove actions from non-existent role + let remove_indices = vec![0]; + let ix_data = build_remove_actions_from_role_ix_data(0, 999, remove_indices); // Invalid target role ID + + let accounts = vec![ + solana_sdk::instruction::AccountMeta::new(swig_pubkey, false), + solana_sdk::instruction::AccountMeta::new(swig_authority.pubkey(), true), + solana_sdk::instruction::AccountMeta::new_readonly(pinocchio_system::ID.into(), false), + solana_sdk::instruction::AccountMeta::new_readonly(swig_authority.pubkey(), true), /* Authority signer */ + ]; + + let ix = solana_sdk::instruction::Instruction { + program_id: program_id(), + accounts, + data: ix_data, + }; + + let msg = v0::Message::try_compile( + &swig_authority.pubkey(), + &[ix], + &[], + context.svm.latest_blockhash(), + ) + .unwrap(); + + let tx = VersionedTransaction::try_new( + VersionedMessage::V0(msg), + &[swig_authority.insecure_clone()], + ) + .unwrap(); + + let result = context.svm.send_transaction(tx); + assert!(result.is_err(), "Should fail with non-existent target role"); +} + +#[test_log::test] +fn test_remove_actions_from_role_index_out_of_bounds() { + let mut context = setup_test_context().unwrap(); + let swig_authority = Keypair::new(); + + context + .svm + .airdrop(&swig_authority.pubkey(), 10_000_000_000) + .unwrap(); + + let id = rand::random::<[u8; 32]>(); + let (swig_pubkey, _) = create_swig_ed25519(&mut context, &swig_authority, id).unwrap(); + + // Create a second authority with just one action + let second_authority = Keypair::new(); + context + .svm + .airdrop(&second_authority.pubkey(), 10_000_000_000) + .unwrap(); + + add_authority_with_ed25519_root( + &mut context, + &swig_pubkey, + &swig_authority, + AuthorityConfig { + authority_type: AuthorityType::Ed25519, + authority: second_authority.pubkey().as_ref(), + }, + vec![ClientAction::SolLimit(SolLimit { amount: 1_000_000 })], + ) + .unwrap(); + + // Try to remove an action with an out-of-bounds index + let remove_indices = vec![5]; // Index 5 doesn't exist (only index 0 exists) + let ix_data = build_remove_actions_from_role_ix_data(0, 1, remove_indices); + + let accounts = vec![ + solana_sdk::instruction::AccountMeta::new(swig_pubkey, false), + solana_sdk::instruction::AccountMeta::new(swig_authority.pubkey(), true), + solana_sdk::instruction::AccountMeta::new_readonly(pinocchio_system::ID.into(), false), + solana_sdk::instruction::AccountMeta::new_readonly(swig_authority.pubkey(), true), /* Authority signer */ + ]; + + let ix = solana_sdk::instruction::Instruction { + program_id: program_id(), + accounts, + data: ix_data, + }; + + let msg = v0::Message::try_compile( + &swig_authority.pubkey(), + &[ix], + &[], + context.svm.latest_blockhash(), + ) + .unwrap(); + + let tx = VersionedTransaction::try_new( + VersionedMessage::V0(msg), + &[swig_authority.insecure_clone()], + ) + .unwrap(); + + let result = context.svm.send_transaction(tx); + assert!(result.is_err(), "Should fail with out-of-bounds index"); +} + +#[test_log::test] +fn test_remove_actions_from_role_remove_all_actions() { + let mut context = setup_test_context().unwrap(); + let swig_authority = Keypair::new(); + + context + .svm + .airdrop(&swig_authority.pubkey(), 10_000_000_000) + .unwrap(); + + let id = rand::random::<[u8; 32]>(); + let (swig_pubkey, _) = create_swig_ed25519(&mut context, &swig_authority, id).unwrap(); + + // Create a second authority with just one action + let second_authority = Keypair::new(); + context + .svm + .airdrop(&second_authority.pubkey(), 10_000_000_000) + .unwrap(); + + add_authority_with_ed25519_root( + &mut context, + &swig_pubkey, + &swig_authority, + AuthorityConfig { + authority_type: AuthorityType::Ed25519, + authority: second_authority.pubkey().as_ref(), + }, + vec![ClientAction::SolLimit(SolLimit { amount: 1_000_000 })], + ) + .unwrap(); + + // Try to remove the only action (index 0) - this should fail + let remove_indices = vec![0]; // Try to remove the only action + let ix_data = build_remove_actions_from_role_ix_data(0, 1, remove_indices); + + let accounts = vec![ + solana_sdk::instruction::AccountMeta::new(swig_pubkey, false), + solana_sdk::instruction::AccountMeta::new(swig_authority.pubkey(), true), + solana_sdk::instruction::AccountMeta::new_readonly(pinocchio_system::ID.into(), false), + solana_sdk::instruction::AccountMeta::new_readonly(swig_authority.pubkey(), true), /* Authority signer */ + ]; + + let ix = solana_sdk::instruction::Instruction { + program_id: program_id(), + accounts, + data: ix_data, + }; + + let msg = v0::Message::try_compile( + &swig_authority.pubkey(), + &[ix], + &[], + context.svm.latest_blockhash(), + ) + .unwrap(); + + let tx = VersionedTransaction::try_new( + VersionedMessage::V0(msg), + &[swig_authority.insecure_clone()], + ) + .unwrap(); + + let result = context.svm.send_transaction(tx); + assert!( + result.is_err(), + "Should fail when trying to remove all actions from a role" + ); +} + +#[test_log::test] +fn test_remove_actions_from_role_duplicate_indices() { + let mut context = setup_test_context().unwrap(); + let swig_authority = Keypair::new(); + + context + .svm + .airdrop(&swig_authority.pubkey(), 10_000_000_000) + .unwrap(); + + let id = rand::random::<[u8; 32]>(); + let (swig_pubkey, _) = create_swig_ed25519(&mut context, &swig_authority, id).unwrap(); + + // Create a second authority with multiple actions + let second_authority = Keypair::new(); + context + .svm + .airdrop(&second_authority.pubkey(), 10_000_000_000) + .unwrap(); + + add_authority_with_ed25519_root( + &mut context, + &swig_pubkey, + &swig_authority, + AuthorityConfig { + authority_type: AuthorityType::Ed25519, + authority: second_authority.pubkey().as_ref(), + }, + vec![ + ClientAction::SolLimit(SolLimit { amount: 1_000_000 }), + ClientAction::Program(Program { + program_id: [4; 32], + }), + ClientAction::SolLimit(SolLimit { amount: 5_000_000 }), + ], + ) + .unwrap(); + + // Try to remove actions with duplicate indices + let remove_indices = vec![1, 1]; // Duplicate index 1 + let ix_data = build_remove_actions_from_role_ix_data(0, 1, remove_indices); + + let accounts = vec![ + solana_sdk::instruction::AccountMeta::new(swig_pubkey, false), + solana_sdk::instruction::AccountMeta::new(swig_authority.pubkey(), true), + solana_sdk::instruction::AccountMeta::new_readonly(pinocchio_system::ID.into(), false), + solana_sdk::instruction::AccountMeta::new_readonly(swig_authority.pubkey(), true), /* Authority signer */ + ]; + + let ix = solana_sdk::instruction::Instruction { + program_id: program_id(), + accounts, + data: ix_data, + }; + + let msg = v0::Message::try_compile( + &swig_authority.pubkey(), + &[ix], + &[], + context.svm.latest_blockhash(), + ) + .unwrap(); + + let tx = VersionedTransaction::try_new( + VersionedMessage::V0(msg), + &[swig_authority.insecure_clone()], + ) + .unwrap(); + + let result = context.svm.send_transaction(tx); + assert!(result.is_err(), "Should fail with duplicate indices"); +} + +#[test_log::test] +fn test_remove_actions_from_role_empty_indices() { + let mut context = setup_test_context().unwrap(); + let swig_authority = Keypair::new(); + + context + .svm + .airdrop(&swig_authority.pubkey(), 10_000_000_000) + .unwrap(); + + let id = rand::random::<[u8; 32]>(); + let (swig_pubkey, _) = create_swig_ed25519(&mut context, &swig_authority, id).unwrap(); + + // Try to remove actions with no indices provided + let remove_indices: Vec = vec![]; // Empty indices + let ix_data = build_remove_actions_from_role_ix_data(0, 0, remove_indices); + + let accounts = vec![ + solana_sdk::instruction::AccountMeta::new(swig_pubkey, false), + solana_sdk::instruction::AccountMeta::new(swig_authority.pubkey(), true), + solana_sdk::instruction::AccountMeta::new_readonly(pinocchio_system::ID.into(), false), + solana_sdk::instruction::AccountMeta::new_readonly(swig_authority.pubkey(), true), /* Authority signer */ + ]; + + let ix = solana_sdk::instruction::Instruction { + program_id: program_id(), + accounts, + data: ix_data, + }; + + let msg = v0::Message::try_compile( + &swig_authority.pubkey(), + &[ix], + &[], + context.svm.latest_blockhash(), + ) + .unwrap(); + + let tx = VersionedTransaction::try_new( + VersionedMessage::V0(msg), + &[swig_authority.insecure_clone()], + ) + .unwrap(); + + let result = context.svm.send_transaction(tx); + assert!(result.is_err(), "Should fail with empty indices"); +} diff --git a/program/tests/sign.rs b/program/tests/sign.rs index e8515a3e..ed83bf40 100644 --- a/program/tests/sign.rs +++ b/program/tests/sign.rs @@ -18,9 +18,10 @@ use solana_sdk::{ }; use swig_interface::{AuthorityConfig, ClientAction}; use swig_state_x::{ - action::{all::All, sol_limit::SolLimit}, + action::{all::All, sol_limit::SolLimit, token_limit::TokenLimit}, authority::AuthorityType, swig::{swig_account_seeds, SwigWithRoles}, + Transmutable, }; #[test_log::test] diff --git a/state-x/src/action/authorization_lock.rs b/state-x/src/action/authorization_lock.rs new file mode 100644 index 00000000..3d1e1ef4 --- /dev/null +++ b/state-x/src/action/authorization_lock.rs @@ -0,0 +1,147 @@ +//! Authorization lock action type. +//! +//! This module defines the AuthorizationLock action type which places +//! temporary locks on token amounts to prevent transfers that would reduce +//! the wallet balance below the locked amount during the lock period. + +use no_padding::NoPadding; +use pinocchio::{msg, program_error::ProgramError}; + +use super::{Actionable, Permission}; +use crate::{IntoBytes, SwigAuthenticateError, Transmutable, TransmutableMut}; + +/// Represents a temporary authorization lock on a specific token mint. +/// +/// This struct tracks a locked amount of tokens that cannot be transferred +/// out of the wallet until the expiry slot is reached. This is useful for +/// implementing authorization holds like those used by card companies. +#[repr(C, align(8))] +#[derive(Debug, NoPadding)] +pub struct AuthorizationLock { + /// The mint address of the token this lock applies to + pub token_mint: [u8; 32], + /// The amount of tokens locked (minimum balance required) + pub locked_amount: u64, + /// The slot when this lock expires + pub expiry_slot: u64, + /// The role ID of the authority that created this lock + pub creator_role_id: u32, + /// Padding to maintain 8-byte alignment + _padding: u32, +} + +impl AuthorizationLock { + /// Creates a new authorization lock. + /// + /// # Arguments + /// * `token_mint` - The mint address of the token + /// * `locked_amount` - The amount to lock + /// * `expiry_slot` - When the lock expires + /// * `creator_role_id` - The role ID of the authority creating this lock + pub fn new( + token_mint: [u8; 32], + locked_amount: u64, + expiry_slot: u64, + creator_role_id: u32, + ) -> Self { + Self { + token_mint, + locked_amount, + expiry_slot, + creator_role_id, + _padding: 0, + } + } + + /// Checks if this authorization lock has expired. + /// + /// # Arguments + /// * `current_slot` - The current slot number + /// + /// # Returns + /// * `bool` - True if the lock has expired + pub fn is_expired(&self, current_slot: u64) -> bool { + current_slot >= self.expiry_slot + } + + /// Checks if a transfer would violate this authorization lock. + /// + /// # Arguments + /// * `current_balance` - The current token balance + /// * `transfer_amount` - The amount being transferred out + /// * `current_slot` - The current slot number + /// + /// # Returns + /// * `Ok(())` - If the transfer is allowed + /// * `Err(ProgramError)` - If the transfer would violate the lock + pub fn check_authorization( + &self, + current_balance: &u64, + transfer_amount: u64, + current_slot: u64, + ) -> Result<(), ProgramError> { + // If the lock has expired, allow the transfer + if self.is_expired(current_slot) { + return Ok(()); + } + + // Check if the transfer would reduce balance below locked amount + let remaining_balance = current_balance.saturating_sub(transfer_amount); + if remaining_balance < self.locked_amount { + msg!("PermissionDeniedAuthorizationLockViolation"); + return Err(SwigAuthenticateError::PermissionDeniedAuthorizationLockViolation.into()); + } + + Ok(()) + } + + /// Updates the locked amount for this authorization. + /// + /// # Arguments + /// * `new_amount` - The new amount to lock + pub fn update_locked_amount(&mut self, new_amount: u64) { + self.locked_amount = new_amount; + } + + /// Extends the expiry slot for this authorization. + /// + /// # Arguments + /// * `new_expiry_slot` - The new expiry slot + pub fn extend_expiry(&mut self, new_expiry_slot: u64) { + if new_expiry_slot > self.expiry_slot { + self.expiry_slot = new_expiry_slot; + } + } +} + +impl Transmutable for AuthorizationLock { + /// Size of the AuthorizationLock struct in bytes (32 bytes for mint + 8 + /// bytes for amount + 8 bytes for expiry + 4 bytes for creator_role_id + /// + 4 bytes padding) + const LEN: usize = 56; +} + +impl TransmutableMut for AuthorizationLock {} + +impl IntoBytes for AuthorizationLock { + fn into_bytes(&self) -> Result<&[u8], ProgramError> { + let bytes = + unsafe { core::slice::from_raw_parts(self as *const Self as *const u8, Self::LEN) }; + Ok(bytes) + } +} + +impl<'a> Actionable<'a> for AuthorizationLock { + /// This action represents the AuthorizationLock permission type + const TYPE: Permission = Permission::AuthorizationLock; + /// Multiple authorization locks can exist per role (one per token mint) + const REPEATABLE: bool = true; + + /// Checks if this authorization lock matches the provided token mint. + /// + /// # Arguments + /// * `data` - The token mint to check against (first 32 bytes) + fn match_data(&self, data: &[u8]) -> bool { + data[0..32] == self.token_mint + } +} diff --git a/state-x/src/action/manage_authorization_lock.rs b/state-x/src/action/manage_authorization_lock.rs new file mode 100644 index 00000000..2b1143dd --- /dev/null +++ b/state-x/src/action/manage_authorization_lock.rs @@ -0,0 +1,36 @@ +//! Authorization lock management action type. +//! +//! This module defines the ManageAuthorizationLock action type which grants permission +//! to manage authorization lock settings within the Swig wallet system. + +use pinocchio::program_error::ProgramError; + +use super::{Actionable, Permission}; +use crate::{IntoBytes, Transmutable, TransmutableMut}; + +/// Represents permission to manage authorization lock settings. +/// +/// This is a marker struct that grants access to authorization lock management +/// operations such as adding, removing, or modifying authorization locks. It contains +/// no data since its mere presence indicates management access. +#[repr(C)] +pub struct ManageAuthorizationLock; + +impl Transmutable for ManageAuthorizationLock { + const LEN: usize = 0; // Since this is just a marker with no data +} + +impl TransmutableMut for ManageAuthorizationLock {} + +impl IntoBytes for ManageAuthorizationLock { + fn into_bytes(&self) -> Result<&[u8], ProgramError> { + Ok(&[]) + } +} + +impl<'a> Actionable<'a> for ManageAuthorizationLock { + /// This action represents the ManageAuthorizationLock permission type + const TYPE: Permission = Permission::ManageAuthorizationLock; + /// Only one instance of authorization lock management permissions can exist per role + const REPEATABLE: bool = false; +} diff --git a/state-x/src/action/mod.rs b/state-x/src/action/mod.rs index 836b969e..ac602251 100644 --- a/state-x/src/action/mod.rs +++ b/state-x/src/action/mod.rs @@ -6,7 +6,9 @@ //! stake management. pub mod all; +pub mod authorization_lock; pub mod manage_authority; +pub mod manage_authorization_lock; pub mod program; pub mod program_scope; pub mod sol_limit; @@ -18,7 +20,9 @@ pub mod sub_account; pub mod token_limit; pub mod token_recurring_limit; use all::All; +use authorization_lock::AuthorizationLock; use manage_authority::ManageAuthority; +use manage_authorization_lock::ManageAuthorizationLock; use no_padding::NoPadding; use pinocchio::program_error::ProgramError; use program::Program; @@ -130,6 +134,10 @@ pub enum Permission { StakeRecurringLimit = 11, /// Permission to perform all stake operations StakeAll = 12, + /// Permission to place authorization locks on token amounts + AuthorizationLock = 13, + /// Permission to manage authorization locks (add, remove, update) + ManageAuthorizationLock = 14, } impl TryFrom for Permission { @@ -139,7 +147,7 @@ impl TryFrom for Permission { fn try_from(value: u16) -> Result { match value { // SAFETY: `value` is guaranteed to be in the range of the enum variants. - 0..=12 => Ok(unsafe { core::mem::transmute::(value) }), + 0..=14 => Ok(unsafe { core::mem::transmute::(value) }), _ => Err(SwigStateError::PermissionLoadError.into()), } } @@ -196,6 +204,8 @@ impl ActionLoader { Permission::StakeLimit => StakeLimit::valid_layout(data), Permission::StakeRecurringLimit => StakeRecurringLimit::valid_layout(data), Permission::StakeAll => StakeAll::valid_layout(data), + Permission::AuthorizationLock => AuthorizationLock::valid_layout(data), + Permission::ManageAuthorizationLock => ManageAuthorizationLock::valid_layout(data), _ => Ok(false), } } diff --git a/state-x/src/lib.rs b/state-x/src/lib.rs index 21a5284d..c74f58f2 100644 --- a/state-x/src/lib.rs +++ b/state-x/src/lib.rs @@ -143,6 +143,8 @@ pub enum SwigAuthenticateError { InvalidSessionKeyCannotReuseSessionKey, /// Invalid session duration InvalidSessionDuration, + /// Authorization lock violation - transfer would reduce balance below locked amount + PermissionDeniedAuthorizationLockViolation, } impl From for ProgramError { diff --git a/state-x/src/role.rs b/state-x/src/role.rs index 58854284..06912e75 100644 --- a/state-x/src/role.rs +++ b/state-x/src/role.rs @@ -5,7 +5,7 @@ //! core role-based access control (RBAC) system. use no_padding::NoPadding; -use pinocchio::program_error::ProgramError; +use pinocchio::{msg, program_error::ProgramError}; use crate::{ action::{Action, Actionable}, @@ -87,7 +87,7 @@ impl<'a> Role<'a> { Action::load_unchecked(self.actions.get_unchecked(cursor..cursor + Action::LEN))? }; actions.push(action); - cursor += action.boundary() as usize; + cursor = action.boundary() as usize; } Ok(actions) } diff --git a/state-x/src/swig.rs b/state-x/src/swig.rs index 27a2d3a7..f5ffa2e9 100644 --- a/state-x/src/swig.rs +++ b/state-x/src/swig.rs @@ -293,6 +293,8 @@ impl<'a> SwigBuilder<'a> { cursor += authority_length; // todo check actions for duplicates let mut action_cursor = 0; + let actions_start_cursor = cursor; // Remember where actions start in the role buffer + for _i in 0..num_actions { let header = &actions_data[action_cursor..action_cursor + Action::LEN]; let action_header = unsafe { Action::load_unchecked(header)? }; @@ -303,11 +305,12 @@ impl<'a> SwigBuilder<'a> { if ActionLoader::validate_layout(action_header.permission()?, action_slice)? { self.role_buffer[cursor..cursor + Action::LEN].copy_from_slice(header); - // change boundary to the new boundary - self.role_buffer[cursor + 4..cursor + 8].copy_from_slice( - &((cursor + Action::LEN + action_header.length() as usize) as u32) - .to_le_bytes(), - ); + // Fix boundary: position where next action starts within actions buffer + let current_action_pos_in_actions = cursor - actions_start_cursor; + let next_action_pos_in_actions = + current_action_pos_in_actions + Action::LEN + action_header.length() as usize; + self.role_buffer[cursor + 4..cursor + 8] + .copy_from_slice(&(next_action_pos_in_actions as u32).to_le_bytes()); cursor += Action::LEN; self.role_buffer[cursor..cursor + action_header.length() as usize] .copy_from_slice(action_slice); @@ -394,8 +397,11 @@ impl Swig { _ => return Err(ProgramError::InvalidAccountData), }; - let action_data_end = position.boundary() as usize - Position::LEN - authority_length; + let action_data_end = + position.boundary() as usize - (offset + Position::LEN + authority_length); + let (actions, _rest) = unsafe { actions.split_at_mut_unchecked(action_data_end) }; + let role = RoleMut { position, authority: auth,