Skip to main content

cow_settlement_interface/instruction/
reclaim_buffer.rs

1//! `ReclaimBuffer` instruction builder.
2//!
3//! Closes one or more buffer PDAs (see [`crate::pda::buffer`]) and sends each
4//! closed buffer's rent lamports to a `reclaim_recipient` of the caller's
5//! choosing. Only the settlement's configured `reclaim_authority` (see
6//! [`crate::data::state::StateAccount`]) may authorize this.
7//!
8//! Each relaimed buffer takes a burn limit. Up to this amount of balance is
9//! burned, so that rent reclamation remains possible when the tokens can't be
10//! otherwise be disposed of. A zero limit prevents any burning and is the
11//! expected standard way to reclaim buffers, since most tokens should rather
12//! be sold in advance than burned.
13//!
14//! The token_program supplied to this instruction must be the owner of all mints
15//! supplied. Only one token program can be supplied to this instruction at a time.
16//! If mints from two separate token programs are required, the client needs to
17//! divide it into separate instructions.
18//!
19//! Wire format (with `n` buffers): `[discriminator=6][burn_limit: u64 LE ×n]`.
20//! Required accounts:
21//! `[state_pda (R), reclaim_authority (R,S), reclaim_recipient (W),
22//! token_program (R), (buffer_pda (W), mint (W))...]`.
23
24use solana_instruction::{AccountMeta, Instruction};
25use solana_program_error::ProgramError;
26use solana_pubkey::Pubkey;
27
28use super::InstructionInputParsing;
29use crate::SettlementInstruction;
30
31/// Number of accounts each buffer contributes to the instruction: its buffer
32/// PDA and its mint.
33pub const ACCOUNTS_PER_BUFFER: usize = 2;
34
35/// Builder for a `ReclaimBuffer` instruction that closes one buffer per
36/// `(buffer_pda, mint, burn_limit)` entry in `buffers`.
37///
38/// `state_pda` must be the settlement state PDA (see
39/// [`crate::pda::state::find_state_pda`]). `reclaim_authority`
40/// must sign and must match the `reclaim_authority` recorded in the state PDA's data.
41/// `reclaim_recipient` is the account receiving the closed buffer's lamports.
42/// Each `buffer_pda` must be the canonical PDA returned by
43/// [`crate::pda::buffer::find_buffer_pda`] for its paired `mint`, which is
44/// passed both so that derivation can be checked on-chain and so the balance can
45/// be burned when clearing the buffer.
46///
47/// `burn_limit` caps how much of a buffer's balance may be burned to clear it:
48/// a buffer whose balance doesn't exceed its limit is burned to zero (when
49/// non-empty) and closed, while one that exceeds its limit is skipped so the
50/// rest of the batch still closes; the instruction reverts only when every
51/// buffer is over its limit. A zero limit prevents any burning.
52pub struct ReclaimBuffer<'a> {
53    pub program_id: Pubkey,
54    pub state_pda: Pubkey,
55    pub reclaim_authority: Pubkey,
56    pub reclaim_recipient: Pubkey,
57    /// The token program owning every buffer this instruction closes. Must be
58    /// the address of a [`crate::token_program::TokenProgram`].
59    pub token_program: Pubkey,
60    /// One `(buffer_pda, mint, burn_limit)` entry per buffer to close.
61    pub buffers: &'a [(Pubkey, Pubkey, u64)],
62}
63
64impl From<ReclaimBuffer<'_>> for Instruction {
65    fn from(builder: ReclaimBuffer<'_>) -> Self {
66        let mut accounts = vec![
67            AccountMeta::new_readonly(builder.state_pda, false),
68            AccountMeta::new_readonly(builder.reclaim_authority, true),
69            AccountMeta::new(builder.reclaim_recipient, false),
70            AccountMeta::new_readonly(builder.token_program, false),
71        ];
72        let mut data = vec![SettlementInstruction::ReclaimBuffer.discriminator()];
73        for (buffer_pda, mint, burn_limit) in builder.buffers {
74            accounts.push(AccountMeta::new(*buffer_pda, false));
75            // The mint is writable so the handler can burn the buffer's
76            // balance to clear it, which decrements the mint's supply.
77            accounts.push(AccountMeta::new(*mint, false));
78            data.extend_from_slice(&burn_limit.to_le_bytes());
79        }
80        Instruction {
81            program_id: builder.program_id,
82            accounts,
83            data,
84        }
85    }
86}
87
88/// Decoded info for a single buffer to reclaim
89pub struct Buffer<'a, A> {
90    pub buffer_pda: &'a A,
91    pub mint: &'a A,
92    pub burn_limit: u64,
93}
94
95/// The `(buffer_pda, mint)` account pairs of a `ReclaimBuffer` and their
96/// parallel per-buffer burn limits. Parsing guarantees the two have the same
97/// length.
98pub struct Buffers<'a, A> {
99    accounts: &'a [[A; ACCOUNTS_PER_BUFFER]],
100    burn_limits: &'a [[u8; core::mem::size_of::<u64>()]],
101}
102
103impl<'a, A> Buffers<'a, A> {
104    pub fn iter(&self) -> impl Iterator<Item = Buffer<'a, A>> + '_ {
105        self.accounts
106            .iter()
107            .zip(self.burn_limits)
108            .map(|([buffer_pda, mint], burn_limit)| Buffer {
109                buffer_pda,
110                mint,
111                burn_limit: u64::from_le_bytes(*burn_limit),
112            })
113    }
114}
115
116/// Parsed inputs of a `ReclaimBuffer` instruction.
117pub struct ReclaimBufferInput<'a, A> {
118    pub state_pda: &'a A,
119    pub reclaim_authority: &'a A,
120    pub reclaim_recipient: &'a A,
121    pub buffers: Buffers<'a, A>,
122}
123
124impl<'a, A> InstructionInputParsing<'a, A> for ReclaimBufferInput<'a, A> {
125    const DISCRIMINATOR: SettlementInstruction = SettlementInstruction::ReclaimBuffer;
126
127    fn parse_body(instruction_data: &'a [u8], accounts: &'a [A]) -> Result<Self, ProgramError> {
128        // Accounts: [state_pda (R), reclaim_authority (R,S), reclaim_recipient
129        // (W), token_program (R), (buffer_pda (W), mint (W))...]. The four
130        // shared accounts come first; the per-buffer pairs follow, one pair per
131        // buffer. The token program is skipped rather than read: each buffer is
132        // closed by the program that owns it, so the account is only there to
133        // put that program in the transaction.
134        let [state_pda, reclaim_authority, reclaim_recipient, _token_program, rest @ ..] = accounts
135        else {
136            return Err(ProgramError::NotEnoughAccountKeys);
137        };
138        // Group the trailing accounts into `[buffer_pda, mint]` pairs. Each
139        // buffer needs both, so a stray leftover account is a malformed
140        // instruction. There must be at least one pair: an instruction that
141        // reclaims no buffers is rejected as a likely encoding issue.
142        let (buffer_pairs, remainder) = rest.as_chunks::<ACCOUNTS_PER_BUFFER>();
143        if !remainder.is_empty() || buffer_pairs.is_empty() {
144            return Err(ProgramError::NotEnoughAccountKeys);
145        }
146
147        // The data carries one little-endian `u64` burn limit per buffer, in
148        // buffer order. A trailing partial limit, or a limit count that doesn't
149        // match the buffers, is a malformed instruction.
150        let (burn_limits, remainder) =
151            instruction_data.as_chunks::<{ core::mem::size_of::<u64>() }>();
152        if !remainder.is_empty() || burn_limits.len() != buffer_pairs.len() {
153            return Err(ProgramError::InvalidInstructionData);
154        }
155
156        Ok(Self {
157            state_pda,
158            reclaim_authority,
159            reclaim_recipient,
160            buffers: Buffers {
161                accounts: buffer_pairs,
162                burn_limits,
163            },
164        })
165    }
166}
167
168/// Test scaffolding for `ReclaimBuffer` parsing and handling, shared by this
169/// crate's tests and the settlement program's via the `test-fixtures` feature.
170#[cfg(any(test, feature = "test-fixtures"))]
171pub mod fixtures {
172    use solana_address::Address;
173
174    use super::{Instruction, ReclaimBuffer};
175
176    /// Number of accounts that don't depend on the number of buffers
177    /// reclaimed: state PDA, reclaim authority, reclaim recipient, and token
178    /// program.
179    pub const NUM_SHARED_ACCOUNTS: usize = 4;
180
181    /// `ReclaimBuffer` instruction data with placeholder addresses and data,
182    /// for failure cases where the input is irrelevant.
183    pub fn reclaim_buffer_data() -> Vec<u8> {
184        let zero = Address::new_from_array([0; 32]);
185        Instruction::from(ReclaimBuffer {
186            program_id: zero,
187            state_pda: zero,
188            reclaim_authority: zero,
189            reclaim_recipient: zero,
190            token_program: zero,
191            buffers: &[(zero, zero, 0)],
192        })
193        .data
194    }
195}
196
197#[cfg(test)]
198mod tests {
199    use super::fixtures::{reclaim_buffer_data, NUM_SHARED_ACCOUNTS};
200    use super::*;
201    use crate::fixtures::pubkey_from_seed;
202    use crate::instruction::fixtures::{fake_account, fake_sequential_accounts};
203    use crate::instruction::tests::{
204        assert_readonly_nonsigner, assert_readonly_signer, assert_writable_nonsigner,
205    };
206
207    #[test]
208    fn reclaim_buffer_input_parses_valid_input() {
209        let program_id = pubkey_from_seed("program id");
210        let state_pda = pubkey_from_seed("state pda");
211        let reclaim_authority = pubkey_from_seed("reclaim authority");
212        let reclaim_recipient = pubkey_from_seed("reclaim recipient");
213        let token_program = pubkey_from_seed("token program");
214        let buffer_pda = pubkey_from_seed("buffer pda");
215        let mint = pubkey_from_seed("mint");
216
217        let data = Instruction::from(ReclaimBuffer {
218            program_id,
219            state_pda,
220            reclaim_authority,
221            reclaim_recipient,
222            token_program,
223            buffers: &[(buffer_pda, mint, 1337)],
224        })
225        .data;
226        let token_program = pubkey_from_seed("token program");
227        let accounts = [
228            fake_account(state_pda),
229            fake_account(reclaim_authority),
230            fake_account(reclaim_recipient),
231            fake_account(token_program),
232            fake_account(buffer_pda),
233            fake_account(mint),
234        ];
235
236        let ReclaimBufferInput {
237            state_pda: parsed_state_pda,
238            reclaim_authority: parsed_reclaim_authority,
239            reclaim_recipient: parsed_reclaim_recipient,
240            buffers,
241        } = ReclaimBufferInput::parse(&data, &accounts).expect("parse should succeed");
242
243        assert_eq!(*parsed_state_pda.address(), state_pda);
244        assert_eq!(*parsed_reclaim_authority.address(), reclaim_authority);
245        assert_eq!(*parsed_reclaim_recipient.address(), reclaim_recipient);
246        let parsed: Vec<_> = buffers.iter().collect();
247        let [Buffer {
248            buffer_pda: parsed_buffer_pda,
249            mint: parsed_mint,
250            burn_limit,
251        }] = parsed.as_slice()
252        else {
253            panic!("expected exactly one parsed buffer");
254        };
255        assert_eq!(*parsed_buffer_pda.address(), buffer_pda);
256        assert_eq!(*parsed_mint.address(), mint);
257        assert_eq!(*burn_limit, 1337);
258    }
259
260    #[test]
261    fn reclaim_buffer_input_parses_multiple_buffers() {
262        let program_id = pubkey_from_seed("program id");
263        let state_pda = pubkey_from_seed("state pda");
264        let reclaim_authority = pubkey_from_seed("reclaim authority");
265        let reclaim_recipient = pubkey_from_seed("reclaim recipient");
266        let token_program = pubkey_from_seed("token program");
267        let buffer_a = pubkey_from_seed("buffer a");
268        let mint_a = pubkey_from_seed("mint a");
269        let buffer_b = pubkey_from_seed("buffer b");
270        let mint_b = pubkey_from_seed("mint b");
271
272        let data = Instruction::from(ReclaimBuffer {
273            program_id,
274            state_pda,
275            token_program,
276            reclaim_authority,
277            reclaim_recipient,
278            buffers: &[(buffer_a, mint_a, 42), (buffer_b, mint_b, 1337)],
279        })
280        .data;
281        let accounts = [
282            fake_account(state_pda),
283            fake_account(reclaim_authority),
284            fake_account(reclaim_recipient),
285            fake_account(token_program),
286            fake_account(buffer_a),
287            fake_account(mint_a),
288            fake_account(buffer_b),
289            fake_account(mint_b),
290        ];
291
292        let ReclaimBufferInput { buffers, .. } =
293            ReclaimBufferInput::parse(&data, &accounts).expect("parse should succeed");
294
295        let parsed: Vec<_> = buffers.iter().collect();
296        let [Buffer {
297            buffer_pda: parsed_buffer_a,
298            mint: parsed_mint_a,
299            burn_limit: limit_a,
300        }, Buffer {
301            buffer_pda: parsed_buffer_b,
302            mint: parsed_mint_b,
303            burn_limit: limit_b,
304        }] = parsed.as_slice()
305        else {
306            panic!("expected exactly two parsed buffers");
307        };
308        assert_eq!(*parsed_buffer_a.address(), buffer_a);
309        assert_eq!(*parsed_mint_a.address(), mint_a);
310        assert_eq!(*limit_a, 42);
311        assert_eq!(*parsed_buffer_b.address(), buffer_b);
312        assert_eq!(*parsed_mint_b.address(), mint_b);
313        assert_eq!(*limit_b, 1337);
314    }
315
316    #[test]
317    fn reclaim_buffer_input_rejects_zero_buffers() {
318        let data = reclaim_buffer_data();
319        // Only the four shared accounts, no buffer pairs.
320        let accounts = fake_sequential_accounts::<NUM_SHARED_ACCOUNTS>();
321        assert_eq!(
322            ReclaimBufferInput::parse(&data, &accounts).err(),
323            Some(ProgramError::NotEnoughAccountKeys),
324            "an instruction that reclaims no buffers is rejected",
325        );
326    }
327
328    #[test]
329    fn reclaim_buffer_input_rejects_a_partial_trailing_limit() {
330        let mut data = reclaim_buffer_data();
331        data.push(0); // trailing byte
332        let accounts = fake_sequential_accounts::<{ NUM_SHARED_ACCOUNTS + ACCOUNTS_PER_BUFFER }>();
333        assert_eq!(
334            ReclaimBufferInput::parse(&data, &accounts).err(),
335            Some(ProgramError::InvalidInstructionData),
336        );
337    }
338
339    #[test]
340    fn reclaim_buffer_input_rejects_a_limit_count_that_does_not_match_the_buffers() {
341        // Data for one buffer, but two buffer pairs in the accounts.
342        let data = reclaim_buffer_data();
343        let accounts =
344            fake_sequential_accounts::<{ NUM_SHARED_ACCOUNTS + 2 * ACCOUNTS_PER_BUFFER }>();
345        assert_eq!(
346            ReclaimBufferInput::parse(&data, &accounts).err(),
347            Some(ProgramError::InvalidInstructionData),
348        );
349    }
350
351    #[test]
352    fn reclaim_buffer_input_rejects_missing_accounts() {
353        let data = reclaim_buffer_data();
354        // Fewer than the four shared accounts.
355        let accounts = fake_sequential_accounts::<{ NUM_SHARED_ACCOUNTS - 1 }>();
356        assert_eq!(
357            ReclaimBufferInput::parse(&data, &accounts).err(),
358            Some(ProgramError::NotEnoughAccountKeys),
359        );
360    }
361
362    #[test]
363    fn reclaim_buffer_input_rejects_incomplete_pair() {
364        let data = reclaim_buffer_data();
365        // The shared accounts plus one dangling account that can't form a
366        // full pair.
367        let accounts =
368            fake_sequential_accounts::<{ NUM_SHARED_ACCOUNTS + ACCOUNTS_PER_BUFFER + 1 }>();
369        assert_eq!(
370            ReclaimBufferInput::parse(&data, &accounts).err(),
371            Some(ProgramError::NotEnoughAccountKeys),
372        );
373    }
374
375    #[test]
376    fn instruction_data_has_expected_layout() {
377        let program_id = pubkey_from_seed("program id");
378        let state_pda = pubkey_from_seed("state pda");
379        let reclaim_authority = pubkey_from_seed("reclaim authority");
380        let reclaim_recipient = pubkey_from_seed("reclaim recipient");
381        let token_program = pubkey_from_seed("token program");
382        let buffer_pda = pubkey_from_seed("buffer pda");
383        let mint = pubkey_from_seed("mint");
384        let Instruction { data, .. } = ReclaimBuffer {
385            program_id,
386            state_pda,
387            reclaim_authority,
388            reclaim_recipient,
389            token_program,
390            buffers: &[(buffer_pda, mint, 0x1337)],
391        }
392        .into();
393        let mut expected = vec![SettlementInstruction::ReclaimBuffer.discriminator()];
394        expected.extend_from_slice(&[0x37, 0x13, 0, 0, 0, 0, 0, 0]);
395        assert_eq!(data, expected);
396    }
397
398    #[test]
399    fn single_buffer_has_expected_accounts() {
400        let program_id = pubkey_from_seed("program id");
401        let state_pda = pubkey_from_seed("state pda");
402        let reclaim_authority = pubkey_from_seed("reclaim authority");
403        let reclaim_recipient = pubkey_from_seed("reclaim recipient");
404        let token_program = pubkey_from_seed("token program");
405        let buffer_pda = pubkey_from_seed("buffer pda");
406        let mint = pubkey_from_seed("mint");
407        let Instruction { accounts, .. } = ReclaimBuffer {
408            program_id,
409            state_pda,
410            reclaim_authority,
411            reclaim_recipient,
412            token_program,
413            buffers: &[(buffer_pda, mint, 0)],
414        }
415        .into();
416
417        assert_eq!(accounts.len(), 6);
418        assert_readonly_nonsigner(&accounts[0], state_pda);
419        assert_readonly_signer(&accounts[1], reclaim_authority);
420        assert_writable_nonsigner(&accounts[2], reclaim_recipient);
421        assert_readonly_nonsigner(&accounts[3], token_program);
422        assert_writable_nonsigner(&accounts[4], buffer_pda);
423        assert_writable_nonsigner(&accounts[5], mint);
424    }
425
426    #[test]
427    fn recipient_may_be_the_reclaim_authority_itself() {
428        let reclaim_authority = pubkey_from_seed("reclaim authority");
429        let Instruction { accounts, .. } = ReclaimBuffer {
430            program_id: pubkey_from_seed("program id"),
431            state_pda: pubkey_from_seed("state pda"),
432            reclaim_authority,
433            reclaim_recipient: reclaim_authority,
434            token_program: pubkey_from_seed("token program"),
435            buffers: &[(pubkey_from_seed("buffer pda"), pubkey_from_seed("mint"), 0)],
436        }
437        .into();
438
439        assert_readonly_signer(&accounts[1], reclaim_authority);
440        assert_writable_nonsigner(&accounts[2], reclaim_authority);
441    }
442
443    #[test]
444    fn multiple_buffers_append_pairs_after_shared_accounts() {
445        let program_id = pubkey_from_seed("program id");
446        let state_pda = pubkey_from_seed("state pda");
447        let reclaim_authority = pubkey_from_seed("reclaim authority");
448        let reclaim_recipient = pubkey_from_seed("reclaim recipient");
449        let token_program = pubkey_from_seed("token program");
450        let buffer_a = pubkey_from_seed("buffer a");
451        let mint_a = pubkey_from_seed("mint a");
452        let buffer_b = pubkey_from_seed("buffer b");
453        let mint_b = pubkey_from_seed("mint b");
454        let Instruction { accounts, .. } = ReclaimBuffer {
455            program_id,
456            state_pda,
457            reclaim_authority,
458            reclaim_recipient,
459            token_program,
460            buffers: &[(buffer_a, mint_a, 0), (buffer_b, mint_b, 0)],
461        }
462        .into();
463
464        // The shared accounts followed by two (buffer, mint) pairs.
465        assert_eq!(
466            accounts.len(),
467            NUM_SHARED_ACCOUNTS + 2 * ACCOUNTS_PER_BUFFER
468        );
469        assert_writable_nonsigner(&accounts[4], buffer_a);
470        assert_writable_nonsigner(&accounts[5], mint_a);
471        assert_writable_nonsigner(&accounts[6], buffer_b);
472        assert_writable_nonsigner(&accounts[7], mint_b);
473    }
474
475    #[test]
476    fn empty_buffers_has_only_shared_accounts() {
477        let program_id = pubkey_from_seed("program id");
478        let state_pda = pubkey_from_seed("state pda");
479        let reclaim_authority = pubkey_from_seed("reclaim authority");
480        let reclaim_recipient = pubkey_from_seed("reclaim recipient");
481        let token_program = pubkey_from_seed("token program");
482        let Instruction { accounts, .. } = ReclaimBuffer {
483            program_id,
484            state_pda,
485            reclaim_authority,
486            reclaim_recipient,
487            token_program,
488            buffers: &[],
489        }
490        .into();
491        assert_eq!(accounts.len(), NUM_SHARED_ACCOUNTS);
492    }
493}