Skip to main content

light_client/interface/
decompress_mint.rs

1//! Mint interface types for hot/cold handling.
2
3use borsh::BorshDeserialize;
4use light_compressed_account::{
5    address::derive_address, instruction_data::compressed_proof::ValidityProof,
6};
7use light_token::instruction::DecompressMint;
8use light_token_interface::{
9    instructions::mint_action::{MintInstructionData, MintWithContext},
10    state::Mint,
11    MINT_ADDRESS_TREE,
12};
13use solana_account::Account;
14use solana_instruction::Instruction;
15use solana_pubkey::Pubkey;
16use thiserror::Error;
17
18use super::AccountInterface;
19use crate::indexer::{CompressedAccount, Indexer, ValidityProofWithContext};
20
21/// Error type for mint load operations.
22#[derive(Debug, Error)]
23pub enum DecompressMintError {
24    #[error("Mint not found for address {address:?}")]
25    MintNotFound { address: Pubkey },
26
27    #[error("Missing mint data in cold account")]
28    MissingMintData,
29
30    #[error("Program error: {0}")]
31    ProgramError(#[from] solana_program_error::ProgramError),
32
33    #[error("Mint already hot")]
34    AlreadyDecompressed,
35
36    #[error("Validity proof required for cold mint")]
37    ProofRequired,
38
39    #[error("Indexer error: {0}")]
40    IndexerError(#[from] crate::indexer::IndexerError),
41}
42
43/// Mint state: hot (on-chain), cold (compressed), or none.
44#[derive(Debug, Clone, PartialEq, Default)]
45#[allow(clippy::large_enum_variant)]
46pub enum MintState {
47    /// On-chain.
48    Hot { account: Account },
49    /// Compressed.
50    Cold {
51        compressed: CompressedAccount,
52        mint_data: Mint,
53    },
54    /// Doesn't exist.
55    #[default]
56    None,
57}
58
59/// Mint interface for hot/cold handling.
60#[derive(Debug, Clone, PartialEq, Default)]
61pub struct MintInterface {
62    pub mint: Pubkey,
63    pub address_tree: Pubkey,
64    pub compressed_address: [u8; 32],
65    pub state: MintState,
66}
67
68impl MintInterface {
69    #[inline]
70    pub fn is_cold(&self) -> bool {
71        matches!(self.state, MintState::Cold { .. })
72    }
73
74    #[inline]
75    pub fn is_hot(&self) -> bool {
76        matches!(self.state, MintState::Hot { .. })
77    }
78
79    pub fn hash(&self) -> Option<[u8; 32]> {
80        match &self.state {
81            MintState::Cold { compressed, .. } => Some(compressed.hash),
82            _ => None,
83        }
84    }
85
86    pub fn account(&self) -> Option<&Account> {
87        match &self.state {
88            MintState::Hot { account } => Some(account),
89            _ => None,
90        }
91    }
92
93    pub fn compressed(&self) -> Option<(&CompressedAccount, &Mint)> {
94        match &self.state {
95            MintState::Cold {
96                compressed,
97                mint_data,
98            } => Some((compressed, mint_data)),
99            _ => None,
100        }
101    }
102}
103
104impl From<MintInterface> for AccountInterface {
105    fn from(mi: MintInterface) -> Self {
106        match mi.state {
107            MintState::Hot { account } => Self {
108                key: mi.mint,
109                account,
110                cold: None,
111            },
112            MintState::Cold {
113                compressed,
114                mint_data: _,
115            } => {
116                let data = compressed
117                    .data
118                    .as_ref()
119                    .map(|d| {
120                        let mut buf = d.discriminator.to_vec();
121                        buf.extend_from_slice(&d.data);
122                        buf
123                    })
124                    .unwrap_or_default();
125
126                Self {
127                    key: mi.mint,
128                    account: Account {
129                        lamports: compressed.lamports,
130                        data,
131                        owner: Pubkey::new_from_array(
132                            light_token_interface::LIGHT_TOKEN_PROGRAM_ID,
133                        ),
134                        executable: false,
135                        rent_epoch: 0,
136                    },
137                    cold: Some(compressed),
138                }
139            }
140            MintState::None => Self {
141                key: mi.mint,
142                account: Account::default(),
143                cold: None,
144            },
145        }
146    }
147}
148
149pub const DEFAULT_RENT_PAYMENT: u8 = 2;
150pub const DEFAULT_WRITE_TOP_UP: u32 = 0;
151
152/// Builds load instruction for a cold mint. Returns empty vec if already hot.
153pub fn build_decompress_mint(
154    mint: &MintInterface,
155    fee_payer: Pubkey,
156    validity_proof: Option<ValidityProofWithContext>,
157    rent_payment: Option<u8>,
158    write_top_up: Option<u32>,
159) -> Result<Vec<Instruction>, DecompressMintError> {
160    // Fast exit if hot
161    let mint_data = match &mint.state {
162        MintState::Hot { .. } | MintState::None => return Ok(vec![]),
163        MintState::Cold { mint_data, .. } => mint_data,
164    };
165
166    // Check if already decompressed flag is set - return empty vec (idempotent)
167    if mint_data.metadata.mint_decompressed {
168        return Ok(vec![]);
169    }
170
171    // Proof required for cold mint
172    let proof_result = validity_proof.ok_or(DecompressMintError::ProofRequired)?;
173
174    // Extract tree info from proof result
175    let account_info = &proof_result.accounts[0];
176    let state_tree = account_info.tree_info.tree;
177    let input_queue = account_info.tree_info.queue;
178    let output_queue = account_info
179        .tree_info
180        .next_tree_info
181        .as_ref()
182        .map(|next| next.queue)
183        .unwrap_or(input_queue);
184
185    // Build MintWithContext
186    let mint_instruction_data = MintInstructionData::try_from(mint_data.clone())
187        .map_err(|_| DecompressMintError::MissingMintData)?;
188
189    let compressed_mint_with_context = MintWithContext {
190        leaf_index: account_info.leaf_index as u32,
191        prove_by_index: account_info.root_index.proof_by_index(),
192        root_index: account_info.root_index.root_index().unwrap_or_default(),
193        address: mint.compressed_address,
194        mint: Some(mint_instruction_data),
195    };
196
197    // Build DecompressMint instruction
198    let decompress = DecompressMint {
199        payer: fee_payer,
200        authority: fee_payer, // Permissionless - any signer works
201        state_tree,
202        input_queue,
203        output_queue,
204        compressed_mint_with_context,
205        proof: ValidityProof(proof_result.proof.into()),
206        rent_payment: rent_payment.unwrap_or(DEFAULT_RENT_PAYMENT),
207        write_top_up: write_top_up.unwrap_or(DEFAULT_WRITE_TOP_UP),
208    };
209
210    let ix = decompress
211        .instruction()
212        .map_err(DecompressMintError::from)?;
213    Ok(vec![ix])
214}
215
216/// Load (decompress) a pre-fetched mint. Returns empty vec if already hot.
217pub async fn decompress_mint<I: Indexer>(
218    mint: &MintInterface,
219    fee_payer: Pubkey,
220    indexer: &I,
221) -> Result<Vec<Instruction>, DecompressMintError> {
222    // Fast exit if hot or doesn't exist
223    let hash = match mint.hash() {
224        Some(h) => h,
225        None => return Ok(vec![]),
226    };
227
228    // Check decompressed flag before fetching proof
229    if let Some((_, mint_data)) = mint.compressed() {
230        if mint_data.metadata.mint_decompressed {
231            return Ok(vec![]);
232        }
233    }
234
235    // Get validity proof
236    let proof = indexer
237        .get_validity_proof(vec![hash], vec![], None)
238        .await?
239        .value;
240
241    // Build instruction (sync)
242    build_decompress_mint(mint, fee_payer, Some(proof), None, None)
243}
244
245/// Request to load (decompress) a cold mint.
246#[derive(Debug, Clone)]
247pub struct DecompressMintRequest {
248    pub mint_seed_pubkey: Pubkey,
249    pub address_tree: Option<Pubkey>,
250    pub rent_payment: Option<u8>,
251    pub write_top_up: Option<u32>,
252}
253
254impl DecompressMintRequest {
255    pub fn new(mint_seed_pubkey: Pubkey) -> Self {
256        Self {
257            mint_seed_pubkey,
258            address_tree: None,
259            rent_payment: None,
260            write_top_up: None,
261        }
262    }
263
264    pub fn with_address_tree(mut self, address_tree: Pubkey) -> Self {
265        self.address_tree = Some(address_tree);
266        self
267    }
268
269    pub fn with_rent_payment(mut self, rent_payment: u8) -> Self {
270        self.rent_payment = Some(rent_payment);
271        self
272    }
273
274    pub fn with_write_top_up(mut self, write_top_up: u32) -> Self {
275        self.write_top_up = Some(write_top_up);
276        self
277    }
278}
279
280/// Loads (decompresses) a cold mint to on-chain. Idempotent.
281pub async fn decompress_mint_idempotent<I: Indexer>(
282    request: DecompressMintRequest,
283    fee_payer: Pubkey,
284    indexer: &I,
285) -> Result<Vec<Instruction>, DecompressMintError> {
286    // 1. Derive addresses
287    let address_tree = request
288        .address_tree
289        .unwrap_or(Pubkey::new_from_array(MINT_ADDRESS_TREE));
290    let compressed_address = derive_address(
291        &request.mint_seed_pubkey.to_bytes(),
292        &address_tree.to_bytes(),
293        &light_token_interface::LIGHT_TOKEN_PROGRAM_ID,
294    );
295
296    // 2. Fetch cold mint from indexer
297    let compressed_account = indexer
298        .get_compressed_account(compressed_address, None)
299        .await?
300        .value
301        .ok_or(DecompressMintError::MintNotFound {
302            address: request.mint_seed_pubkey,
303        })?;
304
305    // 3. Check if data is empty (already hot)
306    let data = match compressed_account.data.as_ref() {
307        Some(d) if !d.data.is_empty() => d,
308        _ => return Ok(vec![]), // Empty data = already decompressed (idempotent)
309    };
310
311    // 4. Parse mint data from cold account
312    let mint_data =
313        Mint::try_from_slice(&data.data).map_err(|_| DecompressMintError::MissingMintData)?;
314
315    // 5. Check if already decompressed flag is set - return empty vec (idempotent)
316    if mint_data.metadata.mint_decompressed {
317        return Ok(vec![]);
318    }
319
320    // 5. Get validity proof
321    let proof_result = indexer
322        .get_validity_proof(vec![compressed_account.hash], vec![], None)
323        .await?
324        .value;
325
326    // 6. Extract tree info from proof result
327    let account_info = &proof_result.accounts[0];
328    let state_tree = account_info.tree_info.tree;
329    let input_queue = account_info.tree_info.queue;
330    let output_queue = account_info
331        .tree_info
332        .next_tree_info
333        .as_ref()
334        .map(|next| next.queue)
335        .unwrap_or(input_queue);
336
337    // 7. Build MintWithContext
338    let mint_instruction_data = MintInstructionData::try_from(mint_data)
339        .map_err(|_| DecompressMintError::MissingMintData)?;
340
341    let compressed_mint_with_context = MintWithContext {
342        leaf_index: account_info.leaf_index as u32,
343        prove_by_index: account_info.root_index.proof_by_index(),
344        root_index: account_info.root_index.root_index().unwrap_or_default(),
345        address: compressed_address,
346        mint: Some(mint_instruction_data),
347    };
348
349    // 8. Build DecompressMint instruction
350    let decompress = DecompressMint {
351        payer: fee_payer,
352        authority: fee_payer, // Permissionless - any signer works
353        state_tree,
354        input_queue,
355        output_queue,
356        compressed_mint_with_context,
357        proof: ValidityProof(proof_result.proof.into()),
358        rent_payment: request.rent_payment.unwrap_or(DEFAULT_RENT_PAYMENT),
359        write_top_up: request.write_top_up.unwrap_or(DEFAULT_WRITE_TOP_UP),
360    };
361
362    let ix = decompress
363        .instruction()
364        .map_err(DecompressMintError::from)?;
365    Ok(vec![ix])
366}
367
368/// Create MintInterface from mint address and state data.
369pub fn create_mint_interface(
370    address: Pubkey,
371    address_tree: Pubkey,
372    onchain_account: Option<Account>,
373    compressed: Option<(CompressedAccount, Mint)>,
374) -> MintInterface {
375    let compressed_address = light_compressed_account::address::derive_address(
376        &address.to_bytes(),
377        &address_tree.to_bytes(),
378        &light_token_interface::LIGHT_TOKEN_PROGRAM_ID,
379    );
380
381    let state = if let Some(account) = onchain_account {
382        MintState::Hot { account }
383    } else if let Some((compressed, mint_data)) = compressed {
384        MintState::Cold {
385            compressed,
386            mint_data,
387        }
388    } else {
389        MintState::None
390    };
391
392    MintInterface {
393        mint: address,
394        address_tree,
395        compressed_address,
396        state,
397    }
398}