From 210aa0fb70126524b8f47b003aed93f7e17f00a2 Mon Sep 17 00:00:00 2001 From: Jo D Date: Wed, 18 Mar 2026 14:16:55 -0400 Subject: [PATCH 1/8] fix(program): remediate escrow audit findings 2-14 Apply and test remediations for the confirmed findings from the Creature audit repository, including account validation hardening, extension enforcement updates, hook context/signer restrictions, discriminator/version guards, and prefunded PDA-safe creation semantics.\n\nFinding #15 remains intentionally deferred for later design alignment. --- idl/escrow_program.json | 8 +- .../src/instructions/block_mint/processor.rs | 18 +-- program/src/instructions/deposit/accounts.rs | 5 +- program/src/instructions/deposit/processor.rs | 31 ++--- .../extensions/add_timelock/processor.rs | 17 ++- .../extensions/set_arbiter/processor.rs | 18 ++- .../extensions/set_hook/processor.rs | 17 ++- program/src/instructions/withdraw/accounts.rs | 5 +- .../src/instructions/withdraw/processor.rs | 23 ++-- program/src/state/allowed_mint.rs | 17 ++- program/src/state/escrow.rs | 2 +- program/src/state/escrow_extensions.rs | 19 +++- program/src/state/extensions/hook.rs | 6 +- program/src/state/receipt.rs | 15 ++- program/src/traits/account.rs | 46 +++++++- program/src/utils/pda_utils.rs | 19 +++- program/src/utils/token2022_utils.rs | 22 +++- .../integration-tests/src/fixtures/deposit.rs | 29 +++-- .../src/test_add_timelock.rs | 12 +- .../integration-tests/src/test_allow_mint.rs | 57 ++++++++-- .../src/test_create_escrow.rs | 25 ++++- tests/integration-tests/src/test_deposit.rs | 103 ++++++++++++++++- .../integration-tests/src/test_set_arbiter.rs | 22 ++-- tests/integration-tests/src/test_set_hook.rs | 19 +++- tests/integration-tests/src/test_withdraw.rs | 106 +++++++++++++++++- .../src/utils/extensions_utils.rs | 2 +- .../src/utils/token_utils.rs | 9 +- tests/test-hook-program/src/lib.rs | 18 ++- 28 files changed, 560 insertions(+), 130 deletions(-) diff --git a/idl/escrow_program.json b/idl/escrow_program.json index 32f7a65..4b7a7d8 100644 --- a/idl/escrow_program.json +++ b/idl/escrow_program.json @@ -61,7 +61,7 @@ { "defaultValue": { "kind": "numberValueNode", - "number": 3 + "number": 4 }, "kind": "structFieldTypeNode", "name": "discriminator", @@ -103,7 +103,7 @@ { "defaultValue": { "kind": "numberValueNode", - "number": 0 + "number": 1 }, "kind": "structFieldTypeNode", "name": "discriminator", @@ -159,7 +159,7 @@ { "defaultValue": { "kind": "numberValueNode", - "number": 1 + "number": 2 }, "kind": "structFieldTypeNode", "name": "discriminator", @@ -210,7 +210,7 @@ { "defaultValue": { "kind": "numberValueNode", - "number": 2 + "number": 3 }, "kind": "structFieldTypeNode", "name": "discriminator", diff --git a/program/src/instructions/block_mint/processor.rs b/program/src/instructions/block_mint/processor.rs index b6f6bba..e1b12ff 100644 --- a/program/src/instructions/block_mint/processor.rs +++ b/program/src/instructions/block_mint/processor.rs @@ -3,8 +3,8 @@ use pinocchio::{account::AccountView, Address, ProgramResult}; use crate::{ events::BlockMintEvent, instructions::BlockMint, - state::{AllowedMint, AllowedMintPda, Escrow}, - traits::{EventSerialize, PdaSeeds}, + state::{AllowedMint, Escrow}, + traits::EventSerialize, utils::{close_pda_account, emit_event}, }; @@ -19,13 +19,15 @@ pub fn process_block_mint(program_id: &Address, accounts: &[AccountView], instru let escrow = Escrow::from_account(&escrow_data, ix.accounts.escrow, program_id)?; escrow.validate_admin(ix.accounts.admin.address())?; - // Verify allowed_mint exists and is valid + // Verify allowed_mint account exists and self-validates against escrow + mint PDA derivation let allowed_mint_data = ix.accounts.allowed_mint.try_borrow()?; - let allowed_mint = AllowedMint::from_account(&allowed_mint_data)?; - - // Validate that allowed_mint PDA matches the escrow + mint combination - let pda_seeds = AllowedMintPda::new(ix.accounts.escrow.address(), ix.accounts.mint.address()); - pda_seeds.validate_pda(ix.accounts.allowed_mint, program_id, allowed_mint.bump)?; + let _allowed_mint = AllowedMint::from_account( + &allowed_mint_data, + ix.accounts.allowed_mint, + program_id, + ix.accounts.escrow.address(), + ix.accounts.mint.address(), + )?; drop(allowed_mint_data); // Close the AllowedMint account and return lamports to rent_recipient diff --git a/program/src/instructions/deposit/accounts.rs b/program/src/instructions/deposit/accounts.rs index 55e5e48..8a75f05 100644 --- a/program/src/instructions/deposit/accounts.rs +++ b/program/src/instructions/deposit/accounts.rs @@ -4,8 +4,8 @@ use crate::{ traits::InstructionAccounts, utils::{ validate_associated_token_account, verify_current_program, verify_current_program_account, - verify_event_authority, verify_readonly, verify_signer, verify_system_program, verify_token_program, - verify_writable, + verify_event_authority, verify_owned_by, verify_readonly, verify_signer, verify_system_program, + verify_token_program, verify_writable, }, }; @@ -77,6 +77,7 @@ impl<'a> TryFrom<&'a [AccountView]> for DepositAccounts<'a> { // 4. Validate program IDs verify_token_program(token_program)?; + verify_owned_by(mint, token_program.address())?; verify_system_program(system_program)?; verify_current_program(escrow_program)?; verify_event_authority(event_authority)?; diff --git a/program/src/instructions/deposit/processor.rs b/program/src/instructions/deposit/processor.rs index 850f3b3..2d692e9 100644 --- a/program/src/instructions/deposit/processor.rs +++ b/program/src/instructions/deposit/processor.rs @@ -13,11 +13,11 @@ use crate::{ events::DepositEvent, instructions::Deposit, state::{ - get_extensions_from_account, validate_extensions_pda, AllowedMint, AllowedMintPda, Escrow, ExtensionType, - HookData, HookPoint, Receipt, + get_extensions_from_account, validate_extensions_pda, AllowedMint, Escrow, ExtensionType, HookData, HookPoint, + Receipt, }, traits::{AccountSerialize, AccountSize, EventSerialize, ExtensionData, PdaSeeds}, - utils::{create_pda_account, emit_event, get_mint_decimals}, + utils::{create_pda_account, emit_event, get_mint_decimals, validate_mint_extensions}, }; /// Processes the Deposit instruction. @@ -30,15 +30,16 @@ pub fn process_deposit(program_id: &Address, accounts: &[AccountView], instructi let escrow_data = ix.accounts.escrow.try_borrow()?; let _escrow = Escrow::from_account(&escrow_data, ix.accounts.escrow, program_id)?; - // Verify allowed_mint PDA exists and matches expected derivation + // Verify allowed_mint account exists and self-validates against escrow + mint PDA derivation let allowed_mint_data = ix.accounts.allowed_mint.try_borrow()?; - let allowed_mint = AllowedMint::from_account(&allowed_mint_data).map_err(|_| EscrowProgramError::MintNotAllowed)?; - - // Validate that the allowed_mint PDA is derived from the correct escrow + mint - let pda_seeds = AllowedMintPda::new(ix.accounts.escrow.address(), ix.accounts.mint.address()); - pda_seeds - .validate_pda(ix.accounts.allowed_mint, program_id, allowed_mint.bump) - .map_err(|_| EscrowProgramError::MintNotAllowed)?; + let _allowed_mint = AllowedMint::from_account( + &allowed_mint_data, + ix.accounts.allowed_mint, + program_id, + ix.accounts.escrow.address(), + ix.accounts.mint.address(), + ) + .map_err(|_| EscrowProgramError::MintNotAllowed)?; // Get current timestamp from Clock sysvar let clock = Clock::get()?; @@ -74,6 +75,10 @@ pub fn process_deposit(program_id: &Address, accounts: &[AccountView], instructi // Validate extensions PDA validate_extensions_pda(ix.accounts.escrow, ix.accounts.extensions, program_id)?; + // Re-check mint extensions against the current escrow blocklist. + // This prevents stale AllowedMint entries from bypassing new blocklist rules. + validate_mint_extensions(ix.accounts.mint, ix.accounts.extensions)?; + // Get hook extension if present let exts = get_extensions_from_account(ix.accounts.extensions, &[ExtensionType::Hook])?; let hook_data = exts[0].as_ref().map(|b| HookData::from_bytes(b)).transpose()?; @@ -83,7 +88,7 @@ pub fn process_deposit(program_id: &Address, accounts: &[AccountView], instructi hook.invoke( HookPoint::PreDeposit, ix.accounts.remaining_accounts, - &[ix.accounts.escrow, ix.accounts.depositor, ix.accounts.mint, ix.accounts.receipt], + &[ix.accounts.escrow, ix.accounts.mint, ix.accounts.receipt], )?; } @@ -106,7 +111,7 @@ pub fn process_deposit(program_id: &Address, accounts: &[AccountView], instructi hook.invoke( HookPoint::PostDeposit, ix.accounts.remaining_accounts, - &[ix.accounts.escrow, ix.accounts.depositor, ix.accounts.mint, ix.accounts.receipt], + &[ix.accounts.escrow, ix.accounts.mint, ix.accounts.receipt], )?; } diff --git a/program/src/instructions/extensions/add_timelock/processor.rs b/program/src/instructions/extensions/add_timelock/processor.rs index e6fabb1..3ebd0ee 100644 --- a/program/src/instructions/extensions/add_timelock/processor.rs +++ b/program/src/instructions/extensions/add_timelock/processor.rs @@ -4,9 +4,9 @@ use pinocchio::{account::AccountView, cpi::Seed, error::ProgramError, Address, P use crate::{ events::TimelockAddedEvent, instructions::AddTimelock, - state::{append_extension, Escrow, ExtensionType, ExtensionsPda, TimelockData}, - traits::{EventSerialize, PdaSeeds}, - utils::{emit_event, TlvWriter}, + state::{update_or_append_extension, Escrow, ExtensionType, ExtensionsPda, TimelockData}, + traits::{EventSerialize, ExtensionData, PdaSeeds}, + utils::emit_event, }; /// Processes the AddTimelock instruction. @@ -24,23 +24,22 @@ pub fn process_add_timelock(program_id: &Address, accounts: &[AccountView], inst let extensions_pda = ExtensionsPda::new(ix.accounts.escrow.address()); extensions_pda.validate_pda(ix.accounts.extensions, program_id, ix.data.extensions_bump)?; - // Build TLV data + // Build extension data let timelock = TimelockData::new(ix.data.lock_duration); - let mut tlv_writer = TlvWriter::new(); - tlv_writer.write_timelock(&timelock); + let timelock_bytes = timelock.to_bytes(); - // Get seeds and append extension + // Get seeds and append/update extension let extensions_bump_seed = [ix.data.extensions_bump]; let extensions_seeds: Vec = extensions_pda.seeds_with_bump(&extensions_bump_seed); let extensions_seeds_array: [Seed; 3] = extensions_seeds.try_into().map_err(|_| ProgramError::InvalidArgument)?; - append_extension( + update_or_append_extension( ix.accounts.payer, ix.accounts.extensions, program_id, ix.data.extensions_bump, ExtensionType::Timelock, - &tlv_writer.into_bytes(), + &timelock_bytes, extensions_seeds_array, )?; diff --git a/program/src/instructions/extensions/set_arbiter/processor.rs b/program/src/instructions/extensions/set_arbiter/processor.rs index bedfbcd..bc60322 100644 --- a/program/src/instructions/extensions/set_arbiter/processor.rs +++ b/program/src/instructions/extensions/set_arbiter/processor.rs @@ -4,15 +4,14 @@ use pinocchio::{account::AccountView, cpi::Seed, error::ProgramError, Address, P use crate::{ events::ArbiterSetEvent, instructions::SetArbiter, - state::{append_extension, ArbiterData, Escrow, ExtensionType, ExtensionsPda}, - traits::{EventSerialize, PdaSeeds}, - utils::{emit_event, TlvWriter}, + state::{update_or_append_extension, ArbiterData, Escrow, ExtensionType, ExtensionsPda}, + traits::{EventSerialize, ExtensionData, PdaSeeds}, + utils::emit_event, }; /// Processes the SetArbiter instruction. /// /// Sets the arbiter on an escrow. Creates extensions PDA if it doesn't exist. -/// The arbiter is immutable — this instruction will fail if an arbiter is already set. pub fn process_set_arbiter(program_id: &Address, accounts: &[AccountView], instruction_data: &[u8]) -> ProgramResult { let ix = SetArbiter::try_from((instruction_data, accounts))?; @@ -25,23 +24,22 @@ pub fn process_set_arbiter(program_id: &Address, accounts: &[AccountView], instr let extensions_pda = ExtensionsPda::new(ix.accounts.escrow.address()); extensions_pda.validate_pda(ix.accounts.extensions, program_id, ix.data.extensions_bump)?; - // Build TLV data + // Build extension data let arbiter = ArbiterData::new(*ix.accounts.arbiter.address()); - let mut tlv_writer = TlvWriter::new(); - tlv_writer.write_arbiter(&arbiter); + let arbiter_bytes = arbiter.to_bytes(); - // Get seeds and append extension (fails if arbiter already exists, enforcing immutability) + // Get seeds and append/update extension let extensions_bump_seed = [ix.data.extensions_bump]; let extensions_seeds: Vec = extensions_pda.seeds_with_bump(&extensions_bump_seed); let extensions_seeds_array: [Seed; 3] = extensions_seeds.try_into().map_err(|_| ProgramError::InvalidArgument)?; - append_extension( + update_or_append_extension( ix.accounts.payer, ix.accounts.extensions, program_id, ix.data.extensions_bump, ExtensionType::Arbiter, - &tlv_writer.into_bytes(), + &arbiter_bytes, extensions_seeds_array, )?; diff --git a/program/src/instructions/extensions/set_hook/processor.rs b/program/src/instructions/extensions/set_hook/processor.rs index 6c9e027..8529194 100644 --- a/program/src/instructions/extensions/set_hook/processor.rs +++ b/program/src/instructions/extensions/set_hook/processor.rs @@ -4,9 +4,9 @@ use pinocchio::{account::AccountView, cpi::Seed, error::ProgramError, Address, P use crate::{ events::HookSetEvent, instructions::SetHook, - state::{append_extension, Escrow, ExtensionType, ExtensionsPda, HookData}, - traits::{EventSerialize, PdaSeeds}, - utils::{emit_event, TlvWriter}, + state::{update_or_append_extension, Escrow, ExtensionType, ExtensionsPda, HookData}, + traits::{EventSerialize, ExtensionData, PdaSeeds}, + utils::emit_event, }; /// Processes the SetHook instruction. @@ -24,23 +24,22 @@ pub fn process_set_hook(program_id: &Address, accounts: &[AccountView], instruct let extensions_pda = ExtensionsPda::new(ix.accounts.escrow.address()); extensions_pda.validate_pda(ix.accounts.extensions, program_id, ix.data.extensions_bump)?; - // Build TLV data + // Build extension data let hook = HookData::new(ix.data.hook_program); - let mut tlv_writer = TlvWriter::new(); - tlv_writer.write_hook(&hook); + let hook_bytes = hook.to_bytes(); - // Get seeds and append extension + // Get seeds and append/update extension let extensions_bump_seed = [ix.data.extensions_bump]; let extensions_seeds: Vec = extensions_pda.seeds_with_bump(&extensions_bump_seed); let extensions_seeds_array: [Seed; 3] = extensions_seeds.try_into().map_err(|_| ProgramError::InvalidArgument)?; - append_extension( + update_or_append_extension( ix.accounts.payer, ix.accounts.extensions, program_id, ix.data.extensions_bump, ExtensionType::Hook, - &tlv_writer.into_bytes(), + &hook_bytes, extensions_seeds_array, )?; diff --git a/program/src/instructions/withdraw/accounts.rs b/program/src/instructions/withdraw/accounts.rs index 99c8604..2ab6f29 100644 --- a/program/src/instructions/withdraw/accounts.rs +++ b/program/src/instructions/withdraw/accounts.rs @@ -4,8 +4,8 @@ use crate::{ traits::InstructionAccounts, utils::{ validate_associated_token_account, verify_current_program, verify_current_program_account, - verify_event_authority, verify_readonly, verify_signer, verify_system_program, verify_token_program, - verify_writable, + verify_event_authority, verify_owned_by, verify_readonly, verify_signer, verify_system_program, + verify_token_program, verify_writable, }, }; @@ -74,6 +74,7 @@ impl<'a> TryFrom<&'a [AccountView]> for WithdrawAccounts<'a> { // 4. Validate program IDs verify_token_program(token_program)?; + verify_owned_by(mint, token_program.address())?; verify_system_program(system_program)?; verify_current_program(escrow_program)?; verify_event_authority(event_authority)?; diff --git a/program/src/instructions/withdraw/processor.rs b/program/src/instructions/withdraw/processor.rs index 404e513..5747df4 100644 --- a/program/src/instructions/withdraw/processor.rs +++ b/program/src/instructions/withdraw/processor.rs @@ -1,4 +1,4 @@ -use pinocchio::{account::AccountView, Address, ProgramResult}; +use pinocchio::{account::AccountView, error::ProgramError, Address, ProgramResult}; use pinocchio_token_2022::instructions::TransferChecked; use crate::{ @@ -25,7 +25,7 @@ pub fn process_withdraw(program_id: &Address, accounts: &[AccountView], instruct } // Read and validate receipt - let (amount, receipt_seed, mint, deposited_at) = { + let (amount, receipt_seed, receipt_mint, deposited_at) = { let receipt_data = ix.accounts.receipt.try_borrow()?; let receipt = Receipt::from_account(&receipt_data, ix.accounts.receipt, program_id)?; @@ -35,6 +35,11 @@ pub fn process_withdraw(program_id: &Address, accounts: &[AccountView], instruct (receipt.amount, receipt.receipt_seed, receipt.mint, receipt.deposited_at) }; + // Ensure the mint account matches the receipt's mint to prevent cross-mint withdrawals. + if receipt_mint != *ix.accounts.mint.address() { + return Err(ProgramError::InvalidAccountData); + } + // Validate extensions PDA validate_extensions_pda(ix.accounts.escrow, ix.accounts.extensions, program_id)?; @@ -68,7 +73,7 @@ pub fn process_withdraw(program_id: &Address, accounts: &[AccountView], instruct hook.invoke( HookPoint::PreWithdraw, remaining_accounts, - &[ix.accounts.escrow, ix.accounts.withdrawer, ix.accounts.mint, ix.accounts.receipt], + &[ix.accounts.escrow, ix.accounts.mint, ix.accounts.receipt], )?; } @@ -92,23 +97,23 @@ pub fn process_withdraw(program_id: &Address, accounts: &[AccountView], instruct })?; } - // Close receipt account and return lamports to rent_recipient - close_pda_account(ix.accounts.receipt, ix.accounts.rent_recipient)?; - - // Invoke post-withdraw hook if configured (receipt is closed, don't pass it) + // Invoke post-withdraw hook if configured (receipt is still open, pass it for context) if let Some(ref hook) = hook_data { hook.invoke( HookPoint::PostWithdraw, remaining_accounts, - &[ix.accounts.escrow, ix.accounts.withdrawer, ix.accounts.mint], + &[ix.accounts.escrow, ix.accounts.mint, ix.accounts.receipt], )?; } + // Close receipt account and return lamports to rent_recipient + close_pda_account(ix.accounts.receipt, ix.accounts.rent_recipient)?; + // Emit event let event = WithdrawEvent::new( *ix.accounts.escrow.address(), *ix.accounts.withdrawer.address(), - mint, + receipt_mint, receipt_seed, amount, ); diff --git a/program/src/state/allowed_mint.rs b/program/src/state/allowed_mint.rs index e938b31..e9de052 100644 --- a/program/src/state/allowed_mint.rs +++ b/program/src/state/allowed_mint.rs @@ -1,7 +1,7 @@ use alloc::vec; use alloc::vec::Vec; use codama::CodamaAccount; -use pinocchio::{cpi::Seed, error::ProgramError, Address}; +use pinocchio::{account::AccountView, cpi::Seed, error::ProgramError, Address}; use crate::assert_no_padding; use crate::traits::{ @@ -17,7 +17,7 @@ use crate::traits::{ /// # PDA Seeds /// `[b"allowed_mint", escrow.as_ref(), mint.as_ref()]` #[derive(Clone, Debug, PartialEq, CodamaAccount)] -#[codama(field("discriminator", number(u8), default_value = 3))] +#[codama(field("discriminator", number(u8), default_value = 4))] #[codama(discriminator(field = "discriminator"))] #[codama(seed(type = string(utf8), value = "allowed_mint"))] #[codama(seed(name = "escrow", type = public_key))] @@ -57,8 +57,17 @@ impl AllowedMint { } #[inline(always)] - pub fn from_account(data: &[u8]) -> Result<&Self, ProgramError> { - Self::from_bytes(data) + pub fn from_account<'a>( + data: &'a [u8], + account: &AccountView, + program_id: &Address, + escrow: &Address, + mint: &Address, + ) -> Result<&'a Self, ProgramError> { + let state = Self::from_bytes(data)?; + let pda = AllowedMintPda::new(escrow, mint); + pda.validate_pda(account, program_id, state.bump)?; + Ok(state) } } diff --git a/program/src/state/escrow.rs b/program/src/state/escrow.rs index 1fe5605..3899e48 100644 --- a/program/src/state/escrow.rs +++ b/program/src/state/escrow.rs @@ -20,7 +20,7 @@ use crate::traits::{ /// # PDA Seeds /// `[b"escrow", escrow_seed.as_ref()]` #[derive(Clone, Debug, PartialEq, CodamaAccount)] -#[codama(field("discriminator", number(u8), default_value = 0))] +#[codama(field("discriminator", number(u8), default_value = 1))] #[codama(discriminator(field = "discriminator"))] #[codama(seed(type = string(utf8), value = "escrow"))] #[codama(seed(name = "escrowSeed", type = public_key))] diff --git a/program/src/state/escrow_extensions.rs b/program/src/state/escrow_extensions.rs index 49479f8..4ea6fe7 100644 --- a/program/src/state/escrow_extensions.rs +++ b/program/src/state/escrow_extensions.rs @@ -46,7 +46,7 @@ pub const TLV_HEADER_SIZE: usize = 4; /// [discriminator: 1][version: 1][header: 2][TLV extensions: variable] /// ``` #[derive(Clone, Debug, PartialEq, CodamaAccount)] -#[codama(field("discriminator", number(u8), default_value = 1))] +#[codama(field("discriminator", number(u8), default_value = 2))] #[codama(discriminator(field = "discriminator"))] #[codama(pda = "extensions")] #[codama(seed(type = string(utf8), value = "extensions"))] @@ -86,6 +86,9 @@ impl EscrowExtensionsHeader { require_len!(data, Self::LEN); validate_discriminator!(data, Self::DISCRIMINATOR); + if data[1] != Self::VERSION { + return Err(ProgramError::InvalidAccountData); + } // Skip discriminator (byte 0) and version (byte 1) Ok(Self { bump: data[2], extension_count: data[3] }) @@ -309,6 +312,10 @@ pub fn validate_extensions_pda(escrow: &AccountView, extensions: &AccountView, p let expected_bump = extensions_pda.validate_pda_address(extensions, program_id)?; if extensions.data_len() > 0 { + if !extensions.owned_by(program_id) { + return Err(ProgramError::InvalidAccountOwner); + } + let data = extensions.try_borrow()?; let header = EscrowExtensionsHeader::from_bytes(&data)?; if header.bump != expected_bump { @@ -405,6 +412,16 @@ mod tests { assert_eq!(parsed.extension_count, header.extension_count); } + #[test] + fn test_header_from_bytes_wrong_version() { + let header = EscrowExtensionsHeader::new(100, 2); + let mut bytes = header.to_bytes(); + bytes[1] = EscrowExtensionsHeader::VERSION.wrapping_add(1); + + let result = EscrowExtensionsHeader::from_bytes(&bytes); + assert_eq!(result, Err(ProgramError::InvalidAccountData)); + } + #[test] fn test_calculate_extensions_account_size() { let no_extensions = calculate_extensions_account_size(false); diff --git a/program/src/state/extensions/hook.rs b/program/src/state/extensions/hook.rs index f7d0c0d..c3118a6 100644 --- a/program/src/state/extensions/hook.rs +++ b/program/src/state/extensions/hook.rs @@ -51,7 +51,7 @@ impl HookData { /// # Arguments /// * `hook_point` - The hook point discriminator /// * `remaining_accounts` - Remaining accounts slice: [hook_program, extra_accounts...] - /// * `core_accounts` - Core accounts to pass to hook (escrow, actor, mint, receipt, vault) + /// * `core_accounts` - Core accounts to pass to hook (escrow, mint, receipt) /// /// # Returns /// * `Ok(())` if hook succeeds @@ -65,6 +65,10 @@ impl HookData { self.validate(remaining_accounts)?; let extra_accounts = remaining_accounts.get(1..).unwrap_or(&[]); + if extra_accounts.iter().any(|acc| acc.is_signer()) { + return Err(EscrowProgramError::HookRejected.into()); + } + let all_accounts: Vec<&AccountView> = core_accounts.iter().copied().chain(extra_accounts.iter()).collect(); // Build instruction accounts - ALL accounts are read-only diff --git a/program/src/state/receipt.rs b/program/src/state/receipt.rs index 945bffd..82917a6 100644 --- a/program/src/state/receipt.rs +++ b/program/src/state/receipt.rs @@ -15,7 +15,7 @@ use crate::{assert_no_padding, require_account_len, validate_discriminator}; /// # PDA Seeds /// `[b"receipt", escrow.as_ref(), depositor.as_ref(), mint.as_ref(), receipt_seed.as_ref()]` #[derive(Clone, Debug, PartialEq, CodamaAccount)] -#[codama(field("discriminator", number(u8), default_value = 2))] +#[codama(field("discriminator", number(u8), default_value = 3))] #[codama(discriminator(field = "discriminator"))] #[codama(seed(type = string(utf8), value = "receipt"))] #[codama(seed(name = "escrow", type = public_key))] @@ -56,6 +56,9 @@ impl AccountParse for Receipt { fn parse_from_bytes(data: &[u8]) -> Result { require_account_len!(data, Self::LEN); validate_discriminator!(data, Self::DISCRIMINATOR); + if data[1] != Self::VERSION { + return Err(ProgramError::InvalidAccountData); + } // Skip discriminator (byte 0) and version (byte 1) let data = &data[2..]; @@ -267,4 +270,14 @@ mod tests { let result = Receipt::parse_from_bytes(&bytes); assert!(result.is_err()); } + + #[test] + fn test_receipt_parse_from_bytes_wrong_version() { + let receipt = create_test_receipt(); + let mut bytes = receipt.to_bytes(); + bytes[1] = Receipt::VERSION.wrapping_add(1); + + let result = Receipt::parse_from_bytes(&bytes); + assert_eq!(result, Err(ProgramError::InvalidAccountData)); + } } diff --git a/program/src/traits/account.rs b/program/src/traits/account.rs index f486938..d80caa0 100644 --- a/program/src/traits/account.rs +++ b/program/src/traits/account.rs @@ -29,6 +29,9 @@ pub trait AccountDeserialize: AccountSize { fn from_bytes(data: &[u8]) -> Result<&Self, ProgramError> { require_len!(data, Self::LEN); validate_discriminator!(data, Self::DISCRIMINATOR); + if data[1] != Self::VERSION { + return Err(ProgramError::InvalidAccountData); + } // Skip discriminator (byte 0) and version (byte 1) unsafe { Self::from_bytes_unchecked(&data[2..]) } @@ -52,6 +55,9 @@ pub trait AccountDeserialize: AccountSize { fn from_bytes_mut(data: &mut [u8]) -> Result<&mut Self, ProgramError> { require_len!(data, Self::LEN); validate_discriminator!(data, Self::DISCRIMINATOR); + if data[1] != Self::VERSION { + return Err(ProgramError::InvalidAccountData); + } // Skip discriminator (byte 0) and version (byte 1) unsafe { Self::from_bytes_mut_unchecked(&mut data[2..]) } @@ -74,10 +80,10 @@ pub trait AccountDeserialize: AccountSize { /// Account discriminator values for this program #[repr(u8)] pub enum EscrowAccountDiscriminators { - EscrowDiscriminator = 0, - EscrowExtensionsDiscriminator = 1, - ReceiptDiscriminator = 2, - AllowedMintDiscriminator = 3, + EscrowDiscriminator = 1, + EscrowExtensionsDiscriminator = 2, + ReceiptDiscriminator = 3, + AllowedMintDiscriminator = 4, } /// Manual account deserialization (non-zero-copy) @@ -178,6 +184,30 @@ mod tests { assert_eq!(deserialized.admin, escrow.admin); } + #[test] + fn test_from_bytes_wrong_version() { + let escrow_seed = Address::new_from_array([1u8; 32]); + let admin = Address::new_from_array([2u8; 32]); + let escrow = Escrow::new(100, escrow_seed, admin); + let mut bytes = escrow.to_bytes(); + bytes[1] = Escrow::VERSION.wrapping_add(1); + + let result = Escrow::from_bytes(&bytes); + assert_eq!(result, Err(ProgramError::InvalidAccountData)); + } + + #[test] + fn test_from_bytes_mut_wrong_version() { + let escrow_seed = Address::new_from_array([1u8; 32]); + let admin = Address::new_from_array([2u8; 32]); + let escrow = Escrow::new(100, escrow_seed, admin); + let mut bytes = escrow.to_bytes(); + bytes[1] = Escrow::VERSION.wrapping_add(1); + + let result = Escrow::from_bytes_mut(&mut bytes); + assert_eq!(result, Err(ProgramError::InvalidAccountData)); + } + #[test] fn test_write_to_slice_exact_size() { let escrow_seed = Address::new_from_array([1u8; 32]); @@ -203,4 +233,12 @@ mod tests { assert_eq!(bytes[0], Escrow::DISCRIMINATOR); assert_eq!(bytes[1], Escrow::VERSION); } + + #[test] + fn test_account_discriminators_are_non_zero_and_stable() { + assert_eq!(EscrowAccountDiscriminators::EscrowDiscriminator as u8, 1); + assert_eq!(EscrowAccountDiscriminators::EscrowExtensionsDiscriminator as u8, 2); + assert_eq!(EscrowAccountDiscriminators::ReceiptDiscriminator as u8, 3); + assert_eq!(EscrowAccountDiscriminators::AllowedMintDiscriminator as u8, 4); + } } diff --git a/program/src/utils/pda_utils.rs b/program/src/utils/pda_utils.rs index 610fb37..9ba15c8 100644 --- a/program/src/utils/pda_utils.rs +++ b/program/src/utils/pda_utils.rs @@ -18,7 +18,11 @@ pub fn close_pda_account(pda_account: &AccountView, recipient: &AccountView) -> /// Create a PDA account for the given seeds. /// -/// Will return an error if the account already exists (has lamports). +/// Strict create-once semantics: +/// - If account has data: returns `AccountAlreadyInitialized` +/// - If account has lamports but no data: completes initialization via +/// transfer (if needed) + allocate + assign +/// - If account is fully absent (0 lamports): uses `CreateAccount` pub fn create_pda_account( payer: &AccountView, space: usize, @@ -33,7 +37,18 @@ pub fn create_pda_account( let signers = [Signer::from(&pda_signer_seeds)]; if pda_account.lamports() > 0 { - Err(ProgramError::AccountAlreadyInitialized) + if pda_account.data_len() > 0 { + return Err(ProgramError::AccountAlreadyInitialized); + } + + // PDA was prefunded but not initialized yet. + let additional_lamports = required_lamports.saturating_sub(pda_account.lamports()); + if additional_lamports > 0 { + Transfer { from: payer, to: pda_account, lamports: additional_lamports }.invoke()?; + } + + Allocate { account: pda_account, space: space as u64 }.invoke_signed(&signers)?; + Assign { account: pda_account, owner }.invoke_signed(&signers) } else { CreateAccount { from: payer, to: pda_account, lamports: required_lamports, space: space as u64, owner } .invoke_signed(&signers) diff --git a/program/src/utils/token2022_utils.rs b/program/src/utils/token2022_utils.rs index 627091f..235e663 100644 --- a/program/src/utils/token2022_utils.rs +++ b/program/src/utils/token2022_utils.rs @@ -1,6 +1,6 @@ //! Token2022 extension validation utilities. -use alloc::vec::Vec; +use alloc::vec; use pinocchio::{account::AccountView, error::ProgramError}; use pinocchio_token_2022::ID as TOKEN_2022_PROGRAM_ID; use spl_token_2022::{ @@ -19,6 +19,8 @@ use crate::{errors::EscrowProgramError, utils::TlvReader}; /// - `PermanentDelegate`: Authority can transfer/burn tokens from ANY account /// - `NonTransferable`: Tokens cannot be transferred /// - `Pausable`: Authority can pause all transfers +/// - `TransferFeeConfig`: Transfer fees break escrow accounting invariants +/// - `MintCloseAuthority`: Mint can be closed and recreated with unsafe configuration /// /// # Escrow-Specific Blocklist /// If `extensions` account is provided and contains a `BlockTokenExtensions` extension, @@ -46,12 +48,14 @@ pub fn validate_mint_extensions(mint: &AccountView, extensions: &AccountView) -> let extension_types = mint_state.get_extension_types()?; // Build combined blocklist: global + escrow-specific (as u16 values) - let mut blocked_types_u16 = Vec::new(); - // Add global blocklist (convert ExtensionType enum to u16) - blocked_types_u16.push(ExtensionType::PermanentDelegate as u16); - blocked_types_u16.push(ExtensionType::NonTransferable as u16); - blocked_types_u16.push(ExtensionType::Pausable as u16); + let mut blocked_types_u16 = vec![ + ExtensionType::PermanentDelegate as u16, + ExtensionType::NonTransferable as u16, + ExtensionType::Pausable as u16, + ExtensionType::TransferFeeConfig as u16, + ExtensionType::MintCloseAuthority as u16, + ]; // Add escrow-specific blocklist if extensions account has data if extensions.data_len() > 0 { @@ -78,6 +82,12 @@ pub fn validate_mint_extensions(mint: &AccountView, extensions: &AccountView) -> ExtensionType::Pausable => { return Err(EscrowProgramError::PausableNotAllowed.into()); } + ExtensionType::TransferFeeConfig => { + return Err(EscrowProgramError::MintNotAllowed.into()); + } + ExtensionType::MintCloseAuthority => { + return Err(EscrowProgramError::MintNotAllowed.into()); + } _ => { // Escrow-specific blocked extension return Err(EscrowProgramError::MintNotAllowed.into()); diff --git a/tests/integration-tests/src/fixtures/deposit.rs b/tests/integration-tests/src/fixtures/deposit.rs index 615180b..6a71485 100644 --- a/tests/integration-tests/src/fixtures/deposit.rs +++ b/tests/integration-tests/src/fixtures/deposit.rs @@ -5,6 +5,7 @@ use solana_sdk::{ pubkey::Pubkey, signature::{Keypair, Signer}, }; +use spl_token_2022::extension::ExtensionType; use spl_token_2022::ID as TOKEN_2022_PROGRAM_ID; use spl_token_interface::ID as TOKEN_PROGRAM_ID; @@ -85,11 +86,12 @@ pub struct DepositSetupBuilder<'a> { ctx: &'a mut TestContext, token_program: Pubkey, hook_program: Option, + mint_extension: Option, } impl<'a> DepositSetupBuilder<'a> { fn new(ctx: &'a mut TestContext) -> Self { - Self { ctx, token_program: TOKEN_PROGRAM_ID, hook_program: None } + Self { ctx, token_program: TOKEN_PROGRAM_ID, hook_program: None, mint_extension: None } } pub fn token_2022(mut self) -> Self { @@ -107,6 +109,12 @@ impl<'a> DepositSetupBuilder<'a> { self } + pub fn mint_extension(mut self, extension: ExtensionType) -> Self { + self.mint_extension = Some(extension); + self.token_program = TOKEN_2022_PROGRAM_ID; + self + } + pub fn build(self) -> DepositSetup { let admin = self.ctx.create_funded_keypair(); let escrow_seed = Keypair::new(); @@ -140,12 +148,19 @@ impl<'a> DepositSetupBuilder<'a> { let token_program = self.token_program; let (vault, depositor_token_account); - if token_program == TOKEN_2022_PROGRAM_ID { - self.ctx.create_token_2022_mint(&mint, &self.ctx.payer.pubkey(), 6); - vault = self.ctx.create_token_2022_account(&escrow_pda, &mint.pubkey()); - } else { - self.ctx.create_mint(&mint, &self.ctx.payer.pubkey(), 6); - vault = self.ctx.create_token_account(&escrow_pda, &mint.pubkey()); + match self.mint_extension { + Some(extension) => { + self.ctx.create_token_2022_mint_with_extension(&mint, &self.ctx.payer.pubkey(), 6, extension); + vault = self.ctx.create_token_2022_account(&escrow_pda, &mint.pubkey()); + } + None if token_program == TOKEN_2022_PROGRAM_ID => { + self.ctx.create_token_2022_mint(&mint, &self.ctx.payer.pubkey(), 6); + vault = self.ctx.create_token_2022_account(&escrow_pda, &mint.pubkey()); + } + None => { + self.ctx.create_mint(&mint, &self.ctx.payer.pubkey(), 6); + vault = self.ctx.create_token_account(&escrow_pda, &mint.pubkey()); + } } let (allowed_mint_pda, allowed_mint_bump) = find_allowed_mint_pda(&escrow_pda, &mint.pubkey()); diff --git a/tests/integration-tests/src/test_add_timelock.rs b/tests/integration-tests/src/test_add_timelock.rs index ccfdefd..d61ec95 100644 --- a/tests/integration-tests/src/test_add_timelock.rs +++ b/tests/integration-tests/src/test_add_timelock.rs @@ -95,7 +95,7 @@ fn test_add_timelock_escrow_not_owned_by_program() { } #[test] -fn test_add_timelock_duplicate_extension() { +fn test_add_timelock_updates_existing_extension() { let mut ctx = TestContext::new(); let escrow_ix = CreateEscrowFixture::build_valid(&mut ctx); @@ -104,13 +104,19 @@ fn test_add_timelock_duplicate_extension() { escrow_ix.send_expect_success(&mut ctx); let (escrow_pda, _) = find_escrow_pda(&escrow_seed); + let (extensions_pda, extensions_bump) = find_extensions_pda(&escrow_pda); let first_ix = AddTimelockFixture::build_with_escrow(&mut ctx, escrow_pda, admin.insecure_clone(), 3600); first_ix.send_expect_success(&mut ctx); + assert_extensions_header(&ctx, &extensions_pda, extensions_bump, 1); + assert_timelock_extension(&ctx, &extensions_pda, 3600); let second_ix = AddTimelockFixture::build_with_escrow(&mut ctx, escrow_pda, admin, 7200); - let error = second_ix.send_expect_error(&mut ctx); - assert_instruction_error(error, InstructionError::AccountAlreadyInitialized); + second_ix.send_expect_success(&mut ctx); + + // Updating timelock should replace value in place without increasing count. + assert_extensions_header(&ctx, &extensions_pda, extensions_bump, 1); + assert_timelock_extension(&ctx, &extensions_pda, 7200); } // ============================================================================ diff --git a/tests/integration-tests/src/test_allow_mint.rs b/tests/integration-tests/src/test_allow_mint.rs index a3634ef..d1c1719 100644 --- a/tests/integration-tests/src/test_allow_mint.rs +++ b/tests/integration-tests/src/test_allow_mint.rs @@ -7,7 +7,7 @@ use crate::{ }, }; use escrow_program_client::instructions::AllowMintBuilder; -use solana_sdk::{instruction::InstructionError, signature::Signer}; +use solana_sdk::{account::Account, instruction::InstructionError, pubkey::Pubkey, signature::Signer}; use spl_associated_token_account::get_associated_token_address; use spl_token_2022::extension::ExtensionType; @@ -147,6 +147,26 @@ fn test_allow_mint_success() { assert_allowed_mint_account(&ctx, &setup.allowed_mint_pda, setup.allowed_mint_bump); } +#[test] +fn test_allow_mint_prefunded_allowed_mint_pda_succeeds() { + let mut ctx = TestContext::new(); + let setup = AllowMintSetup::new(&mut ctx); + + // Simulate griefing by pre-funding the AllowedMint PDA before initialization. + ctx.svm + .set_account( + setup.allowed_mint_pda, + Account { lamports: 1, data: vec![], owner: Pubkey::default(), executable: false, rent_epoch: 0 }, + ) + .unwrap(); + + let test_ix = setup.build_instruction(&ctx); + test_ix.send_expect_success(&mut ctx); + + assert_account_exists(&ctx, &setup.allowed_mint_pda); + assert_allowed_mint_account(&ctx, &setup.allowed_mint_pda, setup.allowed_mint_bump); +} + #[test] fn test_allow_mint_multiple_mints() { let mut ctx = TestContext::new(); @@ -234,6 +254,28 @@ fn test_allow_mint_rejects_pausable() { assert_escrow_error(error, EscrowError::PausableNotAllowed); } +#[test] +fn test_allow_mint_rejects_transfer_fee_config() { + let mut ctx = TestContext::new(); + let setup = AllowMintSetup::new_with_extension(&mut ctx, ExtensionType::TransferFeeConfig); + + let test_ix = setup.build_instruction(&ctx); + let error = test_ix.send_expect_error(&mut ctx); + + assert_escrow_error(error, EscrowError::MintNotAllowed); +} + +#[test] +fn test_allow_mint_rejects_mint_close_authority() { + let mut ctx = TestContext::new(); + let setup = AllowMintSetup::new_with_extension(&mut ctx, ExtensionType::MintCloseAuthority); + + let test_ix = setup.build_instruction(&ctx); + let error = test_ix.send_expect_error(&mut ctx); + + assert_escrow_error(error, EscrowError::MintNotAllowed); +} + // ============================================================================ // Escrow-Specific Blocked Extension Tests // ============================================================================ @@ -242,8 +284,9 @@ fn test_allow_mint_rejects_pausable() { fn test_allow_mint_rejects_escrow_blocked_extension() { let mut ctx = TestContext::new(); - // Create escrow with TransferFeeConfig blocked, then try to allow a mint with that extension - let setup = AllowMintSetup::new_with_escrow_blocked_extension(&mut ctx, ExtensionType::TransferFeeConfig); + // Create escrow with MetadataPointer blocked, then try to allow a mint with that extension. + // MetadataPointer is not globally blocked, so rejection should come from escrow-specific blocklist. + let setup = AllowMintSetup::new_with_escrow_blocked_extension(&mut ctx, ExtensionType::MetadataPointer); let test_ix = setup.build_instruction(&ctx); let error = test_ix.send_expect_error(&mut ctx); @@ -255,12 +298,8 @@ fn test_allow_mint_rejects_escrow_blocked_extension() { fn test_allow_mint_accepts_mint_without_escrow_blocked_extension() { let mut ctx = TestContext::new(); - // Block TransferFeeConfig, but create a mint with MintCloseAuthority (different extension) - let setup = AllowMintSetup::new_with_different_extension_blocked( - &mut ctx, - ExtensionType::TransferFeeConfig, - ExtensionType::MintCloseAuthority, - ); + // Block MetadataPointer, but create a Token-2022 mint without that extension. + let setup = AllowMintSetup::builder(&mut ctx).block_extension(ExtensionType::MetadataPointer).token_2022().build(); let test_ix = setup.build_instruction(&ctx); test_ix.send_expect_success(&mut ctx); diff --git a/tests/integration-tests/src/test_create_escrow.rs b/tests/integration-tests/src/test_create_escrow.rs index 772171e..5a1afd9 100644 --- a/tests/integration-tests/src/test_create_escrow.rs +++ b/tests/integration-tests/src/test_create_escrow.rs @@ -6,7 +6,7 @@ use crate::{ }, }; use escrow_program_client::instructions::CreatesEscrowBuilder; -use solana_sdk::{instruction::InstructionError, signature::Signer}; +use solana_sdk::{account::Account, instruction::InstructionError, pubkey::Pubkey, signature::Signer}; // ============================================================================ // Error Tests - Using Generic Test Helpers @@ -94,6 +94,29 @@ fn test_create_escrow_success() { assert_escrow_account(&ctx, &escrow_pda, &admin_pubkey, bump, &escrow_seed_pubkey); } +#[test] +fn test_create_escrow_prefunded_pda_succeeds() { + let mut ctx = TestContext::new(); + let test_ix = CreateEscrowFixture::build_valid(&mut ctx); + + let admin_pubkey = test_ix.signers[0].pubkey(); + let escrow_seed_pubkey = test_ix.signers[1].pubkey(); + let escrow_pda = test_ix.instruction.accounts[3].pubkey; + let bump = test_ix.instruction.data[1]; + + // Simulate griefing by pre-funding the PDA before initialization. + ctx.svm + .set_account( + escrow_pda, + Account { lamports: 1, data: vec![], owner: Pubkey::default(), executable: false, rent_epoch: 0 }, + ) + .unwrap(); + + test_ix.send_expect_success(&mut ctx); + + assert_escrow_account(&ctx, &escrow_pda, &admin_pubkey, bump, &escrow_seed_pubkey); +} + // ============================================================================ // Re-initialization Protection Tests // ============================================================================ diff --git a/tests/integration-tests/src/test_deposit.rs b/tests/integration-tests/src/test_deposit.rs index e6c2ff5..236268b 100644 --- a/tests/integration-tests/src/test_deposit.rs +++ b/tests/integration-tests/src/test_deposit.rs @@ -1,5 +1,5 @@ use crate::{ - fixtures::{DepositFixture, DepositSetup, DEFAULT_DEPOSIT_AMOUNT}, + fixtures::{AddBlockTokenExtensionsFixture, DepositFixture, DepositSetup, DEFAULT_DEPOSIT_AMOUNT}, utils::{ assert_custom_error, assert_escrow_error, assert_instruction_error, find_receipt_pda, test_empty_data, test_missing_signer, test_not_writable, test_wrong_account, test_wrong_current_program, test_wrong_owner, @@ -7,8 +7,16 @@ use crate::{ TEST_HOOK_ALLOW_ID, TEST_HOOK_DENY_ERROR, TEST_HOOK_DENY_ID, }, }; +use escrow_program_client::instructions::AddTimelockBuilder; use escrow_program_client::instructions::DepositBuilder; -use solana_sdk::{instruction::InstructionError, pubkey::Pubkey, signature::Signer}; +use solana_sdk::{ + account::Account, + instruction::{AccountMeta, InstructionError}, + pubkey::Pubkey, + signature::Signer, +}; +use spl_token_2022::extension::ExtensionType; +use spl_token_2022::ID as TOKEN_2022_PROGRAM_ID; // ============================================================================ // Error Tests - Using Generic Test Helpers @@ -80,6 +88,20 @@ fn test_deposit_wrong_token_program() { test_wrong_token_program::(&mut ctx, 9); } +#[test] +fn test_deposit_mint_owner_mismatch_token_program() { + let mut ctx = TestContext::new(); + let setup = DepositSetup::new(&mut ctx); + + let mut mint_account = ctx.get_account(&setup.mint.pubkey()).expect("Mint account should exist"); + mint_account.owner = TOKEN_2022_PROGRAM_ID; + ctx.svm.set_account(setup.mint.pubkey(), mint_account).unwrap(); + + let test_ix = setup.build_instruction(&ctx); + let error = test_ix.send_expect_error(&mut ctx); + assert_instruction_error(error, InstructionError::InvalidAccountOwner); +} + #[test] fn test_deposit_wrong_escrow_owner() { let mut ctx = TestContext::new(); @@ -92,6 +114,69 @@ fn test_deposit_wrong_allowed_mint_owner() { test_wrong_owner::(&mut ctx, 3); } +#[test] +fn test_deposit_initialized_extensions_wrong_owner() { + let mut ctx = TestContext::new(); + let setup = DepositSetup::new(&mut ctx); + + let (extensions_pda, extensions_bump) = crate::utils::find_extensions_pda(&setup.escrow_pda); + let add_timelock_ix = AddTimelockBuilder::new() + .payer(ctx.payer.pubkey()) + .admin(setup.admin.pubkey()) + .escrow(setup.escrow_pda) + .extensions(extensions_pda) + .extensions_bump(extensions_bump) + .lock_duration(1) + .instruction(); + ctx.send_transaction(add_timelock_ix, &[&setup.admin]).unwrap(); + + let mut extensions_account = ctx.get_account(&setup.extensions_pda).expect("Extensions account should exist"); + extensions_account.owner = Pubkey::new_unique(); + ctx.svm.set_account(setup.extensions_pda, extensions_account).unwrap(); + + let test_ix = setup.build_instruction(&ctx); + let error = test_ix.send_expect_error(&mut ctx); + assert_instruction_error(error, InstructionError::InvalidAccountOwner); +} + +#[test] +fn test_deposit_rejects_newly_blocked_mint_extension() { + let mut ctx = TestContext::new(); + let setup = DepositSetup::builder(&mut ctx).mint_extension(ExtensionType::MetadataPointer).build(); + + let block_extension_ix = AddBlockTokenExtensionsFixture::build_with_escrow( + &mut ctx, + setup.escrow_pda, + setup.admin.insecure_clone(), + ExtensionType::MetadataPointer as u16, + ); + block_extension_ix.send_expect_success(&mut ctx); + + let test_ix = setup.build_instruction(&ctx); + let error = test_ix.send_expect_error(&mut ctx); + assert_escrow_error(error, EscrowError::MintNotAllowed); +} + +#[test] +fn test_deposit_prefunded_receipt_pda_succeeds() { + let mut ctx = TestContext::new(); + let setup = DepositSetup::new(&mut ctx); + + // Simulate griefing by pre-funding the receipt PDA before initialization. + ctx.svm + .set_account( + setup.receipt_pda, + Account { lamports: 1, data: vec![], owner: Pubkey::default(), executable: false, rent_epoch: 0 }, + ) + .unwrap(); + + let test_ix = setup.build_instruction(&ctx); + test_ix.send_expect_success(&mut ctx); + + let receipt_account = ctx.get_account(&setup.receipt_pda).expect("Deposit receipt should exist"); + assert!(!receipt_account.data.is_empty()); +} + #[test] fn test_deposit_zero_amount() { let mut ctx = TestContext::new(); @@ -337,6 +422,20 @@ fn test_deposit_with_hook_rejected() { assert_custom_error(error, TEST_HOOK_DENY_ERROR); } +#[test] +fn test_deposit_with_hook_extra_signer_rejected() { + let mut ctx = TestContext::new(); + let setup = DepositSetup::new_with_hook(&mut ctx, TEST_HOOK_ALLOW_ID); + let extra_signer = ctx.create_funded_keypair(); + + let mut test_ix = setup.build_instruction(&ctx); + test_ix.instruction.accounts.push(AccountMeta::new_readonly(extra_signer.pubkey(), true)); + test_ix.signers.push(extra_signer.insecure_clone()); + + let error = test_ix.send_expect_error(&mut ctx); + assert_escrow_error(error, EscrowError::HookRejected); +} + // ============================================================================ // Additional Tests // ============================================================================ diff --git a/tests/integration-tests/src/test_set_arbiter.rs b/tests/integration-tests/src/test_set_arbiter.rs index 382be3a..aad3cf2 100644 --- a/tests/integration-tests/src/test_set_arbiter.rs +++ b/tests/integration-tests/src/test_set_arbiter.rs @@ -106,7 +106,7 @@ fn test_set_arbiter_escrow_not_owned_by_program() { } #[test] -fn test_set_arbiter_duplicate_extension() { +fn test_set_arbiter_updates_existing_extension() { let mut ctx = TestContext::new(); let escrow_ix = CreateEscrowFixture::build_valid(&mut ctx); @@ -115,15 +115,23 @@ fn test_set_arbiter_duplicate_extension() { escrow_ix.send_expect_success(&mut ctx); let (escrow_pda, _) = find_escrow_pda(&escrow_seed); - let arbiter = Keypair::new(); + let (extensions_pda, extensions_bump) = find_extensions_pda(&escrow_pda); + let first_arbiter = Keypair::new(); + let first_arbiter_pubkey = first_arbiter.pubkey(); - let first_ix = SetArbiterFixture::build_with_escrow(&mut ctx, escrow_pda, admin.insecure_clone(), arbiter); + let first_ix = SetArbiterFixture::build_with_escrow(&mut ctx, escrow_pda, admin.insecure_clone(), first_arbiter); first_ix.send_expect_success(&mut ctx); + assert_extensions_header(&ctx, &extensions_pda, extensions_bump, 1); + assert_arbiter_extension(&ctx, &extensions_pda, &first_arbiter_pubkey); - // Second attempt should fail — arbiter is immutable - let second_ix = SetArbiterFixture::build_with_escrow(&mut ctx, escrow_pda, admin, Keypair::new()); - let error = second_ix.send_expect_error(&mut ctx); - assert_instruction_error(error, InstructionError::AccountAlreadyInitialized); + let second_arbiter = Keypair::new(); + let second_arbiter_pubkey = second_arbiter.pubkey(); + let second_ix = SetArbiterFixture::build_with_escrow(&mut ctx, escrow_pda, admin, second_arbiter); + second_ix.send_expect_success(&mut ctx); + + // Updating arbiter should replace value in place without increasing count. + assert_extensions_header(&ctx, &extensions_pda, extensions_bump, 1); + assert_arbiter_extension(&ctx, &extensions_pda, &second_arbiter_pubkey); } // ============================================================================ diff --git a/tests/integration-tests/src/test_set_hook.rs b/tests/integration-tests/src/test_set_hook.rs index 34889c1..98cc556 100644 --- a/tests/integration-tests/src/test_set_hook.rs +++ b/tests/integration-tests/src/test_set_hook.rs @@ -98,7 +98,7 @@ fn test_set_hook_escrow_not_owned_by_program() { } #[test] -fn test_set_hook_duplicate_extension() { +fn test_set_hook_updates_existing_extension() { let mut ctx = TestContext::new(); let escrow_ix = CreateEscrowFixture::build_valid(&mut ctx); @@ -107,14 +107,21 @@ fn test_set_hook_duplicate_extension() { escrow_ix.send_expect_success(&mut ctx); let (escrow_pda, _) = find_escrow_pda(&escrow_seed); - let hook_program = Pubkey::new_unique(); + let (extensions_pda, extensions_bump) = find_extensions_pda(&escrow_pda); + let first_hook_program = Pubkey::new_unique(); - let first_ix = SetHookFixture::build_with_escrow(&mut ctx, escrow_pda, admin.insecure_clone(), hook_program); + let first_ix = SetHookFixture::build_with_escrow(&mut ctx, escrow_pda, admin.insecure_clone(), first_hook_program); first_ix.send_expect_success(&mut ctx); + assert_extensions_header(&ctx, &extensions_pda, extensions_bump, 1); + assert_hook_extension(&ctx, &extensions_pda, &first_hook_program); - let second_ix = SetHookFixture::build_with_escrow(&mut ctx, escrow_pda, admin, Pubkey::new_unique()); - let error = second_ix.send_expect_error(&mut ctx); - assert_instruction_error(error, InstructionError::AccountAlreadyInitialized); + let second_hook_program = Pubkey::new_unique(); + let second_ix = SetHookFixture::build_with_escrow(&mut ctx, escrow_pda, admin, second_hook_program); + second_ix.send_expect_success(&mut ctx); + + // Updating hook should replace value in place without increasing count. + assert_extensions_header(&ctx, &extensions_pda, extensions_bump, 1); + assert_hook_extension(&ctx, &extensions_pda, &second_hook_program); } // ============================================================================ diff --git a/tests/integration-tests/src/test_withdraw.rs b/tests/integration-tests/src/test_withdraw.rs index 6e6a55e..fb734d4 100644 --- a/tests/integration-tests/src/test_withdraw.rs +++ b/tests/integration-tests/src/test_withdraw.rs @@ -1,19 +1,22 @@ use crate::{ fixtures::{AllowMintSetup, WithdrawFixture, WithdrawSetup, DEFAULT_DEPOSIT_AMOUNT}, utils::{ - assert_custom_error, assert_escrow_error, assert_instruction_error, test_missing_signer, test_not_writable, - test_wrong_account, test_wrong_current_program, test_wrong_owner, test_wrong_system_program, + assert_custom_error, assert_escrow_error, assert_instruction_error, find_allowed_mint_pda, test_missing_signer, + test_not_writable, test_wrong_account, test_wrong_current_program, test_wrong_owner, test_wrong_system_program, test_wrong_token_program, EscrowError, TestContext, TestInstruction, TEST_HOOK_ALLOW_ID, TEST_HOOK_DENY_ERROR, TEST_HOOK_DENY_ID, }, }; +use escrow_program_client::instructions::AddTimelockBuilder; +use escrow_program_client::instructions::AllowMintBuilder; use escrow_program_client::instructions::WithdrawBuilder; use solana_sdk::{ account::Account, instruction::{AccountMeta, InstructionError}, pubkey::Pubkey, - signature::Signer, + signature::{Keypair, Signer}, }; +use spl_token_2022::ID as TOKEN_2022_PROGRAM_ID; // ============================================================================ // Error Tests - Using Generic Test Helpers @@ -67,6 +70,20 @@ fn test_withdraw_wrong_token_program() { test_wrong_token_program::(&mut ctx, 8); } +#[test] +fn test_withdraw_mint_owner_mismatch_token_program() { + let mut ctx = TestContext::new(); + let setup = WithdrawSetup::new(&mut ctx); + + let mut mint_account = ctx.get_account(&setup.mint.pubkey()).expect("Mint account should exist"); + mint_account.owner = TOKEN_2022_PROGRAM_ID; + ctx.svm.set_account(setup.mint.pubkey(), mint_account).unwrap(); + + let test_ix = setup.build_instruction(&ctx); + let error = test_ix.send_expect_error(&mut ctx); + assert_instruction_error(error, InstructionError::InvalidAccountOwner); +} + #[test] fn test_withdraw_wrong_escrow_owner() { let mut ctx = TestContext::new(); @@ -79,6 +96,31 @@ fn test_withdraw_wrong_receipt_owner() { test_wrong_owner::(&mut ctx, 4); } +#[test] +fn test_withdraw_initialized_extensions_wrong_owner() { + let mut ctx = TestContext::new(); + let setup = WithdrawSetup::new(&mut ctx); + + let (extensions_pda, extensions_bump) = crate::utils::find_extensions_pda(&setup.escrow_pda); + let add_timelock_ix = AddTimelockBuilder::new() + .payer(ctx.payer.pubkey()) + .admin(setup.admin.pubkey()) + .escrow(setup.escrow_pda) + .extensions(extensions_pda) + .extensions_bump(extensions_bump) + .lock_duration(1) + .instruction(); + ctx.send_transaction(add_timelock_ix, &[&setup.admin]).unwrap(); + + let mut extensions_account = ctx.get_account(&setup.extensions_pda).expect("Extensions account should exist"); + extensions_account.owner = Pubkey::new_unique(); + ctx.svm.set_account(setup.extensions_pda, extensions_account).unwrap(); + + let test_ix = setup.build_instruction(&ctx); + let error = test_ix.send_expect_error(&mut ctx); + assert_instruction_error(error, InstructionError::InvalidAccountOwner); +} + #[test] fn test_withdraw_wrong_extensions_account() { let mut ctx = TestContext::new(); @@ -407,6 +449,21 @@ fn test_withdraw_with_hook_rejected() { assert_eq!(final_vault_balance, initial_vault_balance, "Vault balance should be unchanged"); } +#[test] +fn test_withdraw_with_hook_extra_signer_rejected() { + let mut ctx = TestContext::new(); + let setup = WithdrawSetup::new_with_hook(&mut ctx, TEST_HOOK_ALLOW_ID); + let extra_signer = ctx.create_funded_keypair(); + + let mut test_ix = setup.build_instruction(&ctx); + test_ix.instruction.accounts.push(AccountMeta::new_readonly(extra_signer.pubkey(), true)); + test_ix.signers.push(extra_signer.insecure_clone()); + + let error = test_ix.send_expect_error(&mut ctx); + assert_escrow_error(error, EscrowError::HookRejected); + assert!(ctx.get_account(&setup.receipt_pda).is_some(), "Receipt should remain after rejected hook"); +} + // ============================================================================ // Cross-Escrow Protection Tests // ============================================================================ @@ -440,6 +497,49 @@ fn test_withdraw_receipt_for_different_escrow_fails() { assert_escrow_error(error, EscrowError::InvalidReceiptEscrow); } +#[test] +fn test_withdraw_receipt_mint_mismatch_fails() { + let mut ctx = TestContext::new(); + let setup = WithdrawSetup::new(&mut ctx); + + let second_mint = Keypair::new(); + ctx.create_mint(&second_mint, &ctx.payer.pubkey(), 6); + let second_vault = + ctx.create_token_account_with_balance(&setup.escrow_pda, &second_mint.pubkey(), DEFAULT_DEPOSIT_AMOUNT); + let second_withdrawer_token_account = ctx.create_token_account(&setup.depositor.pubkey(), &second_mint.pubkey()); + + let (second_allowed_mint, second_allowed_mint_bump) = + find_allowed_mint_pda(&setup.escrow_pda, &second_mint.pubkey()); + let allow_second_mint_ix = AllowMintBuilder::new() + .payer(ctx.payer.pubkey()) + .admin(setup.admin.pubkey()) + .escrow(setup.escrow_pda) + .escrow_extensions(setup.extensions_pda) + .mint(second_mint.pubkey()) + .allowed_mint(second_allowed_mint) + .vault(second_vault) + .token_program(setup.token_program) + .bump(second_allowed_mint_bump) + .instruction(); + ctx.send_transaction(allow_second_mint_ix, &[&setup.admin]).unwrap(); + + let instruction = WithdrawBuilder::new() + .rent_recipient(ctx.payer.pubkey()) + .withdrawer(setup.depositor.pubkey()) + .escrow(setup.escrow_pda) + .extensions(setup.extensions_pda) + .receipt(setup.receipt_pda) + .vault(second_vault) + .withdrawer_token_account(second_withdrawer_token_account) + .mint(second_mint.pubkey()) + .token_program(setup.token_program) + .instruction(); + + let error = ctx.send_transaction_expect_error(instruction, &[&setup.depositor]); + assert_instruction_error(error, InstructionError::InvalidAccountData); + assert!(ctx.get_account(&setup.receipt_pda).is_some(), "Receipt should remain after rejected withdraw"); +} + // ============================================================================ // Closed Account Protection Tests // ============================================================================ diff --git a/tests/integration-tests/src/utils/extensions_utils.rs b/tests/integration-tests/src/utils/extensions_utils.rs index ca221dc..9b6d956 100644 --- a/tests/integration-tests/src/utils/extensions_utils.rs +++ b/tests/integration-tests/src/utils/extensions_utils.rs @@ -5,7 +5,7 @@ pub const EXTENSION_TYPE_HOOK: u16 = 1; pub const EXTENSION_TYPE_BLOCK_TOKEN_EXTENSIONS: u16 = 2; pub const EXTENSION_TYPE_ARBITER: u16 = 3; -pub const ESCROW_EXTENSIONS_DISCRIMINATOR: u8 = 1; +pub const ESCROW_EXTENSIONS_DISCRIMINATOR: u8 = 2; pub const ESCROW_EXTENSIONS_HEADER_LEN: usize = 4; // discriminator + bump + version + extension_count pub const TIMELOCK_DATA_LEN: usize = 8; diff --git a/tests/integration-tests/src/utils/token_utils.rs b/tests/integration-tests/src/utils/token_utils.rs index de1b4ac..8a467d7 100644 --- a/tests/integration-tests/src/utils/token_utils.rs +++ b/tests/integration-tests/src/utils/token_utils.rs @@ -8,9 +8,9 @@ use solana_sdk::{ use spl_associated_token_account::{get_associated_token_address, get_associated_token_address_with_program_id}; use spl_token_2022::{ extension::{ - mint_close_authority::MintCloseAuthority, non_transferable::NonTransferable, pausable::PausableConfig, - permanent_delegate::PermanentDelegate, transfer_fee::TransferFeeConfig, BaseStateWithExtensionsMut, - ExtensionType, StateWithExtensionsMut, + metadata_pointer::MetadataPointer, mint_close_authority::MintCloseAuthority, non_transferable::NonTransferable, + pausable::PausableConfig, permanent_delegate::PermanentDelegate, transfer_fee::TransferFeeConfig, + BaseStateWithExtensionsMut, ExtensionType, StateWithExtensionsMut, }, state::Mint as Token2022Mint, ID as TOKEN_2022_PROGRAM_ID, @@ -204,6 +204,9 @@ impl TestContext { let ext = state.init_extension::(true).unwrap(); ext.close_authority = COption::Some(*mint_authority).try_into().unwrap(); } + ExtensionType::MetadataPointer => { + state.init_extension::(true).unwrap(); + } _ => panic!("Unsupported extension type for test helper"), } diff --git a/tests/test-hook-program/src/lib.rs b/tests/test-hook-program/src/lib.rs index 4768e54..3e4b72f 100644 --- a/tests/test-hook-program/src/lib.rs +++ b/tests/test-hook-program/src/lib.rs @@ -17,9 +17,23 @@ pinocchio::nostd_panic_handler!(); #[cfg(feature = "allow")] pub fn process_instruction( _program_id: &Address, - _accounts: &[AccountView], - _instruction_data: &[u8], + accounts: &[AccountView], + instruction_data: &[u8], ) -> ProgramResult { + use pinocchio::error::ProgramError; + + // Validate core context shape so integration tests catch missing account context. + // hook_point: 0=PreDeposit, 1=PostDeposit, 2=PreWithdraw, 3=PostWithdraw + let hook_point = *instruction_data.first().ok_or(ProgramError::InvalidInstructionData)?; + match hook_point { + 0..=3 => { + if accounts.len() < 3 { + return Err(ProgramError::Custom(42)); + } + } + _ => return Err(ProgramError::InvalidInstructionData), + } + Ok(()) } From 2299d72591750f6e5404a537c715b16b96c8ce05 Mon Sep 17 00:00:00 2001 From: Jo D Date: Mon, 23 Mar 2026 12:02:39 -0400 Subject: [PATCH 2/8] fix(program): downgrade hook CPI account privileges --- program/src/state/extensions/hook.rs | 10 ++++------ tests/integration-tests/src/test_deposit.rs | 8 +++++--- tests/integration-tests/src/test_withdraw.rs | 7 +++---- 3 files changed, 12 insertions(+), 13 deletions(-) diff --git a/program/src/state/extensions/hook.rs b/program/src/state/extensions/hook.rs index c3118a6..0f6fd93 100644 --- a/program/src/state/extensions/hook.rs +++ b/program/src/state/extensions/hook.rs @@ -65,14 +65,12 @@ impl HookData { self.validate(remaining_accounts)?; let extra_accounts = remaining_accounts.get(1..).unwrap_or(&[]); - if extra_accounts.iter().any(|acc| acc.is_signer()) { - return Err(EscrowProgramError::HookRejected.into()); - } - let all_accounts: Vec<&AccountView> = core_accounts.iter().copied().chain(extra_accounts.iter()).collect(); - // Build instruction accounts - ALL accounts are read-only - let instruction_accounts: Vec = all_accounts.iter().map(|acc| (*acc).into()).collect(); + // Build instruction accounts with least privilege for hook CPI + // (read-only + non-signer regardless of caller flags). + let instruction_accounts: Vec = + all_accounts.iter().map(|acc| InstructionAccount::new(acc.address(), false, false)).collect(); // Instruction data is just the 1-byte hook point discriminator let instruction_data = [hook_point as u8]; diff --git a/tests/integration-tests/src/test_deposit.rs b/tests/integration-tests/src/test_deposit.rs index 236268b..524afb9 100644 --- a/tests/integration-tests/src/test_deposit.rs +++ b/tests/integration-tests/src/test_deposit.rs @@ -423,7 +423,7 @@ fn test_deposit_with_hook_rejected() { } #[test] -fn test_deposit_with_hook_extra_signer_rejected() { +fn test_deposit_with_hook_extra_signer_is_downgraded() { let mut ctx = TestContext::new(); let setup = DepositSetup::new_with_hook(&mut ctx, TEST_HOOK_ALLOW_ID); let extra_signer = ctx.create_funded_keypair(); @@ -432,8 +432,10 @@ fn test_deposit_with_hook_extra_signer_rejected() { test_ix.instruction.accounts.push(AccountMeta::new_readonly(extra_signer.pubkey(), true)); test_ix.signers.push(extra_signer.insecure_clone()); - let error = test_ix.send_expect_error(&mut ctx); - assert_escrow_error(error, EscrowError::HookRejected); + test_ix.send_expect_success(&mut ctx); + + let receipt_account = ctx.get_account(&setup.receipt_pda).expect("Deposit receipt should exist"); + assert!(!receipt_account.data.is_empty()); } // ============================================================================ diff --git a/tests/integration-tests/src/test_withdraw.rs b/tests/integration-tests/src/test_withdraw.rs index fb734d4..a17b420 100644 --- a/tests/integration-tests/src/test_withdraw.rs +++ b/tests/integration-tests/src/test_withdraw.rs @@ -450,7 +450,7 @@ fn test_withdraw_with_hook_rejected() { } #[test] -fn test_withdraw_with_hook_extra_signer_rejected() { +fn test_withdraw_with_hook_extra_signer_is_downgraded() { let mut ctx = TestContext::new(); let setup = WithdrawSetup::new_with_hook(&mut ctx, TEST_HOOK_ALLOW_ID); let extra_signer = ctx.create_funded_keypair(); @@ -459,9 +459,8 @@ fn test_withdraw_with_hook_extra_signer_rejected() { test_ix.instruction.accounts.push(AccountMeta::new_readonly(extra_signer.pubkey(), true)); test_ix.signers.push(extra_signer.insecure_clone()); - let error = test_ix.send_expect_error(&mut ctx); - assert_escrow_error(error, EscrowError::HookRejected); - assert!(ctx.get_account(&setup.receipt_pda).is_some(), "Receipt should remain after rejected hook"); + test_ix.send_expect_success(&mut ctx); + assert!(ctx.get_account(&setup.receipt_pda).is_none(), "Receipt should be closed after successful withdraw"); } // ============================================================================ From cd10ec2e00a88a8142b8cd9d906f5d3ef0bd813b Mon Sep 17 00:00:00 2001 From: Jo D Date: Mon, 23 Mar 2026 13:07:07 -0400 Subject: [PATCH 3/8] feat(extensions): add generic remove-extension instruction --- apps/web/src/app/page.tsx | 4 + .../instructions/RemoveExtension.tsx | 88 +++++++ .../contexts/RecentTransactionsContext.tsx | 1 + idl/escrow_program.json | 168 +++++++++++++ program/src/entrypoint.rs | 7 +- .../events/extensions/extension_removed.rs | 64 +++++ program/src/events/extensions/mod.rs | 2 + program/src/instructions/definition.rs | 29 +++ program/src/instructions/extensions/mod.rs | 2 + .../extensions/remove_extension/accounts.rs | 62 +++++ .../extensions/remove_extension/data.rs | 53 ++++ .../extensions/remove_extension/mod.rs | 8 + .../extensions/remove_extension/processor.rs | 39 +++ program/src/instructions/impl_instructions.rs | 2 + program/src/state/escrow_extensions.rs | 70 ++++++ program/src/traits/event.rs | 1 + program/src/traits/instruction.rs | 11 +- tests/integration-tests/src/fixtures/mod.rs | 2 + .../src/fixtures/remove_extension.rs | 78 ++++++ tests/integration-tests/src/lib.rs | 2 + .../src/test_remove_extension.rs | 237 ++++++++++++++++++ .../integration-tests/src/utils/assertions.rs | 6 + 22 files changed, 933 insertions(+), 3 deletions(-) create mode 100644 apps/web/src/components/instructions/RemoveExtension.tsx create mode 100644 program/src/events/extensions/extension_removed.rs create mode 100644 program/src/instructions/extensions/remove_extension/accounts.rs create mode 100644 program/src/instructions/extensions/remove_extension/data.rs create mode 100644 program/src/instructions/extensions/remove_extension/mod.rs create mode 100644 program/src/instructions/extensions/remove_extension/processor.rs create mode 100644 tests/integration-tests/src/fixtures/remove_extension.rs create mode 100644 tests/integration-tests/src/test_remove_extension.rs diff --git a/apps/web/src/app/page.tsx b/apps/web/src/app/page.tsx index a780f2c..924314c 100644 --- a/apps/web/src/app/page.tsx +++ b/apps/web/src/app/page.tsx @@ -15,6 +15,7 @@ import { AddTimelock } from '@/components/instructions/AddTimelock'; import { SetHook } from '@/components/instructions/SetHook'; import { BlockTokenExtension } from '@/components/instructions/BlockTokenExtension'; import { SetArbiter } from '@/components/instructions/SetArbiter'; +import { RemoveExtension } from '@/components/instructions/RemoveExtension'; import { Deposit } from '@/components/instructions/Deposit'; import { Withdraw } from '@/components/instructions/Withdraw'; @@ -27,6 +28,7 @@ type InstructionId = | 'setHook' | 'blockTokenExtension' | 'setArbiter' + | 'removeExtension' | 'deposit' | 'withdraw'; @@ -50,6 +52,7 @@ const NAV: { { id: 'setHook', label: 'Set Hook' }, { id: 'blockTokenExtension', label: 'Block Token Ext' }, { id: 'setArbiter', label: 'Set Arbiter' }, + { id: 'removeExtension', label: 'Remove Extension' }, ], }, { @@ -70,6 +73,7 @@ const PANELS: Record(null); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + reset(); + setFormError(null); + const signer = createSigner(); + if (!signer) return; + + const validationError = firstValidationError(validateAddress(escrow, 'Escrow address')); + if (validationError) { + setFormError(validationError); + return; + } + + const ix = await getRemoveExtensionInstructionAsync( + { + admin: signer, + escrow: escrow as Address, + extensionType: Number(extensionType), + payer: signer, + }, + { programAddress: programId as Address }, + ); + const txSignature = await send([ix], { + action: 'Remove Extension', + values: { escrow, extensionType }, + }); + if (txSignature) { + rememberEscrow(escrow); + } + }; + + return ( +
{ + void handleSubmit(e); + }} + style={{ display: 'flex', flexDirection: 'column', gap: 16 }} + > + + + + + + ); +} diff --git a/apps/web/src/contexts/RecentTransactionsContext.tsx b/apps/web/src/contexts/RecentTransactionsContext.tsx index b4b2374..39d6c63 100644 --- a/apps/web/src/contexts/RecentTransactionsContext.tsx +++ b/apps/web/src/contexts/RecentTransactionsContext.tsx @@ -11,6 +11,7 @@ export interface RecentTransactionValues { mint?: string; receipt?: string; amount?: string; + extensionType?: string; lockDuration?: string; hookProgram?: string; rentRecipient?: string; diff --git a/idl/escrow_program.json b/idl/escrow_program.json index 4b7a7d8..b93dbb6 100644 --- a/idl/escrow_program.json +++ b/idl/escrow_program.json @@ -478,6 +478,31 @@ "kind": "structTypeNode" } }, + { + "kind": "definedTypeNode", + "name": "extensionRemovedEvent", + "type": { + "fields": [ + { + "kind": "structFieldTypeNode", + "name": "escrow", + "type": { + "kind": "publicKeyTypeNode" + } + }, + { + "kind": "structFieldTypeNode", + "name": "extensionType", + "type": { + "endian": "le", + "format": "u16", + "kind": "numberTypeNode" + } + } + ], + "kind": "structTypeNode" + } + }, { "kind": "definedTypeNode", "name": "hookSetEvent", @@ -2433,6 +2458,149 @@ ], "kind": "instructionNode", "name": "setArbiter" + }, + { + "accounts": [ + { + "docs": [ + "Pays for transaction fees" + ], + "isSigner": true, + "isWritable": true, + "kind": "instructionAccountNode", + "name": "payer" + }, + { + "docs": [ + "Admin authority for the escrow" + ], + "isSigner": true, + "isWritable": false, + "kind": "instructionAccountNode", + "name": "admin" + }, + { + "docs": [ + "Escrow account to remove extension from" + ], + "isSigner": false, + "isWritable": false, + "kind": "instructionAccountNode", + "name": "escrow" + }, + { + "defaultValue": { + "kind": "pdaValueNode", + "pda": { + "kind": "pdaLinkNode", + "name": "extensions" + }, + "seeds": [ + { + "kind": "pdaSeedValueNode", + "name": "escrow", + "value": { + "kind": "accountValueNode", + "name": "escrow" + } + } + ] + }, + "docs": [ + "Extensions PDA account to mutate" + ], + "isSigner": false, + "isWritable": true, + "kind": "instructionAccountNode", + "name": "extensions" + }, + { + "defaultValue": { + "kind": "publicKeyValueNode", + "publicKey": "11111111111111111111111111111111" + }, + "docs": [ + "System program" + ], + "isSigner": false, + "isWritable": false, + "kind": "instructionAccountNode", + "name": "systemProgram" + }, + { + "defaultValue": { + "kind": "publicKeyValueNode", + "publicKey": "Eq63FWYo9DXgwoTnpK9gjp7BH4PyhSPo11zEF9FK7f4M" + }, + "docs": [ + "Event authority PDA for CPI event emission" + ], + "isSigner": false, + "isWritable": false, + "kind": "instructionAccountNode", + "name": "eventAuthority" + }, + { + "defaultValue": { + "kind": "publicKeyValueNode", + "publicKey": "Escrowae7RaUfNn4oEZHywMXE5zWzYCXenwrCDaEoifg" + }, + "docs": [ + "Escrow program for CPI event emission" + ], + "isSigner": false, + "isWritable": false, + "kind": "instructionAccountNode", + "name": "escrowProgram" + } + ], + "arguments": [ + { + "defaultValue": { + "kind": "numberValueNode", + "number": 10 + }, + "defaultValueStrategy": "omitted", + "kind": "instructionArgumentNode", + "name": "discriminator", + "type": { + "endian": "le", + "format": "u8", + "kind": "numberTypeNode" + } + }, + { + "defaultValue": { + "kind": "accountBumpValueNode", + "name": "extensions" + }, + "kind": "instructionArgumentNode", + "name": "extensionsBump", + "type": { + "endian": "le", + "format": "u8", + "kind": "numberTypeNode" + } + }, + { + "kind": "instructionArgumentNode", + "name": "extensionType", + "type": { + "endian": "le", + "format": "u16", + "kind": "numberTypeNode" + } + } + ], + "discriminators": [ + { + "kind": "fieldDiscriminatorNode", + "name": "discriminator", + "offset": 0 + } + ], + "kind": "instructionNode", + "name": "removeExtension" } ], "kind": "programNode", diff --git a/program/src/entrypoint.rs b/program/src/entrypoint.rs index caa2044..f33305d 100644 --- a/program/src/entrypoint.rs +++ b/program/src/entrypoint.rs @@ -3,8 +3,8 @@ use pinocchio::{account::AccountView, entrypoint, error::ProgramError, Address, use crate::{ instructions::{ process_add_timelock, process_allow_mint, process_block_mint, process_block_token_extension, - process_create_escrow, process_deposit, process_emit_event, process_set_arbiter, process_set_hook, - process_update_admin, process_withdraw, + process_create_escrow, process_deposit, process_emit_event, process_remove_extension, process_set_arbiter, + process_set_hook, process_update_admin, process_withdraw, }, traits::EscrowInstructionDiscriminators, }; @@ -29,6 +29,9 @@ pub fn process_instruction(program_id: &Address, accounts: &[AccountView], instr EscrowInstructionDiscriminators::BlockTokenExtension => { process_block_token_extension(program_id, accounts, instruction_data) } + EscrowInstructionDiscriminators::RemoveExtension => { + process_remove_extension(program_id, accounts, instruction_data) + } EscrowInstructionDiscriminators::SetArbiter => process_set_arbiter(program_id, accounts, instruction_data), EscrowInstructionDiscriminators::EmitEvent => process_emit_event(program_id, accounts), } diff --git a/program/src/events/extensions/extension_removed.rs b/program/src/events/extensions/extension_removed.rs new file mode 100644 index 0000000..ba0c7c7 --- /dev/null +++ b/program/src/events/extensions/extension_removed.rs @@ -0,0 +1,64 @@ +use alloc::vec::Vec; +use codama::CodamaType; +use pinocchio::Address; + +use crate::traits::{EventDiscriminator, EventDiscriminators, EventSerialize}; + +#[derive(CodamaType)] +pub struct ExtensionRemovedEvent { + pub escrow: Address, + pub extension_type: u16, +} + +impl EventDiscriminator for ExtensionRemovedEvent { + const DISCRIMINATOR: u8 = EventDiscriminators::ExtensionRemoved as u8; +} + +impl EventSerialize for ExtensionRemovedEvent { + #[inline(always)] + fn to_bytes_inner(&self) -> Vec { + let mut data = Vec::with_capacity(Self::DATA_LEN); + data.extend_from_slice(self.escrow.as_ref()); + data.extend_from_slice(&self.extension_type.to_le_bytes()); + data + } +} + +impl ExtensionRemovedEvent { + pub const DATA_LEN: usize = 32 + 2; // escrow + extension_type + + #[inline(always)] + pub fn new(escrow: Address, extension_type: u16) -> Self { + Self { escrow, extension_type } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::events::EVENT_IX_TAG_LE; + use crate::traits::EVENT_DISCRIMINATOR_LEN; + + #[test] + fn test_extension_removed_event_new() { + let escrow = Address::new_from_array([1u8; 32]); + let event = ExtensionRemovedEvent::new(escrow, 2); + + assert_eq!(event.escrow, escrow); + assert_eq!(event.extension_type, 2); + } + + #[test] + fn test_extension_removed_event_to_bytes() { + let escrow = Address::new_from_array([1u8; 32]); + let event = ExtensionRemovedEvent::new(escrow, 3); + + let bytes = event.to_bytes(); + assert_eq!(bytes.len(), EVENT_DISCRIMINATOR_LEN + ExtensionRemovedEvent::DATA_LEN); + assert_eq!(&bytes[..8], EVENT_IX_TAG_LE); + assert_eq!(bytes[8], EventDiscriminators::ExtensionRemoved as u8); + + let extension_type_offset = EVENT_DISCRIMINATOR_LEN + 32; + assert_eq!(u16::from_le_bytes([bytes[extension_type_offset], bytes[extension_type_offset + 1]]), 3); + } +} diff --git a/program/src/events/extensions/mod.rs b/program/src/events/extensions/mod.rs index 31f2822..d224435 100644 --- a/program/src/events/extensions/mod.rs +++ b/program/src/events/extensions/mod.rs @@ -1,9 +1,11 @@ pub mod arbiter_set; +pub mod extension_removed; pub mod hook_set; pub mod timelock_added; pub mod token_extension_blocked; pub use arbiter_set::*; +pub use extension_removed::*; pub use hook_set::*; pub use timelock_added::*; pub use token_extension_blocked::*; diff --git a/program/src/instructions/definition.rs b/program/src/instructions/definition.rs index d3bd480..c05dfb6 100644 --- a/program/src/instructions/definition.rs +++ b/program/src/instructions/definition.rs @@ -328,6 +328,35 @@ pub enum EscrowProgramInstruction { extensions_bump: u8, } = 9, + /// Remove an extension from an escrow. + #[codama(account(name = "payer", docs = "Pays for transaction fees", signer, writable))] + #[codama(account(name = "admin", docs = "Admin authority for the escrow", signer))] + #[codama(account(name = "escrow", docs = "Escrow account to remove extension from"))] + #[codama(account( + name = "extensions", + docs = "Extensions PDA account to mutate", + writable, + default_value = pda("extensions", [seed("escrow", account("escrow"))]) + ))] + #[codama(account(name = "system_program", docs = "System program", default_value = program("system")))] + #[codama(account( + name = "event_authority", + docs = "Event authority PDA for CPI event emission", + default_value = public_key("Eq63FWYo9DXgwoTnpK9gjp7BH4PyhSPo11zEF9FK7f4M") + ))] + #[codama(account( + name = "escrow_program", + docs = "Escrow program for CPI event emission", + default_value = public_key("Escrowae7RaUfNn4oEZHywMXE5zWzYCXenwrCDaEoifg") + ))] + RemoveExtension { + /// Bump for extensions PDA + #[codama(default_value = account_bump("extensions"))] + extensions_bump: u8, + /// Escrow extension type discriminator to remove + extension_type: u16, + } = 10, + /// Invoked via CPI to emit event data in instruction args (prevents log truncation). #[codama(skip)] #[codama(account( diff --git a/program/src/instructions/extensions/mod.rs b/program/src/instructions/extensions/mod.rs index 337094b..c5125a5 100644 --- a/program/src/instructions/extensions/mod.rs +++ b/program/src/instructions/extensions/mod.rs @@ -1,8 +1,10 @@ pub mod add_timelock; pub mod block_token_extension; +pub mod remove_extension; pub mod set_arbiter; pub mod set_hook; pub use add_timelock::*; pub use block_token_extension::*; +pub use remove_extension::*; pub use set_arbiter::*; pub use set_hook::*; diff --git a/program/src/instructions/extensions/remove_extension/accounts.rs b/program/src/instructions/extensions/remove_extension/accounts.rs new file mode 100644 index 0000000..318da76 --- /dev/null +++ b/program/src/instructions/extensions/remove_extension/accounts.rs @@ -0,0 +1,62 @@ +use pinocchio::{account::AccountView, error::ProgramError}; + +use crate::{ + traits::InstructionAccounts, + utils::{ + verify_current_program, verify_current_program_account, verify_event_authority, verify_readonly, verify_signer, + verify_system_program, verify_writable, + }, +}; + +/// Accounts for the RemoveExtension instruction +/// +/// # Account Layout +/// 0. `[signer, writable]` payer - Included for consistency with extension mutation flows +/// 1. `[signer]` admin - Must match escrow.admin +/// 2. `[]` escrow - Escrow account to remove extension from +/// 3. `[writable]` extensions - Extensions PDA +/// 4. `[]` system_program - System program +/// 5. `[]` event_authority - Event authority PDA +/// 6. `[]` escrow_program - Current program +pub struct RemoveExtensionAccounts<'a> { + pub payer: &'a AccountView, + pub admin: &'a AccountView, + pub escrow: &'a AccountView, + pub extensions: &'a AccountView, + pub system_program: &'a AccountView, + pub event_authority: &'a AccountView, + pub escrow_program: &'a AccountView, +} + +impl<'a> TryFrom<&'a [AccountView]> for RemoveExtensionAccounts<'a> { + type Error = ProgramError; + + #[inline(always)] + fn try_from(accounts: &'a [AccountView]) -> Result { + let [payer, admin, escrow, extensions, system_program, event_authority, escrow_program] = accounts else { + return Err(ProgramError::NotEnoughAccountKeys); + }; + + // 1. Validate signers + verify_signer(payer, true)?; + verify_signer(admin, false)?; + + // 2. Validate writable + verify_writable(extensions, true)?; + + // 3. Validate readonly + verify_readonly(escrow)?; + + // 4. Validate program IDs + verify_system_program(system_program)?; + verify_current_program(escrow_program)?; + verify_event_authority(event_authority)?; + + // 5. Validate accounts owned by current program + verify_current_program_account(escrow)?; + + Ok(Self { payer, admin, escrow, extensions, system_program, event_authority, escrow_program }) + } +} + +impl<'a> InstructionAccounts<'a> for RemoveExtensionAccounts<'a> {} diff --git a/program/src/instructions/extensions/remove_extension/data.rs b/program/src/instructions/extensions/remove_extension/data.rs new file mode 100644 index 0000000..950d046 --- /dev/null +++ b/program/src/instructions/extensions/remove_extension/data.rs @@ -0,0 +1,53 @@ +use pinocchio::error::ProgramError; + +use crate::{require_len, traits::InstructionData}; + +/// Instruction data for RemoveExtension +/// +/// # Layout +/// * `extensions_bump` (u8) - Bump for extensions PDA +/// * `extension_type` (u16) - Escrow extension type discriminator to remove +pub struct RemoveExtensionData { + pub extensions_bump: u8, + pub extension_type: u16, +} + +impl<'a> TryFrom<&'a [u8]> for RemoveExtensionData { + type Error = ProgramError; + + #[inline(always)] + fn try_from(data: &'a [u8]) -> Result { + require_len!(data, Self::LEN); + + Ok(Self { extensions_bump: data[0], extension_type: u16::from_le_bytes([data[1], data[2]]) }) + } +} + +impl<'a> InstructionData<'a> for RemoveExtensionData { + const LEN: usize = 1 + 2; +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_remove_extension_data_try_from_valid() { + let mut data = [0u8; RemoveExtensionData::LEN]; + data[0] = 255; // extensions_bump + data[1..3].copy_from_slice(&2u16.to_le_bytes()); // extension_type + + let result = RemoveExtensionData::try_from(&data[..]); + assert!(result.is_ok()); + let parsed = result.unwrap(); + assert_eq!(parsed.extensions_bump, 255); + assert_eq!(parsed.extension_type, 2); + } + + #[test] + fn test_remove_extension_data_try_from_empty() { + let data: [u8; 0] = []; + let result = RemoveExtensionData::try_from(&data[..]); + assert!(matches!(result, Err(ProgramError::InvalidInstructionData))); + } +} diff --git a/program/src/instructions/extensions/remove_extension/mod.rs b/program/src/instructions/extensions/remove_extension/mod.rs new file mode 100644 index 0000000..0582bd8 --- /dev/null +++ b/program/src/instructions/extensions/remove_extension/mod.rs @@ -0,0 +1,8 @@ +mod accounts; +mod data; +mod processor; + +pub use crate::instructions::impl_instructions::RemoveExtension; +pub use accounts::*; +pub use data::*; +pub use processor::*; diff --git a/program/src/instructions/extensions/remove_extension/processor.rs b/program/src/instructions/extensions/remove_extension/processor.rs new file mode 100644 index 0000000..5e70f9d --- /dev/null +++ b/program/src/instructions/extensions/remove_extension/processor.rs @@ -0,0 +1,39 @@ +use pinocchio::{account::AccountView, Address, ProgramResult}; + +use crate::{ + events::ExtensionRemovedEvent, + instructions::RemoveExtension, + state::{remove_extension, Escrow, ExtensionType, ExtensionsPda}, + traits::{EventSerialize, PdaSeeds}, + utils::emit_event, +}; + +/// Processes the RemoveExtension instruction. +/// +/// Removes an existing extension entry from the escrow extensions account. +pub fn process_remove_extension( + program_id: &Address, + accounts: &[AccountView], + instruction_data: &[u8], +) -> ProgramResult { + let ix = RemoveExtension::try_from((instruction_data, accounts))?; + + // Read escrow and validate + let escrow_data = ix.accounts.escrow.try_borrow()?; + let escrow = Escrow::from_account(&escrow_data, ix.accounts.escrow, program_id)?; + escrow.validate_admin(ix.accounts.admin.address())?; + + // Validate extensions PDA + let extensions_pda = ExtensionsPda::new(ix.accounts.escrow.address()); + extensions_pda.validate_pda(ix.accounts.extensions, program_id, ix.data.extensions_bump)?; + + // Parse extension type and remove matching extension + let extension_type = ExtensionType::try_from(ix.data.extension_type)?; + remove_extension(ix.accounts.extensions, extension_type)?; + + // Emit event + let event = ExtensionRemovedEvent::new(*ix.accounts.escrow.address(), ix.data.extension_type); + emit_event(program_id, ix.accounts.event_authority, ix.accounts.escrow_program, &event.to_bytes())?; + + Ok(()) +} diff --git a/program/src/instructions/impl_instructions.rs b/program/src/instructions/impl_instructions.rs index 8971b1e..a6ee8ef 100644 --- a/program/src/instructions/impl_instructions.rs +++ b/program/src/instructions/impl_instructions.rs @@ -7,6 +7,7 @@ use super::deposit::{DepositAccounts, DepositData}; use super::extensions::{ add_timelock::{AddTimelockAccounts, AddTimelockData}, block_token_extension::{BlockTokenExtensionAccounts, BlockTokenExtensionData}, + remove_extension::{RemoveExtensionAccounts, RemoveExtensionData}, set_arbiter::{SetArbiterAccounts, SetArbiterData}, set_hook::{SetHookAccounts, SetHookData}, }; @@ -19,6 +20,7 @@ define_instruction!(CreateEscrow, CreateEscrowAccounts, CreateEscrowData); define_instruction!(Deposit, DepositAccounts, DepositData); define_instruction!(AddTimelock, AddTimelockAccounts, AddTimelockData); define_instruction!(BlockTokenExtension, BlockTokenExtensionAccounts, BlockTokenExtensionData); +define_instruction!(RemoveExtension, RemoveExtensionAccounts, RemoveExtensionData); define_instruction!(SetArbiter, SetArbiterAccounts, SetArbiterData); define_instruction!(SetHook, SetHookAccounts, SetHookData); define_instruction!(UpdateAdmin, UpdateAdminAccounts, UpdateAdminData); diff --git a/program/src/state/escrow_extensions.rs b/program/src/state/escrow_extensions.rs index 4ea6fe7..a4bb976 100644 --- a/program/src/state/escrow_extensions.rs +++ b/program/src/state/escrow_extensions.rs @@ -257,6 +257,76 @@ pub fn update_extension( Ok(()) } +/// Removes an existing TLV extension entry from the extensions PDA. +/// +/// Finds the extension by type, removes it from TLV data, decrements extension count, +/// and shrinks the account data size accordingly. +/// Returns error if the extension type doesn't exist. +pub fn remove_extension(extensions: &AccountView, ext_type: ExtensionType) -> ProgramResult { + let current_data_len = extensions.data_len(); + if current_data_len == 0 { + return Err(ProgramError::UninitializedAccount); + } + + let data = extensions.try_borrow()?; + let header = EscrowExtensionsHeader::from_bytes(&data)?; + + if header.extension_count == 0 { + return Err(ProgramError::UninitializedAccount); + } + + // Find the extension entry. + let mut offset = EscrowExtensionsHeader::LEN; + let mut found_offset = None; + let mut removed_tlv_len = 0; + + while offset + TLV_HEADER_SIZE <= data.len() { + let type_bytes = u16::from_le_bytes([data[offset], data[offset + 1]]); + let length = u16::from_le_bytes([data[offset + 2], data[offset + 3]]) as usize; + + if offset + TLV_HEADER_SIZE + length > data.len() { + break; + } + + if type_bytes == ext_type as u16 { + found_offset = Some(offset); + removed_tlv_len = TLV_HEADER_SIZE + length; + break; + } + + offset += TLV_HEADER_SIZE + length; + } + + let found_offset = found_offset.ok_or(ProgramError::UninitializedAccount)?; + let new_extension_count = header.extension_count.checked_sub(1).ok_or(ProgramError::InvalidAccountData)?; + + // Build new TLV data without the removed extension. + let before = data[EscrowExtensionsHeader::LEN..found_offset].to_vec(); + let after_start = found_offset + removed_tlv_len; + let after = data[after_start..].to_vec(); + let mut new_tlv_data = Vec::with_capacity(before.len() + after.len()); + new_tlv_data.extend_from_slice(&before); + new_tlv_data.extend_from_slice(&after); + + drop(data); + + let required_size = EscrowExtensionsHeader::LEN + new_tlv_data.len(); + + // Shrink the account to reclaim unused data bytes. + extensions.resize(required_size)?; + + // Write updated header and TLV bytes. + let mut data = extensions.try_borrow_mut()?; + let new_header = EscrowExtensionsHeader::new(header.bump, new_extension_count); + let header_bytes = new_header.to_bytes(); + data[..EscrowExtensionsHeader::LEN].copy_from_slice(&header_bytes); + if !new_tlv_data.is_empty() { + data[EscrowExtensionsHeader::LEN..required_size].copy_from_slice(&new_tlv_data); + } + + Ok(()) +} + /// Updates or appends a TLV extension to the extensions PDA. /// /// Simplifies the common pattern of checking if extension exists and either updating or appending: diff --git a/program/src/traits/event.rs b/program/src/traits/event.rs index b9e913c..ee6e425 100644 --- a/program/src/traits/event.rs +++ b/program/src/traits/event.rs @@ -18,6 +18,7 @@ pub enum EventDiscriminators { BlockMint = 7, TokenExtensionBlocked = 8, ArbiterSet = 9, + ExtensionRemoved = 10, } /// Event discriminator with Anchor-compatible prefix diff --git a/program/src/traits/instruction.rs b/program/src/traits/instruction.rs index c6a3ef7..c7699d7 100644 --- a/program/src/traits/instruction.rs +++ b/program/src/traits/instruction.rs @@ -13,6 +13,7 @@ pub enum EscrowInstructionDiscriminators { BlockMint = 7, BlockTokenExtension = 8, SetArbiter = 9, + RemoveExtension = 10, EmitEvent = 228, } @@ -31,6 +32,7 @@ impl TryFrom for EscrowInstructionDiscriminators { 7 => Ok(Self::BlockMint), 8 => Ok(Self::BlockTokenExtension), 9 => Ok(Self::SetArbiter), + 10 => Ok(Self::RemoveExtension), 228 => Ok(Self::EmitEvent), _ => Err(ProgramError::InvalidInstructionData), } @@ -147,8 +149,15 @@ mod tests { } #[test] - fn test_discriminator_try_from_invalid() { + fn test_discriminator_try_from_remove_extension() { let result = EscrowInstructionDiscriminators::try_from(10u8); + assert!(result.is_ok()); + assert!(matches!(result.unwrap(), EscrowInstructionDiscriminators::RemoveExtension)); + } + + #[test] + fn test_discriminator_try_from_invalid() { + let result = EscrowInstructionDiscriminators::try_from(11u8); assert!(matches!(result, Err(ProgramError::InvalidInstructionData))); let result = EscrowInstructionDiscriminators::try_from(255u8); diff --git a/tests/integration-tests/src/fixtures/mod.rs b/tests/integration-tests/src/fixtures/mod.rs index 049c7aa..d81e6be 100644 --- a/tests/integration-tests/src/fixtures/mod.rs +++ b/tests/integration-tests/src/fixtures/mod.rs @@ -4,6 +4,7 @@ pub mod block_mint; pub mod block_token_extension; pub mod create_escrow; pub mod deposit; +pub mod remove_extension; pub mod set_arbiter; pub mod set_hook; pub mod update_admin; @@ -15,6 +16,7 @@ pub use block_mint::{BlockMintFixture, BlockMintSetup}; pub use block_token_extension::AddBlockTokenExtensionsFixture; pub use create_escrow::CreateEscrowFixture; pub use deposit::{DepositFixture, DepositSetup, DEFAULT_DEPOSIT_AMOUNT}; +pub use remove_extension::RemoveExtensionFixture; pub use set_arbiter::SetArbiterFixture; pub use set_hook::SetHookFixture; pub use update_admin::UpdateAdminFixture; diff --git a/tests/integration-tests/src/fixtures/remove_extension.rs b/tests/integration-tests/src/fixtures/remove_extension.rs new file mode 100644 index 0000000..7ca235b --- /dev/null +++ b/tests/integration-tests/src/fixtures/remove_extension.rs @@ -0,0 +1,78 @@ +use escrow_program_client::instructions::RemoveExtensionBuilder; +use solana_sdk::{ + pubkey::Pubkey, + signature::{Keypair, Signer}, +}; + +use crate::utils::extensions_utils::EXTENSION_TYPE_HOOK; +use crate::{ + fixtures::{CreateEscrowFixture, SetHookFixture}, + utils::{find_escrow_pda, find_extensions_pda, TestContext}, +}; + +use crate::utils::traits::{InstructionTestFixture, TestInstruction}; + +pub struct RemoveExtensionFixture; + +impl RemoveExtensionFixture { + pub fn build_with_escrow( + ctx: &mut TestContext, + escrow_pda: Pubkey, + admin: Keypair, + extension_type: u16, + ) -> TestInstruction { + let (extensions_pda, extensions_bump) = find_extensions_pda(&escrow_pda); + + let instruction = RemoveExtensionBuilder::new() + .payer(ctx.payer.pubkey()) + .admin(admin.pubkey()) + .escrow(escrow_pda) + .extensions(extensions_pda) + .extensions_bump(extensions_bump) + .extension_type(extension_type) + .instruction(); + + TestInstruction { instruction, signers: vec![admin], name: Self::INSTRUCTION_NAME } + } +} + +impl InstructionTestFixture for RemoveExtensionFixture { + const INSTRUCTION_NAME: &'static str = "RemoveExtension"; + + fn build_valid(ctx: &mut TestContext) -> TestInstruction { + let escrow_ix = CreateEscrowFixture::build_valid(ctx); + let admin = escrow_ix.signers[0].insecure_clone(); + let escrow_seed = escrow_ix.signers[1].pubkey(); + escrow_ix.send_expect_success(ctx); + + let (escrow_pda, _) = find_escrow_pda(&escrow_seed); + let hook_ix = SetHookFixture::build_with_escrow(ctx, escrow_pda, admin.insecure_clone(), Pubkey::new_unique()); + hook_ix.send_expect_success(ctx); + + Self::build_with_escrow(ctx, escrow_pda, admin, EXTENSION_TYPE_HOOK) + } + + /// Account indices that must be signers: + /// 1: admin (payer at 0 is handled separately by TestContext) + fn required_signers() -> &'static [usize] { + &[1] + } + + /// Account indices that must be writable: + /// 3: extensions (payer at 0 is handled separately by TestContext) + fn required_writable() -> &'static [usize] { + &[3] + } + + fn system_program_index() -> Option { + Some(4) + } + + fn current_program_index() -> Option { + Some(6) + } + + fn data_len() -> usize { + 3 // extensions_bump + extension_type + } +} diff --git a/tests/integration-tests/src/lib.rs b/tests/integration-tests/src/lib.rs index b1aef43..7b46daf 100644 --- a/tests/integration-tests/src/lib.rs +++ b/tests/integration-tests/src/lib.rs @@ -14,6 +14,8 @@ mod test_create_escrow; #[cfg(test)] mod test_deposit; #[cfg(test)] +mod test_remove_extension; +#[cfg(test)] mod test_set_arbiter; #[cfg(test)] mod test_set_hook; diff --git a/tests/integration-tests/src/test_remove_extension.rs b/tests/integration-tests/src/test_remove_extension.rs new file mode 100644 index 0000000..0d073b4 --- /dev/null +++ b/tests/integration-tests/src/test_remove_extension.rs @@ -0,0 +1,237 @@ +use crate::{ + fixtures::{ + AddBlockTokenExtensionsFixture, AddTimelockFixture, CreateEscrowFixture, RemoveExtensionFixture, + SetArbiterFixture, SetHookFixture, + }, + utils::extensions_utils::{ + EXTENSION_TYPE_ARBITER, EXTENSION_TYPE_BLOCK_TOKEN_EXTENSIONS, EXTENSION_TYPE_HOOK, EXTENSION_TYPE_TIMELOCK, + }, + utils::{ + assert_arbiter_extension, assert_block_token_extensions_extension, assert_extension_missing, + assert_extensions_header, assert_instruction_error, find_escrow_pda, find_extensions_pda, test_empty_data, + test_missing_signer, test_not_writable, test_truncated_data, test_wrong_account, test_wrong_current_program, + test_wrong_system_program, InstructionTestFixture, TestContext, RANDOM_PUBKEY, + }, +}; +use solana_sdk::{instruction::InstructionError, pubkey::Pubkey, signature::Signer}; + +// ============================================================================ +// Error Tests - Using Generic Test Helpers +// ============================================================================ + +#[test] +fn test_remove_extension_missing_admin_signer() { + let mut ctx = TestContext::new(); + test_missing_signer::(&mut ctx, 1, 0); +} + +#[test] +fn test_remove_extension_extensions_not_writable() { + let mut ctx = TestContext::new(); + test_not_writable::(&mut ctx, 3); +} + +#[test] +fn test_remove_extension_wrong_system_program() { + let mut ctx = TestContext::new(); + test_wrong_system_program::(&mut ctx); +} + +#[test] +fn test_remove_extension_wrong_escrow_program() { + let mut ctx = TestContext::new(); + test_wrong_current_program::(&mut ctx); +} + +#[test] +fn test_remove_extension_invalid_event_authority() { + let mut ctx = TestContext::new(); + test_wrong_account::(&mut ctx, 5, InstructionError::Custom(2)); +} + +#[test] +fn test_remove_extension_invalid_extensions_bump() { + let mut ctx = TestContext::new(); + let test_ix = RemoveExtensionFixture::build_valid(&mut ctx); + let correct_bump = test_ix.instruction.data[1]; + let invalid_bump = correct_bump.wrapping_add(1); + let error = test_ix.with_data_byte_at(1, invalid_bump).send_expect_error(&mut ctx); + assert_instruction_error(error, InstructionError::InvalidSeeds); +} + +#[test] +fn test_remove_extension_empty_data() { + let mut ctx = TestContext::new(); + test_empty_data::(&mut ctx); +} + +#[test] +fn test_remove_extension_truncated_data() { + let mut ctx = TestContext::new(); + test_truncated_data::(&mut ctx); +} + +// ============================================================================ +// Custom Error Tests +// ============================================================================ + +#[test] +fn test_remove_extension_wrong_admin() { + let mut ctx = TestContext::new(); + + let escrow_ix = CreateEscrowFixture::build_valid(&mut ctx); + let admin = escrow_ix.signers[0].insecure_clone(); + let escrow_seed = escrow_ix.signers[1].pubkey(); + escrow_ix.send_expect_success(&mut ctx); + + let (escrow_pda, _) = find_escrow_pda(&escrow_seed); + SetHookFixture::build_with_escrow(&mut ctx, escrow_pda, admin, Pubkey::new_unique()).send_expect_success(&mut ctx); + + let wrong_admin = ctx.create_funded_keypair(); + let test_ix = RemoveExtensionFixture::build_with_escrow(&mut ctx, escrow_pda, wrong_admin, EXTENSION_TYPE_HOOK); + + let error = test_ix.send_expect_error(&mut ctx); + assert_instruction_error(error, InstructionError::Custom(1)); +} + +#[test] +fn test_remove_extension_escrow_not_owned_by_program() { + let mut ctx = TestContext::new(); + let test_ix = RemoveExtensionFixture::build_valid(&mut ctx); + + let error = test_ix.with_account_at(2, RANDOM_PUBKEY).send_expect_error(&mut ctx); + assert_instruction_error(error, InstructionError::InvalidAccountOwner); +} + +#[test] +fn test_remove_extension_invalid_type() { + let mut ctx = TestContext::new(); + let test_ix = RemoveExtensionFixture::build_valid(&mut ctx); + + // extension_type starts at data[2..4] + let error = test_ix.with_data_byte_at(2, 250).with_data_byte_at(3, 0).send_expect_error(&mut ctx); + assert_instruction_error(error, InstructionError::InvalidAccountData); +} + +#[test] +fn test_remove_extension_not_found() { + let mut ctx = TestContext::new(); + + let escrow_ix = CreateEscrowFixture::build_valid(&mut ctx); + let admin = escrow_ix.signers[0].insecure_clone(); + let escrow_seed = escrow_ix.signers[1].pubkey(); + escrow_ix.send_expect_success(&mut ctx); + + let (escrow_pda, _) = find_escrow_pda(&escrow_seed); + SetHookFixture::build_with_escrow(&mut ctx, escrow_pda, admin.insecure_clone(), Pubkey::new_unique()) + .send_expect_success(&mut ctx); + + // Timelock was never set. + let test_ix = RemoveExtensionFixture::build_with_escrow(&mut ctx, escrow_pda, admin, EXTENSION_TYPE_TIMELOCK); + let error = test_ix.send_expect_error(&mut ctx); + assert_instruction_error(error, InstructionError::UninitializedAccount); +} + +// ============================================================================ +// Success Tests +// ============================================================================ + +#[test] +fn test_remove_extension_success_remove_hook() { + let mut ctx = TestContext::new(); + + let escrow_ix = CreateEscrowFixture::build_valid(&mut ctx); + let admin = escrow_ix.signers[0].insecure_clone(); + let escrow_seed = escrow_ix.signers[1].pubkey(); + escrow_ix.send_expect_success(&mut ctx); + + let (escrow_pda, _) = find_escrow_pda(&escrow_seed); + let (extensions_pda, extensions_bump) = find_extensions_pda(&escrow_pda); + + SetHookFixture::build_with_escrow(&mut ctx, escrow_pda, admin.insecure_clone(), Pubkey::new_unique()) + .send_expect_success(&mut ctx); + assert_extensions_header(&ctx, &extensions_pda, extensions_bump, 1); + + let remove_ix = RemoveExtensionFixture::build_with_escrow(&mut ctx, escrow_pda, admin, EXTENSION_TYPE_HOOK); + remove_ix.send_expect_success(&mut ctx); + + assert_extensions_header(&ctx, &extensions_pda, extensions_bump, 0); + assert_extension_missing(&ctx, &extensions_pda, EXTENSION_TYPE_HOOK); +} + +#[test] +fn test_remove_extension_success_remove_timelock_keeps_other_extensions() { + let mut ctx = TestContext::new(); + + let escrow_ix = CreateEscrowFixture::build_valid(&mut ctx); + let admin = escrow_ix.signers[0].insecure_clone(); + let escrow_seed = escrow_ix.signers[1].pubkey(); + escrow_ix.send_expect_success(&mut ctx); + + let (escrow_pda, _) = find_escrow_pda(&escrow_seed); + let (extensions_pda, extensions_bump) = find_extensions_pda(&escrow_pda); + + AddTimelockFixture::build_with_escrow(&mut ctx, escrow_pda, admin.insecure_clone(), 3600) + .send_expect_success(&mut ctx); + let hook_program = Pubkey::new_unique(); + SetHookFixture::build_with_escrow(&mut ctx, escrow_pda, admin.insecure_clone(), hook_program) + .send_expect_success(&mut ctx); + assert_extensions_header(&ctx, &extensions_pda, extensions_bump, 2); + + let remove_ix = RemoveExtensionFixture::build_with_escrow(&mut ctx, escrow_pda, admin, EXTENSION_TYPE_TIMELOCK); + remove_ix.send_expect_success(&mut ctx); + + assert_extensions_header(&ctx, &extensions_pda, extensions_bump, 1); + assert_extension_missing(&ctx, &extensions_pda, EXTENSION_TYPE_TIMELOCK); + crate::utils::assert_hook_extension(&ctx, &extensions_pda, &hook_program); +} + +#[test] +fn test_remove_extension_success_remove_arbiter() { + let mut ctx = TestContext::new(); + + let escrow_ix = CreateEscrowFixture::build_valid(&mut ctx); + let admin = escrow_ix.signers[0].insecure_clone(); + let escrow_seed = escrow_ix.signers[1].pubkey(); + escrow_ix.send_expect_success(&mut ctx); + + let (escrow_pda, _) = find_escrow_pda(&escrow_seed); + let (extensions_pda, extensions_bump) = find_extensions_pda(&escrow_pda); + + let arbiter = ctx.create_funded_keypair(); + SetArbiterFixture::build_with_escrow(&mut ctx, escrow_pda, admin.insecure_clone(), arbiter.insecure_clone()) + .send_expect_success(&mut ctx); + assert_extensions_header(&ctx, &extensions_pda, extensions_bump, 1); + assert_arbiter_extension(&ctx, &extensions_pda, &arbiter.pubkey()); + + let remove_ix = RemoveExtensionFixture::build_with_escrow(&mut ctx, escrow_pda, admin, EXTENSION_TYPE_ARBITER); + remove_ix.send_expect_success(&mut ctx); + + assert_extensions_header(&ctx, &extensions_pda, extensions_bump, 0); + assert_extension_missing(&ctx, &extensions_pda, EXTENSION_TYPE_ARBITER); +} + +#[test] +fn test_remove_extension_success_remove_blocked_token_extensions() { + let mut ctx = TestContext::new(); + + let escrow_ix = CreateEscrowFixture::build_valid(&mut ctx); + let admin = escrow_ix.signers[0].insecure_clone(); + let escrow_seed = escrow_ix.signers[1].pubkey(); + escrow_ix.send_expect_success(&mut ctx); + + let (escrow_pda, _) = find_escrow_pda(&escrow_seed); + let (extensions_pda, extensions_bump) = find_extensions_pda(&escrow_pda); + + AddBlockTokenExtensionsFixture::build_with_escrow(&mut ctx, escrow_pda, admin.insecure_clone(), 99u16) + .send_expect_success(&mut ctx); + assert_extensions_header(&ctx, &extensions_pda, extensions_bump, 1); + assert_block_token_extensions_extension(&ctx, &extensions_pda, &[99u16]); + + let remove_ix = + RemoveExtensionFixture::build_with_escrow(&mut ctx, escrow_pda, admin, EXTENSION_TYPE_BLOCK_TOKEN_EXTENSIONS); + remove_ix.send_expect_success(&mut ctx); + + assert_extensions_header(&ctx, &extensions_pda, extensions_bump, 0); + assert_extension_missing(&ctx, &extensions_pda, EXTENSION_TYPE_BLOCK_TOKEN_EXTENSIONS); +} diff --git a/tests/integration-tests/src/utils/assertions.rs b/tests/integration-tests/src/utils/assertions.rs index 4bc3efc..4c3dbe3 100644 --- a/tests/integration-tests/src/utils/assertions.rs +++ b/tests/integration-tests/src/utils/assertions.rs @@ -135,6 +135,12 @@ pub fn assert_arbiter_extension(ctx: &TestContext, extensions_pda: &Pubkey, expe assert_eq!(arbiter, *expected_arbiter, "Wrong arbiter"); } +pub fn assert_extension_missing(ctx: &TestContext, extensions_pda: &Pubkey, extension_type: u16) { + let account = ctx.get_account(extensions_pda).expect("Extensions account should exist"); + let data = &account.data; + assert!(find_extension(data, extension_type).is_none(), "Extension type {extension_type} should be absent"); +} + pub fn assert_allowed_mint_account(ctx: &TestContext, allowed_mint_pda: &Pubkey, expected_bump: u8) { let account = ctx.get_account(allowed_mint_pda).expect("AllowedMint account should exist"); From 818bd1a7f102d055f2140121687a402f04adc0f3 Mon Sep 17 00:00:00 2001 From: Jo D Date: Mon, 23 Mar 2026 13:32:28 -0400 Subject: [PATCH 4/8] feat(extensions): add unblock token extension flow --- apps/web/src/app/page.tsx | 4 + .../instructions/BlockTokenExtension.tsx | 1 + .../instructions/UnblockTokenExtension.tsx | 91 ++++++++ apps/web/src/lib/transactionErrors.ts | 2 + idl/escrow_program.json | 174 ++++++++++++++ program/src/entrypoint.rs | 5 +- program/src/errors.rs | 4 + program/src/events/extensions/mod.rs | 2 + .../extensions/token_extension_unblocked.rs | 64 ++++++ program/src/instructions/definition.rs | 29 +++ program/src/instructions/extensions/mod.rs | 2 + .../unblock_token_extension/accounts.rs | 62 +++++ .../unblock_token_extension/data.rs | 53 +++++ .../extensions/unblock_token_extension/mod.rs | 8 + .../unblock_token_extension/processor.rs | 68 ++++++ program/src/instructions/impl_instructions.rs | 2 + .../state/extensions/block_token_extension.rs | 41 ++++ program/src/traits/event.rs | 1 + program/src/traits/instruction.rs | 11 +- tests/integration-tests/src/fixtures/mod.rs | 2 + .../src/fixtures/unblock_token_extension.rs | 77 +++++++ tests/integration-tests/src/lib.rs | 2 + tests/integration-tests/src/test_deposit.rs | 29 ++- .../src/test_unblock_token_extension.rs | 216 ++++++++++++++++++ 24 files changed, 947 insertions(+), 3 deletions(-) create mode 100644 apps/web/src/components/instructions/UnblockTokenExtension.tsx create mode 100644 program/src/events/extensions/token_extension_unblocked.rs create mode 100644 program/src/instructions/extensions/unblock_token_extension/accounts.rs create mode 100644 program/src/instructions/extensions/unblock_token_extension/data.rs create mode 100644 program/src/instructions/extensions/unblock_token_extension/mod.rs create mode 100644 program/src/instructions/extensions/unblock_token_extension/processor.rs create mode 100644 tests/integration-tests/src/fixtures/unblock_token_extension.rs create mode 100644 tests/integration-tests/src/test_unblock_token_extension.rs diff --git a/apps/web/src/app/page.tsx b/apps/web/src/app/page.tsx index 924314c..b2d72c9 100644 --- a/apps/web/src/app/page.tsx +++ b/apps/web/src/app/page.tsx @@ -16,6 +16,7 @@ import { SetHook } from '@/components/instructions/SetHook'; import { BlockTokenExtension } from '@/components/instructions/BlockTokenExtension'; import { SetArbiter } from '@/components/instructions/SetArbiter'; import { RemoveExtension } from '@/components/instructions/RemoveExtension'; +import { UnblockTokenExtension } from '@/components/instructions/UnblockTokenExtension'; import { Deposit } from '@/components/instructions/Deposit'; import { Withdraw } from '@/components/instructions/Withdraw'; @@ -27,6 +28,7 @@ type InstructionId = | 'addTimelock' | 'setHook' | 'blockTokenExtension' + | 'unblockTokenExtension' | 'setArbiter' | 'removeExtension' | 'deposit' @@ -51,6 +53,7 @@ const NAV: { { id: 'addTimelock', label: 'Add Timelock' }, { id: 'setHook', label: 'Set Hook' }, { id: 'blockTokenExtension', label: 'Block Token Ext' }, + { id: 'unblockTokenExtension', label: 'Unblock Token Ext' }, { id: 'setArbiter', label: 'Set Arbiter' }, { id: 'removeExtension', label: 'Remove Extension' }, ], @@ -72,6 +75,7 @@ const PANELS: Record(null); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + reset(); + setFormError(null); + const signer = createSigner(); + if (!signer) return; + + const validationError = firstValidationError(validateAddress(escrow, 'Escrow address')); + if (validationError) { + setFormError(validationError); + return; + } + + const ix = await getUnblockTokenExtensionInstructionAsync( + { + admin: signer, + escrow: escrow as Address, + blockedExtension: Number(blockedExtension), + payer: signer, + }, + { programAddress: programId as Address }, + ); + const txSignature = await send([ix], { + action: 'Unblock Token Extension', + values: { escrow, extensionType: blockedExtension }, + }); + if (txSignature) { + rememberEscrow(escrow); + } + }; + + return ( +
{ + void handleSubmit(e); + }} + style={{ display: 'flex', flexDirection: 'column', gap: 16 }} + > + + + + + + ); +} diff --git a/apps/web/src/lib/transactionErrors.ts b/apps/web/src/lib/transactionErrors.ts index b14a8af..3f4b595 100644 --- a/apps/web/src/lib/transactionErrors.ts +++ b/apps/web/src/lib/transactionErrors.ts @@ -15,6 +15,7 @@ import { ESCROW_PROGRAM_ERROR__PERMANENT_DELEGATE_NOT_ALLOWED, ESCROW_PROGRAM_ERROR__TIMELOCK_NOT_EXPIRED, ESCROW_PROGRAM_ERROR__TOKEN_EXTENSION_ALREADY_BLOCKED, + ESCROW_PROGRAM_ERROR__TOKEN_EXTENSION_NOT_BLOCKED, ESCROW_PROGRAM_ERROR__ZERO_DEPOSIT_AMOUNT, } from '@solana/escrow-program-client'; @@ -32,6 +33,7 @@ const ESCROW_PROGRAM_ERROR_MESSAGES: Record = { [ESCROW_PROGRAM_ERROR__NON_TRANSFERABLE_NOT_ALLOWED]: 'Mint has NonTransferable extension which is not allowed', [ESCROW_PROGRAM_ERROR__PAUSABLE_NOT_ALLOWED]: 'Mint has Pausable extension which is not allowed', [ESCROW_PROGRAM_ERROR__TOKEN_EXTENSION_ALREADY_BLOCKED]: 'Token extension already blocked', + [ESCROW_PROGRAM_ERROR__TOKEN_EXTENSION_NOT_BLOCKED]: 'Token extension is not currently blocked', [ESCROW_PROGRAM_ERROR__ZERO_DEPOSIT_AMOUNT]: 'Zero deposit amount', [ESCROW_PROGRAM_ERROR__INVALID_ARBITER]: 'Arbiter signer is missing or does not match', }; diff --git a/idl/escrow_program.json b/idl/escrow_program.json index b93dbb6..913ea37 100644 --- a/idl/escrow_program.json +++ b/idl/escrow_program.json @@ -576,6 +576,31 @@ "kind": "structTypeNode" } }, + { + "kind": "definedTypeNode", + "name": "tokenExtensionUnblocked", + "type": { + "fields": [ + { + "kind": "structFieldTypeNode", + "name": "escrow", + "type": { + "kind": "publicKeyTypeNode" + } + }, + { + "kind": "structFieldTypeNode", + "name": "unblockedExtension", + "type": { + "endian": "le", + "format": "u16", + "kind": "numberTypeNode" + } + } + ], + "kind": "structTypeNode" + } + }, { "kind": "definedTypeNode", "name": "withdrawEvent", @@ -713,6 +738,12 @@ "kind": "errorNode", "message": "Arbiter signer is missing or does not match", "name": "invalidArbiter" + }, + { + "code": 15, + "kind": "errorNode", + "message": "Token extension is not currently blocked", + "name": "tokenExtensionNotBlocked" } ], "instructions": [ @@ -2601,6 +2632,149 @@ ], "kind": "instructionNode", "name": "removeExtension" + }, + { + "accounts": [ + { + "docs": [ + "Pays for transaction fees" + ], + "isSigner": true, + "isWritable": true, + "kind": "instructionAccountNode", + "name": "payer" + }, + { + "docs": [ + "Admin authority for the escrow" + ], + "isSigner": true, + "isWritable": false, + "kind": "instructionAccountNode", + "name": "admin" + }, + { + "docs": [ + "Escrow account to unblock extension on" + ], + "isSigner": false, + "isWritable": false, + "kind": "instructionAccountNode", + "name": "escrow" + }, + { + "defaultValue": { + "kind": "pdaValueNode", + "pda": { + "kind": "pdaLinkNode", + "name": "extensions" + }, + "seeds": [ + { + "kind": "pdaSeedValueNode", + "name": "escrow", + "value": { + "kind": "accountValueNode", + "name": "escrow" + } + } + ] + }, + "docs": [ + "Extensions PDA account to mutate" + ], + "isSigner": false, + "isWritable": true, + "kind": "instructionAccountNode", + "name": "extensions" + }, + { + "defaultValue": { + "kind": "publicKeyValueNode", + "publicKey": "11111111111111111111111111111111" + }, + "docs": [ + "System program" + ], + "isSigner": false, + "isWritable": false, + "kind": "instructionAccountNode", + "name": "systemProgram" + }, + { + "defaultValue": { + "kind": "publicKeyValueNode", + "publicKey": "Eq63FWYo9DXgwoTnpK9gjp7BH4PyhSPo11zEF9FK7f4M" + }, + "docs": [ + "Event authority PDA for CPI event emission" + ], + "isSigner": false, + "isWritable": false, + "kind": "instructionAccountNode", + "name": "eventAuthority" + }, + { + "defaultValue": { + "kind": "publicKeyValueNode", + "publicKey": "Escrowae7RaUfNn4oEZHywMXE5zWzYCXenwrCDaEoifg" + }, + "docs": [ + "Escrow program for CPI event emission" + ], + "isSigner": false, + "isWritable": false, + "kind": "instructionAccountNode", + "name": "escrowProgram" + } + ], + "arguments": [ + { + "defaultValue": { + "kind": "numberValueNode", + "number": 11 + }, + "defaultValueStrategy": "omitted", + "kind": "instructionArgumentNode", + "name": "discriminator", + "type": { + "endian": "le", + "format": "u8", + "kind": "numberTypeNode" + } + }, + { + "defaultValue": { + "kind": "accountBumpValueNode", + "name": "extensions" + }, + "kind": "instructionArgumentNode", + "name": "extensionsBump", + "type": { + "endian": "le", + "format": "u8", + "kind": "numberTypeNode" + } + }, + { + "kind": "instructionArgumentNode", + "name": "blockedExtension", + "type": { + "endian": "le", + "format": "u16", + "kind": "numberTypeNode" + } + } + ], + "discriminators": [ + { + "kind": "fieldDiscriminatorNode", + "name": "discriminator", + "offset": 0 + } + ], + "kind": "instructionNode", + "name": "unblockTokenExtension" } ], "kind": "programNode", diff --git a/program/src/entrypoint.rs b/program/src/entrypoint.rs index f33305d..644c2a8 100644 --- a/program/src/entrypoint.rs +++ b/program/src/entrypoint.rs @@ -4,7 +4,7 @@ use crate::{ instructions::{ process_add_timelock, process_allow_mint, process_block_mint, process_block_token_extension, process_create_escrow, process_deposit, process_emit_event, process_remove_extension, process_set_arbiter, - process_set_hook, process_update_admin, process_withdraw, + process_set_hook, process_unblock_token_extension, process_update_admin, process_withdraw, }, traits::EscrowInstructionDiscriminators, }; @@ -32,6 +32,9 @@ pub fn process_instruction(program_id: &Address, accounts: &[AccountView], instr EscrowInstructionDiscriminators::RemoveExtension => { process_remove_extension(program_id, accounts, instruction_data) } + EscrowInstructionDiscriminators::UnblockTokenExtension => { + process_unblock_token_extension(program_id, accounts, instruction_data) + } EscrowInstructionDiscriminators::SetArbiter => process_set_arbiter(program_id, accounts, instruction_data), EscrowInstructionDiscriminators::EmitEvent => process_emit_event(program_id, accounts), } diff --git a/program/src/errors.rs b/program/src/errors.rs index 58fe4be..d5919fa 100644 --- a/program/src/errors.rs +++ b/program/src/errors.rs @@ -64,6 +64,10 @@ pub enum EscrowProgramError { /// (14) Arbiter signer is missing or does not match #[error("Arbiter signer is missing or does not match")] InvalidArbiter, + + /// (15) Token extension is not currently blocked + #[error("Token extension is not currently blocked")] + TokenExtensionNotBlocked, } impl From for ProgramError { diff --git a/program/src/events/extensions/mod.rs b/program/src/events/extensions/mod.rs index d224435..9bd7aec 100644 --- a/program/src/events/extensions/mod.rs +++ b/program/src/events/extensions/mod.rs @@ -3,9 +3,11 @@ pub mod extension_removed; pub mod hook_set; pub mod timelock_added; pub mod token_extension_blocked; +pub mod token_extension_unblocked; pub use arbiter_set::*; pub use extension_removed::*; pub use hook_set::*; pub use timelock_added::*; pub use token_extension_blocked::*; +pub use token_extension_unblocked::*; diff --git a/program/src/events/extensions/token_extension_unblocked.rs b/program/src/events/extensions/token_extension_unblocked.rs new file mode 100644 index 0000000..4a0adcc --- /dev/null +++ b/program/src/events/extensions/token_extension_unblocked.rs @@ -0,0 +1,64 @@ +use alloc::vec::Vec; +use codama::CodamaType; +use pinocchio::Address; + +use crate::traits::{EventDiscriminator, EventDiscriminators, EventSerialize}; + +/// Event emitted when a token extension is unblocked. +#[derive(CodamaType)] +pub struct TokenExtensionUnblocked { + pub escrow: Address, + pub unblocked_extension: u16, +} + +impl EventDiscriminator for TokenExtensionUnblocked { + const DISCRIMINATOR: u8 = EventDiscriminators::TokenExtensionUnblocked as u8; +} + +impl EventSerialize for TokenExtensionUnblocked { + #[inline(always)] + fn to_bytes_inner(&self) -> Vec { + let mut data = Vec::with_capacity(Self::DATA_LEN); + data.extend_from_slice(self.escrow.as_ref()); + data.extend_from_slice(&self.unblocked_extension.to_le_bytes()); + data + } +} + +impl TokenExtensionUnblocked { + pub const DATA_LEN: usize = 32 + 2; // escrow + unblocked_extension + + #[inline(always)] + pub fn new(escrow: Address, unblocked_extension: u16) -> Self { + Self { escrow, unblocked_extension } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::events::EVENT_IX_TAG_LE; + use crate::traits::EVENT_DISCRIMINATOR_LEN; + + #[test] + fn test_token_extension_unblocked_event_new() { + let escrow = Address::new_from_array([1u8; 32]); + let event = TokenExtensionUnblocked::new(escrow, 42); + + assert_eq!(event.escrow, escrow); + assert_eq!(event.unblocked_extension, 42); + } + + #[test] + fn test_token_extension_unblocked_event_to_bytes() { + let escrow = Address::new_from_array([1u8; 32]); + let event = TokenExtensionUnblocked::new(escrow, 100); + + let bytes = event.to_bytes(); + assert_eq!(bytes.len(), EVENT_DISCRIMINATOR_LEN + TokenExtensionUnblocked::DATA_LEN); + assert_eq!(&bytes[..8], EVENT_IX_TAG_LE); + assert_eq!(bytes[8], EventDiscriminators::TokenExtensionUnblocked as u8); + assert_eq!(&bytes[9..41], escrow.as_ref()); + assert_eq!(u16::from_le_bytes([bytes[41], bytes[42]]), 100); + } +} diff --git a/program/src/instructions/definition.rs b/program/src/instructions/definition.rs index c05dfb6..92c1bcc 100644 --- a/program/src/instructions/definition.rs +++ b/program/src/instructions/definition.rs @@ -357,6 +357,35 @@ pub enum EscrowProgramInstruction { extension_type: u16, } = 10, + /// Unblock a previously blocked token extension for an escrow. + #[codama(account(name = "payer", docs = "Pays for transaction fees", signer, writable))] + #[codama(account(name = "admin", docs = "Admin authority for the escrow", signer))] + #[codama(account(name = "escrow", docs = "Escrow account to unblock extension on"))] + #[codama(account( + name = "extensions", + docs = "Extensions PDA account to mutate", + writable, + default_value = pda("extensions", [seed("escrow", account("escrow"))]) + ))] + #[codama(account(name = "system_program", docs = "System program", default_value = program("system")))] + #[codama(account( + name = "event_authority", + docs = "Event authority PDA for CPI event emission", + default_value = public_key("Eq63FWYo9DXgwoTnpK9gjp7BH4PyhSPo11zEF9FK7f4M") + ))] + #[codama(account( + name = "escrow_program", + docs = "Escrow program for CPI event emission", + default_value = public_key("Escrowae7RaUfNn4oEZHywMXE5zWzYCXenwrCDaEoifg") + ))] + UnblockTokenExtension { + /// Bump for extensions PDA + #[codama(default_value = account_bump("extensions"))] + extensions_bump: u8, + /// Token-2022 ExtensionType value to unblock + blocked_extension: u16, + } = 11, + /// Invoked via CPI to emit event data in instruction args (prevents log truncation). #[codama(skip)] #[codama(account( diff --git a/program/src/instructions/extensions/mod.rs b/program/src/instructions/extensions/mod.rs index c5125a5..a70376e 100644 --- a/program/src/instructions/extensions/mod.rs +++ b/program/src/instructions/extensions/mod.rs @@ -3,8 +3,10 @@ pub mod block_token_extension; pub mod remove_extension; pub mod set_arbiter; pub mod set_hook; +pub mod unblock_token_extension; pub use add_timelock::*; pub use block_token_extension::*; pub use remove_extension::*; pub use set_arbiter::*; pub use set_hook::*; +pub use unblock_token_extension::*; diff --git a/program/src/instructions/extensions/unblock_token_extension/accounts.rs b/program/src/instructions/extensions/unblock_token_extension/accounts.rs new file mode 100644 index 0000000..6ef344b --- /dev/null +++ b/program/src/instructions/extensions/unblock_token_extension/accounts.rs @@ -0,0 +1,62 @@ +use pinocchio::{account::AccountView, error::ProgramError}; + +use crate::{ + traits::InstructionAccounts, + utils::{ + verify_current_program, verify_current_program_account, verify_event_authority, verify_readonly, verify_signer, + verify_system_program, verify_writable, + }, +}; + +/// Accounts for the UnblockTokenExtension instruction +/// +/// # Account Layout +/// 0. `[signer, writable]` payer - Included for consistency with extension mutation flows +/// 1. `[signer]` admin - Must match escrow.admin +/// 2. `[]` escrow - Escrow account to unblock extension on +/// 3. `[writable]` extensions - Extensions PDA +/// 4. `[]` system_program - System program +/// 5. `[]` event_authority - Event authority PDA +/// 6. `[]` escrow_program - Current program +pub struct UnblockTokenExtensionAccounts<'a> { + pub payer: &'a AccountView, + pub admin: &'a AccountView, + pub escrow: &'a AccountView, + pub extensions: &'a AccountView, + pub system_program: &'a AccountView, + pub event_authority: &'a AccountView, + pub escrow_program: &'a AccountView, +} + +impl<'a> TryFrom<&'a [AccountView]> for UnblockTokenExtensionAccounts<'a> { + type Error = ProgramError; + + #[inline(always)] + fn try_from(accounts: &'a [AccountView]) -> Result { + let [payer, admin, escrow, extensions, system_program, event_authority, escrow_program] = accounts else { + return Err(ProgramError::NotEnoughAccountKeys); + }; + + // 1. Validate signers + verify_signer(payer, true)?; + verify_signer(admin, false)?; + + // 2. Validate writable + verify_writable(extensions, true)?; + + // 3. Validate readonly + verify_readonly(escrow)?; + + // 4. Validate program IDs + verify_system_program(system_program)?; + verify_current_program(escrow_program)?; + verify_event_authority(event_authority)?; + + // 5. Validate accounts owned by current program + verify_current_program_account(escrow)?; + + Ok(Self { payer, admin, escrow, extensions, system_program, event_authority, escrow_program }) + } +} + +impl<'a> InstructionAccounts<'a> for UnblockTokenExtensionAccounts<'a> {} diff --git a/program/src/instructions/extensions/unblock_token_extension/data.rs b/program/src/instructions/extensions/unblock_token_extension/data.rs new file mode 100644 index 0000000..e093757 --- /dev/null +++ b/program/src/instructions/extensions/unblock_token_extension/data.rs @@ -0,0 +1,53 @@ +use pinocchio::error::ProgramError; + +use crate::{require_len, traits::InstructionData}; + +/// Instruction data for UnblockTokenExtension +/// +/// # Layout +/// * `extensions_bump` (u8) - Bump for extensions PDA +/// * `blocked_extension` (u16) - Token-2022 ExtensionType value to unblock +pub struct UnblockTokenExtensionData { + pub extensions_bump: u8, + pub blocked_extension: u16, +} + +impl<'a> TryFrom<&'a [u8]> for UnblockTokenExtensionData { + type Error = ProgramError; + + #[inline(always)] + fn try_from(data: &'a [u8]) -> Result { + require_len!(data, Self::LEN); + + Ok(Self { extensions_bump: data[0], blocked_extension: u16::from_le_bytes([data[1], data[2]]) }) + } +} + +impl<'a> InstructionData<'a> for UnblockTokenExtensionData { + const LEN: usize = 1 + 2; +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_unblock_token_extension_data_try_from_valid() { + let mut data = [0u8; UnblockTokenExtensionData::LEN]; + data[0] = 255; // extensions_bump + data[1..3].copy_from_slice(&42u16.to_le_bytes()); // blocked_extension + + let result = UnblockTokenExtensionData::try_from(&data[..]); + assert!(result.is_ok()); + let parsed = result.unwrap(); + assert_eq!(parsed.extensions_bump, 255); + assert_eq!(parsed.blocked_extension, 42); + } + + #[test] + fn test_unblock_token_extension_data_try_from_empty() { + let data: [u8; 0] = []; + let result = UnblockTokenExtensionData::try_from(&data[..]); + assert!(matches!(result, Err(ProgramError::InvalidInstructionData))); + } +} diff --git a/program/src/instructions/extensions/unblock_token_extension/mod.rs b/program/src/instructions/extensions/unblock_token_extension/mod.rs new file mode 100644 index 0000000..e1a7a1c --- /dev/null +++ b/program/src/instructions/extensions/unblock_token_extension/mod.rs @@ -0,0 +1,8 @@ +mod accounts; +mod data; +mod processor; + +pub use crate::instructions::impl_instructions::UnblockTokenExtension; +pub use accounts::*; +pub use data::*; +pub use processor::*; diff --git a/program/src/instructions/extensions/unblock_token_extension/processor.rs b/program/src/instructions/extensions/unblock_token_extension/processor.rs new file mode 100644 index 0000000..722457d --- /dev/null +++ b/program/src/instructions/extensions/unblock_token_extension/processor.rs @@ -0,0 +1,68 @@ +use alloc::vec::Vec; +use pinocchio::{account::AccountView, cpi::Seed, error::ProgramError, Address, ProgramResult}; + +use crate::{ + errors::EscrowProgramError, + events::TokenExtensionUnblocked, + instructions::UnblockTokenExtension, + state::{remove_extension, update_extension, Escrow, ExtensionType, ExtensionsPda}, + traits::{EventSerialize, ExtensionData, PdaSeeds}, + utils::{emit_event, TlvReader}, +}; + +/// Processes the UnblockTokenExtension instruction. +/// +/// Removes a single blocked token extension value from the escrow's blocked list. +/// If the list becomes empty, removes the entire BlockedTokenExtensions TLV entry. +pub fn process_unblock_token_extension( + program_id: &Address, + accounts: &[AccountView], + instruction_data: &[u8], +) -> ProgramResult { + let ix = UnblockTokenExtension::try_from((instruction_data, accounts))?; + + // Read escrow and validate + let escrow_data = ix.accounts.escrow.try_borrow()?; + let escrow = Escrow::from_account(&escrow_data, ix.accounts.escrow, program_id)?; + escrow.validate_admin(ix.accounts.admin.address())?; + + // Validate extensions PDA + let extensions_pda = ExtensionsPda::new(ix.accounts.escrow.address()); + extensions_pda.validate_pda(ix.accounts.extensions, program_id, ix.data.extensions_bump)?; + + // Get seeds for PDA operations + let extensions_bump_seed = [ix.data.extensions_bump]; + let extensions_seeds: Vec = extensions_pda.seeds_with_bump(&extensions_bump_seed); + let extensions_seeds_array: [Seed; 3] = extensions_seeds.try_into().map_err(|_| ProgramError::InvalidArgument)?; + + // Read existing BlockedTokenExtensions data if present + let mut blocked_token_extensions = { + if ix.accounts.extensions.data_len() == 0 { + return Err(EscrowProgramError::TokenExtensionNotBlocked.into()); + } + + let data = ix.accounts.extensions.try_borrow()?; + let reader = TlvReader::new(&data); + reader.read_blocked_token_extensions().ok_or(EscrowProgramError::TokenExtensionNotBlocked)? + }; + + blocked_token_extensions.remove_extension(ix.data.blocked_extension)?; + + if blocked_token_extensions.count == 0 { + remove_extension(ix.accounts.extensions, ExtensionType::BlockedTokenExtensions)?; + } else { + update_extension( + ix.accounts.payer, + ix.accounts.extensions, + ExtensionType::BlockedTokenExtensions, + &blocked_token_extensions.to_bytes(), + extensions_seeds_array, + )?; + } + + // Emit event + let event = TokenExtensionUnblocked::new(*ix.accounts.escrow.address(), ix.data.blocked_extension); + emit_event(program_id, ix.accounts.event_authority, ix.accounts.escrow_program, &event.to_bytes())?; + + Ok(()) +} diff --git a/program/src/instructions/impl_instructions.rs b/program/src/instructions/impl_instructions.rs index a6ee8ef..ffd0295 100644 --- a/program/src/instructions/impl_instructions.rs +++ b/program/src/instructions/impl_instructions.rs @@ -10,6 +10,7 @@ use super::extensions::{ remove_extension::{RemoveExtensionAccounts, RemoveExtensionData}, set_arbiter::{SetArbiterAccounts, SetArbiterData}, set_hook::{SetHookAccounts, SetHookData}, + unblock_token_extension::{UnblockTokenExtensionAccounts, UnblockTokenExtensionData}, }; use super::update_admin::{UpdateAdminAccounts, UpdateAdminData}; use super::withdraw::{WithdrawAccounts, WithdrawData}; @@ -23,5 +24,6 @@ define_instruction!(BlockTokenExtension, BlockTokenExtensionAccounts, BlockToken define_instruction!(RemoveExtension, RemoveExtensionAccounts, RemoveExtensionData); define_instruction!(SetArbiter, SetArbiterAccounts, SetArbiterData); define_instruction!(SetHook, SetHookAccounts, SetHookData); +define_instruction!(UnblockTokenExtension, UnblockTokenExtensionAccounts, UnblockTokenExtensionData); define_instruction!(UpdateAdmin, UpdateAdminAccounts, UpdateAdminData); define_instruction!(Withdraw, WithdrawAccounts, WithdrawData); diff --git a/program/src/state/extensions/block_token_extension.rs b/program/src/state/extensions/block_token_extension.rs index c52cfc4..0c36606 100644 --- a/program/src/state/extensions/block_token_extension.rs +++ b/program/src/state/extensions/block_token_extension.rs @@ -58,6 +58,20 @@ impl BlockTokenExtensionsData { Ok(()) } + + /// Remove a single extension from the list. + /// + /// Returns an error if the extension does not exist. + pub fn remove_extension(&mut self, extension: u16) -> Result<(), ProgramError> { + let Some(index) = self.blocked_extensions.iter().position(|&ext| ext == extension) else { + return Err(EscrowProgramError::TokenExtensionNotBlocked.into()); + }; + + self.blocked_extensions.remove(index); + self.count = self.count.checked_sub(1).ok_or(ProgramError::InvalidAccountData)?; + + Ok(()) + } } impl ExtensionData for BlockTokenExtensionsData { @@ -90,6 +104,8 @@ impl ExtensionData for BlockTokenExtensionsData { #[cfg(test)] mod tests { + use alloc::vec; + use super::*; #[test] @@ -184,6 +200,31 @@ mod tests { assert_eq!(data.count, 1); } + #[test] + fn test_block_token_extensions_remove_extension() { + let mut data = BlockTokenExtensionsData::new(&[1u16, 2u16, 3u16]).unwrap(); + data.remove_extension(2u16).unwrap(); + assert_eq!(data.count, 2); + assert_eq!(data.blocked_extensions, vec![1u16, 3u16]); + } + + #[test] + fn test_block_token_extensions_remove_extension_last_item() { + let mut data = BlockTokenExtensionsData::new(&[1u16]).unwrap(); + data.remove_extension(1u16).unwrap(); + assert_eq!(data.count, 0); + assert!(data.blocked_extensions.is_empty()); + } + + #[test] + fn test_block_token_extensions_remove_extension_missing() { + let mut data = BlockTokenExtensionsData::new(&[1u16, 2u16]).unwrap(); + let result = data.remove_extension(3u16); + assert!(result.is_err()); + assert_eq!(data.count, 2); + assert_eq!(data.blocked_extensions, vec![1u16, 2u16]); + } + #[test] fn test_block_token_extensions_from_bytes_empty_fails() { let result = BlockTokenExtensionsData::from_bytes(&[]); diff --git a/program/src/traits/event.rs b/program/src/traits/event.rs index ee6e425..b97b0c7 100644 --- a/program/src/traits/event.rs +++ b/program/src/traits/event.rs @@ -19,6 +19,7 @@ pub enum EventDiscriminators { TokenExtensionBlocked = 8, ArbiterSet = 9, ExtensionRemoved = 10, + TokenExtensionUnblocked = 11, } /// Event discriminator with Anchor-compatible prefix diff --git a/program/src/traits/instruction.rs b/program/src/traits/instruction.rs index c7699d7..867efe1 100644 --- a/program/src/traits/instruction.rs +++ b/program/src/traits/instruction.rs @@ -14,6 +14,7 @@ pub enum EscrowInstructionDiscriminators { BlockTokenExtension = 8, SetArbiter = 9, RemoveExtension = 10, + UnblockTokenExtension = 11, EmitEvent = 228, } @@ -33,6 +34,7 @@ impl TryFrom for EscrowInstructionDiscriminators { 8 => Ok(Self::BlockTokenExtension), 9 => Ok(Self::SetArbiter), 10 => Ok(Self::RemoveExtension), + 11 => Ok(Self::UnblockTokenExtension), 228 => Ok(Self::EmitEvent), _ => Err(ProgramError::InvalidInstructionData), } @@ -156,8 +158,15 @@ mod tests { } #[test] - fn test_discriminator_try_from_invalid() { + fn test_discriminator_try_from_unblock_token_extension() { let result = EscrowInstructionDiscriminators::try_from(11u8); + assert!(result.is_ok()); + assert!(matches!(result.unwrap(), EscrowInstructionDiscriminators::UnblockTokenExtension)); + } + + #[test] + fn test_discriminator_try_from_invalid() { + let result = EscrowInstructionDiscriminators::try_from(12u8); assert!(matches!(result, Err(ProgramError::InvalidInstructionData))); let result = EscrowInstructionDiscriminators::try_from(255u8); diff --git a/tests/integration-tests/src/fixtures/mod.rs b/tests/integration-tests/src/fixtures/mod.rs index d81e6be..b43e3be 100644 --- a/tests/integration-tests/src/fixtures/mod.rs +++ b/tests/integration-tests/src/fixtures/mod.rs @@ -7,6 +7,7 @@ pub mod deposit; pub mod remove_extension; pub mod set_arbiter; pub mod set_hook; +pub mod unblock_token_extension; pub mod update_admin; pub mod withdraw; @@ -19,5 +20,6 @@ pub use deposit::{DepositFixture, DepositSetup, DEFAULT_DEPOSIT_AMOUNT}; pub use remove_extension::RemoveExtensionFixture; pub use set_arbiter::SetArbiterFixture; pub use set_hook::SetHookFixture; +pub use unblock_token_extension::UnblockTokenExtensionFixture; pub use update_admin::UpdateAdminFixture; pub use withdraw::{WithdrawFixture, WithdrawSetup}; diff --git a/tests/integration-tests/src/fixtures/unblock_token_extension.rs b/tests/integration-tests/src/fixtures/unblock_token_extension.rs new file mode 100644 index 0000000..649cdf1 --- /dev/null +++ b/tests/integration-tests/src/fixtures/unblock_token_extension.rs @@ -0,0 +1,77 @@ +use escrow_program_client::instructions::UnblockTokenExtensionBuilder; +use solana_sdk::{ + pubkey::Pubkey, + signature::{Keypair, Signer}, +}; + +use crate::{ + fixtures::{AddBlockTokenExtensionsFixture, CreateEscrowFixture}, + utils::{find_escrow_pda, find_extensions_pda, TestContext}, +}; + +use crate::utils::traits::{InstructionTestFixture, TestInstruction}; + +pub struct UnblockTokenExtensionFixture; + +impl UnblockTokenExtensionFixture { + pub fn build_with_escrow( + ctx: &mut TestContext, + escrow_pda: Pubkey, + admin: Keypair, + blocked_extension: u16, + ) -> TestInstruction { + let (extensions_pda, extensions_bump) = find_extensions_pda(&escrow_pda); + + let instruction = UnblockTokenExtensionBuilder::new() + .payer(ctx.payer.pubkey()) + .admin(admin.pubkey()) + .escrow(escrow_pda) + .extensions(extensions_pda) + .extensions_bump(extensions_bump) + .blocked_extension(blocked_extension) + .instruction(); + + TestInstruction { instruction, signers: vec![admin], name: Self::INSTRUCTION_NAME } + } +} + +impl InstructionTestFixture for UnblockTokenExtensionFixture { + const INSTRUCTION_NAME: &'static str = "UnblockTokenExtension"; + + fn build_valid(ctx: &mut TestContext) -> TestInstruction { + let escrow_ix = CreateEscrowFixture::build_valid(ctx); + let admin = escrow_ix.signers[0].insecure_clone(); + let escrow_seed = escrow_ix.signers[1].pubkey(); + escrow_ix.send_expect_success(ctx); + + let (escrow_pda, _) = find_escrow_pda(&escrow_seed); + AddBlockTokenExtensionsFixture::build_with_escrow(ctx, escrow_pda, admin.insecure_clone(), 1u16) + .send_expect_success(ctx); + + Self::build_with_escrow(ctx, escrow_pda, admin, 1u16) + } + + /// Account indices that must be signers: + /// 1: admin (payer at 0 is handled separately by TestContext) + fn required_signers() -> &'static [usize] { + &[1] + } + + /// Account indices that must be writable: + /// 3: extensions (payer at 0 is handled separately by TestContext) + fn required_writable() -> &'static [usize] { + &[3] + } + + fn system_program_index() -> Option { + Some(4) + } + + fn current_program_index() -> Option { + Some(6) + } + + fn data_len() -> usize { + 3 // extensions_bump (1) + blocked_extension (2) + } +} diff --git a/tests/integration-tests/src/lib.rs b/tests/integration-tests/src/lib.rs index 7b46daf..6e76ab7 100644 --- a/tests/integration-tests/src/lib.rs +++ b/tests/integration-tests/src/lib.rs @@ -20,6 +20,8 @@ mod test_set_arbiter; #[cfg(test)] mod test_set_hook; #[cfg(test)] +mod test_unblock_token_extension; +#[cfg(test)] mod test_update_admin; #[cfg(test)] mod test_withdraw; diff --git a/tests/integration-tests/src/test_deposit.rs b/tests/integration-tests/src/test_deposit.rs index 524afb9..62cbea7 100644 --- a/tests/integration-tests/src/test_deposit.rs +++ b/tests/integration-tests/src/test_deposit.rs @@ -1,5 +1,8 @@ use crate::{ - fixtures::{AddBlockTokenExtensionsFixture, DepositFixture, DepositSetup, DEFAULT_DEPOSIT_AMOUNT}, + fixtures::{ + AddBlockTokenExtensionsFixture, DepositFixture, DepositSetup, UnblockTokenExtensionFixture, + DEFAULT_DEPOSIT_AMOUNT, + }, utils::{ assert_custom_error, assert_escrow_error, assert_instruction_error, find_receipt_pda, test_empty_data, test_missing_signer, test_not_writable, test_wrong_account, test_wrong_current_program, test_wrong_owner, @@ -157,6 +160,30 @@ fn test_deposit_rejects_newly_blocked_mint_extension() { assert_escrow_error(error, EscrowError::MintNotAllowed); } +#[test] +fn test_deposit_allows_previously_unblocked_mint_extension() { + let mut ctx = TestContext::new(); + let setup = DepositSetup::builder(&mut ctx).mint_extension(ExtensionType::MetadataPointer).build(); + + AddBlockTokenExtensionsFixture::build_with_escrow( + &mut ctx, + setup.escrow_pda, + setup.admin.insecure_clone(), + ExtensionType::MetadataPointer as u16, + ) + .send_expect_success(&mut ctx); + + UnblockTokenExtensionFixture::build_with_escrow( + &mut ctx, + setup.escrow_pda, + setup.admin.insecure_clone(), + ExtensionType::MetadataPointer as u16, + ) + .send_expect_success(&mut ctx); + + setup.build_instruction(&ctx).send_expect_success(&mut ctx); +} + #[test] fn test_deposit_prefunded_receipt_pda_succeeds() { let mut ctx = TestContext::new(); diff --git a/tests/integration-tests/src/test_unblock_token_extension.rs b/tests/integration-tests/src/test_unblock_token_extension.rs new file mode 100644 index 0000000..d8e3f10 --- /dev/null +++ b/tests/integration-tests/src/test_unblock_token_extension.rs @@ -0,0 +1,216 @@ +use crate::{ + fixtures::{AddBlockTokenExtensionsFixture, AddTimelockFixture, CreateEscrowFixture, UnblockTokenExtensionFixture}, + utils::extensions_utils::EXTENSION_TYPE_BLOCK_TOKEN_EXTENSIONS, + utils::{ + assert_block_token_extensions_extension, assert_escrow_error, assert_extension_missing, + assert_extensions_header, assert_instruction_error, assert_timelock_extension, find_escrow_pda, + find_extensions_pda, test_empty_data, test_missing_signer, test_not_writable, test_truncated_data, + test_wrong_account, test_wrong_current_program, test_wrong_system_program, EscrowError, InstructionTestFixture, + TestContext, RANDOM_PUBKEY, + }, +}; +use solana_sdk::{instruction::InstructionError, signature::Signer}; + +// ============================================================================ +// Error Tests - Using Generic Test Helpers +// ============================================================================ + +#[test] +fn test_unblock_token_extension_missing_admin_signer() { + let mut ctx = TestContext::new(); + test_missing_signer::(&mut ctx, 1, 0); +} + +#[test] +fn test_unblock_token_extension_extensions_not_writable() { + let mut ctx = TestContext::new(); + test_not_writable::(&mut ctx, 3); +} + +#[test] +fn test_unblock_token_extension_wrong_system_program() { + let mut ctx = TestContext::new(); + test_wrong_system_program::(&mut ctx); +} + +#[test] +fn test_unblock_token_extension_wrong_escrow_program() { + let mut ctx = TestContext::new(); + test_wrong_current_program::(&mut ctx); +} + +#[test] +fn test_unblock_token_extension_invalid_event_authority() { + let mut ctx = TestContext::new(); + test_wrong_account::(&mut ctx, 5, InstructionError::Custom(2)); +} + +#[test] +fn test_unblock_token_extension_invalid_extensions_bump() { + let mut ctx = TestContext::new(); + let test_ix = UnblockTokenExtensionFixture::build_valid(&mut ctx); + let correct_bump = test_ix.instruction.data[1]; + let invalid_bump = correct_bump.wrapping_add(1); + let error = test_ix.with_data_byte_at(1, invalid_bump).send_expect_error(&mut ctx); + assert_instruction_error(error, InstructionError::InvalidSeeds); +} + +#[test] +fn test_unblock_token_extension_empty_data() { + let mut ctx = TestContext::new(); + test_empty_data::(&mut ctx); +} + +#[test] +fn test_unblock_token_extension_truncated_data() { + let mut ctx = TestContext::new(); + test_truncated_data::(&mut ctx); +} + +// ============================================================================ +// Custom Error Tests +// ============================================================================ + +#[test] +fn test_unblock_token_extension_wrong_admin() { + let mut ctx = TestContext::new(); + + let escrow_ix = CreateEscrowFixture::build_valid(&mut ctx); + let admin = escrow_ix.signers[0].insecure_clone(); + let escrow_seed = escrow_ix.signers[1].pubkey(); + escrow_ix.send_expect_success(&mut ctx); + + let (escrow_pda, _) = find_escrow_pda(&escrow_seed); + AddBlockTokenExtensionsFixture::build_with_escrow(&mut ctx, escrow_pda, admin, 1u16).send_expect_success(&mut ctx); + + let wrong_admin = ctx.create_funded_keypair(); + let test_ix = UnblockTokenExtensionFixture::build_with_escrow(&mut ctx, escrow_pda, wrong_admin, 1u16); + + let error = test_ix.send_expect_error(&mut ctx); + assert_instruction_error(error, InstructionError::Custom(1)); +} + +#[test] +fn test_unblock_token_extension_escrow_not_owned_by_program() { + let mut ctx = TestContext::new(); + let test_ix = UnblockTokenExtensionFixture::build_valid(&mut ctx); + + let error = test_ix.with_account_at(2, RANDOM_PUBKEY).send_expect_error(&mut ctx); + assert_instruction_error(error, InstructionError::InvalidAccountOwner); +} + +#[test] +fn test_unblock_token_extension_not_blocked_when_extensions_missing() { + let mut ctx = TestContext::new(); + + let escrow_ix = CreateEscrowFixture::build_valid(&mut ctx); + let admin = escrow_ix.signers[0].insecure_clone(); + let escrow_seed = escrow_ix.signers[1].pubkey(); + escrow_ix.send_expect_success(&mut ctx); + + let (escrow_pda, _) = find_escrow_pda(&escrow_seed); + let test_ix = UnblockTokenExtensionFixture::build_with_escrow(&mut ctx, escrow_pda, admin, 1u16); + + let error = test_ix.send_expect_error(&mut ctx); + assert_escrow_error(error, EscrowError::TokenExtensionNotBlocked); +} + +#[test] +fn test_unblock_token_extension_not_blocked_when_target_missing() { + let mut ctx = TestContext::new(); + + let escrow_ix = CreateEscrowFixture::build_valid(&mut ctx); + let admin = escrow_ix.signers[0].insecure_clone(); + let escrow_seed = escrow_ix.signers[1].pubkey(); + escrow_ix.send_expect_success(&mut ctx); + + let (escrow_pda, _) = find_escrow_pda(&escrow_seed); + AddBlockTokenExtensionsFixture::build_with_escrow(&mut ctx, escrow_pda, admin.insecure_clone(), 1u16) + .send_expect_success(&mut ctx); + + let test_ix = UnblockTokenExtensionFixture::build_with_escrow(&mut ctx, escrow_pda, admin, 2u16); + let error = test_ix.send_expect_error(&mut ctx); + assert_escrow_error(error, EscrowError::TokenExtensionNotBlocked); +} + +// ============================================================================ +// Success Tests +// ============================================================================ + +#[test] +fn test_unblock_token_extension_success_remove_single() { + let mut ctx = TestContext::new(); + + let escrow_ix = CreateEscrowFixture::build_valid(&mut ctx); + let admin = escrow_ix.signers[0].insecure_clone(); + let escrow_seed = escrow_ix.signers[1].pubkey(); + escrow_ix.send_expect_success(&mut ctx); + + let (escrow_pda, _) = find_escrow_pda(&escrow_seed); + let (extensions_pda, extensions_bump) = find_extensions_pda(&escrow_pda); + + AddBlockTokenExtensionsFixture::build_with_escrow(&mut ctx, escrow_pda, admin.insecure_clone(), 1u16) + .send_expect_success(&mut ctx); + assert_extensions_header(&ctx, &extensions_pda, extensions_bump, 1); + assert_block_token_extensions_extension(&ctx, &extensions_pda, &[1u16]); + + let unblock_ix = UnblockTokenExtensionFixture::build_with_escrow(&mut ctx, escrow_pda, admin, 1u16); + unblock_ix.send_expect_success(&mut ctx); + + assert_extensions_header(&ctx, &extensions_pda, extensions_bump, 0); + assert_extension_missing(&ctx, &extensions_pda, EXTENSION_TYPE_BLOCK_TOKEN_EXTENSIONS); +} + +#[test] +fn test_unblock_token_extension_success_remove_middle() { + let mut ctx = TestContext::new(); + + let escrow_ix = CreateEscrowFixture::build_valid(&mut ctx); + let admin = escrow_ix.signers[0].insecure_clone(); + let escrow_seed = escrow_ix.signers[1].pubkey(); + escrow_ix.send_expect_success(&mut ctx); + + let (escrow_pda, _) = find_escrow_pda(&escrow_seed); + let (extensions_pda, extensions_bump) = find_extensions_pda(&escrow_pda); + + AddBlockTokenExtensionsFixture::build_with_escrow(&mut ctx, escrow_pda, admin.insecure_clone(), 1u16) + .send_expect_success(&mut ctx); + AddBlockTokenExtensionsFixture::build_with_escrow(&mut ctx, escrow_pda, admin.insecure_clone(), 2u16) + .send_expect_success(&mut ctx); + AddBlockTokenExtensionsFixture::build_with_escrow(&mut ctx, escrow_pda, admin.insecure_clone(), 3u16) + .send_expect_success(&mut ctx); + assert_extensions_header(&ctx, &extensions_pda, extensions_bump, 1); + assert_block_token_extensions_extension(&ctx, &extensions_pda, &[1u16, 2u16, 3u16]); + + let unblock_ix = UnblockTokenExtensionFixture::build_with_escrow(&mut ctx, escrow_pda, admin, 2u16); + unblock_ix.send_expect_success(&mut ctx); + + assert_extensions_header(&ctx, &extensions_pda, extensions_bump, 1); + assert_block_token_extensions_extension(&ctx, &extensions_pda, &[1u16, 3u16]); +} + +#[test] +fn test_unblock_token_extension_success_remove_last_keeps_other_extensions() { + let mut ctx = TestContext::new(); + + let escrow_ix = CreateEscrowFixture::build_valid(&mut ctx); + let admin = escrow_ix.signers[0].insecure_clone(); + let escrow_seed = escrow_ix.signers[1].pubkey(); + escrow_ix.send_expect_success(&mut ctx); + + let (escrow_pda, _) = find_escrow_pda(&escrow_seed); + let (extensions_pda, extensions_bump) = find_extensions_pda(&escrow_pda); + + AddTimelockFixture::build_with_escrow(&mut ctx, escrow_pda, admin.insecure_clone(), 3600) + .send_expect_success(&mut ctx); + AddBlockTokenExtensionsFixture::build_with_escrow(&mut ctx, escrow_pda, admin.insecure_clone(), 16u16) + .send_expect_success(&mut ctx); + assert_extensions_header(&ctx, &extensions_pda, extensions_bump, 2); + + let unblock_ix = UnblockTokenExtensionFixture::build_with_escrow(&mut ctx, escrow_pda, admin, 16u16); + unblock_ix.send_expect_success(&mut ctx); + + assert_extensions_header(&ctx, &extensions_pda, extensions_bump, 1); + assert_extension_missing(&ctx, &extensions_pda, EXTENSION_TYPE_BLOCK_TOKEN_EXTENSIONS); + assert_timelock_extension(&ctx, &extensions_pda, 3600); +} From 4a79fd8ac711e8bb2ad6d11a9900510ca8ebe3fe Mon Sep 17 00:00:00 2001 From: jo <17280917+dev-jodee@users.noreply.github.com> Date: Mon, 23 Mar 2026 14:33:58 -0400 Subject: [PATCH 5/8] feat: add escrow immutability flow (#30) * feat: add escrow immutability flow * fix(program): align immutability and deposit behavior * fix(program): remove stale escrow immutability error path * test(integration): remove immutable setup from withdraw fixture * fix(tests): gate set_immutable module as test-only Restore #[cfg(test)] on the set_immutable integration test module to satisfy clippy in non-test targets.\n\nAlso keep rustfmt import/module ordering updates produced by formatting. * fix(program): enforce mutability for extension removal ops Require escrow mutability in RemoveExtension and UnblockTokenExtension processors to align with other admin config updates.\n\nAdd integration regressions to assert EscrowImmutable is returned after locking escrow. --------- Co-authored-by: Jo D --- apps/web/src/app/page.tsx | 4 + .../components/instructions/SetImmutable.tsx | 78 ++++++++++++ apps/web/src/lib/transactionErrors.ts | 2 + idl/escrow_program.json | 114 ++++++++++++++++++ program/src/entrypoint.rs | 4 +- program/src/errors.rs | 8 ++ program/src/events/mod.rs | 2 + program/src/events/set_immutable.rs | 63 ++++++++++ .../src/instructions/allow_mint/processor.rs | 1 + .../src/instructions/block_mint/processor.rs | 1 + .../instructions/create_escrow/processor.rs | 2 +- program/src/instructions/definition.rs | 16 ++- .../extensions/add_timelock/processor.rs | 1 + .../block_token_extension/processor.rs | 1 + .../extensions/remove_extension/processor.rs | 1 + .../extensions/set_arbiter/processor.rs | 1 + .../extensions/set_hook/processor.rs | 1 + .../unblock_token_extension/processor.rs | 1 + program/src/instructions/impl_instructions.rs | 2 + program/src/instructions/mod.rs | 2 + .../instructions/set_immutable/accounts.rs | 50 ++++++++ .../src/instructions/set_immutable/data.rs | 40 ++++++ program/src/instructions/set_immutable/mod.rs | 8 ++ .../instructions/set_immutable/processor.rs | 35 ++++++ .../instructions/update_admin/processor.rs | 4 +- program/src/state/escrow.rs | 50 ++++++-- program/src/traits/account.rs | 14 +-- program/src/traits/event.rs | 1 + program/src/traits/instruction.rs | 11 +- program/src/traits/pda.rs | 6 +- tests/integration-tests/src/fixtures/mod.rs | 2 + .../src/fixtures/set_immutable.rs | 63 ++++++++++ tests/integration-tests/src/lib.rs | 2 + .../src/test_add_timelock.rs | 28 ++++- .../integration-tests/src/test_allow_mint.rs | 16 ++- .../integration-tests/src/test_block_mint.rs | 16 ++- .../src/test_block_token_extension.rs | 28 ++++- .../src/test_create_escrow.rs | 7 +- tests/integration-tests/src/test_deposit.rs | 84 ++++++++++--- .../src/test_remove_extension.rs | 32 ++++- .../integration-tests/src/test_set_arbiter.rs | 28 ++++- tests/integration-tests/src/test_set_hook.rs | 28 ++++- .../src/test_set_immutable.rs | 100 +++++++++++++++ .../src/test_unblock_token_extension.rs | 26 +++- .../src/test_update_admin.rs | 27 ++++- tests/integration-tests/src/test_withdraw.rs | 55 +++------ .../integration-tests/src/utils/assertions.rs | 6 + tests/test-hook-program/src/lib.rs | 6 +- 48 files changed, 962 insertions(+), 116 deletions(-) create mode 100644 apps/web/src/components/instructions/SetImmutable.tsx create mode 100644 program/src/events/set_immutable.rs create mode 100644 program/src/instructions/set_immutable/accounts.rs create mode 100644 program/src/instructions/set_immutable/data.rs create mode 100644 program/src/instructions/set_immutable/mod.rs create mode 100644 program/src/instructions/set_immutable/processor.rs create mode 100644 tests/integration-tests/src/fixtures/set_immutable.rs create mode 100644 tests/integration-tests/src/test_set_immutable.rs diff --git a/apps/web/src/app/page.tsx b/apps/web/src/app/page.tsx index b2d72c9..bb17ef1 100644 --- a/apps/web/src/app/page.tsx +++ b/apps/web/src/app/page.tsx @@ -9,6 +9,7 @@ import { QuickDefaults } from '@/components/QuickDefaults'; import { RecentTransactions } from '@/components/RecentTransactions'; import { CreateEscrow } from '@/components/instructions/CreateEscrow'; import { UpdateAdmin } from '@/components/instructions/UpdateAdmin'; +import { SetImmutable } from '@/components/instructions/SetImmutable'; import { AllowMint } from '@/components/instructions/AllowMint'; import { BlockMint } from '@/components/instructions/BlockMint'; import { AddTimelock } from '@/components/instructions/AddTimelock'; @@ -23,6 +24,7 @@ import { Withdraw } from '@/components/instructions/Withdraw'; type InstructionId = | 'createEscrow' | 'updateAdmin' + | 'setImmutable' | 'allowMint' | 'blockMint' | 'addTimelock' @@ -43,6 +45,7 @@ const NAV: { items: [ { id: 'createEscrow', label: 'Create Escrow' }, { id: 'updateAdmin', label: 'Update Admin' }, + { id: 'setImmutable', label: 'Set Immutable' }, { id: 'allowMint', label: 'Allow Mint' }, { id: 'blockMint', label: 'Block Mint' }, ], @@ -70,6 +73,7 @@ const NAV: { const PANELS: Record = { createEscrow: { title: 'Create Escrow', component: CreateEscrow }, updateAdmin: { title: 'Update Admin', component: UpdateAdmin }, + setImmutable: { title: 'Set Immutable', component: SetImmutable }, allowMint: { title: 'Allow Mint', component: AllowMint }, blockMint: { title: 'Block Mint', component: BlockMint }, addTimelock: { title: 'Add Timelock', component: AddTimelock }, diff --git a/apps/web/src/components/instructions/SetImmutable.tsx b/apps/web/src/components/instructions/SetImmutable.tsx new file mode 100644 index 0000000..2229c9e --- /dev/null +++ b/apps/web/src/components/instructions/SetImmutable.tsx @@ -0,0 +1,78 @@ +'use client'; + +import { useState } from 'react'; +import type { Address } from '@solana/kit'; +import { Badge } from '@solana/design-system/badge'; +import { getSetImmutableInstruction } from '@solana/escrow-program-client'; +import { useSendTx } from '@/hooks/useSendTx'; +import { useSavedValues } from '@/contexts/SavedValuesContext'; +import { useWallet } from '@/contexts/WalletContext'; +import { useProgramContext } from '@/contexts/ProgramContext'; +import { TxResult } from '@/components/TxResult'; +import { firstValidationError, validateAddress } from '@/lib/validation'; +import { FormField, SendButton } from './shared'; + +export function SetImmutable() { + const { createSigner } = useWallet(); + const { send, sending, signature, error, reset } = useSendTx(); + const { defaultEscrow, rememberEscrow } = useSavedValues(); + const { programId } = useProgramContext(); + const [escrow, setEscrow] = useState(''); + const [formError, setFormError] = useState(null); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + reset(); + setFormError(null); + const signer = createSigner(); + if (!signer) return; + + const validationError = firstValidationError(validateAddress(escrow, 'Escrow address')); + if (validationError) { + setFormError(validationError); + return; + } + + const ix = getSetImmutableInstruction( + { + admin: signer, + escrow: escrow as Address, + }, + { programAddress: programId as Address }, + ); + + const txSignature = await send([ix], { + action: 'Set Immutable', + values: { escrow }, + }); + if (txSignature) { + rememberEscrow(escrow); + } + }; + + return ( +
{ + void handleSubmit(e); + }} + style={{ display: 'flex', flexDirection: 'column', gap: 16 }} + > +
+ + This action is one-way. Escrow configuration becomes permanently immutable. + +
+ + + + + ); +} diff --git a/apps/web/src/lib/transactionErrors.ts b/apps/web/src/lib/transactionErrors.ts index 3f4b595..0c416c2 100644 --- a/apps/web/src/lib/transactionErrors.ts +++ b/apps/web/src/lib/transactionErrors.ts @@ -1,6 +1,7 @@ 'use client'; import { + ESCROW_PROGRAM_ERROR__ESCROW_IMMUTABLE, ESCROW_PROGRAM_ERROR__HOOK_PROGRAM_MISMATCH, ESCROW_PROGRAM_ERROR__HOOK_REJECTED, ESCROW_PROGRAM_ERROR__INVALID_ADMIN, @@ -36,6 +37,7 @@ const ESCROW_PROGRAM_ERROR_MESSAGES: Record = { [ESCROW_PROGRAM_ERROR__TOKEN_EXTENSION_NOT_BLOCKED]: 'Token extension is not currently blocked', [ESCROW_PROGRAM_ERROR__ZERO_DEPOSIT_AMOUNT]: 'Zero deposit amount', [ESCROW_PROGRAM_ERROR__INVALID_ARBITER]: 'Arbiter signer is missing or does not match', + [ESCROW_PROGRAM_ERROR__ESCROW_IMMUTABLE]: 'Escrow is immutable and cannot be modified', }; const FALLBACK_TX_FAILED_MESSAGE = 'Transaction failed'; diff --git a/idl/escrow_program.json b/idl/escrow_program.json index 913ea37..c8a1f25 100644 --- a/idl/escrow_program.json +++ b/idl/escrow_program.json @@ -135,6 +135,18 @@ "type": { "kind": "publicKeyTypeNode" } + }, + { + "kind": "structFieldTypeNode", + "name": "isImmutable", + "type": { + "kind": "booleanTypeNode", + "size": { + "endian": "le", + "format": "u8", + "kind": "numberTypeNode" + } + } } ], "kind": "structTypeNode" @@ -601,6 +613,29 @@ "kind": "structTypeNode" } }, + { + "kind": "definedTypeNode", + "name": "setImmutableEvent", + "type": { + "fields": [ + { + "kind": "structFieldTypeNode", + "name": "escrow", + "type": { + "kind": "publicKeyTypeNode" + } + }, + { + "kind": "structFieldTypeNode", + "name": "admin", + "type": { + "kind": "publicKeyTypeNode" + } + } + ], + "kind": "structTypeNode" + } + }, { "kind": "definedTypeNode", "name": "withdrawEvent", @@ -744,6 +779,12 @@ "kind": "errorNode", "message": "Token extension is not currently blocked", "name": "tokenExtensionNotBlocked" + }, + { + "code": 16, + "kind": "errorNode", + "message": "Escrow is immutable and cannot be modified", + "name": "escrowImmutable" } ], "instructions": [ @@ -2775,6 +2816,79 @@ ], "kind": "instructionNode", "name": "unblockTokenExtension" + }, + { + "accounts": [ + { + "docs": [ + "Admin authority for the escrow" + ], + "isSigner": true, + "isWritable": false, + "kind": "instructionAccountNode", + "name": "admin" + }, + { + "docs": [ + "Escrow account to lock as immutable" + ], + "isSigner": false, + "isWritable": true, + "kind": "instructionAccountNode", + "name": "escrow" + }, + { + "defaultValue": { + "kind": "publicKeyValueNode", + "publicKey": "Eq63FWYo9DXgwoTnpK9gjp7BH4PyhSPo11zEF9FK7f4M" + }, + "docs": [ + "Event authority PDA for CPI event emission" + ], + "isSigner": false, + "isWritable": false, + "kind": "instructionAccountNode", + "name": "eventAuthority" + }, + { + "defaultValue": { + "kind": "publicKeyValueNode", + "publicKey": "Escrowae7RaUfNn4oEZHywMXE5zWzYCXenwrCDaEoifg" + }, + "docs": [ + "Escrow program for CPI event emission" + ], + "isSigner": false, + "isWritable": false, + "kind": "instructionAccountNode", + "name": "escrowProgram" + } + ], + "arguments": [ + { + "defaultValue": { + "kind": "numberValueNode", + "number": 12 + }, + "defaultValueStrategy": "omitted", + "kind": "instructionArgumentNode", + "name": "discriminator", + "type": { + "endian": "le", + "format": "u8", + "kind": "numberTypeNode" + } + } + ], + "discriminators": [ + { + "kind": "fieldDiscriminatorNode", + "name": "discriminator", + "offset": 0 + } + ], + "kind": "instructionNode", + "name": "setImmutable" } ], "kind": "programNode", diff --git a/program/src/entrypoint.rs b/program/src/entrypoint.rs index 644c2a8..c563901 100644 --- a/program/src/entrypoint.rs +++ b/program/src/entrypoint.rs @@ -4,7 +4,8 @@ use crate::{ instructions::{ process_add_timelock, process_allow_mint, process_block_mint, process_block_token_extension, process_create_escrow, process_deposit, process_emit_event, process_remove_extension, process_set_arbiter, - process_set_hook, process_unblock_token_extension, process_update_admin, process_withdraw, + process_set_hook, process_set_immutable, process_unblock_token_extension, process_update_admin, + process_withdraw, }, traits::EscrowInstructionDiscriminators, }; @@ -36,6 +37,7 @@ pub fn process_instruction(program_id: &Address, accounts: &[AccountView], instr process_unblock_token_extension(program_id, accounts, instruction_data) } EscrowInstructionDiscriminators::SetArbiter => process_set_arbiter(program_id, accounts, instruction_data), + EscrowInstructionDiscriminators::SetImmutable => process_set_immutable(program_id, accounts, instruction_data), EscrowInstructionDiscriminators::EmitEvent => process_emit_event(program_id, accounts), } } diff --git a/program/src/errors.rs b/program/src/errors.rs index d5919fa..df582e6 100644 --- a/program/src/errors.rs +++ b/program/src/errors.rs @@ -68,6 +68,10 @@ pub enum EscrowProgramError { /// (15) Token extension is not currently blocked #[error("Token extension is not currently blocked")] TokenExtensionNotBlocked, + + /// (16) Escrow is immutable and cannot be modified + #[error("Escrow is immutable and cannot be modified")] + EscrowImmutable, } impl From for ProgramError { @@ -99,5 +103,9 @@ mod tests { let error: ProgramError = EscrowProgramError::InvalidWithdrawer.into(); assert_eq!(error, ProgramError::Custom(5)); + + let error: ProgramError = EscrowProgramError::EscrowImmutable.into(); + assert_eq!(error, ProgramError::Custom(16)); + assert_eq!(error, ProgramError::Custom(16)); } } diff --git a/program/src/events/mod.rs b/program/src/events/mod.rs index 4d45a7e..dc71f8e 100644 --- a/program/src/events/mod.rs +++ b/program/src/events/mod.rs @@ -4,6 +4,7 @@ pub mod block_mint; pub mod create_escrow; pub mod deposit; pub mod extensions; +pub mod set_immutable; pub mod shared; pub mod withdraw; @@ -13,5 +14,6 @@ pub use block_mint::*; pub use create_escrow::*; pub use deposit::*; pub use extensions::*; +pub use set_immutable::*; pub use shared::*; pub use withdraw::*; diff --git a/program/src/events/set_immutable.rs b/program/src/events/set_immutable.rs new file mode 100644 index 0000000..58f4a71 --- /dev/null +++ b/program/src/events/set_immutable.rs @@ -0,0 +1,63 @@ +use alloc::vec::Vec; +use codama::CodamaType; +use pinocchio::Address; + +use crate::traits::{EventDiscriminator, EventDiscriminators, EventSerialize}; + +#[derive(CodamaType)] +pub struct SetImmutableEvent { + pub escrow: Address, + pub admin: Address, +} + +impl EventDiscriminator for SetImmutableEvent { + const DISCRIMINATOR: u8 = EventDiscriminators::SetImmutable as u8; +} + +impl EventSerialize for SetImmutableEvent { + #[inline(always)] + fn to_bytes_inner(&self) -> Vec { + let mut data = Vec::with_capacity(Self::DATA_LEN); + data.extend_from_slice(self.escrow.as_ref()); + data.extend_from_slice(self.admin.as_ref()); + data + } +} + +impl SetImmutableEvent { + pub const DATA_LEN: usize = 32 + 32; // escrow + admin + + #[inline(always)] + pub fn new(escrow: Address, admin: Address) -> Self { + Self { escrow, admin } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::events::EVENT_IX_TAG_LE; + use crate::traits::EVENT_DISCRIMINATOR_LEN; + + #[test] + fn test_set_immutable_event_new() { + let escrow = Address::new_from_array([1u8; 32]); + let admin = Address::new_from_array([2u8; 32]); + let event = SetImmutableEvent::new(escrow, admin); + + assert_eq!(event.escrow, escrow); + assert_eq!(event.admin, admin); + } + + #[test] + fn test_set_immutable_event_to_bytes() { + let escrow = Address::new_from_array([1u8; 32]); + let admin = Address::new_from_array([2u8; 32]); + let event = SetImmutableEvent::new(escrow, admin); + + let bytes = event.to_bytes(); + assert_eq!(bytes.len(), EVENT_DISCRIMINATOR_LEN + SetImmutableEvent::DATA_LEN); + assert_eq!(&bytes[..8], EVENT_IX_TAG_LE); + assert_eq!(bytes[8], EventDiscriminators::SetImmutable as u8); + } +} diff --git a/program/src/instructions/allow_mint/processor.rs b/program/src/instructions/allow_mint/processor.rs index db8ec87..0afa69f 100644 --- a/program/src/instructions/allow_mint/processor.rs +++ b/program/src/instructions/allow_mint/processor.rs @@ -20,6 +20,7 @@ pub fn process_allow_mint(program_id: &Address, accounts: &[AccountView], instru let escrow_data = ix.accounts.escrow.try_borrow()?; let escrow = Escrow::from_account(&escrow_data, ix.accounts.escrow, program_id)?; escrow.validate_admin(ix.accounts.admin.address())?; + escrow.require_mutable()?; // Validate AllowedMint PDA using external seeds let pda_seeds = AllowedMintPda::new(ix.accounts.escrow.address(), ix.accounts.mint.address()); diff --git a/program/src/instructions/block_mint/processor.rs b/program/src/instructions/block_mint/processor.rs index e1b12ff..70dea1f 100644 --- a/program/src/instructions/block_mint/processor.rs +++ b/program/src/instructions/block_mint/processor.rs @@ -18,6 +18,7 @@ pub fn process_block_mint(program_id: &Address, accounts: &[AccountView], instru let escrow_data = ix.accounts.escrow.try_borrow()?; let escrow = Escrow::from_account(&escrow_data, ix.accounts.escrow, program_id)?; escrow.validate_admin(ix.accounts.admin.address())?; + escrow.require_mutable()?; // Verify allowed_mint account exists and self-validates against escrow + mint PDA derivation let allowed_mint_data = ix.accounts.allowed_mint.try_borrow()?; diff --git a/program/src/instructions/create_escrow/processor.rs b/program/src/instructions/create_escrow/processor.rs index 9db2ed1..c4f84b0 100644 --- a/program/src/instructions/create_escrow/processor.rs +++ b/program/src/instructions/create_escrow/processor.rs @@ -16,7 +16,7 @@ pub fn process_create_escrow(program_id: &Address, accounts: &[AccountView], ins let ix = CreateEscrow::try_from((instruction_data, accounts))?; // Create Escrow state - let escrow = Escrow::new(ix.data.bump, *ix.accounts.escrow_seed.address(), *ix.accounts.admin.address()); + let escrow = Escrow::new(ix.data.bump, *ix.accounts.escrow_seed.address(), *ix.accounts.admin.address(), false); // Validate Escrow PDA escrow.validate_pda(ix.accounts.escrow, program_id, ix.data.bump)?; diff --git a/program/src/instructions/definition.rs b/program/src/instructions/definition.rs index 92c1bcc..d9552f0 100644 --- a/program/src/instructions/definition.rs +++ b/program/src/instructions/definition.rs @@ -303,7 +303,6 @@ pub enum EscrowProgramInstruction { } = 8, /// Set an arbiter on an escrow. The arbiter must sign withdrawal transactions. - /// This is immutable — once set, the arbiter cannot be changed. #[codama(account(name = "payer", signer, writable))] #[codama(account(name = "admin", signer))] #[codama(account(name = "arbiter", signer))] @@ -386,6 +385,21 @@ pub enum EscrowProgramInstruction { blocked_extension: u16, } = 11, + /// Lock an escrow so configuration can no longer be modified. + #[codama(account(name = "admin", docs = "Admin authority for the escrow", signer))] + #[codama(account(name = "escrow", docs = "Escrow account to lock as immutable", writable))] + #[codama(account( + name = "event_authority", + docs = "Event authority PDA for CPI event emission", + default_value = public_key("Eq63FWYo9DXgwoTnpK9gjp7BH4PyhSPo11zEF9FK7f4M") + ))] + #[codama(account( + name = "escrow_program", + docs = "Escrow program for CPI event emission", + default_value = public_key("Escrowae7RaUfNn4oEZHywMXE5zWzYCXenwrCDaEoifg") + ))] + SetImmutable {} = 12, + /// Invoked via CPI to emit event data in instruction args (prevents log truncation). #[codama(skip)] #[codama(account( diff --git a/program/src/instructions/extensions/add_timelock/processor.rs b/program/src/instructions/extensions/add_timelock/processor.rs index 3ebd0ee..2e9c777 100644 --- a/program/src/instructions/extensions/add_timelock/processor.rs +++ b/program/src/instructions/extensions/add_timelock/processor.rs @@ -19,6 +19,7 @@ pub fn process_add_timelock(program_id: &Address, accounts: &[AccountView], inst let escrow_data = ix.accounts.escrow.try_borrow()?; let escrow = Escrow::from_account(&escrow_data, ix.accounts.escrow, program_id)?; escrow.validate_admin(ix.accounts.admin.address())?; + escrow.require_mutable()?; // Validate extensions PDA let extensions_pda = ExtensionsPda::new(ix.accounts.escrow.address()); diff --git a/program/src/instructions/extensions/block_token_extension/processor.rs b/program/src/instructions/extensions/block_token_extension/processor.rs index ac23a6a..a710cf1 100644 --- a/program/src/instructions/extensions/block_token_extension/processor.rs +++ b/program/src/instructions/extensions/block_token_extension/processor.rs @@ -23,6 +23,7 @@ pub fn process_block_token_extension( let escrow_data = ix.accounts.escrow.try_borrow()?; let escrow = Escrow::from_account(&escrow_data, ix.accounts.escrow, program_id)?; escrow.validate_admin(ix.accounts.admin.address())?; + escrow.require_mutable()?; // Validate extensions PDA let extensions_pda = ExtensionsPda::new(ix.accounts.escrow.address()); diff --git a/program/src/instructions/extensions/remove_extension/processor.rs b/program/src/instructions/extensions/remove_extension/processor.rs index 5e70f9d..b42d489 100644 --- a/program/src/instructions/extensions/remove_extension/processor.rs +++ b/program/src/instructions/extensions/remove_extension/processor.rs @@ -22,6 +22,7 @@ pub fn process_remove_extension( let escrow_data = ix.accounts.escrow.try_borrow()?; let escrow = Escrow::from_account(&escrow_data, ix.accounts.escrow, program_id)?; escrow.validate_admin(ix.accounts.admin.address())?; + escrow.require_mutable()?; // Validate extensions PDA let extensions_pda = ExtensionsPda::new(ix.accounts.escrow.address()); diff --git a/program/src/instructions/extensions/set_arbiter/processor.rs b/program/src/instructions/extensions/set_arbiter/processor.rs index bc60322..0679fcc 100644 --- a/program/src/instructions/extensions/set_arbiter/processor.rs +++ b/program/src/instructions/extensions/set_arbiter/processor.rs @@ -19,6 +19,7 @@ pub fn process_set_arbiter(program_id: &Address, accounts: &[AccountView], instr let escrow_data = ix.accounts.escrow.try_borrow()?; let escrow = Escrow::from_account(&escrow_data, ix.accounts.escrow, program_id)?; escrow.validate_admin(ix.accounts.admin.address())?; + escrow.require_mutable()?; // Validate extensions PDA let extensions_pda = ExtensionsPda::new(ix.accounts.escrow.address()); diff --git a/program/src/instructions/extensions/set_hook/processor.rs b/program/src/instructions/extensions/set_hook/processor.rs index 8529194..6d5db29 100644 --- a/program/src/instructions/extensions/set_hook/processor.rs +++ b/program/src/instructions/extensions/set_hook/processor.rs @@ -19,6 +19,7 @@ pub fn process_set_hook(program_id: &Address, accounts: &[AccountView], instruct let escrow_data = ix.accounts.escrow.try_borrow()?; let escrow = Escrow::from_account(&escrow_data, ix.accounts.escrow, program_id)?; escrow.validate_admin(ix.accounts.admin.address())?; + escrow.require_mutable()?; // Validate extensions PDA let extensions_pda = ExtensionsPda::new(ix.accounts.escrow.address()); diff --git a/program/src/instructions/extensions/unblock_token_extension/processor.rs b/program/src/instructions/extensions/unblock_token_extension/processor.rs index 722457d..b13b0aa 100644 --- a/program/src/instructions/extensions/unblock_token_extension/processor.rs +++ b/program/src/instructions/extensions/unblock_token_extension/processor.rs @@ -25,6 +25,7 @@ pub fn process_unblock_token_extension( let escrow_data = ix.accounts.escrow.try_borrow()?; let escrow = Escrow::from_account(&escrow_data, ix.accounts.escrow, program_id)?; escrow.validate_admin(ix.accounts.admin.address())?; + escrow.require_mutable()?; // Validate extensions PDA let extensions_pda = ExtensionsPda::new(ix.accounts.escrow.address()); diff --git a/program/src/instructions/impl_instructions.rs b/program/src/instructions/impl_instructions.rs index ffd0295..4457ec1 100644 --- a/program/src/instructions/impl_instructions.rs +++ b/program/src/instructions/impl_instructions.rs @@ -12,6 +12,7 @@ use super::extensions::{ set_hook::{SetHookAccounts, SetHookData}, unblock_token_extension::{UnblockTokenExtensionAccounts, UnblockTokenExtensionData}, }; +use super::set_immutable::{SetImmutableAccounts, SetImmutableData}; use super::update_admin::{UpdateAdminAccounts, UpdateAdminData}; use super::withdraw::{WithdrawAccounts, WithdrawData}; @@ -25,5 +26,6 @@ define_instruction!(RemoveExtension, RemoveExtensionAccounts, RemoveExtensionDat define_instruction!(SetArbiter, SetArbiterAccounts, SetArbiterData); define_instruction!(SetHook, SetHookAccounts, SetHookData); define_instruction!(UnblockTokenExtension, UnblockTokenExtensionAccounts, UnblockTokenExtensionData); +define_instruction!(SetImmutable, SetImmutableAccounts, SetImmutableData); define_instruction!(UpdateAdmin, UpdateAdminAccounts, UpdateAdminData); define_instruction!(Withdraw, WithdrawAccounts, WithdrawData); diff --git a/program/src/instructions/mod.rs b/program/src/instructions/mod.rs index 83cdcb2..84157df 100644 --- a/program/src/instructions/mod.rs +++ b/program/src/instructions/mod.rs @@ -6,6 +6,7 @@ pub mod deposit; pub mod emit_event; pub mod extensions; pub mod impl_instructions; +pub mod set_immutable; pub mod update_admin; pub mod withdraw; @@ -18,5 +19,6 @@ pub use deposit::*; pub use emit_event::*; pub use extensions::*; pub use impl_instructions::*; +pub use set_immutable::*; pub use update_admin::*; pub use withdraw::*; diff --git a/program/src/instructions/set_immutable/accounts.rs b/program/src/instructions/set_immutable/accounts.rs new file mode 100644 index 0000000..48e1827 --- /dev/null +++ b/program/src/instructions/set_immutable/accounts.rs @@ -0,0 +1,50 @@ +use pinocchio::{account::AccountView, error::ProgramError}; + +use crate::{ + traits::InstructionAccounts, + utils::{ + verify_current_program, verify_current_program_account, verify_event_authority, verify_signer, verify_writable, + }, +}; + +/// Accounts for the SetImmutable instruction +/// +/// # Account Layout +/// 0. `[signer]` admin - Current admin, must match escrow.admin +/// 1. `[writable]` escrow - Escrow account to lock as immutable +/// 2. `[]` event_authority - Event authority PDA +/// 3. `[]` escrow_program - Current program +pub struct SetImmutableAccounts<'a> { + pub admin: &'a AccountView, + pub escrow: &'a AccountView, + pub event_authority: &'a AccountView, + pub escrow_program: &'a AccountView, +} + +impl<'a> TryFrom<&'a [AccountView]> for SetImmutableAccounts<'a> { + type Error = ProgramError; + + #[inline(always)] + fn try_from(accounts: &'a [AccountView]) -> Result { + let [admin, escrow, event_authority, escrow_program] = accounts else { + return Err(ProgramError::NotEnoughAccountKeys); + }; + + // 1. Validate signers + verify_signer(admin, false)?; + + // 2. Validate writable + verify_writable(escrow, true)?; + + // 3. Validate program IDs + verify_current_program(escrow_program)?; + verify_event_authority(event_authority)?; + + // 4. Validate accounts owned by current program + verify_current_program_account(escrow)?; + + Ok(Self { admin, escrow, event_authority, escrow_program }) + } +} + +impl<'a> InstructionAccounts<'a> for SetImmutableAccounts<'a> {} diff --git a/program/src/instructions/set_immutable/data.rs b/program/src/instructions/set_immutable/data.rs new file mode 100644 index 0000000..fb2d896 --- /dev/null +++ b/program/src/instructions/set_immutable/data.rs @@ -0,0 +1,40 @@ +use pinocchio::error::ProgramError; + +use crate::traits::InstructionData; + +/// Instruction data for SetImmutable +/// +/// No additional data is required. +pub struct SetImmutableData; + +impl<'a> TryFrom<&'a [u8]> for SetImmutableData { + type Error = ProgramError; + + #[inline(always)] + fn try_from(_data: &'a [u8]) -> Result { + Ok(Self) + } +} + +impl<'a> InstructionData<'a> for SetImmutableData { + const LEN: usize = 0; +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_set_immutable_data_try_from_empty() { + let data: [u8; 0] = []; + let result = SetImmutableData::try_from(&data[..]); + assert!(result.is_ok()); + } + + #[test] + fn test_set_immutable_data_try_from_with_extra_bytes() { + let data = [1u8, 2, 3]; + let result = SetImmutableData::try_from(&data[..]); + assert!(result.is_ok()); + } +} diff --git a/program/src/instructions/set_immutable/mod.rs b/program/src/instructions/set_immutable/mod.rs new file mode 100644 index 0000000..6769860 --- /dev/null +++ b/program/src/instructions/set_immutable/mod.rs @@ -0,0 +1,8 @@ +mod accounts; +mod data; +mod processor; + +pub use crate::instructions::impl_instructions::SetImmutable; +pub use accounts::*; +pub use data::*; +pub use processor::*; diff --git a/program/src/instructions/set_immutable/processor.rs b/program/src/instructions/set_immutable/processor.rs new file mode 100644 index 0000000..5bb5fc3 --- /dev/null +++ b/program/src/instructions/set_immutable/processor.rs @@ -0,0 +1,35 @@ +use pinocchio::{account::AccountView, Address, ProgramResult}; + +use crate::{ + events::SetImmutableEvent, + instructions::SetImmutable, + state::Escrow, + traits::{AccountSerialize, EventSerialize}, + utils::emit_event, +}; + +/// Processes the SetImmutable instruction. +/// +/// Locks an escrow configuration so it can no longer be modified. +pub fn process_set_immutable(program_id: &Address, accounts: &[AccountView], instruction_data: &[u8]) -> ProgramResult { + let ix = SetImmutable::try_from((instruction_data, accounts))?; + + // Read and validate escrow + let updated_escrow = { + let escrow_data = ix.accounts.escrow.try_borrow()?; + let escrow = Escrow::from_account(&escrow_data, ix.accounts.escrow, program_id)?; + escrow.validate_admin(ix.accounts.admin.address())?; + escrow.require_mutable()?; + escrow.set_immutable() + }; + + // Write updated escrow. + let mut escrow_data = ix.accounts.escrow.try_borrow_mut()?; + updated_escrow.write_to_slice(&mut escrow_data)?; + + // Emit event + let event = SetImmutableEvent::new(*ix.accounts.escrow.address(), *ix.accounts.admin.address()); + emit_event(program_id, ix.accounts.event_authority, ix.accounts.escrow_program, &event.to_bytes())?; + + Ok(()) +} diff --git a/program/src/instructions/update_admin/processor.rs b/program/src/instructions/update_admin/processor.rs index 4682ab9..30236c0 100644 --- a/program/src/instructions/update_admin/processor.rs +++ b/program/src/instructions/update_admin/processor.rs @@ -18,10 +18,12 @@ pub fn process_update_admin(program_id: &Address, accounts: &[AccountView], inst let escrow_data = ix.accounts.escrow.try_borrow()?; let escrow = Escrow::from_account(&escrow_data, ix.accounts.escrow, program_id)?; escrow.validate_admin(ix.accounts.admin.address())?; + escrow.require_mutable()?; // Copy values we need for the update let old_admin = escrow.admin; - let updated_escrow = Escrow::new(escrow.bump, escrow.escrow_seed, *ix.accounts.new_admin.address()); + let updated_escrow = + Escrow::new(escrow.bump, escrow.escrow_seed, *ix.accounts.new_admin.address(), escrow.is_immutable); drop(escrow_data); // Write updated escrow diff --git a/program/src/state/escrow.rs b/program/src/state/escrow.rs index 3899e48..a2a99af 100644 --- a/program/src/state/escrow.rs +++ b/program/src/state/escrow.rs @@ -29,9 +29,10 @@ pub struct Escrow { pub bump: u8, pub escrow_seed: Address, pub admin: Address, + pub is_immutable: bool, } -assert_no_padding!(Escrow, 1 + 32 + 32); +assert_no_padding!(Escrow, 1 + 32 + 32 + 1); impl Discriminator for Escrow { const DISCRIMINATOR: u8 = EscrowAccountDiscriminators::EscrowDiscriminator as u8; @@ -42,7 +43,7 @@ impl Versioned for Escrow { } impl AccountSize for Escrow { - const DATA_LEN: usize = 1 + 32 + 32; // bump + escrow_seed + admin + const DATA_LEN: usize = 1 + 32 + 32 + 1; // bump + escrow_seed + admin + is_immutable } impl AccountDeserialize for Escrow {} @@ -54,6 +55,7 @@ impl AccountSerialize for Escrow { data.push(self.bump); data.extend_from_slice(self.escrow_seed.as_ref()); data.extend_from_slice(self.admin.as_ref()); + data.push(self.is_immutable as u8); data } } @@ -81,8 +83,8 @@ impl PdaAccount for Escrow { impl Escrow { #[inline(always)] - pub fn new(bump: u8, escrow_seed: Address, admin: Address) -> Self { - Self { bump, escrow_seed, admin } + pub fn new(bump: u8, escrow_seed: Address, admin: Address, is_immutable: bool) -> Self { + Self { bump, escrow_seed, admin, is_immutable } } #[inline(always)] @@ -104,6 +106,19 @@ impl Escrow { Ok(()) } + #[inline(always)] + pub fn require_mutable(&self) -> Result<(), ProgramError> { + if self.is_immutable { + return Err(EscrowProgramError::EscrowImmutable.into()); + } + Ok(()) + } + + #[inline(always)] + pub fn set_immutable(&self) -> Self { + Self::new(self.bump, self.escrow_seed, self.admin, true) + } + /// Execute a CPI with this escrow PDA as signer #[inline(always)] pub fn with_signer(&self, f: F) -> R @@ -124,7 +139,7 @@ mod tests { fn create_test_escrow() -> Escrow { let escrow_seed = Address::new_from_array([1u8; 32]); let admin = Address::new_from_array([2u8; 32]); - Escrow::new(255, escrow_seed, admin) + Escrow::new(255, escrow_seed, admin, false) } #[test] @@ -132,11 +147,12 @@ mod tests { let escrow_seed = Address::new_from_array([1u8; 32]); let admin = Address::new_from_array([2u8; 32]); - let escrow = Escrow::new(200, escrow_seed, admin); + let escrow = Escrow::new(200, escrow_seed, admin, false); assert_eq!(escrow.bump, 200); assert_eq!(escrow.escrow_seed, escrow_seed); assert_eq!(escrow.admin, admin); + assert!(!escrow.is_immutable); } #[test] @@ -165,6 +181,7 @@ mod tests { assert_eq!(bytes[0], 255); // bump assert_eq!(&bytes[1..33], &[1u8; 32]); // escrow_seed assert_eq!(&bytes[33..65], &[2u8; 32]); // admin + assert_eq!(bytes[65], 0); // is_immutable } #[test] @@ -176,6 +193,7 @@ mod tests { assert_eq!(bytes[0], Escrow::DISCRIMINATOR); assert_eq!(bytes[1], Escrow::VERSION); // version auto-prepended assert_eq!(bytes[2], 255); // bump + assert_eq!(bytes[67], 0); // is_immutable } #[test] @@ -188,6 +206,7 @@ mod tests { assert_eq!(deserialized.bump, escrow.bump); assert_eq!(deserialized.escrow_seed, escrow.escrow_seed); assert_eq!(deserialized.admin, escrow.admin); + assert_eq!(deserialized.is_immutable, escrow.is_immutable); } #[test] @@ -199,7 +218,7 @@ mod tests { #[test] fn test_escrow_from_bytes_wrong_discriminator() { - let mut bytes = [0u8; 67]; + let mut bytes = [0u8; 68]; bytes[0] = 99; // wrong discriminator let result = Escrow::from_bytes(&bytes); assert_eq!(result, Err(ProgramError::InvalidAccountData)); @@ -235,6 +254,23 @@ mod tests { assert_eq!(dest[2], escrow.bump); } + #[test] + fn test_set_immutable_sets_flag() { + let escrow = create_test_escrow(); + let immutable = escrow.set_immutable(); + assert!(immutable.is_immutable); + assert_eq!(immutable.bump, escrow.bump); + assert_eq!(immutable.admin, escrow.admin); + assert_eq!(immutable.escrow_seed, escrow.escrow_seed); + } + + #[test] + fn test_require_mutable_fails_when_immutable() { + let escrow = Escrow::new(1, Address::new_from_array([1u8; 32]), Address::new_from_array([2u8; 32]), true); + let result = escrow.require_mutable(); + assert_eq!(result, Err(EscrowProgramError::EscrowImmutable.into())); + } + #[test] fn test_escrow_write_to_slice_too_small() { let escrow = create_test_escrow(); diff --git a/program/src/traits/account.rs b/program/src/traits/account.rs index d80caa0..873b553 100644 --- a/program/src/traits/account.rs +++ b/program/src/traits/account.rs @@ -136,7 +136,7 @@ mod tests { fn test_from_bytes_mut_modifies_original() { let escrow_seed = Address::new_from_array([1u8; 32]); let admin = Address::new_from_array([2u8; 32]); - let escrow = Escrow::new(100, escrow_seed, admin); + let escrow = Escrow::new(100, escrow_seed, admin, false); let mut bytes = escrow.to_bytes(); { @@ -152,7 +152,7 @@ mod tests { fn test_from_bytes_unchecked_skips_discriminator_and_version() { let escrow_seed = Address::new_from_array([1u8; 32]); let admin = Address::new_from_array([2u8; 32]); - let escrow = Escrow::new(100, escrow_seed, admin); + let escrow = Escrow::new(100, escrow_seed, admin, false); let bytes = escrow.to_bytes(); // Skip discriminator (byte 0) and version (byte 1) @@ -174,7 +174,7 @@ mod tests { fn test_to_bytes_roundtrip() { let escrow_seed = Address::new_from_array([42u8; 32]); let admin = Address::new_from_array([99u8; 32]); - let escrow = Escrow::new(128, escrow_seed, admin); + let escrow = Escrow::new(128, escrow_seed, admin, false); let bytes = escrow.to_bytes(); let deserialized = Escrow::from_bytes(&bytes).unwrap(); @@ -188,7 +188,7 @@ mod tests { fn test_from_bytes_wrong_version() { let escrow_seed = Address::new_from_array([1u8; 32]); let admin = Address::new_from_array([2u8; 32]); - let escrow = Escrow::new(100, escrow_seed, admin); + let escrow = Escrow::new(100, escrow_seed, admin, false); let mut bytes = escrow.to_bytes(); bytes[1] = Escrow::VERSION.wrapping_add(1); @@ -200,7 +200,7 @@ mod tests { fn test_from_bytes_mut_wrong_version() { let escrow_seed = Address::new_from_array([1u8; 32]); let admin = Address::new_from_array([2u8; 32]); - let escrow = Escrow::new(100, escrow_seed, admin); + let escrow = Escrow::new(100, escrow_seed, admin, false); let mut bytes = escrow.to_bytes(); bytes[1] = Escrow::VERSION.wrapping_add(1); @@ -212,7 +212,7 @@ mod tests { fn test_write_to_slice_exact_size() { let escrow_seed = Address::new_from_array([1u8; 32]); let admin = Address::new_from_array([2u8; 32]); - let escrow = Escrow::new(100, escrow_seed, admin); + let escrow = Escrow::new(100, escrow_seed, admin, false); let mut dest = vec![0u8; Escrow::LEN]; assert!(escrow.write_to_slice(&mut dest).is_ok()); @@ -225,7 +225,7 @@ mod tests { fn test_version_auto_serialized() { let escrow_seed = Address::new_from_array([1u8; 32]); let admin = Address::new_from_array([2u8; 32]); - let escrow = Escrow::new(100, escrow_seed, admin); + let escrow = Escrow::new(100, escrow_seed, admin, false); let bytes = escrow.to_bytes(); diff --git a/program/src/traits/event.rs b/program/src/traits/event.rs index b97b0c7..2b40b41 100644 --- a/program/src/traits/event.rs +++ b/program/src/traits/event.rs @@ -20,6 +20,7 @@ pub enum EventDiscriminators { ArbiterSet = 9, ExtensionRemoved = 10, TokenExtensionUnblocked = 11, + SetImmutable = 12, } /// Event discriminator with Anchor-compatible prefix diff --git a/program/src/traits/instruction.rs b/program/src/traits/instruction.rs index 867efe1..1700147 100644 --- a/program/src/traits/instruction.rs +++ b/program/src/traits/instruction.rs @@ -15,6 +15,7 @@ pub enum EscrowInstructionDiscriminators { SetArbiter = 9, RemoveExtension = 10, UnblockTokenExtension = 11, + SetImmutable = 12, EmitEvent = 228, } @@ -35,6 +36,7 @@ impl TryFrom for EscrowInstructionDiscriminators { 9 => Ok(Self::SetArbiter), 10 => Ok(Self::RemoveExtension), 11 => Ok(Self::UnblockTokenExtension), + 12 => Ok(Self::SetImmutable), 228 => Ok(Self::EmitEvent), _ => Err(ProgramError::InvalidInstructionData), } @@ -165,8 +167,15 @@ mod tests { } #[test] - fn test_discriminator_try_from_invalid() { + fn test_discriminator_try_from_set_immutable() { let result = EscrowInstructionDiscriminators::try_from(12u8); + assert!(result.is_ok()); + assert!(matches!(result.unwrap(), EscrowInstructionDiscriminators::SetImmutable)); + } + + #[test] + fn test_discriminator_try_from_invalid() { + let result = EscrowInstructionDiscriminators::try_from(13u8); assert!(matches!(result, Err(ProgramError::InvalidInstructionData))); let result = EscrowInstructionDiscriminators::try_from(255u8); diff --git a/program/src/traits/pda.rs b/program/src/traits/pda.rs index 05b4114..174f127 100644 --- a/program/src/traits/pda.rs +++ b/program/src/traits/pda.rs @@ -92,7 +92,7 @@ mod tests { fn test_derive_address_deterministic() { let escrow_seed = Address::new_from_array([1u8; 32]); let admin = Address::new_from_array([2u8; 32]); - let escrow = Escrow::new(0, escrow_seed, admin); + let escrow = Escrow::new(0, escrow_seed, admin, false); let (address1, bump1) = escrow.derive_address(&ID); let (address2, bump2) = escrow.derive_address(&ID); @@ -105,8 +105,8 @@ mod tests { fn test_derive_address_different_seeds() { let admin = Address::new_from_array([2u8; 32]); - let escrow1 = Escrow::new(0, Address::new_from_array([1u8; 32]), admin); - let escrow2 = Escrow::new(0, Address::new_from_array([3u8; 32]), admin); + let escrow1 = Escrow::new(0, Address::new_from_array([1u8; 32]), admin, false); + let escrow2 = Escrow::new(0, Address::new_from_array([3u8; 32]), admin, false); let (address1, _) = escrow1.derive_address(&ID); let (address2, _) = escrow2.derive_address(&ID); diff --git a/tests/integration-tests/src/fixtures/mod.rs b/tests/integration-tests/src/fixtures/mod.rs index b43e3be..78d3832 100644 --- a/tests/integration-tests/src/fixtures/mod.rs +++ b/tests/integration-tests/src/fixtures/mod.rs @@ -7,6 +7,7 @@ pub mod deposit; pub mod remove_extension; pub mod set_arbiter; pub mod set_hook; +pub mod set_immutable; pub mod unblock_token_extension; pub mod update_admin; pub mod withdraw; @@ -20,6 +21,7 @@ pub use deposit::{DepositFixture, DepositSetup, DEFAULT_DEPOSIT_AMOUNT}; pub use remove_extension::RemoveExtensionFixture; pub use set_arbiter::SetArbiterFixture; pub use set_hook::SetHookFixture; +pub use set_immutable::SetImmutableFixture; pub use unblock_token_extension::UnblockTokenExtensionFixture; pub use update_admin::UpdateAdminFixture; pub use withdraw::{WithdrawFixture, WithdrawSetup}; diff --git a/tests/integration-tests/src/fixtures/set_immutable.rs b/tests/integration-tests/src/fixtures/set_immutable.rs new file mode 100644 index 0000000..cd207fa --- /dev/null +++ b/tests/integration-tests/src/fixtures/set_immutable.rs @@ -0,0 +1,63 @@ +use escrow_program_client::instructions::SetImmutableBuilder; +use solana_sdk::{ + pubkey::Pubkey, + signature::{Keypair, Signer}, +}; + +use crate::{ + fixtures::CreateEscrowFixture, + utils::{find_escrow_pda, TestContext}, +}; + +use crate::utils::traits::{InstructionTestFixture, TestInstruction}; + +pub struct SetImmutableFixture; + +impl SetImmutableFixture { + pub fn build_with_escrow(_ctx: &mut TestContext, escrow_pda: Pubkey, admin: Keypair) -> TestInstruction { + let instruction = SetImmutableBuilder::new().admin(admin.pubkey()).escrow(escrow_pda).instruction(); + + TestInstruction { instruction, signers: vec![admin], name: Self::INSTRUCTION_NAME } + } +} + +impl InstructionTestFixture for SetImmutableFixture { + const INSTRUCTION_NAME: &'static str = "SetImmutable"; + + fn build_valid(ctx: &mut TestContext) -> TestInstruction { + let escrow_ix = CreateEscrowFixture::build_valid(ctx); + let admin = escrow_ix.signers[0].insecure_clone(); + let escrow_seed = escrow_ix.signers[1].pubkey(); + escrow_ix.send_expect_success(ctx); + + let (escrow_pda, _) = find_escrow_pda(&escrow_seed); + + let instruction = SetImmutableBuilder::new().admin(admin.pubkey()).escrow(escrow_pda).instruction(); + + TestInstruction { instruction, signers: vec![admin], name: Self::INSTRUCTION_NAME } + } + + /// Account indices that must be signers: + /// 0: admin + fn required_signers() -> &'static [usize] { + &[0] + } + + /// Account indices that must be writable: + /// 1: escrow + fn required_writable() -> &'static [usize] { + &[1] + } + + fn system_program_index() -> Option { + None + } + + fn current_program_index() -> Option { + Some(3) + } + + fn data_len() -> usize { + 1 // Just the discriminator + } +} diff --git a/tests/integration-tests/src/lib.rs b/tests/integration-tests/src/lib.rs index 6e76ab7..264b12b 100644 --- a/tests/integration-tests/src/lib.rs +++ b/tests/integration-tests/src/lib.rs @@ -20,6 +20,8 @@ mod test_set_arbiter; #[cfg(test)] mod test_set_hook; #[cfg(test)] +mod test_set_immutable; +#[cfg(test)] mod test_unblock_token_extension; #[cfg(test)] mod test_update_admin; diff --git a/tests/integration-tests/src/test_add_timelock.rs b/tests/integration-tests/src/test_add_timelock.rs index d61ec95..df558f9 100644 --- a/tests/integration-tests/src/test_add_timelock.rs +++ b/tests/integration-tests/src/test_add_timelock.rs @@ -1,10 +1,10 @@ use crate::{ - fixtures::{AddTimelockFixture, CreateEscrowFixture}, + fixtures::{AddTimelockFixture, CreateEscrowFixture, SetImmutableFixture}, utils::{ - assert_extensions_header, assert_instruction_error, assert_timelock_extension, find_escrow_pda, - find_extensions_pda, test_empty_data, test_missing_signer, test_not_writable, test_truncated_data, - test_wrong_account, test_wrong_current_program, test_wrong_system_program, InstructionTestFixture, TestContext, - RANDOM_PUBKEY, + assert_escrow_error, assert_extensions_header, assert_instruction_error, assert_timelock_extension, + find_escrow_pda, find_extensions_pda, test_empty_data, test_missing_signer, test_not_writable, + test_truncated_data, test_wrong_account, test_wrong_current_program, test_wrong_system_program, EscrowError, + InstructionTestFixture, TestContext, RANDOM_PUBKEY, }, }; use solana_sdk::{instruction::InstructionError, signature::Signer}; @@ -94,6 +94,24 @@ fn test_add_timelock_escrow_not_owned_by_program() { assert_instruction_error(error, InstructionError::InvalidAccountOwner); } +#[test] +fn test_add_timelock_fails_when_escrow_is_immutable() { + let mut ctx = TestContext::new(); + + let escrow_ix = CreateEscrowFixture::build_valid(&mut ctx); + let admin = escrow_ix.signers[0].insecure_clone(); + let escrow_seed = escrow_ix.signers[1].pubkey(); + escrow_ix.send_expect_success(&mut ctx); + + let (escrow_pda, _) = find_escrow_pda(&escrow_seed); + let set_immutable_ix = SetImmutableFixture::build_with_escrow(&mut ctx, escrow_pda, admin.insecure_clone()); + set_immutable_ix.send_expect_success(&mut ctx); + + let add_timelock_ix = AddTimelockFixture::build_with_escrow(&mut ctx, escrow_pda, admin, 3600); + let error = add_timelock_ix.send_expect_error(&mut ctx); + assert_escrow_error(error, EscrowError::EscrowImmutable); +} + #[test] fn test_add_timelock_updates_existing_extension() { let mut ctx = TestContext::new(); diff --git a/tests/integration-tests/src/test_allow_mint.rs b/tests/integration-tests/src/test_allow_mint.rs index d1c1719..173c236 100644 --- a/tests/integration-tests/src/test_allow_mint.rs +++ b/tests/integration-tests/src/test_allow_mint.rs @@ -6,7 +6,7 @@ use crate::{ test_wrong_system_program, EscrowError, InstructionTestFixture, TestContext, RANDOM_PUBKEY, }, }; -use escrow_program_client::instructions::AllowMintBuilder; +use escrow_program_client::instructions::{AllowMintBuilder, SetImmutableBuilder}; use solana_sdk::{account::Account, instruction::InstructionError, pubkey::Pubkey, signature::Signer}; use spl_associated_token_account::get_associated_token_address; use spl_token_2022::extension::ExtensionType; @@ -131,6 +131,20 @@ fn test_allow_mint_duplicate() { assert!(matches!(error, solana_sdk::transaction::TransactionError::AlreadyProcessed)); } +#[test] +fn test_allow_mint_fails_when_escrow_is_immutable() { + let mut ctx = TestContext::new(); + let setup = AllowMintSetup::new(&mut ctx); + + let set_immutable_ix = + SetImmutableBuilder::new().admin(setup.admin.pubkey()).escrow(setup.escrow_pda).instruction(); + ctx.send_transaction(set_immutable_ix, &[&setup.admin]).unwrap(); + + let test_ix = setup.build_instruction(&ctx); + let error = test_ix.send_expect_error(&mut ctx); + assert_escrow_error(error, EscrowError::EscrowImmutable); +} + // ============================================================================ // Happy Path Test // ============================================================================ diff --git a/tests/integration-tests/src/test_block_mint.rs b/tests/integration-tests/src/test_block_mint.rs index 0503a7c..53e780f 100644 --- a/tests/integration-tests/src/test_block_mint.rs +++ b/tests/integration-tests/src/test_block_mint.rs @@ -6,7 +6,7 @@ use crate::{ InstructionTestFixture, TestContext, TestInstruction, RANDOM_PUBKEY, }, }; -use escrow_program_client::instructions::{AllowMintBuilder, BlockMintBuilder}; +use escrow_program_client::instructions::{AllowMintBuilder, BlockMintBuilder, SetImmutableBuilder}; use solana_sdk::{instruction::InstructionError, signature::Signer}; use spl_associated_token_account::get_associated_token_address; @@ -61,6 +61,20 @@ fn test_block_mint_wrong_admin() { assert_escrow_error(error, EscrowError::InvalidAdmin); } +#[test] +fn test_block_mint_fails_when_escrow_is_immutable() { + let mut ctx = TestContext::new(); + let setup = BlockMintSetup::new(&mut ctx); + + let set_immutable_ix = + SetImmutableBuilder::new().admin(setup.admin.pubkey()).escrow(setup.escrow_pda).instruction(); + ctx.send_transaction(set_immutable_ix, &[&setup.admin]).unwrap(); + + let test_ix = setup.build_instruction(&ctx); + let error = test_ix.send_expect_error(&mut ctx); + assert_escrow_error(error, EscrowError::EscrowImmutable); +} + #[test] fn test_block_mint_wrong_escrow() { let mut ctx = TestContext::new(); diff --git a/tests/integration-tests/src/test_block_token_extension.rs b/tests/integration-tests/src/test_block_token_extension.rs index f857585..06bd970 100644 --- a/tests/integration-tests/src/test_block_token_extension.rs +++ b/tests/integration-tests/src/test_block_token_extension.rs @@ -1,10 +1,10 @@ use crate::{ - fixtures::{AddBlockTokenExtensionsFixture, CreateEscrowFixture}, + fixtures::{AddBlockTokenExtensionsFixture, CreateEscrowFixture, SetImmutableFixture}, utils::{ - assert_block_token_extensions_extension, assert_extensions_header, assert_instruction_error, find_escrow_pda, - find_extensions_pda, test_empty_data, test_missing_signer, test_not_writable, test_truncated_data, - test_wrong_account, test_wrong_current_program, test_wrong_system_program, InstructionTestFixture, TestContext, - RANDOM_PUBKEY, + assert_block_token_extensions_extension, assert_escrow_error, assert_extensions_header, + assert_instruction_error, find_escrow_pda, find_extensions_pda, test_empty_data, test_missing_signer, + test_not_writable, test_truncated_data, test_wrong_account, test_wrong_current_program, + test_wrong_system_program, EscrowError, InstructionTestFixture, TestContext, RANDOM_PUBKEY, }, }; use solana_sdk::{instruction::InstructionError, signature::Signer}; @@ -87,6 +87,24 @@ fn test_block_token_extension_escrow_not_owned_by_program() { assert_instruction_error(error, InstructionError::InvalidAccountOwner); } +#[test] +fn test_block_token_extension_fails_when_escrow_is_immutable() { + let mut ctx = TestContext::new(); + + let escrow_ix = CreateEscrowFixture::build_valid(&mut ctx); + let admin = escrow_ix.signers[0].insecure_clone(); + let escrow_seed = escrow_ix.signers[1].pubkey(); + escrow_ix.send_expect_success(&mut ctx); + + let (escrow_pda, _) = find_escrow_pda(&escrow_seed); + let set_immutable_ix = SetImmutableFixture::build_with_escrow(&mut ctx, escrow_pda, admin.insecure_clone()); + set_immutable_ix.send_expect_success(&mut ctx); + + let block_ext_ix = AddBlockTokenExtensionsFixture::build_with_escrow(&mut ctx, escrow_pda, admin, 1u16); + let error = block_ext_ix.send_expect_error(&mut ctx); + assert_escrow_error(error, EscrowError::EscrowImmutable); +} + #[test] fn test_block_token_extension_duplicate_extension() { let mut ctx = TestContext::new(); diff --git a/tests/integration-tests/src/test_create_escrow.rs b/tests/integration-tests/src/test_create_escrow.rs index 5a1afd9..5d312ce 100644 --- a/tests/integration-tests/src/test_create_escrow.rs +++ b/tests/integration-tests/src/test_create_escrow.rs @@ -1,8 +1,9 @@ use crate::{ fixtures::CreateEscrowFixture, utils::{ - assert_escrow_account, assert_instruction_error, test_empty_data, test_missing_signer, test_not_writable, - test_wrong_account, test_wrong_current_program, test_wrong_system_program, InstructionTestFixture, TestContext, + assert_escrow_account, assert_escrow_mutability, assert_instruction_error, test_empty_data, + test_missing_signer, test_not_writable, test_wrong_account, test_wrong_current_program, + test_wrong_system_program, InstructionTestFixture, TestContext, }, }; use escrow_program_client::instructions::CreatesEscrowBuilder; @@ -92,6 +93,7 @@ fn test_create_escrow_success() { test_ix.send_expect_success(&mut ctx); assert_escrow_account(&ctx, &escrow_pda, &admin_pubkey, bump, &escrow_seed_pubkey); + assert_escrow_mutability(&ctx, &escrow_pda, false); } #[test] @@ -115,6 +117,7 @@ fn test_create_escrow_prefunded_pda_succeeds() { test_ix.send_expect_success(&mut ctx); assert_escrow_account(&ctx, &escrow_pda, &admin_pubkey, bump, &escrow_seed_pubkey); + assert_escrow_mutability(&ctx, &escrow_pda, false); } // ============================================================================ diff --git a/tests/integration-tests/src/test_deposit.rs b/tests/integration-tests/src/test_deposit.rs index 62cbea7..f8b9571 100644 --- a/tests/integration-tests/src/test_deposit.rs +++ b/tests/integration-tests/src/test_deposit.rs @@ -1,6 +1,6 @@ use crate::{ fixtures::{ - AddBlockTokenExtensionsFixture, DepositFixture, DepositSetup, UnblockTokenExtensionFixture, + AddBlockTokenExtensionsFixture, AllowMintSetup, DepositFixture, DepositSetup, UnblockTokenExtensionFixture, DEFAULT_DEPOSIT_AMOUNT, }, utils::{ @@ -10,13 +10,12 @@ use crate::{ TEST_HOOK_ALLOW_ID, TEST_HOOK_DENY_ERROR, TEST_HOOK_DENY_ID, }, }; -use escrow_program_client::instructions::AddTimelockBuilder; use escrow_program_client::instructions::DepositBuilder; use solana_sdk::{ account::Account, instruction::{AccountMeta, InstructionError}, pubkey::Pubkey, - signature::Signer, + signature::{Keypair, Signer}, }; use spl_token_2022::extension::ExtensionType; use spl_token_2022::ID as TOKEN_2022_PROGRAM_ID; @@ -118,20 +117,51 @@ fn test_deposit_wrong_allowed_mint_owner() { } #[test] -fn test_deposit_initialized_extensions_wrong_owner() { +fn test_deposit_succeeds_when_escrow_is_mutable() { let mut ctx = TestContext::new(); - let setup = DepositSetup::new(&mut ctx); + let setup = AllowMintSetup::new(&mut ctx); + setup.build_instruction(&ctx).send_expect_success(&mut ctx); + + let depositor = ctx.create_funded_keypair(); + let depositor_token_account = + ctx.create_token_account_with_balance(&depositor.pubkey(), &setup.mint_pubkey, DEFAULT_DEPOSIT_AMOUNT * 10); + let initial_depositor_balance = ctx.get_token_balance(&depositor_token_account); + let initial_vault_balance = ctx.get_token_balance(&setup.vault); + let receipt_seed = Keypair::new(); + let (receipt_pda, bump) = + find_receipt_pda(&setup.escrow_pda, &depositor.pubkey(), &setup.mint_pubkey, &receipt_seed.pubkey()); - let (extensions_pda, extensions_bump) = crate::utils::find_extensions_pda(&setup.escrow_pda); - let add_timelock_ix = AddTimelockBuilder::new() + let instruction = DepositBuilder::new() .payer(ctx.payer.pubkey()) - .admin(setup.admin.pubkey()) + .depositor(depositor.pubkey()) .escrow(setup.escrow_pda) - .extensions(extensions_pda) - .extensions_bump(extensions_bump) - .lock_duration(1) + .allowed_mint(setup.allowed_mint_pda) + .receipt_seed(receipt_seed.pubkey()) + .receipt(receipt_pda) + .vault(setup.vault) + .depositor_token_account(depositor_token_account) + .mint(setup.mint_pubkey) + .token_program(setup.token_program) + .extensions(setup.escrow_extensions_pda) + .bump(bump) + .amount(DEFAULT_DEPOSIT_AMOUNT) .instruction(); - ctx.send_transaction(add_timelock_ix, &[&setup.admin]).unwrap(); + + ctx.send_transaction(instruction, &[&depositor, &receipt_seed]).unwrap(); + + let final_depositor_balance = ctx.get_token_balance(&depositor_token_account); + let final_vault_balance = ctx.get_token_balance(&setup.vault); + assert_eq!(final_depositor_balance, initial_depositor_balance - DEFAULT_DEPOSIT_AMOUNT); + assert_eq!(final_vault_balance, initial_vault_balance + DEFAULT_DEPOSIT_AMOUNT); + + let receipt_account = ctx.get_account(&receipt_pda).expect("Deposit receipt should exist"); + assert!(!receipt_account.data.is_empty()); +} + +#[test] +fn test_deposit_initialized_extensions_wrong_owner() { + let mut ctx = TestContext::new(); + let setup = DepositSetup::new_with_hook(&mut ctx, TEST_HOOK_ALLOW_ID); let mut extensions_account = ctx.get_account(&setup.extensions_pda).expect("Extensions account should exist"); extensions_account.owner = Pubkey::new_unique(); @@ -145,7 +175,9 @@ fn test_deposit_initialized_extensions_wrong_owner() { #[test] fn test_deposit_rejects_newly_blocked_mint_extension() { let mut ctx = TestContext::new(); - let setup = DepositSetup::builder(&mut ctx).mint_extension(ExtensionType::MetadataPointer).build(); + let setup = AllowMintSetup::builder(&mut ctx).mint_extension(ExtensionType::MetadataPointer).build(); + + setup.build_instruction(&ctx).send_expect_success(&mut ctx); let block_extension_ix = AddBlockTokenExtensionsFixture::build_with_escrow( &mut ctx, @@ -155,8 +187,30 @@ fn test_deposit_rejects_newly_blocked_mint_extension() { ); block_extension_ix.send_expect_success(&mut ctx); - let test_ix = setup.build_instruction(&ctx); - let error = test_ix.send_expect_error(&mut ctx); + let depositor = ctx.create_funded_keypair(); + let depositor_token_account = + ctx.create_token_2022_account_with_balance(&depositor.pubkey(), &setup.mint_pubkey, DEFAULT_DEPOSIT_AMOUNT); + let receipt_seed = Keypair::new(); + let (receipt_pda, bump) = + find_receipt_pda(&setup.escrow_pda, &depositor.pubkey(), &setup.mint_pubkey, &receipt_seed.pubkey()); + + let instruction = DepositBuilder::new() + .payer(ctx.payer.pubkey()) + .depositor(depositor.pubkey()) + .escrow(setup.escrow_pda) + .allowed_mint(setup.allowed_mint_pda) + .receipt_seed(receipt_seed.pubkey()) + .receipt(receipt_pda) + .vault(setup.vault) + .depositor_token_account(depositor_token_account) + .mint(setup.mint_pubkey) + .token_program(setup.token_program) + .extensions(setup.escrow_extensions_pda) + .bump(bump) + .amount(DEFAULT_DEPOSIT_AMOUNT) + .instruction(); + + let error = ctx.send_transaction_expect_error(instruction, &[&depositor, &receipt_seed]); assert_escrow_error(error, EscrowError::MintNotAllowed); } diff --git a/tests/integration-tests/src/test_remove_extension.rs b/tests/integration-tests/src/test_remove_extension.rs index 0d073b4..c1be510 100644 --- a/tests/integration-tests/src/test_remove_extension.rs +++ b/tests/integration-tests/src/test_remove_extension.rs @@ -1,16 +1,17 @@ use crate::{ fixtures::{ AddBlockTokenExtensionsFixture, AddTimelockFixture, CreateEscrowFixture, RemoveExtensionFixture, - SetArbiterFixture, SetHookFixture, + SetArbiterFixture, SetHookFixture, SetImmutableFixture, }, utils::extensions_utils::{ EXTENSION_TYPE_ARBITER, EXTENSION_TYPE_BLOCK_TOKEN_EXTENSIONS, EXTENSION_TYPE_HOOK, EXTENSION_TYPE_TIMELOCK, }, utils::{ - assert_arbiter_extension, assert_block_token_extensions_extension, assert_extension_missing, - assert_extensions_header, assert_instruction_error, find_escrow_pda, find_extensions_pda, test_empty_data, - test_missing_signer, test_not_writable, test_truncated_data, test_wrong_account, test_wrong_current_program, - test_wrong_system_program, InstructionTestFixture, TestContext, RANDOM_PUBKEY, + assert_arbiter_extension, assert_block_token_extensions_extension, assert_escrow_error, + assert_extension_missing, assert_extensions_header, assert_instruction_error, find_escrow_pda, + find_extensions_pda, test_empty_data, test_missing_signer, test_not_writable, test_truncated_data, + test_wrong_account, test_wrong_current_program, test_wrong_system_program, EscrowError, InstructionTestFixture, + TestContext, RANDOM_PUBKEY, }, }; use solana_sdk::{instruction::InstructionError, pubkey::Pubkey, signature::Signer}; @@ -103,6 +104,27 @@ fn test_remove_extension_escrow_not_owned_by_program() { assert_instruction_error(error, InstructionError::InvalidAccountOwner); } +#[test] +fn test_remove_extension_fails_when_escrow_is_immutable() { + let mut ctx = TestContext::new(); + + let escrow_ix = CreateEscrowFixture::build_valid(&mut ctx); + let admin = escrow_ix.signers[0].insecure_clone(); + let escrow_seed = escrow_ix.signers[1].pubkey(); + escrow_ix.send_expect_success(&mut ctx); + + let (escrow_pda, _) = find_escrow_pda(&escrow_seed); + SetHookFixture::build_with_escrow(&mut ctx, escrow_pda, admin.insecure_clone(), Pubkey::new_unique()) + .send_expect_success(&mut ctx); + + let set_immutable_ix = SetImmutableFixture::build_with_escrow(&mut ctx, escrow_pda, admin.insecure_clone()); + set_immutable_ix.send_expect_success(&mut ctx); + + let remove_ix = RemoveExtensionFixture::build_with_escrow(&mut ctx, escrow_pda, admin, EXTENSION_TYPE_HOOK); + let error = remove_ix.send_expect_error(&mut ctx); + assert_escrow_error(error, EscrowError::EscrowImmutable); +} + #[test] fn test_remove_extension_invalid_type() { let mut ctx = TestContext::new(); diff --git a/tests/integration-tests/src/test_set_arbiter.rs b/tests/integration-tests/src/test_set_arbiter.rs index aad3cf2..deb03b7 100644 --- a/tests/integration-tests/src/test_set_arbiter.rs +++ b/tests/integration-tests/src/test_set_arbiter.rs @@ -1,10 +1,10 @@ use crate::{ - fixtures::{AddTimelockFixture, CreateEscrowFixture, SetArbiterFixture, SetHookFixture}, + fixtures::{AddTimelockFixture, CreateEscrowFixture, SetArbiterFixture, SetHookFixture, SetImmutableFixture}, utils::{ - assert_arbiter_extension, assert_extensions_header, assert_hook_extension, assert_instruction_error, - assert_timelock_extension, find_escrow_pda, find_extensions_pda, test_empty_data, test_missing_signer, - test_not_writable, test_truncated_data, test_wrong_account, test_wrong_current_program, - test_wrong_system_program, InstructionTestFixture, TestContext, RANDOM_PUBKEY, + assert_arbiter_extension, assert_escrow_error, assert_extensions_header, assert_hook_extension, + assert_instruction_error, assert_timelock_extension, find_escrow_pda, find_extensions_pda, test_empty_data, + test_missing_signer, test_not_writable, test_truncated_data, test_wrong_account, test_wrong_current_program, + test_wrong_system_program, EscrowError, InstructionTestFixture, TestContext, RANDOM_PUBKEY, }, }; use solana_sdk::{ @@ -105,6 +105,24 @@ fn test_set_arbiter_escrow_not_owned_by_program() { assert_instruction_error(error, InstructionError::InvalidAccountOwner); } +#[test] +fn test_set_arbiter_fails_when_escrow_is_immutable() { + let mut ctx = TestContext::new(); + + let escrow_ix = CreateEscrowFixture::build_valid(&mut ctx); + let admin = escrow_ix.signers[0].insecure_clone(); + let escrow_seed = escrow_ix.signers[1].pubkey(); + escrow_ix.send_expect_success(&mut ctx); + + let (escrow_pda, _) = find_escrow_pda(&escrow_seed); + let set_immutable_ix = SetImmutableFixture::build_with_escrow(&mut ctx, escrow_pda, admin.insecure_clone()); + set_immutable_ix.send_expect_success(&mut ctx); + + let arbiter_ix = SetArbiterFixture::build_with_escrow(&mut ctx, escrow_pda, admin, Keypair::new()); + let error = arbiter_ix.send_expect_error(&mut ctx); + assert_escrow_error(error, EscrowError::EscrowImmutable); +} + #[test] fn test_set_arbiter_updates_existing_extension() { let mut ctx = TestContext::new(); diff --git a/tests/integration-tests/src/test_set_hook.rs b/tests/integration-tests/src/test_set_hook.rs index 98cc556..1a01cf7 100644 --- a/tests/integration-tests/src/test_set_hook.rs +++ b/tests/integration-tests/src/test_set_hook.rs @@ -1,10 +1,10 @@ use crate::{ - fixtures::{AddTimelockFixture, CreateEscrowFixture, SetHookFixture}, + fixtures::{AddTimelockFixture, CreateEscrowFixture, SetHookFixture, SetImmutableFixture}, utils::{ - assert_extensions_header, assert_hook_extension, assert_instruction_error, assert_timelock_extension, - find_escrow_pda, find_extensions_pda, test_empty_data, test_missing_signer, test_not_writable, - test_truncated_data, test_wrong_account, test_wrong_current_program, test_wrong_system_program, - InstructionTestFixture, TestContext, RANDOM_PUBKEY, + assert_escrow_error, assert_extensions_header, assert_hook_extension, assert_instruction_error, + assert_timelock_extension, find_escrow_pda, find_extensions_pda, test_empty_data, test_missing_signer, + test_not_writable, test_truncated_data, test_wrong_account, test_wrong_current_program, + test_wrong_system_program, EscrowError, InstructionTestFixture, TestContext, RANDOM_PUBKEY, }, }; use escrow_program_client::instructions::SetHookBuilder; @@ -97,6 +97,24 @@ fn test_set_hook_escrow_not_owned_by_program() { assert_instruction_error(error, InstructionError::InvalidAccountOwner); } +#[test] +fn test_set_hook_fails_when_escrow_is_immutable() { + let mut ctx = TestContext::new(); + + let escrow_ix = CreateEscrowFixture::build_valid(&mut ctx); + let admin = escrow_ix.signers[0].insecure_clone(); + let escrow_seed = escrow_ix.signers[1].pubkey(); + escrow_ix.send_expect_success(&mut ctx); + + let (escrow_pda, _) = find_escrow_pda(&escrow_seed); + let set_immutable_ix = SetImmutableFixture::build_with_escrow(&mut ctx, escrow_pda, admin.insecure_clone()); + set_immutable_ix.send_expect_success(&mut ctx); + + let hook_ix = SetHookFixture::build_with_escrow(&mut ctx, escrow_pda, admin, Pubkey::new_unique()); + let error = hook_ix.send_expect_error(&mut ctx); + assert_escrow_error(error, EscrowError::EscrowImmutable); +} + #[test] fn test_set_hook_updates_existing_extension() { let mut ctx = TestContext::new(); diff --git a/tests/integration-tests/src/test_set_immutable.rs b/tests/integration-tests/src/test_set_immutable.rs new file mode 100644 index 0000000..8df1bfd --- /dev/null +++ b/tests/integration-tests/src/test_set_immutable.rs @@ -0,0 +1,100 @@ +use crate::{ + fixtures::{CreateEscrowFixture, SetImmutableFixture}, + utils::{ + assert_escrow_error, assert_escrow_mutability, find_escrow_pda, test_empty_data, test_missing_signer, + test_not_writable, test_wrong_account, test_wrong_current_program, InstructionTestFixture, TestContext, + }, +}; +use solana_sdk::{instruction::InstructionError, signature::Signer}; + +// ============================================================================ +// Error Tests - Using Generic Test Helpers +// ============================================================================ + +#[test] +fn test_set_immutable_missing_admin_signer() { + let mut ctx = TestContext::new(); + test_missing_signer::(&mut ctx, 0, 0); +} + +#[test] +fn test_set_immutable_escrow_not_writable() { + let mut ctx = TestContext::new(); + test_not_writable::(&mut ctx, 1); +} + +#[test] +fn test_set_immutable_wrong_current_program() { + let mut ctx = TestContext::new(); + test_wrong_current_program::(&mut ctx); +} + +#[test] +fn test_set_immutable_invalid_event_authority() { + let mut ctx = TestContext::new(); + test_wrong_account::(&mut ctx, 2, InstructionError::Custom(2)); +} + +#[test] +fn test_set_immutable_empty_data() { + let mut ctx = TestContext::new(); + test_empty_data::(&mut ctx); +} + +#[test] +fn test_set_immutable_wrong_admin() { + let mut ctx = TestContext::new(); + let escrow_ix = CreateEscrowFixture::build_valid(&mut ctx); + let escrow_seed = escrow_ix.signers[1].pubkey(); + escrow_ix.send_expect_success(&mut ctx); + + let wrong_admin = ctx.create_funded_keypair(); + let (escrow_pda, _) = find_escrow_pda(&escrow_seed); + let test_ix = SetImmutableFixture::build_with_escrow(&mut ctx, escrow_pda, wrong_admin); + + let error = test_ix.send_expect_error(&mut ctx); + assert_escrow_error(error, escrow_program_client::errors::EscrowProgramError::InvalidAdmin); +} + +// ============================================================================ +// Happy Path Tests +// ============================================================================ + +#[test] +fn test_set_immutable_success() { + let mut ctx = TestContext::new(); + let escrow_ix = CreateEscrowFixture::build_valid(&mut ctx); + let admin = escrow_ix.signers[0].insecure_clone(); + let escrow_seed = escrow_ix.signers[1].pubkey(); + escrow_ix.send_expect_success(&mut ctx); + + let (escrow_pda, _) = find_escrow_pda(&escrow_seed); + assert_escrow_mutability(&ctx, &escrow_pda, false); + + let test_ix = SetImmutableFixture::build_with_escrow(&mut ctx, escrow_pda, admin); + test_ix.send_expect_success(&mut ctx); + + assert_escrow_mutability(&ctx, &escrow_pda, true); +} + +#[test] +fn test_set_immutable_fails_when_already_immutable() { + let mut ctx = TestContext::new(); + let escrow_ix = CreateEscrowFixture::build_valid(&mut ctx); + let admin = escrow_ix.signers[0].insecure_clone(); + let escrow_seed = escrow_ix.signers[1].pubkey(); + escrow_ix.send_expect_success(&mut ctx); + + let (escrow_pda, _) = find_escrow_pda(&escrow_seed); + + let first_ix = SetImmutableFixture::build_with_escrow(&mut ctx, escrow_pda, admin.insecure_clone()); + first_ix.send_expect_success(&mut ctx); + + ctx.warp_to_slot(2); + + let second_ix = SetImmutableFixture::build_with_escrow(&mut ctx, escrow_pda, admin); + let error = second_ix.send_expect_error(&mut ctx); + assert_escrow_error(error, escrow_program_client::errors::EscrowProgramError::EscrowImmutable); + + assert_escrow_mutability(&ctx, &escrow_pda, true); +} diff --git a/tests/integration-tests/src/test_unblock_token_extension.rs b/tests/integration-tests/src/test_unblock_token_extension.rs index d8e3f10..92309e0 100644 --- a/tests/integration-tests/src/test_unblock_token_extension.rs +++ b/tests/integration-tests/src/test_unblock_token_extension.rs @@ -1,5 +1,8 @@ use crate::{ - fixtures::{AddBlockTokenExtensionsFixture, AddTimelockFixture, CreateEscrowFixture, UnblockTokenExtensionFixture}, + fixtures::{ + AddBlockTokenExtensionsFixture, AddTimelockFixture, CreateEscrowFixture, SetImmutableFixture, + UnblockTokenExtensionFixture, + }, utils::extensions_utils::EXTENSION_TYPE_BLOCK_TOKEN_EXTENSIONS, utils::{ assert_block_token_extensions_extension, assert_escrow_error, assert_extension_missing, @@ -99,6 +102,27 @@ fn test_unblock_token_extension_escrow_not_owned_by_program() { assert_instruction_error(error, InstructionError::InvalidAccountOwner); } +#[test] +fn test_unblock_token_extension_fails_when_escrow_is_immutable() { + let mut ctx = TestContext::new(); + + let escrow_ix = CreateEscrowFixture::build_valid(&mut ctx); + let admin = escrow_ix.signers[0].insecure_clone(); + let escrow_seed = escrow_ix.signers[1].pubkey(); + escrow_ix.send_expect_success(&mut ctx); + + let (escrow_pda, _) = find_escrow_pda(&escrow_seed); + AddBlockTokenExtensionsFixture::build_with_escrow(&mut ctx, escrow_pda, admin.insecure_clone(), 1u16) + .send_expect_success(&mut ctx); + + let set_immutable_ix = SetImmutableFixture::build_with_escrow(&mut ctx, escrow_pda, admin.insecure_clone()); + set_immutable_ix.send_expect_success(&mut ctx); + + let unblock_ix = UnblockTokenExtensionFixture::build_with_escrow(&mut ctx, escrow_pda, admin, 1u16); + let error = unblock_ix.send_expect_error(&mut ctx); + assert_escrow_error(error, EscrowError::EscrowImmutable); +} + #[test] fn test_unblock_token_extension_not_blocked_when_extensions_missing() { let mut ctx = TestContext::new(); diff --git a/tests/integration-tests/src/test_update_admin.rs b/tests/integration-tests/src/test_update_admin.rs index 36c576c..d5b1fc7 100644 --- a/tests/integration-tests/src/test_update_admin.rs +++ b/tests/integration-tests/src/test_update_admin.rs @@ -1,12 +1,12 @@ use crate::{ fixtures::{CreateEscrowFixture, UpdateAdminFixture}, utils::{ - assert_escrow_account, assert_instruction_error, find_escrow_pda, test_missing_signer, test_not_writable, - test_wrong_account, test_wrong_current_program, InstructionTestFixture, TestContext, TestInstruction, - RANDOM_PUBKEY, + assert_escrow_account, assert_escrow_error, assert_instruction_error, find_escrow_pda, test_missing_signer, + test_not_writable, test_wrong_account, test_wrong_current_program, EscrowError, InstructionTestFixture, + TestContext, TestInstruction, RANDOM_PUBKEY, }, }; -use escrow_program_client::instructions::UpdateAdminBuilder; +use escrow_program_client::instructions::{SetImmutableBuilder, UpdateAdminBuilder}; use solana_sdk::{ instruction::InstructionError, signature::{Keypair, Signer}, @@ -78,6 +78,25 @@ fn test_update_admin_escrow_not_owned_by_program() { assert_instruction_error(error, InstructionError::InvalidAccountOwner); } +#[test] +fn test_update_admin_fails_when_escrow_is_immutable() { + let mut ctx = TestContext::new(); + + let escrow_ix = CreateEscrowFixture::build_valid(&mut ctx); + let admin = escrow_ix.signers[0].insecure_clone(); + let escrow_seed = escrow_ix.signers[1].pubkey(); + escrow_ix.send_expect_success(&mut ctx); + + let (escrow_pda, _) = find_escrow_pda(&escrow_seed); + let set_immutable_ix = SetImmutableBuilder::new().admin(admin.pubkey()).escrow(escrow_pda).instruction(); + ctx.send_transaction(set_immutable_ix, &[&admin]).unwrap(); + + let new_admin = Keypair::new(); + let update_ix = UpdateAdminFixture::build_with_escrow(&mut ctx, escrow_pda, admin, new_admin); + let error = update_ix.send_expect_error(&mut ctx); + assert_escrow_error(error, EscrowError::EscrowImmutable); +} + // ============================================================================ // Success Tests // ============================================================================ diff --git a/tests/integration-tests/src/test_withdraw.rs b/tests/integration-tests/src/test_withdraw.rs index a17b420..53dbf02 100644 --- a/tests/integration-tests/src/test_withdraw.rs +++ b/tests/integration-tests/src/test_withdraw.rs @@ -1,14 +1,12 @@ use crate::{ fixtures::{AllowMintSetup, WithdrawFixture, WithdrawSetup, DEFAULT_DEPOSIT_AMOUNT}, utils::{ - assert_custom_error, assert_escrow_error, assert_instruction_error, find_allowed_mint_pda, test_missing_signer, - test_not_writable, test_wrong_account, test_wrong_current_program, test_wrong_owner, test_wrong_system_program, + assert_custom_error, assert_escrow_error, assert_instruction_error, test_missing_signer, test_not_writable, + test_wrong_account, test_wrong_current_program, test_wrong_owner, test_wrong_system_program, test_wrong_token_program, EscrowError, TestContext, TestInstruction, TEST_HOOK_ALLOW_ID, TEST_HOOK_DENY_ERROR, TEST_HOOK_DENY_ID, }, }; -use escrow_program_client::instructions::AddTimelockBuilder; -use escrow_program_client::instructions::AllowMintBuilder; use escrow_program_client::instructions::WithdrawBuilder; use solana_sdk::{ account::Account, @@ -99,18 +97,7 @@ fn test_withdraw_wrong_receipt_owner() { #[test] fn test_withdraw_initialized_extensions_wrong_owner() { let mut ctx = TestContext::new(); - let setup = WithdrawSetup::new(&mut ctx); - - let (extensions_pda, extensions_bump) = crate::utils::find_extensions_pda(&setup.escrow_pda); - let add_timelock_ix = AddTimelockBuilder::new() - .payer(ctx.payer.pubkey()) - .admin(setup.admin.pubkey()) - .escrow(setup.escrow_pda) - .extensions(extensions_pda) - .extensions_bump(extensions_bump) - .lock_duration(1) - .instruction(); - ctx.send_transaction(add_timelock_ix, &[&setup.admin]).unwrap(); + let setup = WithdrawSetup::new_with_hook(&mut ctx, TEST_HOOK_ALLOW_ID); let mut extensions_account = ctx.get_account(&setup.extensions_pda).expect("Extensions account should exist"); extensions_account.owner = Pubkey::new_unique(); @@ -432,9 +419,13 @@ fn test_withdraw_with_hook_success() { #[test] fn test_withdraw_with_hook_rejected() { let mut ctx = TestContext::new(); + let mut setup = WithdrawSetup::new_with_hook(&mut ctx, TEST_HOOK_ALLOW_ID); - let mut setup = WithdrawSetup::new(&mut ctx); - setup.set_hook(&mut ctx, TEST_HOOK_DENY_ID); + // Patch the hook extension directly to simulate a deny hook for withdraw-path rejection coverage. + let mut extensions_account = ctx.get_account(&setup.extensions_pda).expect("Extensions account should exist"); + extensions_account.data[8..40].copy_from_slice(&TEST_HOOK_DENY_ID.to_bytes()); + ctx.svm.set_account(setup.extensions_pda, extensions_account).unwrap(); + setup.hook_program = Some(TEST_HOOK_DENY_ID); let initial_vault_balance = ctx.get_token_balance(&setup.vault); @@ -507,21 +498,6 @@ fn test_withdraw_receipt_mint_mismatch_fails() { ctx.create_token_account_with_balance(&setup.escrow_pda, &second_mint.pubkey(), DEFAULT_DEPOSIT_AMOUNT); let second_withdrawer_token_account = ctx.create_token_account(&setup.depositor.pubkey(), &second_mint.pubkey()); - let (second_allowed_mint, second_allowed_mint_bump) = - find_allowed_mint_pda(&setup.escrow_pda, &second_mint.pubkey()); - let allow_second_mint_ix = AllowMintBuilder::new() - .payer(ctx.payer.pubkey()) - .admin(setup.admin.pubkey()) - .escrow(setup.escrow_pda) - .escrow_extensions(setup.extensions_pda) - .mint(second_mint.pubkey()) - .allowed_mint(second_allowed_mint) - .vault(second_vault) - .token_program(setup.token_program) - .bump(second_allowed_mint_bump) - .instruction(); - ctx.send_transaction(allow_second_mint_ix, &[&setup.admin]).unwrap(); - let instruction = WithdrawBuilder::new() .rent_recipient(ctx.payer.pubkey()) .withdrawer(setup.depositor.pubkey()) @@ -684,8 +660,9 @@ fn test_withdraw_with_arbiter_success() { #[test] fn test_withdraw_with_arbiter_missing_signer() { let mut ctx = TestContext::new(); - let mut setup = WithdrawSetup::new(&mut ctx); - let arbiter = setup.set_arbiter(&mut ctx); + let setup = WithdrawSetup::new_with_arbiter(&mut ctx); + let arbiter = + setup.arbiter.as_ref().expect("arbiter should be configured by WithdrawSetup::new_with_arbiter").pubkey(); // Build instruction manually without arbiter as signer let mut builder = WithdrawBuilder::new(); @@ -701,7 +678,7 @@ fn test_withdraw_with_arbiter_missing_signer() { .token_program(setup.token_program); // Add arbiter as non-signer (should fail) - builder.add_remaining_account(AccountMeta::new_readonly(arbiter.pubkey(), false)); + builder.add_remaining_account(AccountMeta::new_readonly(arbiter, false)); let instruction = builder.instruction(); let test_ix = TestInstruction { instruction, signers: vec![setup.depositor.insecure_clone()], name: "Withdraw" }; @@ -713,8 +690,7 @@ fn test_withdraw_with_arbiter_missing_signer() { #[test] fn test_withdraw_with_arbiter_wrong_address() { let mut ctx = TestContext::new(); - let mut setup = WithdrawSetup::new(&mut ctx); - setup.set_arbiter(&mut ctx); + let setup = WithdrawSetup::new_with_arbiter(&mut ctx); // Build instruction with wrong arbiter address let wrong_arbiter = ctx.create_funded_keypair(); @@ -747,8 +723,7 @@ fn test_withdraw_with_arbiter_wrong_address() { #[test] fn test_withdraw_with_arbiter_no_remaining_accounts() { let mut ctx = TestContext::new(); - let mut setup = WithdrawSetup::new(&mut ctx); - setup.set_arbiter(&mut ctx); + let setup = WithdrawSetup::new_with_arbiter(&mut ctx); // Build instruction without any remaining accounts (arbiter required but missing) let instruction = WithdrawBuilder::new() diff --git a/tests/integration-tests/src/utils/assertions.rs b/tests/integration-tests/src/utils/assertions.rs index 4c3dbe3..0c72003 100644 --- a/tests/integration-tests/src/utils/assertions.rs +++ b/tests/integration-tests/src/utils/assertions.rs @@ -61,6 +61,12 @@ pub fn assert_escrow_account( assert_eq!(escrow.escrow_seed.as_ref(), expected_escrow_seed.as_ref()); } +pub fn assert_escrow_mutability(context: &TestContext, escrow_pda: &Pubkey, expected_is_immutable: bool) { + let account = context.get_account(escrow_pda).expect("Escrow account should exist"); + let escrow = Escrow::from_bytes(&account.data).expect("Should deserialize escrow account"); + assert_eq!(escrow.is_immutable, expected_is_immutable, "Unexpected escrow mutability for {escrow_pda}"); +} + pub fn assert_extensions_header( ctx: &TestContext, extensions_pda: &Pubkey, diff --git a/tests/test-hook-program/src/lib.rs b/tests/test-hook-program/src/lib.rs index 3e4b72f..bc814e5 100644 --- a/tests/test-hook-program/src/lib.rs +++ b/tests/test-hook-program/src/lib.rs @@ -15,11 +15,7 @@ pinocchio::default_allocator!(); pinocchio::nostd_panic_handler!(); #[cfg(feature = "allow")] -pub fn process_instruction( - _program_id: &Address, - accounts: &[AccountView], - instruction_data: &[u8], -) -> ProgramResult { +pub fn process_instruction(_program_id: &Address, accounts: &[AccountView], instruction_data: &[u8]) -> ProgramResult { use pinocchio::error::ProgramError; // Validate core context shape so integration tests catch missing account context. From 0aadfe43ef1e61ac9b54256d3ee72ff2b27caedc Mon Sep 17 00:00:00 2001 From: Jo D Date: Tue, 24 Mar 2026 11:36:40 -0400 Subject: [PATCH 6/8] fix(program): apply remaining audit behavior fixes --- .../src/instructions/allow_mint/processor.rs | 1 - .../src/instructions/block_mint/processor.rs | 1 - .../extensions/remove_extension/accounts.rs | 1 + .../unblock_token_extension/accounts.rs | 1 + .../instructions/update_admin/processor.rs | 1 - .../src/instructions/withdraw/processor.rs | 4 ++-- .../integration-tests/src/test_allow_mint.rs | 8 +++++--- .../integration-tests/src/test_block_mint.rs | 6 +++--- .../src/test_remove_extension.rs | 14 +++++++++++++ .../src/test_unblock_token_extension.rs | 20 ++++++++++++++++--- .../src/test_update_admin.rs | 14 ++++++++----- tests/test-hook-program/src/lib.rs | 7 ++++++- 12 files changed, 58 insertions(+), 20 deletions(-) diff --git a/program/src/instructions/allow_mint/processor.rs b/program/src/instructions/allow_mint/processor.rs index 0afa69f..db8ec87 100644 --- a/program/src/instructions/allow_mint/processor.rs +++ b/program/src/instructions/allow_mint/processor.rs @@ -20,7 +20,6 @@ pub fn process_allow_mint(program_id: &Address, accounts: &[AccountView], instru let escrow_data = ix.accounts.escrow.try_borrow()?; let escrow = Escrow::from_account(&escrow_data, ix.accounts.escrow, program_id)?; escrow.validate_admin(ix.accounts.admin.address())?; - escrow.require_mutable()?; // Validate AllowedMint PDA using external seeds let pda_seeds = AllowedMintPda::new(ix.accounts.escrow.address(), ix.accounts.mint.address()); diff --git a/program/src/instructions/block_mint/processor.rs b/program/src/instructions/block_mint/processor.rs index 70dea1f..e1b12ff 100644 --- a/program/src/instructions/block_mint/processor.rs +++ b/program/src/instructions/block_mint/processor.rs @@ -18,7 +18,6 @@ pub fn process_block_mint(program_id: &Address, accounts: &[AccountView], instru let escrow_data = ix.accounts.escrow.try_borrow()?; let escrow = Escrow::from_account(&escrow_data, ix.accounts.escrow, program_id)?; escrow.validate_admin(ix.accounts.admin.address())?; - escrow.require_mutable()?; // Verify allowed_mint account exists and self-validates against escrow + mint PDA derivation let allowed_mint_data = ix.accounts.allowed_mint.try_borrow()?; diff --git a/program/src/instructions/extensions/remove_extension/accounts.rs b/program/src/instructions/extensions/remove_extension/accounts.rs index 318da76..0b69530 100644 --- a/program/src/instructions/extensions/remove_extension/accounts.rs +++ b/program/src/instructions/extensions/remove_extension/accounts.rs @@ -54,6 +54,7 @@ impl<'a> TryFrom<&'a [AccountView]> for RemoveExtensionAccounts<'a> { // 5. Validate accounts owned by current program verify_current_program_account(escrow)?; + verify_current_program_account(extensions)?; Ok(Self { payer, admin, escrow, extensions, system_program, event_authority, escrow_program }) } diff --git a/program/src/instructions/extensions/unblock_token_extension/accounts.rs b/program/src/instructions/extensions/unblock_token_extension/accounts.rs index 6ef344b..10bfc4d 100644 --- a/program/src/instructions/extensions/unblock_token_extension/accounts.rs +++ b/program/src/instructions/extensions/unblock_token_extension/accounts.rs @@ -54,6 +54,7 @@ impl<'a> TryFrom<&'a [AccountView]> for UnblockTokenExtensionAccounts<'a> { // 5. Validate accounts owned by current program verify_current_program_account(escrow)?; + verify_current_program_account(extensions)?; Ok(Self { payer, admin, escrow, extensions, system_program, event_authority, escrow_program }) } diff --git a/program/src/instructions/update_admin/processor.rs b/program/src/instructions/update_admin/processor.rs index 30236c0..5063fce 100644 --- a/program/src/instructions/update_admin/processor.rs +++ b/program/src/instructions/update_admin/processor.rs @@ -18,7 +18,6 @@ pub fn process_update_admin(program_id: &Address, accounts: &[AccountView], inst let escrow_data = ix.accounts.escrow.try_borrow()?; let escrow = Escrow::from_account(&escrow_data, ix.accounts.escrow, program_id)?; escrow.validate_admin(ix.accounts.admin.address())?; - escrow.require_mutable()?; // Copy values we need for the update let old_admin = escrow.admin; diff --git a/program/src/instructions/withdraw/processor.rs b/program/src/instructions/withdraw/processor.rs index 5747df4..6d52130 100644 --- a/program/src/instructions/withdraw/processor.rs +++ b/program/src/instructions/withdraw/processor.rs @@ -73,7 +73,7 @@ pub fn process_withdraw(program_id: &Address, accounts: &[AccountView], instruct hook.invoke( HookPoint::PreWithdraw, remaining_accounts, - &[ix.accounts.escrow, ix.accounts.mint, ix.accounts.receipt], + &[ix.accounts.escrow, ix.accounts.withdrawer, ix.accounts.mint, ix.accounts.receipt], )?; } @@ -102,7 +102,7 @@ pub fn process_withdraw(program_id: &Address, accounts: &[AccountView], instruct hook.invoke( HookPoint::PostWithdraw, remaining_accounts, - &[ix.accounts.escrow, ix.accounts.mint, ix.accounts.receipt], + &[ix.accounts.escrow, ix.accounts.withdrawer, ix.accounts.mint, ix.accounts.receipt], )?; } diff --git a/tests/integration-tests/src/test_allow_mint.rs b/tests/integration-tests/src/test_allow_mint.rs index 173c236..91b3bc3 100644 --- a/tests/integration-tests/src/test_allow_mint.rs +++ b/tests/integration-tests/src/test_allow_mint.rs @@ -132,7 +132,7 @@ fn test_allow_mint_duplicate() { } #[test] -fn test_allow_mint_fails_when_escrow_is_immutable() { +fn test_allow_mint_succeeds_when_escrow_is_immutable() { let mut ctx = TestContext::new(); let setup = AllowMintSetup::new(&mut ctx); @@ -141,8 +141,10 @@ fn test_allow_mint_fails_when_escrow_is_immutable() { ctx.send_transaction(set_immutable_ix, &[&setup.admin]).unwrap(); let test_ix = setup.build_instruction(&ctx); - let error = test_ix.send_expect_error(&mut ctx); - assert_escrow_error(error, EscrowError::EscrowImmutable); + test_ix.send_expect_success(&mut ctx); + + assert_account_exists(&ctx, &setup.allowed_mint_pda); + assert_allowed_mint_account(&ctx, &setup.allowed_mint_pda, setup.allowed_mint_bump); } // ============================================================================ diff --git a/tests/integration-tests/src/test_block_mint.rs b/tests/integration-tests/src/test_block_mint.rs index 53e780f..19c725f 100644 --- a/tests/integration-tests/src/test_block_mint.rs +++ b/tests/integration-tests/src/test_block_mint.rs @@ -62,7 +62,7 @@ fn test_block_mint_wrong_admin() { } #[test] -fn test_block_mint_fails_when_escrow_is_immutable() { +fn test_block_mint_succeeds_when_escrow_is_immutable() { let mut ctx = TestContext::new(); let setup = BlockMintSetup::new(&mut ctx); @@ -71,8 +71,8 @@ fn test_block_mint_fails_when_escrow_is_immutable() { ctx.send_transaction(set_immutable_ix, &[&setup.admin]).unwrap(); let test_ix = setup.build_instruction(&ctx); - let error = test_ix.send_expect_error(&mut ctx); - assert_escrow_error(error, EscrowError::EscrowImmutable); + test_ix.send_expect_success(&mut ctx); + assert_account_not_exists(&ctx, &setup.allowed_mint_pda); } #[test] diff --git a/tests/integration-tests/src/test_remove_extension.rs b/tests/integration-tests/src/test_remove_extension.rs index c1be510..698444a 100644 --- a/tests/integration-tests/src/test_remove_extension.rs +++ b/tests/integration-tests/src/test_remove_extension.rs @@ -125,6 +125,20 @@ fn test_remove_extension_fails_when_escrow_is_immutable() { assert_escrow_error(error, EscrowError::EscrowImmutable); } +#[test] +fn test_remove_extension_extensions_not_owned_by_program() { + let mut ctx = TestContext::new(); + let test_ix = RemoveExtensionFixture::build_valid(&mut ctx); + let extensions_pda = test_ix.instruction.accounts[3].pubkey; + + let mut extensions_account = ctx.get_account(&extensions_pda).expect("Extensions account should exist"); + extensions_account.owner = Pubkey::new_unique(); + ctx.svm.set_account(extensions_pda, extensions_account).unwrap(); + + let error = test_ix.send_expect_error(&mut ctx); + assert_instruction_error(error, InstructionError::InvalidAccountOwner); +} + #[test] fn test_remove_extension_invalid_type() { let mut ctx = TestContext::new(); diff --git a/tests/integration-tests/src/test_unblock_token_extension.rs b/tests/integration-tests/src/test_unblock_token_extension.rs index 92309e0..f6d073d 100644 --- a/tests/integration-tests/src/test_unblock_token_extension.rs +++ b/tests/integration-tests/src/test_unblock_token_extension.rs @@ -12,7 +12,7 @@ use crate::{ TestContext, RANDOM_PUBKEY, }, }; -use solana_sdk::{instruction::InstructionError, signature::Signer}; +use solana_sdk::{instruction::InstructionError, pubkey::Pubkey, signature::Signer}; // ============================================================================ // Error Tests - Using Generic Test Helpers @@ -124,7 +124,21 @@ fn test_unblock_token_extension_fails_when_escrow_is_immutable() { } #[test] -fn test_unblock_token_extension_not_blocked_when_extensions_missing() { +fn test_unblock_token_extension_extensions_not_owned_by_program() { + let mut ctx = TestContext::new(); + let test_ix = UnblockTokenExtensionFixture::build_valid(&mut ctx); + let extensions_pda = test_ix.instruction.accounts[3].pubkey; + + let mut extensions_account = ctx.get_account(&extensions_pda).expect("Extensions account should exist"); + extensions_account.owner = Pubkey::new_unique(); + ctx.svm.set_account(extensions_pda, extensions_account).unwrap(); + + let error = test_ix.send_expect_error(&mut ctx); + assert_instruction_error(error, InstructionError::InvalidAccountOwner); +} + +#[test] +fn test_unblock_token_extension_extensions_missing() { let mut ctx = TestContext::new(); let escrow_ix = CreateEscrowFixture::build_valid(&mut ctx); @@ -136,7 +150,7 @@ fn test_unblock_token_extension_not_blocked_when_extensions_missing() { let test_ix = UnblockTokenExtensionFixture::build_with_escrow(&mut ctx, escrow_pda, admin, 1u16); let error = test_ix.send_expect_error(&mut ctx); - assert_escrow_error(error, EscrowError::TokenExtensionNotBlocked); + assert_instruction_error(error, InstructionError::InvalidAccountOwner); } #[test] diff --git a/tests/integration-tests/src/test_update_admin.rs b/tests/integration-tests/src/test_update_admin.rs index d5b1fc7..1ce882d 100644 --- a/tests/integration-tests/src/test_update_admin.rs +++ b/tests/integration-tests/src/test_update_admin.rs @@ -1,8 +1,8 @@ use crate::{ fixtures::{CreateEscrowFixture, UpdateAdminFixture}, utils::{ - assert_escrow_account, assert_escrow_error, assert_instruction_error, find_escrow_pda, test_missing_signer, - test_not_writable, test_wrong_account, test_wrong_current_program, EscrowError, InstructionTestFixture, + assert_escrow_account, assert_escrow_mutability, assert_instruction_error, find_escrow_pda, + test_missing_signer, test_not_writable, test_wrong_account, test_wrong_current_program, InstructionTestFixture, TestContext, TestInstruction, RANDOM_PUBKEY, }, }; @@ -79,12 +79,13 @@ fn test_update_admin_escrow_not_owned_by_program() { } #[test] -fn test_update_admin_fails_when_escrow_is_immutable() { +fn test_update_admin_succeeds_when_escrow_is_immutable() { let mut ctx = TestContext::new(); let escrow_ix = CreateEscrowFixture::build_valid(&mut ctx); let admin = escrow_ix.signers[0].insecure_clone(); let escrow_seed = escrow_ix.signers[1].pubkey(); + let bump = escrow_ix.instruction.data[1]; escrow_ix.send_expect_success(&mut ctx); let (escrow_pda, _) = find_escrow_pda(&escrow_seed); @@ -92,9 +93,12 @@ fn test_update_admin_fails_when_escrow_is_immutable() { ctx.send_transaction(set_immutable_ix, &[&admin]).unwrap(); let new_admin = Keypair::new(); + let new_admin_pubkey = new_admin.pubkey(); let update_ix = UpdateAdminFixture::build_with_escrow(&mut ctx, escrow_pda, admin, new_admin); - let error = update_ix.send_expect_error(&mut ctx); - assert_escrow_error(error, EscrowError::EscrowImmutable); + update_ix.send_expect_success(&mut ctx); + + assert_escrow_account(&ctx, &escrow_pda, &new_admin_pubkey, bump, &escrow_seed); + assert_escrow_mutability(&ctx, &escrow_pda, true); } // ============================================================================ diff --git a/tests/test-hook-program/src/lib.rs b/tests/test-hook-program/src/lib.rs index bc814e5..eb1f6ff 100644 --- a/tests/test-hook-program/src/lib.rs +++ b/tests/test-hook-program/src/lib.rs @@ -22,11 +22,16 @@ pub fn process_instruction(_program_id: &Address, accounts: &[AccountView], inst // hook_point: 0=PreDeposit, 1=PostDeposit, 2=PreWithdraw, 3=PostWithdraw let hook_point = *instruction_data.first().ok_or(ProgramError::InvalidInstructionData)?; match hook_point { - 0..=3 => { + 0..=1 => { if accounts.len() < 3 { return Err(ProgramError::Custom(42)); } } + 2..=3 => { + if accounts.len() < 4 { + return Err(ProgramError::Custom(42)); + } + } _ => return Err(ProgramError::InvalidInstructionData), } From a1dbeca25fffd9474a02ba27adac8b8c9e88a7b6 Mon Sep 17 00:00:00 2001 From: Jo D Date: Wed, 25 Mar 2026 09:23:35 -0400 Subject: [PATCH 7/8] docs(program): clarify arbiter mutability comment --- program/src/state/extensions/arbiter.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/program/src/state/extensions/arbiter.rs b/program/src/state/extensions/arbiter.rs index d9e2ed7..18f6d58 100644 --- a/program/src/state/extensions/arbiter.rs +++ b/program/src/state/extensions/arbiter.rs @@ -6,7 +6,7 @@ use crate::{assert_no_padding, errors::EscrowProgramError, require_len, traits:: /// Arbiter extension data (stored in TLV format) /// /// Stores the address of a third-party signer who must authorize withdrawals. -/// Once set, this is immutable — the arbiter cannot be changed. +/// The arbiter value can be updated while the escrow remains mutable. #[derive(Clone, Copy, Debug, PartialEq)] #[repr(C)] pub struct ArbiterData { From 6f8d35fb2e1824083e8aa69bc093108d75f1faba Mon Sep 17 00:00:00 2001 From: Jo D Date: Wed, 25 Mar 2026 10:07:51 -0400 Subject: [PATCH 8/8] fix(program): prevent stale tlv bytes after extension updates --- .../unblock_token_extension/processor.rs | 9 +--- program/src/state/escrow_extensions.rs | 12 ++--- program/src/utils/pda_utils.rs | 23 ++++++++++ tests/integration-tests/src/test_set_hook.rs | 46 +++++++++++++++++-- 4 files changed, 71 insertions(+), 19 deletions(-) diff --git a/program/src/instructions/extensions/unblock_token_extension/processor.rs b/program/src/instructions/extensions/unblock_token_extension/processor.rs index b13b0aa..9a3c9e8 100644 --- a/program/src/instructions/extensions/unblock_token_extension/processor.rs +++ b/program/src/instructions/extensions/unblock_token_extension/processor.rs @@ -1,5 +1,4 @@ -use alloc::vec::Vec; -use pinocchio::{account::AccountView, cpi::Seed, error::ProgramError, Address, ProgramResult}; +use pinocchio::{account::AccountView, Address, ProgramResult}; use crate::{ errors::EscrowProgramError, @@ -31,11 +30,6 @@ pub fn process_unblock_token_extension( let extensions_pda = ExtensionsPda::new(ix.accounts.escrow.address()); extensions_pda.validate_pda(ix.accounts.extensions, program_id, ix.data.extensions_bump)?; - // Get seeds for PDA operations - let extensions_bump_seed = [ix.data.extensions_bump]; - let extensions_seeds: Vec = extensions_pda.seeds_with_bump(&extensions_bump_seed); - let extensions_seeds_array: [Seed; 3] = extensions_seeds.try_into().map_err(|_| ProgramError::InvalidArgument)?; - // Read existing BlockedTokenExtensions data if present let mut blocked_token_extensions = { if ix.accounts.extensions.data_len() == 0 { @@ -57,7 +51,6 @@ pub fn process_unblock_token_extension( ix.accounts.extensions, ExtensionType::BlockedTokenExtensions, &blocked_token_extensions.to_bytes(), - extensions_seeds_array, )?; } diff --git a/program/src/state/escrow_extensions.rs b/program/src/state/escrow_extensions.rs index a4bb976..f619c71 100644 --- a/program/src/state/escrow_extensions.rs +++ b/program/src/state/escrow_extensions.rs @@ -5,8 +5,7 @@ use pinocchio::{account::AccountView, cpi::Seed, error::ProgramError, Address, P use crate::state::extensions::TimelockData; use crate::traits::{AccountSerialize, Discriminator, EscrowAccountDiscriminators, PdaSeeds, Versioned}; -use crate::utils::{create_pda_account_idempotent, TlvReader}; -use crate::ID as ESCROW_PROGRAM_ID; +use crate::utils::{create_pda_account_idempotent, resize_pda_account, TlvReader}; use crate::{assert_no_padding, require_len, validate_discriminator}; /// Extension type discriminators for TLV-encoded extension data @@ -189,12 +188,11 @@ pub fn append_extension( /// /// Finds the extension by type, replaces its data, and resizes account if needed. /// Returns error if the extension type doesn't exist. -pub fn update_extension( +pub fn update_extension( payer: &AccountView, extensions: &AccountView, ext_type: ExtensionType, new_tlv: &[u8], - pda_signer_seeds: [Seed; N], ) -> ProgramResult { let current_data_len = extensions.data_len(); if current_data_len == 0 { @@ -246,8 +244,8 @@ pub fn update_extension( // Calculate required size let required_size = EscrowExtensionsHeader::LEN + new_tlv_data.len(); - // Resize account if needed - create_pda_account_idempotent(payer, required_size, &ESCROW_PROGRAM_ID, extensions, pda_signer_seeds)?; + // Resize to exact length (handles growth rent top-up and shrink truncation). + resize_pda_account(payer, extensions, required_size)?; // Write data let mut data = extensions.try_borrow_mut()?; @@ -364,7 +362,7 @@ pub fn update_or_append_extension( drop(data); if extension_exists { - update_extension(payer, extensions, ext_type, ext_data, pda_signer_seeds) + update_extension(payer, extensions, ext_type, ext_data) } else { let tlv = build_tlv(); append_extension(payer, extensions, program_id, bump, ext_type, &tlv, pda_signer_seeds) diff --git a/program/src/utils/pda_utils.rs b/program/src/utils/pda_utils.rs index 9ba15c8..1909085 100644 --- a/program/src/utils/pda_utils.rs +++ b/program/src/utils/pda_utils.rs @@ -106,3 +106,26 @@ pub fn create_pda_account_idempotent( .invoke_signed(&signers) } } + +/// Resize an initialized PDA account to an exact size. +/// +/// Ensures rent-exempt balance for growth and truncates for shrink, so callers +/// can rely on the account length matching `space` exactly. +pub fn resize_pda_account(payer: &AccountView, pda_account: &AccountView, space: usize) -> ProgramResult { + if pda_account.data_len() == 0 { + return Err(ProgramError::UninitializedAccount); + } + + let rent = Rent::get()?; + let required_lamports = rent.try_minimum_balance(space).unwrap().max(1); + let additional_lamports = required_lamports.saturating_sub(pda_account.lamports()); + if additional_lamports > 0 { + Transfer { from: payer, to: pda_account, lamports: additional_lamports }.invoke()?; + } + + if pda_account.data_len() != space { + pda_account.resize(space)?; + } + + Ok(()) +} diff --git a/tests/integration-tests/src/test_set_hook.rs b/tests/integration-tests/src/test_set_hook.rs index 1a01cf7..c6e2deb 100644 --- a/tests/integration-tests/src/test_set_hook.rs +++ b/tests/integration-tests/src/test_set_hook.rs @@ -1,9 +1,12 @@ use crate::{ - fixtures::{AddTimelockFixture, CreateEscrowFixture, SetHookFixture, SetImmutableFixture}, + fixtures::{ + AddBlockTokenExtensionsFixture, AddTimelockFixture, CreateEscrowFixture, SetHookFixture, SetImmutableFixture, + UnblockTokenExtensionFixture, + }, utils::{ - assert_escrow_error, assert_extensions_header, assert_hook_extension, assert_instruction_error, - assert_timelock_extension, find_escrow_pda, find_extensions_pda, test_empty_data, test_missing_signer, - test_not_writable, test_truncated_data, test_wrong_account, test_wrong_current_program, + assert_block_token_extensions_extension, assert_escrow_error, assert_extensions_header, assert_hook_extension, + assert_instruction_error, assert_timelock_extension, find_escrow_pda, find_extensions_pda, test_empty_data, + test_missing_signer, test_not_writable, test_truncated_data, test_wrong_account, test_wrong_current_program, test_wrong_system_program, EscrowError, InstructionTestFixture, TestContext, RANDOM_PUBKEY, }, }; @@ -251,6 +254,41 @@ fn test_set_hook_then_add_timelock() { assert_timelock_extension(&ctx, &extensions_pda, 7200); } +#[test] +fn test_set_hook_after_blocked_extension_shrink_keeps_tlv_parseable() { + let mut ctx = TestContext::new(); + + let escrow_ix = CreateEscrowFixture::build_valid(&mut ctx); + let admin = escrow_ix.signers[0].insecure_clone(); + let escrow_seed = escrow_ix.signers[1].pubkey(); + escrow_ix.send_expect_success(&mut ctx); + + let (escrow_pda, _) = find_escrow_pda(&escrow_seed); + let (extensions_pda, extensions_bump) = find_extensions_pda(&escrow_pda); + + AddBlockTokenExtensionsFixture::build_with_escrow(&mut ctx, escrow_pda, admin.insecure_clone(), 1u16) + .send_expect_success(&mut ctx); + AddBlockTokenExtensionsFixture::build_with_escrow(&mut ctx, escrow_pda, admin.insecure_clone(), 2u16) + .send_expect_success(&mut ctx); + assert_extensions_header(&ctx, &extensions_pda, extensions_bump, 1); + assert_block_token_extensions_extension(&ctx, &extensions_pda, &[1u16, 2u16]); + + // This path calls update_extension with smaller data (2 entries -> 1 entry). + UnblockTokenExtensionFixture::build_with_escrow(&mut ctx, escrow_pda, admin.insecure_clone(), 1u16) + .send_expect_success(&mut ctx); + assert_extensions_header(&ctx, &extensions_pda, extensions_bump, 1); + assert_block_token_extensions_extension(&ctx, &extensions_pda, &[2u16]); + + // Use deterministic bytes so stale-data parsing bugs are reproducible. + let hook_program = Pubkey::new_from_array([0xAA; 32]); + let hook_ix = SetHookFixture::build_with_escrow(&mut ctx, escrow_pda, admin, hook_program); + hook_ix.send_expect_success(&mut ctx); + + assert_extensions_header(&ctx, &extensions_pda, extensions_bump, 2); + assert_block_token_extensions_extension(&ctx, &extensions_pda, &[2u16]); + assert_hook_extension(&ctx, &extensions_pda, &hook_program); +} + #[test] fn test_multiple_extensions_extension_count() { let mut ctx = TestContext::new();