diff --git a/Cargo.lock b/Cargo.lock index f96263a..03409cf 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3079,6 +3079,7 @@ dependencies = [ "solana-instructions-sysvar", "solana-sdk", "solana-system-interface 3.2.0", + "spl-associated-token-account-interface", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 2e4f19c..55944bf 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -40,7 +40,7 @@ solana-sdk = "3" solana-sdk-ids = "3" solana-sha256-hasher = { version = "3", features = ["sha2"] } solana-system-interface = "3" -spl-associated-token-account-interface = { version = "2" } +spl-associated-token-account-interface = "2" spl-token = "9" spl-token-interface = "2" diff --git a/client/src/instructions.rs b/client/src/instructions.rs index 98c5ead..7a21536 100644 --- a/client/src/instructions.rs +++ b/client/src/instructions.rs @@ -189,6 +189,40 @@ impl From for Instruction { } } +/// Builder for a `ReclaimBuffer` instruction closing the buffer for each of +/// `mints`. +/// +/// **Warning:** any token balance still held by a buffer is burned, not +/// recovered, before the buffer is closed. Only reclaim buffers expected to +/// be empty, or to write off dust/dead balances — never one that might still +/// hold funds of useful value. +pub struct ReclaimBuffer<'a> { + pub program_id: Pubkey, + pub reclaim_authority: Pubkey, + pub mints: &'a [Pubkey], +} + +impl From> for Instruction { + fn from(builder: ReclaimBuffer<'_>) -> Self { + let (state_pda, _bump) = find_state_pda(&builder.program_id); + let buffers: Vec<(Pubkey, Pubkey)> = builder + .mints + .iter() + .map(|mint| { + let (buffer_pda, _bump) = find_buffer_pda(&builder.program_id, mint); + (buffer_pda, *mint) + }) + .collect(); + settlement_interface::instruction::reclaim_buffer::ReclaimBuffer { + program_id: builder.program_id, + state_pda, + reclaim_authority: builder.reclaim_authority, + buffers: &buffers, + } + .into() + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/interface/src/instruction/mod.rs b/interface/src/instruction/mod.rs index 1b3160d..ab881b5 100644 --- a/interface/src/instruction/mod.rs +++ b/interface/src/instruction/mod.rs @@ -11,6 +11,7 @@ use crate::{recover_discriminator, SettlementInstruction}; pub mod create_buffer; pub mod create_order; pub mod initialize; +pub mod reclaim_buffer; pub mod reclaim_order; pub mod settle; diff --git a/interface/src/instruction/reclaim_buffer.rs b/interface/src/instruction/reclaim_buffer.rs new file mode 100644 index 0000000..113a5e8 --- /dev/null +++ b/interface/src/instruction/reclaim_buffer.rs @@ -0,0 +1,373 @@ +//! `ReclaimBuffer` instruction builder. +//! +//! Closes one or more buffer PDAs (see [`crate::pda::buffer`]) and forwards +//! their proceeds to the settlement's configured `receiver` (see +//! [`crate::data::state::StateAccount`]): each buffer's rent lamports go +//! directly to `receiver`, and any leftover token balance is burned (a +//! non-native SPL token account can only be closed once its balance is +//! zero). +//! +//! # Warning: any tokens left in a buffer are destroyed +//! +//! This instruction **burns** whatever balance remains in each buffer before +//! closing it — those tokens are gone permanently, they are not routed to +//! `receiver` or anyone else. Only reclaim a buffer once you expect its +//! balance to be zero or dust that is intentionally being written off (e.g. +//! unroutable remainders left behind by settlement). Reclaiming a buffer that +//! still holds a meaningful balance destroys those funds. +//! +//! Wire format: `[discriminator=6]`, 1 byte. +//! Required accounts: +//! `[state_pda (R), receiver (W,S), token_program (R), (buffer_pda (W), mint (W))...]`. + +use solana_instruction::{AccountMeta, Instruction}; +use solana_program_error::ProgramError; +use solana_pubkey::Pubkey; + +use super::InstructionInputParsing; +pub use crate::instruction::create_buffer::SPL_TOKEN_PROGRAM_ID; +use crate::SettlementInstruction; + +/// Builder for a `ReclaimBuffer` instruction that closes one buffer per +/// `(buffer_pda, mint)` pair in `buffers`. +/// +/// `state_pda` must be the canonical PDA returned by +/// [`crate::pda::state::find_state_pda`]. `receiver` must sign and must match +/// the `receiver` recorded in the state PDA's data; it receives every closed +/// buffer's rent lamports. Each `buffer_pda` must be the canonical PDA +/// returned by [`crate::pda::buffer::find_buffer_pda`] for its paired `mint`. +/// `mint` must be writable: any leftover balance in the buffer is burned, +/// which updates the mint's supply. +/// +/// **Any token balance still held by a buffer at reclaim time is burned, not +/// recovered.** Only use this for buffers expected to be empty, or to write +/// off dust/dead balances — never on a buffer that might still hold funds of +/// useful value. +pub struct ReclaimBuffer<'a> { + pub program_id: Pubkey, + pub state_pda: Pubkey, + pub reclaim_authority: Pubkey, + pub buffers: &'a [(Pubkey, Pubkey)], +} + +impl From> for Instruction { + fn from(builder: ReclaimBuffer<'_>) -> Self { + let mut accounts = vec![ + AccountMeta::new_readonly(builder.state_pda, false), + AccountMeta::new(builder.reclaim_authority, true), + AccountMeta::new_readonly(SPL_TOKEN_PROGRAM_ID, false), + ]; + for (buffer_pda, mint) in builder.buffers { + accounts.push(AccountMeta::new(*buffer_pda, false)); + accounts.push(AccountMeta::new(*mint, false)); + } + Instruction { + program_id: builder.program_id, + accounts, + data: vec![SettlementInstruction::ReclaimBuffer.discriminator()], + } + } +} + +/// Parsed inputs of a `ReclaimBuffer` instruction. +pub struct ReclaimBufferInput<'a, A> { + pub state_pda: &'a A, + pub reclaim_authority: &'a A, + pub token_program: &'a A, + /// One `[buffer_pda, mint]` pair per buffer to close. + pub buffers: &'a [[A; 2]], +} + +impl<'a, A> InstructionInputParsing<'a, A> for ReclaimBufferInput<'a, A> { + const DISCRIMINATOR: SettlementInstruction = SettlementInstruction::ReclaimBuffer; + + fn parse_body(instruction_data: &[u8], accounts: &'a mut [A]) -> Result { + if !instruction_data.is_empty() { + return Err(ProgramError::InvalidInstructionData); + } + // Accounts: [state_pda (R), receiver (W,S), token_program (R), + // (buffer_pda (W), mint (W))...]. The three shared accounts come + // first; the per-buffer pairs follow, one pair per buffer. + let [state_pda, reclaim_authority, token_program, rest @ ..] = accounts else { + return Err(ProgramError::NotEnoughAccountKeys); + }; + // Group the trailing accounts into `[buffer_pda, mint]` pairs. Each + // buffer needs both, so a stray leftover account is a malformed + // instruction. There must be at least one pair: an instruction that + // reclaims no buffers is rejected as a likely encoding issue. + let rest: &'a [A] = rest; + let (buffers, remainder) = rest.as_chunks::<2>(); + if !remainder.is_empty() || buffers.is_empty() { + return Err(ProgramError::NotEnoughAccountKeys); + } + + Ok(Self { + state_pda, + reclaim_authority, + token_program, + buffers, + }) + } +} + +/// Test scaffolding for `ReclaimBuffer` parsing and handling, shared by this +/// crate's tests and the settlement program's via the `test-fixtures` feature. +#[cfg(any(test, feature = "test-fixtures"))] +pub mod fixtures { + use solana_address::Address; + + use super::{Instruction, ReclaimBuffer}; + + /// Number of accounts that don't depend on the number of buffers + /// reclaimed: state PDA, receiver, and token program. + pub const NUM_SHARED_ACCOUNTS: usize = 3; + + /// `ReclaimBuffer` instruction data with placeholder addresses, for + /// failure cases where the input is irrelevant. + pub fn reclaim_buffer_data() -> Vec { + let zero = Address::new_from_array([0; 32]); + Instruction::from(ReclaimBuffer { + program_id: zero, + state_pda: zero, + reclaim_authority: zero, + buffers: &[(zero, zero)], + }) + .data + } +} + +#[cfg(test)] +mod tests { + use super::fixtures::{reclaim_buffer_data, NUM_SHARED_ACCOUNTS}; + use super::*; + use crate::instruction::fixtures::{ + fake_account, fake_account_from_array, fake_sequential_accounts, + }; + use solana_address::Address; + + #[test] + fn reclaim_buffer_input_parses_valid_input() { + let program_id = Address::new_from_array([1; 32]); + let state_pda = Address::new_from_array([2; 32]); + let reclaim_authority = Address::new_from_array([3; 32]); + let buffer_pda = Address::new_from_array([4; 32]); + let mint = Address::new_from_array([5; 32]); + + let data = Instruction::from(ReclaimBuffer { + program_id, + state_pda, + reclaim_authority, + buffers: &[(buffer_pda, mint)], + }) + .data; + let token_program = fake_account_from_array([7; 32]); + let mut accounts = [ + fake_account(state_pda), + fake_account(reclaim_authority), + token_program, + fake_account(buffer_pda), + fake_account(mint), + ]; + + let ReclaimBufferInput { + state_pda: parsed_state_pda, + reclaim_authority: parsed_receiver, + token_program: parsed_token_program, + buffers, + } = ReclaimBufferInput::parse(&data, &mut accounts).expect("parse should succeed"); + + assert_eq!(*parsed_state_pda.address(), state_pda); + assert_eq!(*parsed_receiver.address(), reclaim_authority); + assert_eq!( + *parsed_token_program.address(), + Address::new_from_array([7; 32]) + ); + assert_eq!(buffers.len(), 1, "one buffer is one pair"); + assert_eq!(*buffers[0][0].address(), buffer_pda); + assert_eq!(*buffers[0][1].address(), mint); + } + + #[test] + fn reclaim_buffer_input_parses_multiple_buffers() { + let program_id = Address::new_from_array([1; 32]); + let state_pda = Address::new_from_array([2; 32]); + let reclaim_authority = Address::new_from_array([3; 32]); + let token_program = Address::new_from_array([4; 32]); + let buffer_a = Address::new_from_array([5; 32]); + let mint_a = Address::new_from_array([6; 32]); + let buffer_b = Address::new_from_array([7; 32]); + let mint_b = Address::new_from_array([8; 32]); + + let data = Instruction::from(ReclaimBuffer { + program_id, + state_pda, + reclaim_authority, + buffers: &[(buffer_a, mint_a), (buffer_b, mint_b)], + }) + .data; + let mut accounts = [ + fake_account(state_pda), + fake_account(reclaim_authority), + fake_account(token_program), + fake_account(buffer_a), + fake_account(mint_a), + fake_account(buffer_b), + fake_account(mint_b), + ]; + + let ReclaimBufferInput { buffers, .. } = + ReclaimBufferInput::parse(&data, &mut accounts).expect("parse should succeed"); + + assert_eq!( + buffers[0].each_ref().map(|a| *a.address()), + [buffer_a, mint_a] + ); + assert_eq!( + buffers[1].each_ref().map(|a| *a.address()), + [buffer_b, mint_b] + ); + } + + #[test] + fn reclaim_buffer_input_rejects_zero_buffers() { + let data = vec![SettlementInstruction::ReclaimBuffer.discriminator()]; + // Only the three shared accounts, no buffer pairs. + let mut accounts = fake_sequential_accounts::(); + assert_eq!( + ReclaimBufferInput::parse(&data, &mut accounts).err(), + Some(ProgramError::NotEnoughAccountKeys), + "an instruction that reclaims no buffers is rejected", + ); + } + + #[test] + fn reclaim_buffer_input_rejects_long_data() { + let mut data = reclaim_buffer_data(); + data.push(0); // trailing byte + assert_eq!( + ReclaimBufferInput::parse(&data, &mut [0]).err(), + Some(ProgramError::InvalidInstructionData), + ); + } + + #[test] + fn reclaim_buffer_input_rejects_missing_accounts() { + let data = reclaim_buffer_data(); + // Fewer than the three shared accounts. + let mut accounts = fake_sequential_accounts::<{ NUM_SHARED_ACCOUNTS - 1 }>(); + assert_eq!( + ReclaimBufferInput::parse(&data, &mut accounts).err(), + Some(ProgramError::NotEnoughAccountKeys), + ); + } + + #[test] + fn reclaim_buffer_input_rejects_incomplete_pair() { + let data = reclaim_buffer_data(); + // Three shared accounts plus one dangling account that can't form a + // full pair. + let mut accounts = fake_sequential_accounts::<4>(); + assert_eq!( + ReclaimBufferInput::parse(&data, &mut accounts).err(), + Some(ProgramError::NotEnoughAccountKeys), + ); + } + + #[test] + fn instruction_data_has_expected_layout() { + let program_id = Pubkey::new_from_array([1; 32]); + let state_pda = Pubkey::new_from_array([2; 32]); + let reclaim_authority = Pubkey::new_from_array([3; 32]); + let buffer_pda = Pubkey::new_from_array([4; 32]); + let mint = Pubkey::new_from_array([5; 32]); + let Instruction { data, .. } = ReclaimBuffer { + program_id, + state_pda, + reclaim_authority, + buffers: &[(buffer_pda, mint)], + } + .into(); + assert_eq!( + data, + vec![SettlementInstruction::ReclaimBuffer.discriminator()] + ); + } + + #[test] + fn single_buffer_has_expected_accounts() { + let program_id = Pubkey::new_from_array([1; 32]); + let state_pda = Pubkey::new_from_array([2; 32]); + let reclaim_authority = Pubkey::new_from_array([3; 32]); + let buffer_pda = Pubkey::new_from_array([4; 32]); + let mint = Pubkey::new_from_array([5; 32]); + let Instruction { accounts, .. } = ReclaimBuffer { + program_id, + state_pda, + reclaim_authority, + buffers: &[(buffer_pda, mint)], + } + .into(); + + assert_eq!(accounts.len(), 5); + // state_pda: read-only, not signer + assert_eq!(accounts[0].pubkey, state_pda); + assert!(!accounts[0].is_writable); + assert!(!accounts[0].is_signer); + // receiver: writable, signer + assert_eq!(accounts[1].pubkey, reclaim_authority); + assert!(accounts[1].is_writable); + assert!(accounts[1].is_signer); + // token program: read-only + assert_eq!(accounts[2].pubkey, SPL_TOKEN_PROGRAM_ID); + assert!(!accounts[2].is_writable); + assert!(!accounts[2].is_signer); + // buffer_pda: writable, not signer + assert_eq!(accounts[3].pubkey, buffer_pda); + assert!(accounts[3].is_writable); + assert!(!accounts[3].is_signer); + // mint: writable, not signer (burning updates the mint's supply) + assert_eq!(accounts[4].pubkey, mint); + assert!(accounts[4].is_writable); + assert!(!accounts[4].is_signer); + } + + #[test] + fn multiple_buffers_append_pairs_after_shared_accounts() { + let program_id = Pubkey::new_from_array([1; 32]); + let state_pda = Pubkey::new_from_array([2; 32]); + let reclaim_authority = Pubkey::new_from_array([3; 32]); + let buffer_a = Pubkey::new_from_array([4; 32]); + let mint_a = Pubkey::new_from_array([5; 32]); + let buffer_b = Pubkey::new_from_array([6; 32]); + let mint_b = Pubkey::new_from_array([7; 32]); + let Instruction { accounts, .. } = ReclaimBuffer { + program_id, + state_pda, + reclaim_authority, + buffers: &[(buffer_a, mint_a), (buffer_b, mint_b)], + } + .into(); + + // Three shared accounts followed by two (buffer, mint) pairs. + assert_eq!(accounts.len(), 3 + 2 * 2); + assert_eq!(accounts[3].pubkey, buffer_a); + assert_eq!(accounts[4].pubkey, mint_a); + assert_eq!(accounts[5].pubkey, buffer_b); + assert_eq!(accounts[6].pubkey, mint_b); + } + + #[test] + fn empty_buffers_has_only_shared_accounts() { + let program_id = Pubkey::new_from_array([1; 32]); + let state_pda = Pubkey::new_from_array([2; 32]); + let reclaim_authority = Pubkey::new_from_array([3; 32]); + let Instruction { accounts, .. } = ReclaimBuffer { + program_id, + state_pda, + reclaim_authority, + buffers: &[], + } + .into(); + assert_eq!(accounts.len(), 3); + } +} diff --git a/interface/src/lib.rs b/interface/src/lib.rs index 03dabac..ee1483d 100644 --- a/interface/src/lib.rs +++ b/interface/src/lib.rs @@ -23,6 +23,7 @@ pub enum SettlementInstruction { Initialize = 3, CreateBuffer = 4, ReclaimOrder = 5, + ReclaimBuffer = 6, } impl SettlementInstruction { @@ -179,6 +180,15 @@ pub enum SettlementError { /// `ReclaimOrder`'s `reclaim_recipient` account doesn't match the /// `created_by` address recorded in the order. ReclaimRecipientMismatch = 31, + /// `ReclaimBuffer`'s `receiver` account isn't a signer, or doesn't match + /// the `receiver` address recorded in the settlement state PDA. + ReclaimAuthorityMismatch = 32, + /// A `ReclaimBuffer` `buffer_pda` doesn't sit at the canonical buffer PDA + /// derived from its paired `mint`. + BufferNotCanonical = 33, + /// A `ReclaimBuffer` `receiver_token_account` isn't the receiver's + /// canonical associated token account for the buffer's mint. + ReceiverTokenAccountMismatch = 34, } impl From for u32 { diff --git a/programs/settlement/Cargo.toml b/programs/settlement/Cargo.toml index 028e2c4..fb94d60 100644 --- a/programs/settlement/Cargo.toml +++ b/programs/settlement/Cargo.toml @@ -30,6 +30,7 @@ solana-address-lookup-table-interface = { workspace = true, features = ["bincode solana-instructions-sysvar.workspace = true solana-sdk.workspace = true solana-system-interface.workspace = true +spl-associated-token-account-interface.workspace = true [lints] workspace = true diff --git a/programs/settlement/src/lib.rs b/programs/settlement/src/lib.rs index 5de01b5..773f64d 100644 --- a/programs/settlement/src/lib.rs +++ b/programs/settlement/src/lib.rs @@ -4,6 +4,7 @@ mod create_buffer; mod create_order; mod initialize; mod processor; +mod reclaim_buffer; mod reclaim_order; mod settle; @@ -11,6 +12,7 @@ use create_buffer::process_create_buffer; use create_order::process_create_order; use initialize::process_initialize; use pinocchio::{entrypoint, AccountView, Address, ProgramResult}; +use reclaim_buffer::process_reclaim_buffer; use reclaim_order::process_reclaim_order; use settle::{process_begin_settle, process_finalize_settle}; use settlement_interface::{recover_discriminator, SettlementInstruction}; @@ -42,5 +44,8 @@ pub fn process_instruction( SettlementInstruction::ReclaimOrder => { process_reclaim_order(program_id, accounts, instruction_data) } + SettlementInstruction::ReclaimBuffer => { + process_reclaim_buffer(program_id, accounts, instruction_data) + } } } diff --git a/programs/settlement/src/processor.rs b/programs/settlement/src/processor.rs index 195bcc9..2e14f0c 100644 --- a/programs/settlement/src/processor.rs +++ b/programs/settlement/src/processor.rs @@ -9,6 +9,10 @@ use pinocchio::{ use pinocchio_system::instructions::CreateAccount; +use settlement_interface::{ + pda::state::{state_pda_seeds, state_pda_signer_seeds}, + SettlementError, +}; use solana_instruction::{syscalls::get_stack_height, TRANSACTION_LEVEL_STACK_HEIGHT}; /// Description of a canonical PDA to create: the account at `pda`, assigned to @@ -99,6 +103,28 @@ impl CanonicalPda<'_, N> { } } +/// Validate that `state_pda_account` is the canonical state PDA and run `f` with +/// a signer for it. Both settlement transfers move funds under the state PDA's +/// authority, so it must sign each of them. +/// +/// The signer only borrows its seed buffers, which are local to this frame; +/// running `f` here rather than returning the signer keeps them alive for as +/// long as `f` needs it. +pub fn with_state_pda_signer( + program_id: &Address, + state_pda_account: &AccountView, + f: impl FnOnce(&Signer) -> ProgramResult, +) -> ProgramResult { + let (state_pda, state_bump) = Address::find_program_address(&state_pda_seeds(), program_id); + if state_pda_account.address() != &state_pda { + return Err(SettlementError::StateAccountMismatch.into()); + } + + let state_bump = [state_bump]; + let signer_seeds = state_pda_signer_seeds(&state_bump).map(Seed::from); + f(&Signer::from(&signer_seeds)) +} + pub fn is_cpi_call() -> bool { get_stack_height() > TRANSACTION_LEVEL_STACK_HEIGHT } diff --git a/programs/settlement/src/reclaim_buffer.rs b/programs/settlement/src/reclaim_buffer.rs new file mode 100644 index 0000000..82367b3 --- /dev/null +++ b/programs/settlement/src/reclaim_buffer.rs @@ -0,0 +1,210 @@ +//! `ReclaimBuffer` instruction handler. +//! +//! Warning: any token balance still held by a buffer is burned, not +//! recovered, before the buffer is closed. Callers should only reclaim +//! buffers expected to be empty, or to write off dust/dead balances. + +use pinocchio::{error::ProgramError, AccountView, Address, ProgramResult}; +use pinocchio_token::{instructions::CloseAccount, state::Account as TokenAccount}; +use settlement_interface::{ + data::state::{EncodedStateAccount, StateAccount}, + instruction::{ + create_buffer::SPL_TOKEN_PROGRAM_ID, reclaim_buffer::ReclaimBufferInput, + InstructionInputParsing, + }, + pda::buffer::find_buffer_pda, + Pubkey, SettlementError, +}; + +use crate::processor::with_state_pda_signer; + +struct ReclaimBufferEntry { + buffer_pda: AccountView, + mint: AccountView, +} + +/// Read one slice element into a [`ReclaimBufferEntry`]. +fn read_buffer_entry(&[buffer_pda, mint]: &[AccountView; 2]) -> ReclaimBufferEntry { + ReclaimBufferEntry { buffer_pda, mint } +} + +pub fn process_reclaim_buffer( + program_id: &Address, + accounts: &mut [AccountView], + instruction_data: &[u8], +) -> ProgramResult { + let ReclaimBufferInput { + state_pda, + reclaim_authority, + token_program, + buffers, + } = ReclaimBufferInput::parse(instruction_data, accounts)?; + + if token_program.address() != &SPL_TOKEN_PROGRAM_ID { + return Err(ProgramError::IncorrectProgramId); + } + + // Only the `reclaim_authority`may trigger a reclaim. + let reclaim_authority_pubkey: Pubkey = { + let data = state_pda.try_borrow()?; + let bytes: &[u8; EncodedStateAccount::SIZE] = (&*data) + .try_into() + .map_err(|_| ProgramError::InvalidAccountData)?; + StateAccount::try_from(*bytes)?.reclaim_authority + }; + if !reclaim_authority.is_signer() + || reclaim_authority.address().as_array() != &reclaim_authority_pubkey.to_bytes() + { + return Err(SettlementError::ReclaimAuthorityMismatch.into()); + } + + with_state_pda_signer(program_id, state_pda, |state_signer| { + for ReclaimBufferEntry { buffer_pda, mint } in buffers.iter().map(read_buffer_entry) { + let expected_buffer_pda = find_buffer_pda(program_id, mint.address()).0; + + if buffer_pda.address() != &expected_buffer_pda { + return Err(SettlementError::BufferNotCanonical.into()); + } + + let amount = TokenAccount::from_account_view(&buffer_pda) + .map_err(|_| ProgramError::InvalidAccountData)? + .amount(); + + // We can't close the account unless the balance is zero, so we burn any tokens we find. + // Sending the tokens to another account is much more complicated because the receiving + // account needs to be loaded and likely initialized with rent--all to handle what is likely + // microdust. So burning is the easiest way to get around this issue. + if amount > 0 { + // For now + continue; + } + + CloseAccount::new(&buffer_pda, reclaim_authority, state_pda) + .invoke_signed(core::slice::from_ref(state_signer))?; + } + + Ok(()) + }) +} + +#[cfg(test)] +mod tests { + use pinocchio::account::RuntimeAccount; + use settlement_interface::instruction::fixtures::{ + fake_account, fake_account_from, fake_account_with_data, fake_sequential_accounts, + }; + use settlement_interface::instruction::reclaim_buffer::fixtures::{ + reclaim_buffer_data, NUM_SHARED_ACCOUNTS, + }; + use settlement_interface::pda::state::state_pda_seeds; + + use super::*; + + const PROGRAM_ID: Address = Address::new_from_array([1; 32]); + + /// Build the canonical state PDA encoding for `receiver`: the + /// discriminator byte followed by `receiver`'s bytes. + fn encoded_state(receiver: Address) -> [u8; EncodedStateAccount::SIZE] { + let mut bytes = [0u8; EncodedStateAccount::SIZE]; + bytes[0] = EncodedStateAccount::DISCRIMINATOR; + bytes[1..].copy_from_slice(&receiver.to_bytes()); + bytes + } + + /// A fake `AccountView` for `address` that reports as a transaction + /// signer, as `receiver` must for `ReclaimBuffer` to accept it. + fn fake_signer(address: Address) -> AccountView { + fake_account_from(RuntimeAccount { + address, + is_signer: 1, + ..Default::default() + }) + } + + #[test] + fn process_reclaim_buffer_propagates_parse_error() { + let mut data = reclaim_buffer_data(); + data.push(0); // make the data too long to trigger a parse error + let mut accounts = fake_sequential_accounts::(); + assert_eq!( + process_reclaim_buffer(&PROGRAM_ID, &mut accounts, &data), + Err(ProgramError::InvalidInstructionData), + ); + } + + #[test] + fn process_reclaim_buffer_rejects_wrong_token_program() { + let data = reclaim_buffer_data(); + let receiver_address = Address::new_unique(); + let mut accounts = [ + fake_account_with_data( + Address::find_program_address(&state_pda_seeds(), &PROGRAM_ID).0, + &encoded_state(receiver_address), + ), // state PDA + fake_signer(receiver_address), // receiver + fake_account(Address::new_unique()), // **wrong** token program + fake_account(Address::new_unique()), // buffer PDA + fake_account(Address::new_unique()), // mint + ]; + assert_eq!( + process_reclaim_buffer(&PROGRAM_ID, &mut accounts, &data), + Err(ProgramError::IncorrectProgramId), + ); + } + + #[test] + fn process_reclaim_buffer_rejects_wrong_state_pda() { + let data = reclaim_buffer_data(); + let receiver_address = Address::new_unique(); + let mut accounts = [ + fake_account_with_data(Address::new_unique(), &encoded_state(receiver_address)), // state PDA + fake_signer(receiver_address), // receiver + fake_account(SPL_TOKEN_PROGRAM_ID), + fake_account(Address::new_unique()), // buffer PDA + fake_account(Address::new_unique()), // mint + ]; + assert_eq!( + process_reclaim_buffer(&PROGRAM_ID, &mut accounts, &data), + Err(SettlementError::StateAccountMismatch.into()), + ); + } + + #[test] + fn process_reclaim_buffer_rejects_wrong_receiver() { + let data = reclaim_buffer_data(); + let mut accounts = [ + fake_account_with_data( + Address::find_program_address(&state_pda_seeds(), &PROGRAM_ID).0, + &encoded_state(Address::new_unique()), + ), // state PDA + fake_signer(Address::new_unique()), // receiver + fake_account(SPL_TOKEN_PROGRAM_ID), + fake_account(Address::new_unique()), // buffer PDA + fake_account(Address::new_unique()), // mint + ]; + assert_eq!( + process_reclaim_buffer(&PROGRAM_ID, &mut accounts, &data), + Err(SettlementError::ReclaimAuthorityMismatch.into()), + ); + } + + #[test] + fn process_reclaim_buffer_rejects_wrong_buffer_pda() { + let data = reclaim_buffer_data(); + let receiver_address = Address::new_unique(); + let mut accounts = [ + fake_account_with_data( + Address::find_program_address(&state_pda_seeds(), &PROGRAM_ID).0, + &encoded_state(receiver_address), + ), // state PDA + fake_signer(receiver_address), // receiver + fake_account(SPL_TOKEN_PROGRAM_ID), + fake_account(Address::new_unique()), // buffer PDA + fake_account(Address::new_unique()), // mint + ]; + assert_eq!( + process_reclaim_buffer(&PROGRAM_ID, &mut accounts, &data), + Err(SettlementError::BufferNotCanonical.into()), + ); + } +} diff --git a/programs/settlement/src/settle/begin.rs b/programs/settlement/src/settle/begin.rs index e87e6c6..cc570fc 100644 --- a/programs/settlement/src/settle/begin.rs +++ b/programs/settlement/src/settle/begin.rs @@ -28,9 +28,9 @@ use settlement_interface::{ recover_discriminator, Pubkey, SettlementError, SettlementInstruction, }; -use crate::processor::is_cpi_call; +use crate::processor::{is_cpi_call, with_state_pda_signer}; -use super::{validate_counterpart, validate_token_program_account, with_state_pda_signer}; +use super::{validate_counterpart, validate_token_program_account}; pub fn process_begin_settle( program_id: &Address, diff --git a/programs/settlement/src/settle/finalize.rs b/programs/settlement/src/settle/finalize.rs index c0661f9..faff4fd 100644 --- a/programs/settlement/src/settle/finalize.rs +++ b/programs/settlement/src/settle/finalize.rs @@ -13,9 +13,9 @@ use settlement_interface::{ SettlementError, SettlementInstruction, }; -use crate::processor::is_cpi_call; +use crate::processor::{is_cpi_call, with_state_pda_signer}; -use super::{validate_counterpart, validate_token_program_account, with_state_pda_signer}; +use super::{validate_counterpart, validate_token_program_account}; pub fn process_finalize_settle( program_id: &Address, diff --git a/programs/settlement/src/settle/mod.rs b/programs/settlement/src/settle/mod.rs index 5776d25..46eea17 100644 --- a/programs/settlement/src/settle/mod.rs +++ b/programs/settlement/src/settle/mod.rs @@ -3,14 +3,10 @@ use std::ops::Deref; use pinocchio::{ - cpi::{Seed, Signer}, - error::ProgramError, - sysvars::instructions::Instructions, - AccountView, Address, ProgramResult, + error::ProgramError, sysvars::instructions::Instructions, AccountView, Address, ProgramResult, }; use settlement_interface::{ instruction::{create_buffer::SPL_TOKEN_PROGRAM_ID, settle::recover_counterpart}, - pda::state::{state_pda_seeds, state_pda_signer_seeds}, recover_discriminator, SettlementError, SettlementInstruction, }; @@ -58,25 +54,3 @@ fn validate_token_program_account(token_program_account: &AccountView) -> Progra } Ok(()) } - -/// Validate that `state_pda_account` is the canonical state PDA and run `f` with -/// a signer for it. Both settlement transfers move funds under the state PDA's -/// authority, so it must sign each of them. -/// -/// The signer only borrows its seed buffers, which are local to this frame; -/// running `f` here rather than returning the signer keeps them alive for as -/// long as `f` needs it. -fn with_state_pda_signer( - program_id: &Address, - state_pda_account: &AccountView, - f: impl FnOnce(&Signer) -> ProgramResult, -) -> ProgramResult { - let (state_pda, state_bump) = Address::find_program_address(&state_pda_seeds(), program_id); - if state_pda_account.address() != &state_pda { - return Err(SettlementError::StateAccountMismatch.into()); - } - - let state_bump = [state_bump]; - let signer_seeds = state_pda_signer_seeds(&state_bump).map(Seed::from); - f(&Signer::from(&signer_seeds)) -} diff --git a/programs/settlement/tests/common/mod.rs b/programs/settlement/tests/common/mod.rs index 6f3d5ca..bc64665 100644 --- a/programs/settlement/tests/common/mod.rs +++ b/programs/settlement/tests/common/mod.rs @@ -78,6 +78,12 @@ pub fn assert_instruction_error( ); } +/// Convenience wrapper around [`assert_instruction_error`] for the common case +/// of asserting a specific [`SettlementError`]. +pub fn assert_settlement_error(result: Result, expected: SettlementError) { + assert_instruction_error(result, to_instruction_error(expected)); +} + /// Place a fresh, rent-exempt account holding `data` and owned by `owner` at a /// new address, and return it. Lets a test populate an arbitrary account (e.g. /// program-owned, with a crafted body or a deliberately wrong size or owner) diff --git a/programs/settlement/tests/common/token.rs b/programs/settlement/tests/common/token.rs index 28a8117..d6eebc4 100644 --- a/programs/settlement/tests/common/token.rs +++ b/programs/settlement/tests/common/token.rs @@ -106,6 +106,12 @@ pub fn fund_and_delegate( ); } +pub fn supply(svm: &LiteSVM, mint: &Pubkey) -> u64 { + litesvm_token::get_spl_account::(svm, mint) + .expect("mint should exist and be a valid SPL token mint") + .supply +} + /// Read the SPL token balance of `account`. pub fn balance(svm: &LiteSVM, account: &Pubkey) -> u64 { litesvm_token::get_spl_account::(svm, account) diff --git a/programs/settlement/tests/reclaim_buffer.rs b/programs/settlement/tests/reclaim_buffer.rs new file mode 100644 index 0000000..d552888 --- /dev/null +++ b/programs/settlement/tests/reclaim_buffer.rs @@ -0,0 +1,208 @@ +use settlement_client::instructions::{CreateBuffers, Initialize, ReclaimBuffer}; +use settlement_client::settlement_interface::{ + instruction::reclaim_buffer::ReclaimBuffer as ReclaimBufferRaw, + pda::{buffer::find_buffer_pda, state::find_state_pda}, + SettlementError, +}; +use solana_sdk::{ + pubkey::Pubkey, + signature::{Keypair, Signer}, +}; +use spl_associated_token_account_interface::address::get_associated_token_address; + +mod common; + +/// Initialize the settlement state PDA with `reclaim_authority` as the configured +/// reclaim_authority. +fn initialize( + svm: &mut litesvm::LiteSVM, + program_id: &Pubkey, + payer: &Keypair, + reclaim_authority: Pubkey, +) { + let ix = Initialize { + program_id: *program_id, + payer: payer.pubkey(), + reclaim_authority, + }; + let tx = common::signed_tx(svm, payer, payer, ix); + svm.send_transaction(tx).expect("initialize should succeed"); +} + +/// Create a buffer for `mint`, return its PDA. +fn create_buffer( + svm: &mut litesvm::LiteSVM, + program_id: &Pubkey, + payer: &Keypair, + mint: &Pubkey, +) -> Pubkey { + let (buffer_pda, _bump) = find_buffer_pda(program_id, mint); + let ix = CreateBuffers { + program_id: *program_id, + payer: payer.pubkey(), + mints: &[*mint], + }; + let tx = common::signed_tx(svm, payer, payer, ix); + svm.send_transaction(tx) + .expect("create_buffer should succeed"); + buffer_pda +} + +#[test] +fn funded_buffer_is_skipped() { + let (mut svm, program_id, payer) = common::setup(); + let reclaim_authority = Keypair::new(); + + initialize(&mut svm, &program_id, &payer, reclaim_authority.pubkey()); + + let mint = common::token::create_mint(&mut svm, &payer); + let buffer_pda = create_buffer(&mut svm, &program_id, &payer, &mint); + + // Fund the buffer with tokens + let amount = 1_000; + common::token::mint_to(&mut svm, &payer, &mint, &buffer_pda, amount); + + // Pre-create the reclaim_authority's ATA: the program only validates its address, + // it doesn't create it. + let reclaim_authority_ata = common::token::create_associated_token_account( + &mut svm, + &payer, + &mint, + &reclaim_authority.pubkey(), + ); + assert_eq!( + reclaim_authority_ata, + get_associated_token_address(&reclaim_authority.pubkey(), &mint), + "sanity: helper should derive the canonical ATA" + ); + + let ix = ReclaimBuffer { + program_id, + reclaim_authority: reclaim_authority.pubkey(), + mints: &[mint], + }; + let tx = common::signed_tx(&svm, &payer, &reclaim_authority, ix); + svm.send_transaction(tx) + .expect("reclaim_buffer should succeed"); + + assert!( + svm.get_account(&buffer_pda).is_some(), + "buffer PDA should have been untouched despite transaction succeeding" + ); +} + +#[test] +fn happy_path_reclaims_empty_buffer_without_token_transfer() { + let (mut svm, program_id, payer) = common::setup(); + let reclaim_authority = Keypair::new(); + + initialize(&mut svm, &program_id, &payer, reclaim_authority.pubkey()); + + let mint = common::token::create_mint(&mut svm, &payer); + let buffer_pda = create_buffer(&mut svm, &program_id, &payer, &mint); + + let buffer_lamports_before = svm + .get_account(&buffer_pda) + .expect("buffer must exist before reclaim") + .lamports; + let reclaim_authority_lamports_before = common::lamports(&svm, &reclaim_authority.pubkey()); + + let ix = ReclaimBuffer { + program_id, + reclaim_authority: reclaim_authority.pubkey(), + mints: &[mint], + }; + let tx = common::signed_tx(&svm, &payer, &reclaim_authority, ix); + svm.send_transaction(tx) + .expect("reclaim_buffer should succeed"); + + assert!( + svm.get_account(&buffer_pda).is_none(), + "buffer PDA must be closed after reclaim" + ); + assert_eq!( + common::lamports(&svm, &reclaim_authority.pubkey()) - reclaim_authority_lamports_before, + buffer_lamports_before, + "reclaim_authority must receive exactly the buffer's rent lamports" + ); +} + +#[test] +fn reclaims_multiple_buffers_in_one_instruction() { + let (mut svm, program_id, payer) = common::setup(); + let reclaim_authority = Keypair::new(); + + initialize(&mut svm, &program_id, &payer, reclaim_authority.pubkey()); + + let mint_a = common::token::create_mint(&mut svm, &payer); + let mint_b = common::token::create_mint(&mut svm, &payer); + let buffer_a = create_buffer(&mut svm, &program_id, &payer, &mint_a); + let buffer_b = create_buffer(&mut svm, &program_id, &payer, &mint_b); + + // fund one of the buffers with tokens, leave the other empty + common::token::mint_to(&mut svm, &payer, &mint_b, &buffer_b, 500); + + let ix = ReclaimBuffer { + program_id, + reclaim_authority: reclaim_authority.pubkey(), + mints: &[mint_a, mint_b], + }; + let tx = common::signed_tx(&svm, &payer, &reclaim_authority, ix); + svm.send_transaction(tx) + .expect("reclaim_buffer should succeed"); + + assert!( + svm.get_account(&buffer_a).is_none(), + "buffer_a must be closed" + ); + assert!( + svm.get_account(&buffer_b).is_some(), + "buffer_b must not be closed (because its funded)" + ); +} + +#[test] +fn rejects_when_signer_is_not_the_configured_reclaim_authority() { + let (mut svm, program_id, payer) = common::setup(); + let reclaim_authority = Keypair::new(); + let impostor = Keypair::new(); + svm.airdrop(&impostor.pubkey(), 1_000_000_000) + .expect("airdrop should succeed"); + + initialize(&mut svm, &program_id, &payer, reclaim_authority.pubkey()); + + let mint = common::token::create_mint(&mut svm, &payer); + create_buffer(&mut svm, &program_id, &payer, &mint); + + // Build the instruction as if `impostor` were the configured reclaim_authority. + let ix = ReclaimBuffer { + program_id, + reclaim_authority: impostor.pubkey(), + mints: &[mint], + }; + let tx = common::signed_tx(&svm, &payer, &impostor, ix); + common::assert_settlement_error( + svm.send_transaction(tx).map_err(|e| e.err), + SettlementError::ReclaimAuthorityMismatch, + ); +} + +#[test] +fn rejects_no_buffers() { + let (mut svm, program_id, payer) = common::setup(); + let reclaim_authority = Keypair::new(); + initialize(&mut svm, &program_id, &payer, reclaim_authority.pubkey()); + + let (state_pda, _) = find_state_pda(&program_id); + let ix = ReclaimBufferRaw { + program_id, + state_pda, + reclaim_authority: reclaim_authority.pubkey(), + buffers: &[], + }; + let tx = common::signed_tx(&svm, &payer, &reclaim_authority, ix); + assert!( + svm.send_transaction(tx).is_err(), + "an instruction that reclaims no buffers must be rejected" + ); +}