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::{
12    create_associated_token_account, create_associated_token_account_idempotent,
13};
14use spl_token_2022_interface::instruction::{close_account, initialize_account3, sync_native};
15use spl_token_interface::native_mint;
16
17use crate::{
18    state::{MintAndAta, TokenAccountState},
19    TokenError,
20};
21
22/// Build the ATA create instruction matching the configured mode.
23fn build_create_ata_instruction(
24    mode: AtaCreateMode,
25    payer: &Pubkey,
26    owner: &Pubkey,
27    mint: &Pubkey,
28    token_program: &Pubkey,
29) -> Instruction {
30    match mode {
31        AtaCreateMode::Idempotent => {
32            create_associated_token_account_idempotent(payer, owner, mint, token_program)
33        }
34        AtaCreateMode::Legacy => create_associated_token_account(payer, owner, mint, token_program),
35    }
36}
37
38/// What the caller wants for each mint.
39#[derive(Debug, Clone)]
40pub struct TokenAccountIntent {
41    /// Map of mint pubkey to intent.
42    pub mints: HashMap<Pubkey, MintIntent>,
43}
44
45/// Per-mint intent.
46#[derive(Debug, Clone, Copy, PartialEq, Eq)]
47pub enum MintIntent {
48    /// Just ensure the ATA exists. No balance preparation.
49    EnsureAtaExists,
50    /// SOL/wSOL only: ensure the wSOL account holds at least `lamports`.
51    WithBalance {
52        /// Required lamport balance in the wSOL account.
53        lamports: u64,
54    },
55    /// Non-SOL mints only: validate at plan time that the existing token
56    /// account already holds at least `amount`.
57    RequireTokenBalance {
58        /// Minimum required balance in raw token units.
59        amount: u64,
60    },
61}
62
63/// How to wrap native SOL when needed.
64#[derive(Debug, Clone, Copy, PartialEq, Eq)]
65pub enum WrapSolStrategy {
66    /// Use the persistent wSOL ATA.
67    Ata,
68    /// Create an ephemeral keypair-based wSOL account.
69    Keypair,
70    /// Do not wrap. Incoherent with `MintIntent::WithBalance` for native SOL.
71    None,
72}
73
74/// Whether `prepare_token_accounts` emits idempotent or non-idempotent ATA
75/// creation instructions.
76#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
77pub enum AtaCreateMode {
78    /// Idempotent ATA creation (default).
79    #[default]
80    Idempotent,
81    /// Non-idempotent ATA creation (legacy / byte-compat).
82    Legacy,
83}
84
85/// Configuration for `prepare_token_accounts`.
86///
87/// **No `Default` impl** — `rent_exempt_lamports` has no safe default. Use
88/// `with_rent(...)` to build with sensible defaults for other fields.
89#[derive(Debug, Clone)]
90pub struct TokenAccountPlanConfig {
91    /// How to handle native SOL wrapping.
92    pub wsol_strategy: WrapSolStrategy,
93    /// Whether ATA creation instructions are idempotent or legacy.
94    pub ata_create_mode: AtaCreateMode,
95    /// Rent-exempt lamports for a 165-byte SPL Token account.
96    pub rent_exempt_lamports: u64,
97}
98
99impl TokenAccountPlanConfig {
100    /// Construct a config with `WrapSolStrategy::Ata` and
101    /// `AtaCreateMode::Idempotent` defaults.
102    #[must_use]
103    pub fn with_rent(rent_exempt_lamports: u64) -> Self {
104        Self {
105            wsol_strategy: WrapSolStrategy::Ata,
106            ata_create_mode: AtaCreateMode::Idempotent,
107            rent_exempt_lamports,
108        }
109    }
110}
111
112/// Output of `prepare_token_accounts`.
113#[derive(Debug)]
114pub struct TokenAccountPlan {
115    /// Instructions to execute before the main transaction body.
116    pub create_instructions: Vec<Instruction>,
117    /// Instructions to execute after the main transaction body.
118    pub cleanup_instructions: Vec<Instruction>,
119    /// Ephemeral keypairs for `WrapSolStrategy::Keypair`.
120    pub additional_signers: Vec<Keypair>,
121    /// Mint pubkey to actual token account address.
122    pub token_account_addresses: HashMap<Pubkey, Pubkey>,
123}
124
125/// Build the token-account preparation plan for a transaction.
126///
127/// # Example
128///
129/// ```no_run
130/// # use std::collections::HashMap;
131/// # use solana_token_toolkit::*;
132/// # use solana_pubkey::Pubkey;
133/// let owner = Pubkey::new_unique();
134/// let state = TokenAccountState::empty(owner);
135/// let intent = TokenAccountIntent { mints: HashMap::new() };
136/// let plan = prepare_token_accounts(
137///     &state,
138///     &intent,
139///     TokenAccountPlanConfig::with_rent(0),
140/// ).unwrap();
141/// assert!(plan.create_instructions.is_empty());
142/// ```
143pub fn prepare_token_accounts(
144    state: &TokenAccountState,
145    intent: &TokenAccountIntent,
146    config: TokenAccountPlanConfig,
147) -> Result<TokenAccountPlan, TokenError> {
148    let mut plan = TokenAccountPlan {
149        create_instructions: Vec::new(),
150        cleanup_instructions: Vec::new(),
151        additional_signers: Vec::new(),
152        token_account_addresses: HashMap::new(),
153    };
154
155    let mut sorted_intent: Vec<(&Pubkey, &MintIntent)> = intent.mints.iter().collect();
156    sorted_intent.sort_by_key(|(pubkey, _)| **pubkey);
157
158    for (mint_pubkey, mint_intent) in sorted_intent {
159        let entry = state
160            .mints
161            .get(mint_pubkey)
162            .ok_or(TokenError::MintNotFound(*mint_pubkey))?;
163        let is_native_sol = *mint_pubkey == native_mint::ID;
164
165        match (is_native_sol, mint_intent) {
166            (true, MintIntent::WithBalance { lamports }) => handle_wrap_sol(
167                state.owner,
168                entry,
169                *lamports,
170                config.wsol_strategy,
171                config.rent_exempt_lamports,
172                config.ata_create_mode,
173                &mut plan,
174            )?,
175            (true, MintIntent::EnsureAtaExists) | (false, MintIntent::EnsureAtaExists) => {
176                ensure_ata_exists(
177                    state.owner,
178                    *mint_pubkey,
179                    entry,
180                    config.ata_create_mode,
181                    &mut plan,
182                );
183            }
184            (false, MintIntent::WithBalance { .. }) => {
185                return Err(TokenError::WithBalanceNotSupported(*mint_pubkey));
186            }
187            (true, MintIntent::RequireTokenBalance { .. }) => {
188                return Err(TokenError::RequireBalanceForSolNotSupported(*mint_pubkey));
189            }
190            (false, MintIntent::RequireTokenBalance { amount }) => {
191                if entry.ata_account.is_none() {
192                    return Err(TokenError::InsufficientBalance {
193                        mint: *mint_pubkey,
194                        required: *amount,
195                        actual: 0,
196                    });
197                }
198
199                let actual = read_token_balance(entry.ata_address, &entry.ata_account)?;
200                if actual < *amount {
201                    return Err(TokenError::InsufficientBalance {
202                        mint: *mint_pubkey,
203                        required: *amount,
204                        actual,
205                    });
206                }
207                plan.token_account_addresses
208                    .insert(*mint_pubkey, entry.ata_address);
209            }
210        }
211    }
212
213    Ok(plan)
214}
215
216fn ensure_ata_exists(
217    owner: Pubkey,
218    mint_pubkey: Pubkey,
219    entry: &MintAndAta,
220    ata_create_mode: AtaCreateMode,
221    plan: &mut TokenAccountPlan,
222) {
223    if entry.ata_account.is_none() {
224        plan.create_instructions.push(build_create_ata_instruction(
225            ata_create_mode,
226            &owner,
227            &owner,
228            &mint_pubkey,
229            &entry.mint_account.owner,
230        ));
231    }
232    plan.token_account_addresses
233        .insert(mint_pubkey, entry.ata_address);
234}
235
236fn handle_wrap_sol(
237    owner: Pubkey,
238    entry: &MintAndAta,
239    required_lamports: u64,
240    strategy: WrapSolStrategy,
241    rent_exempt_lamports: u64,
242    ata_create_mode: AtaCreateMode,
243    plan: &mut TokenAccountPlan,
244) -> Result<(), TokenError> {
245    use spl_token::ID as TOKEN_PROGRAM_ID;
246
247    match strategy {
248        WrapSolStrategy::Ata => {
249            let existing_balance = read_token_balance(entry.ata_address, &entry.ata_account)?;
250            let ata_did_not_exist = entry.ata_account.is_none();
251
252            if ata_did_not_exist {
253                plan.create_instructions.push(build_create_ata_instruction(
254                    ata_create_mode,
255                    &owner,
256                    &owner,
257                    &native_mint::ID,
258                    &TOKEN_PROGRAM_ID,
259                ));
260            }
261
262            if existing_balance < required_lamports {
263                let delta = required_lamports - existing_balance;
264                plan.create_instructions.push(system_instruction::transfer(
265                    &owner,
266                    &entry.ata_address,
267                    delta,
268                ));
269                plan.create_instructions.push(
270                    sync_native(&TOKEN_PROGRAM_ID, &entry.ata_address)
271                        .map_err(|e| TokenError::InstructionBuild(format!("sync_native: {e}")))?,
272                );
273            }
274
275            if ata_did_not_exist {
276                plan.cleanup_instructions.push(
277                    close_account(&TOKEN_PROGRAM_ID, &entry.ata_address, &owner, &owner, &[])
278                        .map_err(|e| TokenError::InstructionBuild(format!("close_account: {e}")))?,
279                );
280            }
281
282            plan.token_account_addresses
283                .insert(native_mint::ID, entry.ata_address);
284        }
285        WrapSolStrategy::Keypair => {
286            let kp = Keypair::new();
287            let token_account_pubkey = kp.pubkey();
288            let lamports = required_lamports + rent_exempt_lamports;
289
290            plan.create_instructions
291                .push(system_instruction::create_account(
292                    &owner,
293                    &token_account_pubkey,
294                    lamports,
295                    {
296                        use solana_program_pack::Pack;
297                        spl_token::state::Account::LEN as u64
298                    },
299                    &TOKEN_PROGRAM_ID,
300                ));
301            plan.create_instructions.push(
302                initialize_account3(
303                    &TOKEN_PROGRAM_ID,
304                    &token_account_pubkey,
305                    &native_mint::ID,
306                    &owner,
307                )
308                .map_err(|e| TokenError::InstructionBuild(format!("initialize_account3: {e}")))?,
309            );
310            plan.cleanup_instructions.push(
311                close_account(
312                    &TOKEN_PROGRAM_ID,
313                    &token_account_pubkey,
314                    &owner,
315                    &owner,
316                    &[],
317                )
318                .map_err(|e| TokenError::InstructionBuild(format!("close_account: {e}")))?,
319            );
320            plan.token_account_addresses
321                .insert(native_mint::ID, token_account_pubkey);
322            plan.additional_signers.push(kp);
323        }
324        WrapSolStrategy::None => return Err(TokenError::IncoherentWrapStrategy),
325    }
326    Ok(())
327}
328
329fn read_token_balance(
330    token_account_pubkey: Pubkey,
331    account: &Option<Account>,
332) -> Result<u64, TokenError> {
333    match account {
334        None => Ok(0),
335        Some(acc) => {
336            use spl_token_2022_interface::{
337                extension::StateWithExtensions, state::Account as TokenAccount,
338            };
339
340            StateWithExtensions::<TokenAccount>::unpack(&acc.data)
341                .map(|a| a.base.amount)
342                .map_err(|e| TokenError::TokenAccountDecodeFailed {
343                    token_account: token_account_pubkey,
344                    reason: format!("token account unpack: {e}"),
345                })
346        }
347    }
348}
349
350#[cfg(test)]
351mod tests {
352    use solana_program_pack::Pack;
353    use spl_token::state::Account as SplTokenAccount;
354
355    use super::*;
356
357    fn empty_plan() -> TokenAccountPlan {
358        TokenAccountPlan {
359            create_instructions: vec![],
360            cleanup_instructions: vec![],
361            additional_signers: vec![],
362            token_account_addresses: HashMap::new(),
363        }
364    }
365
366    fn mint_account(token_program: Pubkey) -> Account {
367        Account {
368            lamports: 1_000_000,
369            data: vec![0u8; spl_token::state::Mint::LEN],
370            owner: token_program,
371            executable: false,
372            rent_epoch: 0,
373        }
374    }
375
376    fn token_account_with_amount(amount: u64) -> Account {
377        let token_acc = SplTokenAccount {
378            mint: Pubkey::new_unique(),
379            owner: Pubkey::new_unique(),
380            amount,
381            delegate: spl_token::solana_program::program_option::COption::None,
382            state: spl_token::state::AccountState::Initialized,
383            is_native: spl_token::solana_program::program_option::COption::None,
384            delegated_amount: 0,
385            close_authority: spl_token::solana_program::program_option::COption::None,
386        };
387        let mut data = vec![0u8; SplTokenAccount::LEN];
388        SplTokenAccount::pack(token_acc, &mut data).unwrap();
389        Account {
390            lamports: 2_039_280,
391            data,
392            owner: spl_token::ID,
393            executable: false,
394            rent_epoch: 0,
395        }
396    }
397
398    fn wsol_entry(ata_account: Option<Account>) -> MintAndAta {
399        MintAndAta {
400            mint_account: Account {
401                lamports: 1,
402                data: vec![],
403                owner: spl_token::ID,
404                executable: false,
405                rent_epoch: 0,
406            },
407            ata_address: Pubkey::new_unique(),
408            ata_account,
409        }
410    }
411
412    #[test]
413    fn ensure_ata_exists_pushes_instruction_when_missing() {
414        let owner = Pubkey::new_unique();
415        let mint_pubkey = Pubkey::new_unique();
416        let entry = MintAndAta {
417            mint_account: mint_account(spl_token::ID),
418            ata_address: Pubkey::new_unique(),
419            ata_account: None,
420        };
421        let mut plan = empty_plan();
422        ensure_ata_exists(
423            owner,
424            mint_pubkey,
425            &entry,
426            AtaCreateMode::Idempotent,
427            &mut plan,
428        );
429        assert_eq!(plan.create_instructions.len(), 1);
430        assert_eq!(
431            plan.token_account_addresses[&mint_pubkey],
432            entry.ata_address
433        );
434    }
435
436    #[test]
437    fn read_token_balance_returns_amount_for_valid_account() {
438        let pk = Pubkey::new_unique();
439        assert_eq!(
440            read_token_balance(pk, &Some(token_account_with_amount(1_500_000))).unwrap(),
441            1_500_000
442        );
443    }
444
445    #[test]
446    fn ata_missing_creates_transfers_syncs_and_closes_on_cleanup() {
447        let owner = Pubkey::new_unique();
448        let entry = wsol_entry(None);
449        let mut plan = empty_plan();
450        handle_wrap_sol(
451            owner,
452            &entry,
453            1_500_000_000,
454            WrapSolStrategy::Ata,
455            2_039_280,
456            AtaCreateMode::Idempotent,
457            &mut plan,
458        )
459        .unwrap();
460        assert_eq!(plan.create_instructions.len(), 3);
461        assert_eq!(plan.cleanup_instructions.len(), 1);
462    }
463
464    #[test]
465    fn keypair_creates_ephemeral_account() {
466        let owner = Pubkey::new_unique();
467        let entry = wsol_entry(None);
468        let mut plan = empty_plan();
469        handle_wrap_sol(
470            owner,
471            &entry,
472            1_500_000_000,
473            WrapSolStrategy::Keypair,
474            2_039_280,
475            AtaCreateMode::Idempotent,
476            &mut plan,
477        )
478        .unwrap();
479        assert_eq!(plan.create_instructions.len(), 2);
480        assert_eq!(plan.cleanup_instructions.len(), 1);
481        assert_eq!(plan.additional_signers.len(), 1);
482        assert_eq!(
483            plan.token_account_addresses[&native_mint::ID],
484            plan.additional_signers[0].pubkey()
485        );
486    }
487
488    #[test]
489    fn none_strategy_returns_incoherent_error() {
490        let owner = Pubkey::new_unique();
491        let entry = wsol_entry(None);
492        let mut plan = empty_plan();
493        let err = handle_wrap_sol(
494            owner,
495            &entry,
496            1,
497            WrapSolStrategy::None,
498            0,
499            AtaCreateMode::Idempotent,
500            &mut plan,
501        )
502        .unwrap_err();
503        assert!(matches!(err, TokenError::IncoherentWrapStrategy));
504    }
505
506    #[test]
507    fn non_sol_with_balance_returns_with_balance_not_supported() {
508        let owner = Pubkey::new_unique();
509        let mint = Pubkey::new_unique();
510        let mut mints = HashMap::new();
511        mints.insert(
512            mint,
513            MintAndAta {
514                mint_account: mint_account(spl_token::ID),
515                ata_address: Pubkey::new_unique(),
516                ata_account: None,
517            },
518        );
519        let state = TokenAccountState { owner, mints };
520        let intent = TokenAccountIntent {
521            mints: HashMap::from([(mint, MintIntent::WithBalance { lamports: 100 })]),
522        };
523        let err = prepare_token_accounts(&state, &intent, TokenAccountPlanConfig::with_rent(0))
524            .unwrap_err();
525        assert!(matches!(err, TokenError::WithBalanceNotSupported(m) if m == mint));
526    }
527
528    #[test]
529    fn mixed_mints_emit_instructions_in_pubkey_sort_order() {
530        let owner = Pubkey::new_unique();
531        let mint_low = Pubkey::new_from_array([0x11; 32]);
532        let mint_high = Pubkey::new_from_array([0xEE; 32]);
533        let entry_low = MintAndAta {
534            mint_account: mint_account(spl_token::ID),
535            ata_address: Pubkey::new_from_array([0x22; 32]),
536            ata_account: None,
537        };
538        let entry_high = MintAndAta {
539            mint_account: mint_account(spl_token::ID),
540            ata_address: Pubkey::new_from_array([0xDD; 32]),
541            ata_account: None,
542        };
543        let mut state_mints = HashMap::new();
544        state_mints.insert(mint_high, entry_high);
545        state_mints.insert(mint_low, entry_low);
546        let state = TokenAccountState {
547            owner,
548            mints: state_mints,
549        };
550        let intent = TokenAccountIntent {
551            mints: HashMap::from([
552                (mint_high, MintIntent::EnsureAtaExists),
553                (mint_low, MintIntent::EnsureAtaExists),
554            ]),
555        };
556
557        let mut last_serialized: Option<Vec<Vec<u8>>> = None;
558        for _ in 0..5 {
559            let plan =
560                prepare_token_accounts(&state, &intent, TokenAccountPlanConfig::with_rent(0))
561                    .unwrap();
562            assert_eq!(plan.create_instructions.len(), 2);
563            let serialized: Vec<Vec<u8>> = plan
564                .create_instructions
565                .iter()
566                .map(|ix| ix.data.clone())
567                .collect();
568            if let Some(prev) = last_serialized.as_ref() {
569                assert_eq!(prev, &serialized, "non-deterministic across runs");
570            }
571            last_serialized = Some(serialized);
572        }
573    }
574}