light_client/interface/
decompress_mint.rs

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