From 5dcd01555a502f222f270c7a8154b64f154907b7 Mon Sep 17 00:00:00 2001 From: Jo D Date: Mon, 23 Mar 2026 11:32:25 -0400 Subject: [PATCH 1/6] feat: add escrow immutability flow --- apps/web/src/app/page.tsx | 4 + .../components/instructions/SetImmutable.tsx | 76 +++++++++++ apps/web/src/lib/transactionErrors.ts | 4 + idl/escrow_program.json | 120 ++++++++++++++++++ program/src/entrypoint.rs | 2 + program/src/errors.rs | 14 ++ 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 + program/src/instructions/definition.rs | 16 ++- program/src/instructions/deposit/processor.rs | 3 +- .../extensions/add_timelock/processor.rs | 1 + .../block_token_extension/processor.rs | 1 + .../extensions/set_arbiter/processor.rs | 1 + .../extensions/set_hook/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 | 37 ++++++ .../instructions/update_admin/processor.rs | 8 +- program/src/state/escrow.rs | 71 ++++++++++- program/src/traits/event.rs | 1 + program/src/traits/instruction.rs | 11 +- .../integration-tests/src/fixtures/deposit.rs | 7 +- tests/integration-tests/src/fixtures/mod.rs | 2 + .../src/fixtures/set_immutable.rs | 63 +++++++++ .../src/fixtures/withdraw.rs | 5 +- tests/integration-tests/src/lib.rs | 1 + .../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 | 80 +++++++++--- .../integration-tests/src/test_set_arbiter.rs | 28 +++- tests/integration-tests/src/test_set_hook.rs | 28 +++- .../src/test_set_immutable.rs | 99 +++++++++++++++ .../src/test_update_admin.rs | 27 +++- tests/integration-tests/src/test_withdraw.rs | 56 +++----- .../integration-tests/src/utils/assertions.rs | 6 + 43 files changed, 943 insertions(+), 94 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..3d8f253 --- /dev/null +++ b/apps/web/src/components/instructions/SetImmutable.tsx @@ -0,0 +1,76 @@ +'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..624e222 100644 --- a/apps/web/src/lib/transactionErrors.ts +++ b/apps/web/src/lib/transactionErrors.ts @@ -1,6 +1,8 @@ 'use client'; import { + ESCROW_PROGRAM_ERROR__ESCROW_IMMUTABLE, + ESCROW_PROGRAM_ERROR__ESCROW_NOT_IMMUTABLE, ESCROW_PROGRAM_ERROR__HOOK_PROGRAM_MISMATCH, ESCROW_PROGRAM_ERROR__HOOK_REJECTED, ESCROW_PROGRAM_ERROR__INVALID_ADMIN, @@ -36,6 +38,8 @@ 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', + [ESCROW_PROGRAM_ERROR__ESCROW_NOT_IMMUTABLE]: 'Escrow must be immutable before deposits are allowed', }; const FALLBACK_TX_FAILED_MESSAGE = 'Transaction failed'; diff --git a/idl/escrow_program.json b/idl/escrow_program.json index 913ea37..0dda829 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,18 @@ "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" + }, + { + "code": 17, + "kind": "errorNode", + "message": "Escrow must be immutable before deposits are allowed", + "name": "escrowNotImmutable" } ], "instructions": [ @@ -2775,6 +2822,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..c64aedd 100644 --- a/program/src/entrypoint.rs +++ b/program/src/entrypoint.rs @@ -5,6 +5,7 @@ use crate::{ 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_immutable, }, 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..d500892 100644 --- a/program/src/errors.rs +++ b/program/src/errors.rs @@ -68,6 +68,14 @@ 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, + + /// (17) Escrow must be immutable before deposits are allowed + #[error("Escrow must be immutable before deposits are allowed")] + EscrowNotImmutable, } impl From for ProgramError { @@ -99,5 +107,11 @@ 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)); + + let error: ProgramError = EscrowProgramError::EscrowNotImmutable.into(); + assert_eq!(error, ProgramError::Custom(17)); } } 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/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/deposit/processor.rs b/program/src/instructions/deposit/processor.rs index 2d692e9..3c0bb68 100644 --- a/program/src/instructions/deposit/processor.rs +++ b/program/src/instructions/deposit/processor.rs @@ -28,7 +28,8 @@ pub fn process_deposit(program_id: &Address, accounts: &[AccountView], instructi // Verify escrow exists and is valid let escrow_data = ix.accounts.escrow.try_borrow()?; - let _escrow = Escrow::from_account(&escrow_data, ix.accounts.escrow, program_id)?; + let escrow = Escrow::from_account(&escrow_data, ix.accounts.escrow, program_id)?; + escrow.require_immutable()?; // 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/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/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/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..bb0ec44 --- /dev/null +++ b/program/src/instructions/set_immutable/processor.rs @@ -0,0 +1,37 @@ +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, needs_write) = { + 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.set_immutable(), !escrow.is_immutable) + }; + + // Write updated escrow only when transitioning mutable -> immutable. + if needs_write { + 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..eb100a1 100644 --- a/program/src/instructions/update_admin/processor.rs +++ b/program/src/instructions/update_admin/processor.rs @@ -18,10 +18,16 @@ 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_with_mutability( + 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..91a7d73 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 } } @@ -82,7 +84,12 @@ impl PdaAccount for Escrow { impl Escrow { #[inline(always)] pub fn new(bump: u8, escrow_seed: Address, admin: Address) -> Self { - Self { bump, escrow_seed, admin } + Self { bump, escrow_seed, admin, is_immutable: false } + } + + #[inline(always)] + pub fn new_with_mutability(bump: u8, escrow_seed: Address, admin: Address, is_immutable: bool) -> Self { + Self { bump, escrow_seed, admin, is_immutable } } #[inline(always)] @@ -104,6 +111,27 @@ 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 require_immutable(&self) -> Result<(), ProgramError> { + if !self.is_immutable { + return Err(EscrowProgramError::EscrowNotImmutable.into()); + } + Ok(()) + } + + #[inline(always)] + pub fn set_immutable(&self) -> Self { + Self::new_with_mutability(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 +152,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_with_mutability(255, escrow_seed, admin, false) } #[test] @@ -137,6 +165,7 @@ mod tests { assert_eq!(escrow.bump, 200); assert_eq!(escrow.escrow_seed, escrow_seed); assert_eq!(escrow.admin, admin); + assert!(!escrow.is_immutable); } #[test] @@ -165,6 +194,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 +206,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 +219,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 +231,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 +267,35 @@ 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_with_mutability( + 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_require_immutable_fails_when_mutable() { + let escrow = Escrow::new(1, Address::new_from_array([1u8; 32]), Address::new_from_array([2u8; 32])); + let result = escrow.require_immutable(); + assert_eq!(result, Err(EscrowProgramError::EscrowNotImmutable.into())); + } + #[test] fn test_escrow_write_to_slice_too_small() { let escrow = create_test_escrow(); 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/tests/integration-tests/src/fixtures/deposit.rs b/tests/integration-tests/src/fixtures/deposit.rs index 6a71485..4a28be4 100644 --- a/tests/integration-tests/src/fixtures/deposit.rs +++ b/tests/integration-tests/src/fixtures/deposit.rs @@ -1,4 +1,6 @@ -use escrow_program_client::instructions::{AllowMintBuilder, CreatesEscrowBuilder, DepositBuilder, SetHookBuilder}; +use escrow_program_client::instructions::{ + AllowMintBuilder, CreatesEscrowBuilder, DepositBuilder, SetHookBuilder, SetImmutableBuilder, +}; use solana_address::Address; use solana_sdk::{ instruction::AccountMeta, @@ -179,6 +181,9 @@ impl<'a> DepositSetupBuilder<'a> { self.ctx.send_transaction(allow_mint_ix, &[&admin]).unwrap(); + let set_immutable_ix = SetImmutableBuilder::new().admin(admin.pubkey()).escrow(escrow_pda).instruction(); + self.ctx.send_transaction(set_immutable_ix, &[&admin]).unwrap(); + let depositor = self.ctx.create_funded_keypair(); if token_program == TOKEN_2022_PROGRAM_ID { depositor_token_account = self.ctx.create_token_2022_account_with_balance( diff --git a/tests/integration-tests/src/fixtures/mod.rs b/tests/integration-tests/src/fixtures/mod.rs index b43e3be..7992791 100644 --- a/tests/integration-tests/src/fixtures/mod.rs +++ b/tests/integration-tests/src/fixtures/mod.rs @@ -8,6 +8,7 @@ pub mod remove_extension; pub mod set_arbiter; pub mod set_hook; pub mod unblock_token_extension; +pub mod set_immutable; pub mod update_admin; pub mod withdraw; @@ -21,5 +22,6 @@ pub use remove_extension::RemoveExtensionFixture; pub use set_arbiter::SetArbiterFixture; pub use set_hook::SetHookFixture; pub use unblock_token_extension::UnblockTokenExtensionFixture; +pub use set_immutable::SetImmutableFixture; 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/fixtures/withdraw.rs b/tests/integration-tests/src/fixtures/withdraw.rs index 12b5b06..1674fec 100644 --- a/tests/integration-tests/src/fixtures/withdraw.rs +++ b/tests/integration-tests/src/fixtures/withdraw.rs @@ -1,5 +1,5 @@ use escrow_program_client::instructions::{ - AddTimelockBuilder, AllowMintBuilder, CreatesEscrowBuilder, DepositBuilder, WithdrawBuilder, + AddTimelockBuilder, AllowMintBuilder, CreatesEscrowBuilder, DepositBuilder, SetImmutableBuilder, WithdrawBuilder, }; use solana_sdk::{ instruction::AccountMeta, @@ -233,6 +233,9 @@ impl<'a> WithdrawSetupBuilder<'a> { self.ctx.send_transaction(allow_mint_ix, &[&admin]).unwrap(); + let set_immutable_ix = SetImmutableBuilder::new().admin(admin.pubkey()).escrow(escrow_pda).instruction(); + self.ctx.send_transaction(set_immutable_ix, &[&admin]).unwrap(); + let depositor = self.ctx.create_funded_keypair(); if token_program == TOKEN_2022_PROGRAM_ID { depositor_token_account = self.ctx.create_token_2022_account_with_balance( diff --git a/tests/integration-tests/src/lib.rs b/tests/integration-tests/src/lib.rs index 6e76ab7..ca60ced 100644 --- a/tests/integration-tests/src/lib.rs +++ b/tests/integration-tests/src/lib.rs @@ -21,6 +21,7 @@ mod test_set_arbiter; mod test_set_hook; #[cfg(test)] mod test_unblock_token_extension; +mod test_set_immutable; #[cfg(test)] mod test_update_admin; #[cfg(test)] 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..6fb498b 100644 --- a/tests/integration-tests/src/test_deposit.rs +++ b/tests/integration-tests/src/test_deposit.rs @@ -1,6 +1,7 @@ use crate::{ fixtures::{ - AddBlockTokenExtensionsFixture, DepositFixture, DepositSetup, UnblockTokenExtensionFixture, + AddBlockTokenExtensionsFixture, AllowMintSetup, DepositFixture, DepositSetup, SetImmutableFixture, + UnblockTokenExtensionFixture, DEFAULT_DEPOSIT_AMOUNT, }, utils::{ @@ -10,13 +11,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 +118,42 @@ fn test_deposit_wrong_allowed_mint_owner() { } #[test] -fn test_deposit_initialized_extensions_wrong_owner() { +fn test_deposit_fails_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 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(); + + let error = ctx.send_transaction_expect_error(instruction, &[&depositor, &receipt_seed]); + assert_escrow_error(error, EscrowError::EscrowNotImmutable); +} + +#[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 +167,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 +179,34 @@ 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 set_immutable_ix = + SetImmutableFixture::build_with_escrow(&mut ctx, setup.escrow_pda, setup.admin.insecure_clone()); + set_immutable_ix.send_expect_success(&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_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..f6413de --- /dev/null +++ b/tests/integration-tests/src/test_set_immutable.rs @@ -0,0 +1,99 @@ +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_idempotent() { + 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); + second_ix.send_expect_success(&mut ctx); + + assert_escrow_mutability(&ctx, &escrow_pda, true); +} 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..8a14024 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,14 @@ 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); + // Escrow is immutable by the time receipts exist. 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 +499,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 +661,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 +679,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 +691,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 +724,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, From d0ed7521f28067b9b20755966b8ca65609f51620 Mon Sep 17 00:00:00 2001 From: Jo D Date: Mon, 23 Mar 2026 12:19:53 -0400 Subject: [PATCH 2/6] fix(program): align immutability and deposit behavior --- .../instructions/create_escrow/processor.rs | 2 +- program/src/instructions/deposit/processor.rs | 3 +-- .../instructions/set_immutable/processor.rs | 14 +++++------- .../instructions/update_admin/processor.rs | 8 ++----- program/src/state/escrow.rs | 22 +++++-------------- program/src/traits/account.rs | 14 ++++++------ program/src/traits/pda.rs | 6 ++--- tests/integration-tests/src/test_deposit.rs | 15 ++++++++++--- .../src/test_set_immutable.rs | 5 +++-- 9 files changed, 41 insertions(+), 48 deletions(-) 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/deposit/processor.rs b/program/src/instructions/deposit/processor.rs index 3c0bb68..2d692e9 100644 --- a/program/src/instructions/deposit/processor.rs +++ b/program/src/instructions/deposit/processor.rs @@ -28,8 +28,7 @@ pub fn process_deposit(program_id: &Address, accounts: &[AccountView], instructi // Verify escrow exists and is valid let escrow_data = ix.accounts.escrow.try_borrow()?; - let escrow = Escrow::from_account(&escrow_data, ix.accounts.escrow, program_id)?; - escrow.require_immutable()?; + let _escrow = Escrow::from_account(&escrow_data, ix.accounts.escrow, program_id)?; // 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/set_immutable/processor.rs b/program/src/instructions/set_immutable/processor.rs index bb0ec44..5bb5fc3 100644 --- a/program/src/instructions/set_immutable/processor.rs +++ b/program/src/instructions/set_immutable/processor.rs @@ -15,19 +15,17 @@ pub fn process_set_immutable(program_id: &Address, accounts: &[AccountView], ins let ix = SetImmutable::try_from((instruction_data, accounts))?; // Read and validate escrow - let (updated_escrow, needs_write) = { + 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.set_immutable(), !escrow.is_immutable) + escrow.require_mutable()?; + escrow.set_immutable() }; - // Write updated escrow only when transitioning mutable -> immutable. - if needs_write { - let mut escrow_data = ix.accounts.escrow.try_borrow_mut()?; - updated_escrow.write_to_slice(&mut escrow_data)?; - } + // 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()); diff --git a/program/src/instructions/update_admin/processor.rs b/program/src/instructions/update_admin/processor.rs index eb100a1..30236c0 100644 --- a/program/src/instructions/update_admin/processor.rs +++ b/program/src/instructions/update_admin/processor.rs @@ -22,12 +22,8 @@ pub fn process_update_admin(program_id: &Address, accounts: &[AccountView], inst // Copy values we need for the update let old_admin = escrow.admin; - let updated_escrow = Escrow::new_with_mutability( - escrow.bump, - escrow.escrow_seed, - *ix.accounts.new_admin.address(), - escrow.is_immutable, - ); + 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 91a7d73..bfad0c7 100644 --- a/program/src/state/escrow.rs +++ b/program/src/state/escrow.rs @@ -83,12 +83,7 @@ impl PdaAccount for Escrow { impl Escrow { #[inline(always)] - pub fn new(bump: u8, escrow_seed: Address, admin: Address) -> Self { - Self { bump, escrow_seed, admin, is_immutable: false } - } - - #[inline(always)] - pub fn new_with_mutability(bump: u8, escrow_seed: Address, admin: Address, is_immutable: bool) -> Self { + pub fn new(bump: u8, escrow_seed: Address, admin: Address, is_immutable: bool) -> Self { Self { bump, escrow_seed, admin, is_immutable } } @@ -129,7 +124,7 @@ impl Escrow { #[inline(always)] pub fn set_immutable(&self) -> Self { - Self::new_with_mutability(self.bump, self.escrow_seed, self.admin, true) + Self::new(self.bump, self.escrow_seed, self.admin, true) } /// Execute a CPI with this escrow PDA as signer @@ -152,7 +147,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_with_mutability(255, escrow_seed, admin, false) + Escrow::new(255, escrow_seed, admin, false) } #[test] @@ -160,7 +155,7 @@ 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); @@ -279,19 +274,14 @@ mod tests { #[test] fn test_require_mutable_fails_when_immutable() { - let escrow = Escrow::new_with_mutability( - 1, - Address::new_from_array([1u8; 32]), - Address::new_from_array([2u8; 32]), - true, - ); + 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_require_immutable_fails_when_mutable() { - let escrow = Escrow::new(1, Address::new_from_array([1u8; 32]), Address::new_from_array([2u8; 32])); + let escrow = Escrow::new(1, Address::new_from_array([1u8; 32]), Address::new_from_array([2u8; 32]), false); let result = escrow.require_immutable(); assert_eq!(result, Err(EscrowProgramError::EscrowNotImmutable.into())); } 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/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/test_deposit.rs b/tests/integration-tests/src/test_deposit.rs index 6fb498b..ed3f35d 100644 --- a/tests/integration-tests/src/test_deposit.rs +++ b/tests/integration-tests/src/test_deposit.rs @@ -118,7 +118,7 @@ fn test_deposit_wrong_allowed_mint_owner() { } #[test] -fn test_deposit_fails_when_escrow_is_mutable() { +fn test_deposit_succeeds_when_escrow_is_mutable() { let mut ctx = TestContext::new(); let setup = AllowMintSetup::new(&mut ctx); setup.build_instruction(&ctx).send_expect_success(&mut ctx); @@ -126,6 +126,8 @@ fn test_deposit_fails_when_escrow_is_mutable() { 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()); @@ -146,8 +148,15 @@ fn test_deposit_fails_when_escrow_is_mutable() { .amount(DEFAULT_DEPOSIT_AMOUNT) .instruction(); - let error = ctx.send_transaction_expect_error(instruction, &[&depositor, &receipt_seed]); - assert_escrow_error(error, EscrowError::EscrowNotImmutable); + 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] diff --git a/tests/integration-tests/src/test_set_immutable.rs b/tests/integration-tests/src/test_set_immutable.rs index f6413de..8df1bfd 100644 --- a/tests/integration-tests/src/test_set_immutable.rs +++ b/tests/integration-tests/src/test_set_immutable.rs @@ -78,7 +78,7 @@ fn test_set_immutable_success() { } #[test] -fn test_set_immutable_idempotent() { +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(); @@ -93,7 +93,8 @@ fn test_set_immutable_idempotent() { ctx.warp_to_slot(2); let second_ix = SetImmutableFixture::build_with_escrow(&mut ctx, escrow_pda, admin); - second_ix.send_expect_success(&mut ctx); + 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); } From 334f091852dfba0feaf4c29b9547d84f9bc6d7c6 Mon Sep 17 00:00:00 2001 From: Jo D Date: Mon, 23 Mar 2026 12:30:47 -0400 Subject: [PATCH 3/6] fix(program): remove stale escrow immutability error path --- .../src/components/instructions/SetImmutable.tsx | 4 +++- apps/web/src/lib/transactionErrors.ts | 2 -- idl/escrow_program.json | 6 ------ program/src/errors.rs | 8 +------- program/src/state/escrow.rs | 15 --------------- tests/integration-tests/src/fixtures/deposit.rs | 7 +------ tests/integration-tests/src/test_deposit.rs | 7 +------ tests/test-hook-program/src/lib.rs | 6 +----- 8 files changed, 7 insertions(+), 48 deletions(-) diff --git a/apps/web/src/components/instructions/SetImmutable.tsx b/apps/web/src/components/instructions/SetImmutable.tsx index 3d8f253..2229c9e 100644 --- a/apps/web/src/components/instructions/SetImmutable.tsx +++ b/apps/web/src/components/instructions/SetImmutable.tsx @@ -58,7 +58,9 @@ export function SetImmutable() { style={{ display: 'flex', flexDirection: 'column', gap: 16 }} >
- This action is one-way. Escrow configuration becomes permanently immutable. + + This action is one-way. Escrow configuration becomes permanently immutable. +
= { [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', - [ESCROW_PROGRAM_ERROR__ESCROW_NOT_IMMUTABLE]: 'Escrow must be immutable before deposits are allowed', }; const FALLBACK_TX_FAILED_MESSAGE = 'Transaction failed'; diff --git a/idl/escrow_program.json b/idl/escrow_program.json index 0dda829..c8a1f25 100644 --- a/idl/escrow_program.json +++ b/idl/escrow_program.json @@ -785,12 +785,6 @@ "kind": "errorNode", "message": "Escrow is immutable and cannot be modified", "name": "escrowImmutable" - }, - { - "code": 17, - "kind": "errorNode", - "message": "Escrow must be immutable before deposits are allowed", - "name": "escrowNotImmutable" } ], "instructions": [ diff --git a/program/src/errors.rs b/program/src/errors.rs index d500892..df582e6 100644 --- a/program/src/errors.rs +++ b/program/src/errors.rs @@ -72,10 +72,6 @@ pub enum EscrowProgramError { /// (16) Escrow is immutable and cannot be modified #[error("Escrow is immutable and cannot be modified")] EscrowImmutable, - - /// (17) Escrow must be immutable before deposits are allowed - #[error("Escrow must be immutable before deposits are allowed")] - EscrowNotImmutable, } impl From for ProgramError { @@ -110,8 +106,6 @@ mod tests { let error: ProgramError = EscrowProgramError::EscrowImmutable.into(); assert_eq!(error, ProgramError::Custom(16)); - - let error: ProgramError = EscrowProgramError::EscrowNotImmutable.into(); - assert_eq!(error, ProgramError::Custom(17)); + assert_eq!(error, ProgramError::Custom(16)); } } diff --git a/program/src/state/escrow.rs b/program/src/state/escrow.rs index bfad0c7..a2a99af 100644 --- a/program/src/state/escrow.rs +++ b/program/src/state/escrow.rs @@ -114,14 +114,6 @@ impl Escrow { Ok(()) } - #[inline(always)] - pub fn require_immutable(&self) -> Result<(), ProgramError> { - if !self.is_immutable { - return Err(EscrowProgramError::EscrowNotImmutable.into()); - } - Ok(()) - } - #[inline(always)] pub fn set_immutable(&self) -> Self { Self::new(self.bump, self.escrow_seed, self.admin, true) @@ -279,13 +271,6 @@ mod tests { assert_eq!(result, Err(EscrowProgramError::EscrowImmutable.into())); } - #[test] - fn test_require_immutable_fails_when_mutable() { - let escrow = Escrow::new(1, Address::new_from_array([1u8; 32]), Address::new_from_array([2u8; 32]), false); - let result = escrow.require_immutable(); - assert_eq!(result, Err(EscrowProgramError::EscrowNotImmutable.into())); - } - #[test] fn test_escrow_write_to_slice_too_small() { let escrow = create_test_escrow(); diff --git a/tests/integration-tests/src/fixtures/deposit.rs b/tests/integration-tests/src/fixtures/deposit.rs index 4a28be4..6a71485 100644 --- a/tests/integration-tests/src/fixtures/deposit.rs +++ b/tests/integration-tests/src/fixtures/deposit.rs @@ -1,6 +1,4 @@ -use escrow_program_client::instructions::{ - AllowMintBuilder, CreatesEscrowBuilder, DepositBuilder, SetHookBuilder, SetImmutableBuilder, -}; +use escrow_program_client::instructions::{AllowMintBuilder, CreatesEscrowBuilder, DepositBuilder, SetHookBuilder}; use solana_address::Address; use solana_sdk::{ instruction::AccountMeta, @@ -181,9 +179,6 @@ impl<'a> DepositSetupBuilder<'a> { self.ctx.send_transaction(allow_mint_ix, &[&admin]).unwrap(); - let set_immutable_ix = SetImmutableBuilder::new().admin(admin.pubkey()).escrow(escrow_pda).instruction(); - self.ctx.send_transaction(set_immutable_ix, &[&admin]).unwrap(); - let depositor = self.ctx.create_funded_keypair(); if token_program == TOKEN_2022_PROGRAM_ID { depositor_token_account = self.ctx.create_token_2022_account_with_balance( diff --git a/tests/integration-tests/src/test_deposit.rs b/tests/integration-tests/src/test_deposit.rs index ed3f35d..f8b9571 100644 --- a/tests/integration-tests/src/test_deposit.rs +++ b/tests/integration-tests/src/test_deposit.rs @@ -1,7 +1,6 @@ use crate::{ fixtures::{ - AddBlockTokenExtensionsFixture, AllowMintSetup, DepositFixture, DepositSetup, SetImmutableFixture, - UnblockTokenExtensionFixture, + AddBlockTokenExtensionsFixture, AllowMintSetup, DepositFixture, DepositSetup, UnblockTokenExtensionFixture, DEFAULT_DEPOSIT_AMOUNT, }, utils::{ @@ -188,10 +187,6 @@ fn test_deposit_rejects_newly_blocked_mint_extension() { ); block_extension_ix.send_expect_success(&mut ctx); - let set_immutable_ix = - SetImmutableFixture::build_with_escrow(&mut ctx, setup.escrow_pda, setup.admin.insecure_clone()); - set_immutable_ix.send_expect_success(&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); 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 22dacdc65776aebba3527009218b5351673f05b4 Mon Sep 17 00:00:00 2001 From: Jo D Date: Mon, 23 Mar 2026 13:11:20 -0400 Subject: [PATCH 4/6] test(integration): remove immutable setup from withdraw fixture --- tests/integration-tests/src/fixtures/withdraw.rs | 5 +---- tests/integration-tests/src/test_withdraw.rs | 3 +-- 2 files changed, 2 insertions(+), 6 deletions(-) diff --git a/tests/integration-tests/src/fixtures/withdraw.rs b/tests/integration-tests/src/fixtures/withdraw.rs index 1674fec..12b5b06 100644 --- a/tests/integration-tests/src/fixtures/withdraw.rs +++ b/tests/integration-tests/src/fixtures/withdraw.rs @@ -1,5 +1,5 @@ use escrow_program_client::instructions::{ - AddTimelockBuilder, AllowMintBuilder, CreatesEscrowBuilder, DepositBuilder, SetImmutableBuilder, WithdrawBuilder, + AddTimelockBuilder, AllowMintBuilder, CreatesEscrowBuilder, DepositBuilder, WithdrawBuilder, }; use solana_sdk::{ instruction::AccountMeta, @@ -233,9 +233,6 @@ impl<'a> WithdrawSetupBuilder<'a> { self.ctx.send_transaction(allow_mint_ix, &[&admin]).unwrap(); - let set_immutable_ix = SetImmutableBuilder::new().admin(admin.pubkey()).escrow(escrow_pda).instruction(); - self.ctx.send_transaction(set_immutable_ix, &[&admin]).unwrap(); - let depositor = self.ctx.create_funded_keypair(); if token_program == TOKEN_2022_PROGRAM_ID { depositor_token_account = self.ctx.create_token_2022_account_with_balance( diff --git a/tests/integration-tests/src/test_withdraw.rs b/tests/integration-tests/src/test_withdraw.rs index 8a14024..53dbf02 100644 --- a/tests/integration-tests/src/test_withdraw.rs +++ b/tests/integration-tests/src/test_withdraw.rs @@ -421,8 +421,7 @@ fn test_withdraw_with_hook_rejected() { let mut ctx = TestContext::new(); let mut setup = WithdrawSetup::new_with_hook(&mut ctx, TEST_HOOK_ALLOW_ID); - // Escrow is immutable by the time receipts exist. Patch the hook extension directly - // to simulate a deny hook for withdraw-path rejection coverage. + // 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(); From 3488a7205d4734ef22ba27490ac468b44b2946c3 Mon Sep 17 00:00:00 2001 From: Jo D Date: Mon, 23 Mar 2026 14:18:01 -0400 Subject: [PATCH 5/6] 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. --- program/src/entrypoint.rs | 4 ++-- tests/integration-tests/src/fixtures/mod.rs | 4 ++-- tests/integration-tests/src/lib.rs | 3 ++- 3 files changed, 6 insertions(+), 5 deletions(-) diff --git a/program/src/entrypoint.rs b/program/src/entrypoint.rs index c64aedd..c563901 100644 --- a/program/src/entrypoint.rs +++ b/program/src/entrypoint.rs @@ -4,8 +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_immutable, + process_set_hook, process_set_immutable, process_unblock_token_extension, process_update_admin, + process_withdraw, }, traits::EscrowInstructionDiscriminators, }; diff --git a/tests/integration-tests/src/fixtures/mod.rs b/tests/integration-tests/src/fixtures/mod.rs index 7992791..78d3832 100644 --- a/tests/integration-tests/src/fixtures/mod.rs +++ b/tests/integration-tests/src/fixtures/mod.rs @@ -7,8 +7,8 @@ pub mod deposit; pub mod remove_extension; pub mod set_arbiter; pub mod set_hook; -pub mod unblock_token_extension; pub mod set_immutable; +pub mod unblock_token_extension; pub mod update_admin; pub mod withdraw; @@ -21,7 +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 unblock_token_extension::UnblockTokenExtensionFixture; 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/lib.rs b/tests/integration-tests/src/lib.rs index ca60ced..264b12b 100644 --- a/tests/integration-tests/src/lib.rs +++ b/tests/integration-tests/src/lib.rs @@ -20,9 +20,10 @@ mod test_set_arbiter; #[cfg(test)] mod test_set_hook; #[cfg(test)] -mod test_unblock_token_extension; mod test_set_immutable; #[cfg(test)] +mod test_unblock_token_extension; +#[cfg(test)] mod test_update_admin; #[cfg(test)] mod test_withdraw; From 8447a482cb70add05d6d5fb7c0854c858bae17e6 Mon Sep 17 00:00:00 2001 From: Jo D Date: Mon, 23 Mar 2026 14:32:04 -0400 Subject: [PATCH 6/6] 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. --- .../extensions/remove_extension/processor.rs | 1 + .../unblock_token_extension/processor.rs | 1 + .../src/test_remove_extension.rs | 32 ++++++++++++++++--- .../src/test_unblock_token_extension.rs | 26 ++++++++++++++- 4 files changed, 54 insertions(+), 6 deletions(-) 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/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/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_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();