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.