1use 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
22fn 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#[derive(Debug, Clone)]
40pub struct TokenAccountIntent {
41 pub mints: HashMap<Pubkey, MintIntent>,
43}
44
45#[derive(Debug, Clone, Copy, PartialEq, Eq)]
47pub enum MintIntent {
48 EnsureAtaExists,
50 WithBalance {
52 lamports: u64,
54 },
55 RequireTokenBalance {
58 amount: u64,
60 },
61}
62
63#[derive(Debug, Clone, Copy, PartialEq, Eq)]
65pub enum WrapSolStrategy {
66 Ata,
68 Keypair,
70 None,
72}
73
74#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
77pub enum AtaCreateMode {
78 #[default]
80 Idempotent,
81 Legacy,
83}
84
85#[derive(Debug, Clone)]
90pub struct TokenAccountPlanConfig {
91 pub wsol_strategy: WrapSolStrategy,
93 pub ata_create_mode: AtaCreateMode,
95 pub rent_exempt_lamports: u64,
97}
98
99impl TokenAccountPlanConfig {
100 #[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#[derive(Debug)]
114pub struct TokenAccountPlan {
115 pub create_instructions: Vec<Instruction>,
117 pub cleanup_instructions: Vec<Instruction>,
119 pub additional_signers: Vec<Keypair>,
121 pub token_account_addresses: HashMap<Pubkey, Pubkey>,
123}
124
125pub 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}