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::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#[derive(Debug, Clone)]
22pub struct TokenAccountIntent {
23 pub mints: HashMap<Pubkey, MintIntent>,
25}
26
27#[derive(Debug, Clone, Copy, PartialEq, Eq)]
29pub enum MintIntent {
30 EnsureAtaExists,
32 WithBalance {
34 lamports: u64,
36 },
37}
38
39#[derive(Debug, Clone, Copy, PartialEq, Eq)]
41pub enum WrapSolStrategy {
42 Ata,
44 Keypair,
46 None,
48}
49
50#[derive(Debug)]
52pub struct TokenAccountPlan {
53 pub create_instructions: Vec<Instruction>,
55 pub cleanup_instructions: Vec<Instruction>,
57 pub additional_signers: Vec<Keypair>,
59 pub token_account_addresses: HashMap<Pubkey, Pubkey>,
61}
62
63pub 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 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 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 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}