light_client/interface/
load_accounts.rs

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