Skip to main content

light_client/interface/
load_accounts.rs

1//! Load cold accounts API.
2
3use 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/// Build load instructions for cold accounts. Returns empty vec if all hot.
72///
73/// The rent sponsor PDA is derived internally from the program_id.
74/// Seeds: ["rent_sponsor"]
75///
76/// TODO: reduce ixn count and txn size, reduce roundtrips.
77#[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    // 1. Mint loads first - ATAs require the mint to exist on-chain
133    for (iface, proof) in cold_mints.iter().zip(mint_proofs) {
134        out.push(build_mint_load(iface, proof, fee_payer)?);
135    }
136
137    // 2. DecompressAccountsIdempotent for all cold PDAs (including token PDAs).
138    //    Token PDAs are created on-chain via CPI inside DecompressVariant.
139    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    // 3. ATA loads (CreateAssociatedTokenAccount + Transfer2) - requires mint to exist
149    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    // Derive rent sponsor PDA from program_id
253    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}