Skip to main content

solana_token_toolkit/
plan.rs

1//! Tier 3 — token-account preparation plan generation.
2
3use std::collections::HashMap;
4
5use solana_account::Account;
6use solana_instruction::Instruction;
7use solana_keypair::Keypair;
8use solana_pubkey::Pubkey;
9use solana_signer::Signer;
10use solana_system_interface::instruction as system_instruction;
11use spl_associated_token_account_interface::instruction::create_associated_token_account_idempotent;
12use spl_token_2022_interface::instruction::{close_account, initialize_account3, sync_native};
13use spl_token_interface::native_mint;
14
15use crate::{
16    state::{MintAndAta, TokenAccountState},
17    TokenError,
18};
19
20/// What the caller wants for each mint.
21#[derive(Debug, Clone)]
22pub struct TokenAccountIntent {
23    /// Map of mint pubkey to intent.
24    pub mints: HashMap<Pubkey, MintIntent>,
25}
26
27/// Per-mint intent.
28#[derive(Debug, Clone, Copy, PartialEq, Eq)]
29pub enum MintIntent {
30    /// Just ensure the ATA exists. No balance preparation.
31    EnsureAtaExists,
32    /// SOL/wSOL only: ensure the wSOL account holds at least `lamports`.
33    WithBalance {
34        /// Required lamport balance in the wSOL account.
35        lamports: u64,
36    },
37}
38
39/// How to wrap native SOL when needed.
40#[derive(Debug, Clone, Copy, PartialEq, Eq)]
41pub enum WrapSolStrategy {
42    /// Use the persistent wSOL ATA.
43    Ata,
44    /// Create an ephemeral keypair-based wSOL account.
45    Keypair,
46    /// Do not wrap. Incoherent with `MintIntent::WithBalance` for native SOL.
47    None,
48}
49
50/// Output of `prepare_token_accounts`.
51#[derive(Debug)]
52pub struct TokenAccountPlan {
53    /// Instructions to execute before the main transaction body.
54    pub create_instructions: Vec<Instruction>,
55    /// Instructions to execute after the main transaction body.
56    pub cleanup_instructions: Vec<Instruction>,
57    /// Ephemeral keypairs for `WrapSolStrategy::Keypair`.
58    pub additional_signers: Vec<Keypair>,
59    /// Mint pubkey to actual token account address.
60    pub token_account_addresses: HashMap<Pubkey, Pubkey>,
61}
62
63/// Build the token-account preparation plan for a transaction.
64///
65/// # Example
66///
67/// ```no_run
68/// # use std::collections::HashMap;
69/// # use solana_token_toolkit::*;
70/// # use solana_pubkey::Pubkey;
71/// let owner = Pubkey::new_unique();
72/// let state = TokenAccountState::empty(owner);
73/// let intent = TokenAccountIntent { mints: HashMap::new() };
74/// let plan = prepare_token_accounts(&state, &intent, WrapSolStrategy::Ata, 0).unwrap();
75/// assert!(plan.create_instructions.is_empty());
76/// ```
77pub fn prepare_token_accounts(
78    state: &TokenAccountState,
79    intent: &TokenAccountIntent,
80    wsol_strategy: WrapSolStrategy,
81    rent_exempt_lamports: u64,
82) -> Result<TokenAccountPlan, TokenError> {
83    let mut plan = TokenAccountPlan {
84        create_instructions: Vec::new(),
85        cleanup_instructions: Vec::new(),
86        additional_signers: Vec::new(),
87        token_account_addresses: HashMap::new(),
88    };
89
90    let mut sorted_intent: Vec<(&Pubkey, &MintIntent)> = intent.mints.iter().collect();
91    sorted_intent.sort_by_key(|(pubkey, _)| **pubkey);
92
93    for (mint_pubkey, mint_intent) in sorted_intent {
94        let entry = state
95            .mints
96            .get(mint_pubkey)
97            .ok_or(TokenError::MintNotFound(*mint_pubkey))?;
98        let is_native_sol = *mint_pubkey == native_mint::ID;
99
100        match (is_native_sol, mint_intent) {
101            (true, MintIntent::WithBalance { lamports }) => handle_wrap_sol(
102                state.owner,
103                entry,
104                *lamports,
105                wsol_strategy,
106                rent_exempt_lamports,
107                &mut plan,
108            )?,
109            (true, MintIntent::EnsureAtaExists) | (false, MintIntent::EnsureAtaExists) => {
110                ensure_ata_exists(state.owner, *mint_pubkey, entry, &mut plan);
111            }
112            (false, MintIntent::WithBalance { .. }) => {
113                return Err(TokenError::WithBalanceNotSupported(*mint_pubkey));
114            }
115        }
116    }
117
118    Ok(plan)
119}
120
121fn ensure_ata_exists(
122    owner: Pubkey,
123    mint_pubkey: Pubkey,
124    entry: &MintAndAta,
125    plan: &mut TokenAccountPlan,
126) {
127    if entry.ata_account.is_none() {
128        plan.create_instructions
129            .push(create_associated_token_account_idempotent(
130                &owner,
131                &owner,
132                &mint_pubkey,
133                &entry.mint_account.owner,
134            ));
135    }
136    plan.token_account_addresses
137        .insert(mint_pubkey, entry.ata_address);
138}
139
140fn handle_wrap_sol(
141    owner: Pubkey,
142    entry: &MintAndAta,
143    required_lamports: u64,
144    strategy: WrapSolStrategy,
145    rent_exempt_lamports: u64,
146    plan: &mut TokenAccountPlan,
147) -> Result<(), TokenError> {
148    use spl_token::ID as TOKEN_PROGRAM_ID;
149
150    match strategy {
151        WrapSolStrategy::Ata => {
152            let existing_balance = read_token_balance(entry.ata_address, &entry.ata_account)?;
153            let ata_did_not_exist = entry.ata_account.is_none();
154
155            if ata_did_not_exist {
156                plan.create_instructions
157                    .push(create_associated_token_account_idempotent(
158                        &owner,
159                        &owner,
160                        &native_mint::ID,
161                        &TOKEN_PROGRAM_ID,
162                    ));
163            }
164
165            if existing_balance < required_lamports {
166                let delta = required_lamports - existing_balance;
167                plan.create_instructions.push(system_instruction::transfer(
168                    &owner,
169                    &entry.ata_address,
170                    delta,
171                ));
172                plan.create_instructions.push(
173                    sync_native(&TOKEN_PROGRAM_ID, &entry.ata_address)
174                        .map_err(|e| TokenError::InstructionBuild(format!("sync_native: {e}")))?,
175                );
176            }
177
178            if ata_did_not_exist {
179                plan.cleanup_instructions.push(
180                    close_account(&TOKEN_PROGRAM_ID, &entry.ata_address, &owner, &owner, &[])
181                        .map_err(|e| TokenError::InstructionBuild(format!("close_account: {e}")))?,
182                );
183            }
184
185            plan.token_account_addresses
186                .insert(native_mint::ID, entry.ata_address);
187        }
188        WrapSolStrategy::Keypair => {
189            let kp = Keypair::new();
190            let token_account_pubkey = kp.pubkey();
191            let lamports = required_lamports + rent_exempt_lamports;
192
193            plan.create_instructions
194                .push(system_instruction::create_account(
195                    &owner,
196                    &token_account_pubkey,
197                    lamports,
198                    {
199                        use solana_program_pack::Pack;
200                        spl_token::state::Account::LEN as u64
201                    },
202                    &TOKEN_PROGRAM_ID,
203                ));
204            plan.create_instructions.push(
205                initialize_account3(
206                    &TOKEN_PROGRAM_ID,
207                    &token_account_pubkey,
208                    &native_mint::ID,
209                    &owner,
210                )
211                .map_err(|e| TokenError::InstructionBuild(format!("initialize_account3: {e}")))?,
212            );
213            plan.cleanup_instructions.push(
214                close_account(
215                    &TOKEN_PROGRAM_ID,
216                    &token_account_pubkey,
217                    &owner,
218                    &owner,
219                    &[],
220                )
221                .map_err(|e| TokenError::InstructionBuild(format!("close_account: {e}")))?,
222            );
223            plan.token_account_addresses
224                .insert(native_mint::ID, token_account_pubkey);
225            plan.additional_signers.push(kp);
226        }
227        WrapSolStrategy::None => return Err(TokenError::IncoherentWrapStrategy),
228    }
229    Ok(())
230}
231
232fn read_token_balance(
233    token_account_pubkey: Pubkey,
234    account: &Option<Account>,
235) -> Result<u64, TokenError> {
236    match account {
237        None => Ok(0),
238        Some(acc) => {
239            use solana_program_pack::Pack;
240            use spl_token::state::Account as SplTokenAccount;
241            SplTokenAccount::unpack(&acc.data)
242                .map(|a| a.amount)
243                .map_err(|e| TokenError::TokenAccountDecodeFailed {
244                    token_account: token_account_pubkey,
245                    reason: format!("token account unpack: {e}"),
246                })
247        }
248    }
249}
250
251#[cfg(test)]
252mod tests {
253    use solana_program_pack::Pack;
254    use spl_token::state::Account as SplTokenAccount;
255
256    use super::*;
257
258    fn empty_plan() -> TokenAccountPlan {
259        TokenAccountPlan {
260            create_instructions: vec![],
261            cleanup_instructions: vec![],
262            additional_signers: vec![],
263            token_account_addresses: HashMap::new(),
264        }
265    }
266
267    fn mint_account(token_program: Pubkey) -> Account {
268        Account {
269            lamports: 1_000_000,
270            data: vec![0u8; spl_token::state::Mint::LEN],
271            owner: token_program,
272            executable: false,
273            rent_epoch: 0,
274        }
275    }
276
277    fn token_account_with_amount(amount: u64) -> Account {
278        let token_acc = SplTokenAccount {
279            mint: Pubkey::new_unique(),
280            owner: Pubkey::new_unique(),
281            amount,
282            delegate: spl_token::solana_program::program_option::COption::None,
283            state: spl_token::state::AccountState::Initialized,
284            is_native: spl_token::solana_program::program_option::COption::None,
285            delegated_amount: 0,
286            close_authority: spl_token::solana_program::program_option::COption::None,
287        };
288        let mut data = vec![0u8; SplTokenAccount::LEN];
289        SplTokenAccount::pack(token_acc, &mut data).unwrap();
290        Account {
291            lamports: 2_039_280,
292            data,
293            owner: spl_token::ID,
294            executable: false,
295            rent_epoch: 0,
296        }
297    }
298
299    fn wsol_entry(ata_account: Option<Account>) -> MintAndAta {
300        MintAndAta {
301            mint_account: Account {
302                lamports: 1,
303                data: vec![],
304                owner: spl_token::ID,
305                executable: false,
306                rent_epoch: 0,
307            },
308            ata_address: Pubkey::new_unique(),
309            ata_account,
310        }
311    }
312
313    #[test]
314    fn ensure_ata_exists_pushes_instruction_when_missing() {
315        let owner = Pubkey::new_unique();
316        let mint_pubkey = Pubkey::new_unique();
317        let entry = MintAndAta {
318            mint_account: mint_account(spl_token::ID),
319            ata_address: Pubkey::new_unique(),
320            ata_account: None,
321        };
322        let mut plan = empty_plan();
323        ensure_ata_exists(owner, mint_pubkey, &entry, &mut plan);
324        assert_eq!(plan.create_instructions.len(), 1);
325        assert_eq!(
326            plan.token_account_addresses[&mint_pubkey],
327            entry.ata_address
328        );
329    }
330
331    #[test]
332    fn read_token_balance_returns_amount_for_valid_account() {
333        let pk = Pubkey::new_unique();
334        assert_eq!(
335            read_token_balance(pk, &Some(token_account_with_amount(1_500_000))).unwrap(),
336            1_500_000
337        );
338    }
339
340    #[test]
341    fn ata_missing_creates_transfers_syncs_and_closes_on_cleanup() {
342        let owner = Pubkey::new_unique();
343        let entry = wsol_entry(None);
344        let mut plan = empty_plan();
345        handle_wrap_sol(
346            owner,
347            &entry,
348            1_500_000_000,
349            WrapSolStrategy::Ata,
350            2_039_280,
351            &mut plan,
352        )
353        .unwrap();
354        assert_eq!(plan.create_instructions.len(), 3);
355        assert_eq!(plan.cleanup_instructions.len(), 1);
356    }
357
358    #[test]
359    fn keypair_creates_ephemeral_account() {
360        let owner = Pubkey::new_unique();
361        let entry = wsol_entry(None);
362        let mut plan = empty_plan();
363        handle_wrap_sol(
364            owner,
365            &entry,
366            1_500_000_000,
367            WrapSolStrategy::Keypair,
368            2_039_280,
369            &mut plan,
370        )
371        .unwrap();
372        assert_eq!(plan.create_instructions.len(), 2);
373        assert_eq!(plan.cleanup_instructions.len(), 1);
374        assert_eq!(plan.additional_signers.len(), 1);
375        assert_eq!(
376            plan.token_account_addresses[&native_mint::ID],
377            plan.additional_signers[0].pubkey()
378        );
379    }
380
381    #[test]
382    fn none_strategy_returns_incoherent_error() {
383        let owner = Pubkey::new_unique();
384        let entry = wsol_entry(None);
385        let mut plan = empty_plan();
386        let err =
387            handle_wrap_sol(owner, &entry, 1, WrapSolStrategy::None, 0, &mut plan).unwrap_err();
388        assert!(matches!(err, TokenError::IncoherentWrapStrategy));
389    }
390
391    #[test]
392    fn non_sol_with_balance_returns_with_balance_not_supported() {
393        let owner = Pubkey::new_unique();
394        let mint = Pubkey::new_unique();
395        let mut mints = HashMap::new();
396        mints.insert(
397            mint,
398            MintAndAta {
399                mint_account: mint_account(spl_token::ID),
400                ata_address: Pubkey::new_unique(),
401                ata_account: None,
402            },
403        );
404        let state = TokenAccountState { owner, mints };
405        let intent = TokenAccountIntent {
406            mints: HashMap::from([(mint, MintIntent::WithBalance { lamports: 100 })]),
407        };
408        let err = prepare_token_accounts(&state, &intent, WrapSolStrategy::Ata, 0).unwrap_err();
409        assert!(matches!(err, TokenError::WithBalanceNotSupported(m) if m == mint));
410    }
411
412    #[test]
413    fn mixed_mints_emit_instructions_in_pubkey_sort_order() {
414        // Two non-SOL mints with deterministic-ordered pubkeys, both EnsureAtaExists.
415        // Verifies prepare_token_accounts iterates intent.mints in sorted Pubkey
416        // order (spec §3.4 determinism requirement) regardless of HashMap insertion
417        // order. Re-runs 5 times: same input must produce same address ordering.
418        let owner = Pubkey::new_unique();
419        let mint_low = Pubkey::new_from_array([0x11; 32]);
420        let mint_high = Pubkey::new_from_array([0xEE; 32]);
421        let entry_low = MintAndAta {
422            mint_account: mint_account(spl_token::ID),
423            ata_address: Pubkey::new_from_array([0x22; 32]),
424            ata_account: None,
425        };
426        let entry_high = MintAndAta {
427            mint_account: mint_account(spl_token::ID),
428            ata_address: Pubkey::new_from_array([0xDD; 32]),
429            ata_account: None,
430        };
431        let mut state_mints = HashMap::new();
432        // Insert in reverse-sorted order to verify sort actually fires.
433        state_mints.insert(mint_high, entry_high);
434        state_mints.insert(mint_low, entry_low);
435        let state = TokenAccountState {
436            owner,
437            mints: state_mints,
438        };
439        let intent = TokenAccountIntent {
440            mints: HashMap::from([
441                (mint_high, MintIntent::EnsureAtaExists),
442                (mint_low, MintIntent::EnsureAtaExists),
443            ]),
444        };
445
446        // 5 runs — instruction sequence must be byte-identical.
447        let mut last_serialized: Option<Vec<Vec<u8>>> = None;
448        for _ in 0..5 {
449            let plan = prepare_token_accounts(&state, &intent, WrapSolStrategy::Ata, 0).unwrap();
450            assert_eq!(plan.create_instructions.len(), 2);
451            let serialized: Vec<Vec<u8>> = plan
452                .create_instructions
453                .iter()
454                .map(|ix| ix.data.clone())
455                .collect();
456            if let Some(prev) = last_serialized.as_ref() {
457                assert_eq!(prev, &serialized, "non-deterministic across runs");
458            }
459            last_serialized = Some(serialized);
460        }
461    }
462}