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