1use light_account::{derive_rent_sponsor_pda, Pack};
4use light_compressed_account::{
5 compressed_account::PackedMerkleContext, instruction_data::compressed_proof::ValidityProof,
6};
7use light_compressed_token_sdk::compressed_token::{
8 transfer2::{
9 create_transfer2_instruction, Transfer2AccountsMetaConfig, Transfer2Config, Transfer2Inputs,
10 },
11 CTokenAccount2,
12};
13use light_sdk::instruction::PackedAccounts;
14use light_token::{
15 compat::AccountState,
16 instruction::{
17 derive_token_ata, CreateAssociatedTokenAccount, DecompressMint, LIGHT_TOKEN_PROGRAM_ID,
18 },
19};
20use light_token_interface::{
21 instructions::{
22 extensions::{CompressedOnlyExtensionInstructionData, ExtensionInstructionData},
23 mint_action::{MintInstructionData, MintWithContext},
24 transfer2::MultiInputTokenDataWithContext,
25 },
26 state::{ExtensionStruct, TokenDataVersion},
27};
28use solana_instruction::Instruction;
29use solana_pubkey::Pubkey;
30use thiserror::Error;
31
32use super::{
33 decompress_mint::{DEFAULT_RENT_PAYMENT, DEFAULT_WRITE_TOP_UP},
34 instructions::{self, DECOMPRESS_ACCOUNTS_IDEMPOTENT_DISCRIMINATOR},
35 light_program_interface::{AccountSpec, PdaSpec},
36 AccountInterface, TokenAccountInterface,
37};
38use crate::indexer::{
39 CompressedAccount, CompressedTokenAccount, Indexer, IndexerError, ValidityProofWithContext,
40};
41
42#[derive(Debug, Error)]
43pub enum LoadAccountsError {
44 #[error("Indexer error: {0}")]
45 Indexer(#[from] IndexerError),
46
47 #[error("Build instruction failed: {0}")]
48 BuildInstruction(String),
49
50 #[error("Token SDK error: {0}")]
51 TokenSdk(#[from] light_token::error::TokenSdkError),
52
53 #[error("Cold PDA at index {index} (pubkey {pubkey}) missing data")]
54 MissingPdaCompressed { index: usize, pubkey: Pubkey },
55
56 #[error("Cold ATA at index {index} (pubkey {pubkey}) missing data")]
57 MissingAtaCompressed { index: usize, pubkey: Pubkey },
58
59 #[error("Cold mint at index {index} (mint {mint}) missing hash")]
60 MissingMintHash { index: usize, mint: Pubkey },
61
62 #[error("ATA at index {index} (pubkey {pubkey}) missing compressed data or ATA bump")]
63 MissingAtaContext { index: usize, pubkey: Pubkey },
64
65 #[error("Tree info index {index} out of bounds (len {len})")]
66 TreeInfoIndexOutOfBounds { index: usize, len: usize },
67}
68
69const MAX_ATAS_PER_IX: usize = 8;
70
71#[allow(clippy::too_many_arguments)]
78pub async fn create_load_instructions<V, I>(
79 specs: &[AccountSpec<V>],
80 fee_payer: Pubkey,
81 compression_config: Pubkey,
82 indexer: &I,
83) -> Result<Vec<Instruction>, LoadAccountsError>
84where
85 V: Pack<solana_instruction::AccountMeta> + Clone + std::fmt::Debug,
86 I: Indexer,
87{
88 if !super::light_program_interface::any_cold(specs) {
89 return Ok(vec![]);
90 }
91
92 let cold_pdas: Vec<_> = specs
93 .iter()
94 .filter_map(|s| match s {
95 AccountSpec::Pda(p) if p.is_cold() => Some(p),
96 _ => None,
97 })
98 .collect();
99
100 let cold_atas: Vec<_> = specs
101 .iter()
102 .filter_map(|s| match s {
103 AccountSpec::Ata(a) if a.is_cold() => Some(a.as_ref()),
104 _ => None,
105 })
106 .collect();
107
108 let cold_mints: Vec<_> = specs
109 .iter()
110 .filter_map(|s| match s {
111 AccountSpec::Mint(m) if m.is_cold() => Some(m),
112 _ => None,
113 })
114 .collect();
115
116 let pda_hashes = collect_pda_hashes(&cold_pdas)?;
117 let ata_hashes = collect_ata_hashes(&cold_atas)?;
118 let mint_hashes = collect_mint_hashes(&cold_mints)?;
119
120 let (pda_proofs, ata_proofs, mint_proofs) = futures::join!(
121 fetch_proofs(&pda_hashes, indexer),
122 fetch_proofs_batched(&ata_hashes, MAX_ATAS_PER_IX, indexer),
123 fetch_proofs(&mint_hashes, indexer),
124 );
125
126 let pda_proofs = pda_proofs?;
127 let ata_proofs = ata_proofs?;
128 let mint_proofs = mint_proofs?;
129
130 let mut out = Vec::new();
131
132 for (iface, proof) in cold_mints.iter().zip(mint_proofs) {
134 out.push(build_mint_load(iface, proof, fee_payer)?);
135 }
136
137 for (spec, proof) in cold_pdas.iter().zip(pda_proofs) {
140 out.push(build_pda_load(
141 &[spec],
142 proof,
143 fee_payer,
144 compression_config,
145 )?);
146 }
147
148 let ata_chunks: Vec<_> = cold_atas.chunks(MAX_ATAS_PER_IX).collect();
150 for (chunk, proof) in ata_chunks.into_iter().zip(ata_proofs) {
151 out.extend(build_ata_load(chunk, proof, fee_payer)?);
152 }
153
154 Ok(out)
155}
156
157fn collect_pda_hashes<V>(specs: &[&PdaSpec<V>]) -> Result<Vec<[u8; 32]>, LoadAccountsError> {
158 specs
159 .iter()
160 .enumerate()
161 .map(|(i, s)| {
162 s.hash().ok_or(LoadAccountsError::MissingPdaCompressed {
163 index: i,
164 pubkey: s.address(),
165 })
166 })
167 .collect()
168}
169
170fn collect_ata_hashes(
171 ifaces: &[&TokenAccountInterface],
172) -> Result<Vec<[u8; 32]>, LoadAccountsError> {
173 ifaces
174 .iter()
175 .enumerate()
176 .map(|(i, s)| {
177 s.hash().ok_or(LoadAccountsError::MissingAtaCompressed {
178 index: i,
179 pubkey: s.key,
180 })
181 })
182 .collect()
183}
184
185fn collect_mint_hashes(ifaces: &[&AccountInterface]) -> Result<Vec<[u8; 32]>, LoadAccountsError> {
186 ifaces
187 .iter()
188 .enumerate()
189 .map(|(i, s)| {
190 s.hash().ok_or(LoadAccountsError::MissingMintHash {
191 index: i,
192 mint: s.key,
193 })
194 })
195 .collect()
196}
197
198async fn fetch_proofs<I: Indexer>(
199 hashes: &[[u8; 32]],
200 indexer: &I,
201) -> Result<Vec<ValidityProofWithContext>, IndexerError> {
202 if hashes.is_empty() {
203 return Ok(vec![]);
204 }
205 let mut proofs = Vec::with_capacity(hashes.len());
206 for hash in hashes {
207 proofs.push(
208 indexer
209 .get_validity_proof(vec![*hash], vec![], None)
210 .await?
211 .value,
212 );
213 }
214 Ok(proofs)
215}
216
217async fn fetch_proofs_batched<I: Indexer>(
218 hashes: &[[u8; 32]],
219 batch_size: usize,
220 indexer: &I,
221) -> Result<Vec<ValidityProofWithContext>, IndexerError> {
222 if hashes.is_empty() {
223 return Ok(vec![]);
224 }
225 let mut proofs = Vec::with_capacity(hashes.len().div_ceil(batch_size));
226 for chunk in hashes.chunks(batch_size) {
227 proofs.push(
228 indexer
229 .get_validity_proof(chunk.to_vec(), vec![], None)
230 .await?
231 .value,
232 );
233 }
234 Ok(proofs)
235}
236
237fn build_pda_load<V>(
238 specs: &[&PdaSpec<V>],
239 proof: ValidityProofWithContext,
240 fee_payer: Pubkey,
241 compression_config: Pubkey,
242) -> Result<Instruction, LoadAccountsError>
243where
244 V: Pack<solana_instruction::AccountMeta> + Clone + std::fmt::Debug,
245{
246 let has_tokens = specs.iter().any(|s| {
247 s.compressed()
248 .map(|c| c.owner == LIGHT_TOKEN_PROGRAM_ID)
249 .unwrap_or(false)
250 });
251
252 let program_id = specs.first().map(|s| s.program_id()).unwrap_or_default();
254 let (rent_sponsor, _) = derive_rent_sponsor_pda(&program_id);
255
256 let metas = if has_tokens {
257 instructions::load::accounts(fee_payer, compression_config, rent_sponsor)
258 } else {
259 instructions::load::accounts_pda_only(fee_payer, compression_config, rent_sponsor)
260 };
261
262 let hot_addresses: Vec<Pubkey> = specs.iter().map(|s| s.address()).collect();
263 let cold_accounts: Vec<(CompressedAccount, V)> = specs
264 .iter()
265 .map(|s| {
266 let compressed = s.compressed().expect("cold spec must have data").clone();
267 (compressed, s.variant.clone())
268 })
269 .collect();
270
271 let program_id = specs.first().map(|s| s.program_id()).unwrap_or_default();
272
273 instructions::create_decompress_accounts_idempotent_instruction(
274 &program_id,
275 &DECOMPRESS_ACCOUNTS_IDEMPOTENT_DISCRIMINATOR,
276 &hot_addresses,
277 &cold_accounts,
278 &metas,
279 proof,
280 )
281 .map_err(|e| LoadAccountsError::BuildInstruction(e.to_string()))
282}
283
284struct AtaContext<'a> {
285 compressed: &'a CompressedTokenAccount,
286 wallet_owner: Pubkey,
287 mint: Pubkey,
288 bump: u8,
289}
290
291impl<'a> AtaContext<'a> {
292 fn from_interface(
293 iface: &'a TokenAccountInterface,
294 index: usize,
295 ) -> Result<Self, LoadAccountsError> {
296 let compressed = iface
297 .compressed()
298 .ok_or(LoadAccountsError::MissingAtaContext {
299 index,
300 pubkey: iface.key,
301 })?;
302 let bump = iface
303 .ata_bump()
304 .ok_or(LoadAccountsError::MissingAtaContext {
305 index,
306 pubkey: iface.key,
307 })?;
308 Ok(Self {
309 compressed,
310 wallet_owner: iface.owner(),
311 mint: iface.mint(),
312 bump,
313 })
314 }
315}
316
317fn build_ata_load(
318 ifaces: &[&TokenAccountInterface],
319 proof: ValidityProofWithContext,
320 fee_payer: Pubkey,
321) -> Result<Vec<Instruction>, LoadAccountsError> {
322 let contexts: Vec<AtaContext> = ifaces
323 .iter()
324 .enumerate()
325 .map(|(i, a)| AtaContext::from_interface(a, i))
326 .collect::<Result<Vec<_>, _>>()?;
327
328 let mut out = Vec::with_capacity(contexts.len() + 1);
329
330 for ctx in &contexts {
331 let ix = CreateAssociatedTokenAccount::new(fee_payer, ctx.wallet_owner, ctx.mint)
332 .idempotent()
333 .instruction()
334 .map_err(|e| LoadAccountsError::BuildInstruction(e.to_string()))?;
335 out.push(ix);
336 }
337
338 out.push(build_transfer2(&contexts, proof, fee_payer)?);
339 Ok(out)
340}
341
342fn build_transfer2(
343 contexts: &[AtaContext],
344 proof: ValidityProofWithContext,
345 fee_payer: Pubkey,
346) -> Result<Instruction, LoadAccountsError> {
347 let mut packed = PackedAccounts::default();
348 let packed_trees = proof.pack_tree_infos(&mut packed);
349 let tree_infos = packed_trees
350 .state_trees
351 .as_ref()
352 .ok_or_else(|| LoadAccountsError::BuildInstruction("no state trees".into()))?;
353
354 let mut token_accounts = Vec::with_capacity(contexts.len());
355 let mut tlv_data: Vec<Vec<ExtensionInstructionData>> = Vec::with_capacity(contexts.len());
356 let mut has_tlv = false;
357
358 for (i, ctx) in contexts.iter().enumerate() {
359 let token = &ctx.compressed.token;
360 let tree = tree_infos.packed_tree_infos.get(i).ok_or(
361 LoadAccountsError::TreeInfoIndexOutOfBounds {
362 index: i,
363 len: tree_infos.packed_tree_infos.len(),
364 },
365 )?;
366
367 let owner_idx = packed.insert_or_get_config(ctx.wallet_owner, true, false);
368 let ata_idx = packed.insert_or_get(derive_token_ata(&ctx.wallet_owner, &ctx.mint));
369 let mint_idx = packed.insert_or_get(token.mint);
370 let delegate_idx = token.delegate.map(|d| packed.insert_or_get(d)).unwrap_or(0);
371
372 let source = MultiInputTokenDataWithContext {
373 owner: ata_idx,
374 amount: token.amount,
375 has_delegate: token.delegate.is_some(),
376 delegate: delegate_idx,
377 mint: mint_idx,
378 version: TokenDataVersion::ShaFlat as u8,
379 merkle_context: PackedMerkleContext {
380 merkle_tree_pubkey_index: tree.merkle_tree_pubkey_index,
381 queue_pubkey_index: tree.queue_pubkey_index,
382 prove_by_index: tree.prove_by_index,
383 leaf_index: tree.leaf_index,
384 },
385 root_index: tree.root_index,
386 };
387
388 let mut ctoken = CTokenAccount2::new(vec![source])
389 .map_err(|e| LoadAccountsError::BuildInstruction(e.to_string()))?;
390 ctoken
391 .decompress(token.amount, ata_idx)
392 .map_err(|e| LoadAccountsError::BuildInstruction(e.to_string()))?;
393 token_accounts.push(ctoken);
394
395 let is_frozen = token.state == AccountState::Frozen;
396 let tlv: Vec<ExtensionInstructionData> = token
397 .tlv
398 .as_ref()
399 .map(|exts| {
400 exts.iter()
401 .filter_map(|ext| match ext {
402 ExtensionStruct::CompressedOnly(co) => {
403 Some(ExtensionInstructionData::CompressedOnly(
404 CompressedOnlyExtensionInstructionData {
405 delegated_amount: co.delegated_amount,
406 withheld_transfer_fee: co.withheld_transfer_fee,
407 is_frozen,
408 compression_index: i as u8,
409 is_ata: true,
410 bump: ctx.bump,
411 owner_index: owner_idx,
412 },
413 ))
414 }
415 _ => None,
416 })
417 .collect()
418 })
419 .unwrap_or_default();
420
421 if !tlv.is_empty() {
422 has_tlv = true;
423 }
424 tlv_data.push(tlv);
425 }
426
427 let (metas, _, _) = packed.to_account_metas();
428
429 create_transfer2_instruction(Transfer2Inputs {
430 meta_config: Transfer2AccountsMetaConfig::new(fee_payer, metas),
431 token_accounts,
432 transfer_config: Transfer2Config::default().filter_zero_amount_outputs(),
433 validity_proof: proof.proof,
434 in_tlv: if has_tlv { Some(tlv_data) } else { None },
435 ..Default::default()
436 })
437 .map_err(|e| LoadAccountsError::BuildInstruction(e.to_string()))
438}
439
440fn build_mint_load(
441 iface: &AccountInterface,
442 proof: ValidityProofWithContext,
443 fee_payer: Pubkey,
444) -> Result<Instruction, LoadAccountsError> {
445 let acc = proof
446 .accounts
447 .first()
448 .ok_or_else(|| LoadAccountsError::BuildInstruction("proof has no accounts".into()))?;
449 let state_tree = acc.tree_info.tree;
450 let input_queue = acc.tree_info.queue;
451 let output_queue = acc
452 .tree_info
453 .next_tree_info
454 .as_ref()
455 .map(|n| n.queue)
456 .unwrap_or(input_queue);
457
458 let mint_data = iface
459 .as_mint()
460 .ok_or_else(|| LoadAccountsError::BuildInstruction("missing mint_data".into()))?;
461 let compressed_address = iface
462 .mint_compressed_address()
463 .ok_or_else(|| LoadAccountsError::BuildInstruction("missing compressed_address".into()))?;
464 let mint_ix_data = MintInstructionData::try_from(mint_data)
465 .map_err(|_| LoadAccountsError::BuildInstruction("invalid mint data".into()))?;
466
467 DecompressMint {
468 payer: fee_payer,
469 authority: fee_payer,
470 state_tree,
471 input_queue,
472 output_queue,
473 compressed_mint_with_context: MintWithContext {
474 leaf_index: acc.leaf_index as u32,
475 prove_by_index: acc.root_index.proof_by_index(),
476 root_index: acc.root_index.root_index().unwrap_or_default(),
477 address: compressed_address,
478 mint: Some(mint_ix_data),
479 },
480 proof: ValidityProof(proof.proof.into()),
481 rent_payment: DEFAULT_RENT_PAYMENT,
482 write_top_up: DEFAULT_WRITE_TOP_UP,
483 }
484 .instruction()
485 .map_err(|e| LoadAccountsError::BuildInstruction(e.to_string()))
486}