light_client/interface/
account_interface.rs

1//! Unified account interfaces for hot/cold account handling.
2//!
3//! Core types:
4//! - `AccountInterface` - Generic account (PDAs, mints)
5//! - `TokenAccountInterface` - Token accounts (ATAs, program-owned vaults)
6//!
7//! All interfaces use standard Solana/SPL types:
8//! - `solana_account::Account` for raw account data
9//! - `spl_token_2022_interface::pod::PodAccount` for parsed token data
10
11use light_token::instruction::derive_token_ata;
12use light_token_interface::state::ExtensionStruct;
13use solana_account::Account;
14use solana_pubkey::Pubkey;
15use spl_pod::{
16    bytemuck::{pod_bytes_of, pod_from_bytes, pod_get_packed_len},
17    primitives::PodU64,
18};
19use spl_token_2022_interface::{
20    pod::{PodAccount, PodCOption},
21    state::AccountState,
22};
23use thiserror::Error;
24
25use super::ColdContext;
26use crate::indexer::{CompressedAccount, CompressedTokenAccount, TreeInfo};
27
28/// Error type for account interface operations.
29#[derive(Debug, Error)]
30pub enum AccountInterfaceError {
31    #[error("Account not found")]
32    NotFound,
33
34    #[error("Invalid account data")]
35    InvalidData,
36
37    #[error("Parse error: {0}")]
38    ParseError(String),
39}
40
41/// Unified account interface for PDAs, mints, and tokens.
42///
43/// Uses standard `solana_account::Account` for raw data.
44/// For hot accounts: actual on-chain bytes.
45/// For cold accounts: synthetic bytes from cold data.
46#[derive(Debug, Clone)]
47pub struct AccountInterface {
48    /// The account's public key.
49    pub key: Pubkey,
50    /// Standard Solana Account (lamports, data, owner, executable, rent_epoch).
51    pub account: Account,
52    /// Cold context (only present when cold).
53    pub cold: Option<ColdContext>,
54}
55
56impl AccountInterface {
57    /// Create a hot (on-chain) account interface.
58    pub fn hot(key: Pubkey, account: Account) -> Self {
59        Self {
60            key,
61            account,
62            cold: None,
63        }
64    }
65
66    /// Create a cold account interface for a PDA/mint.
67    pub fn cold(key: Pubkey, compressed: CompressedAccount, owner: Pubkey) -> Self {
68        let data = compressed
69            .data
70            .as_ref()
71            .map(|d| {
72                let mut buf = d.discriminator.to_vec();
73                buf.extend_from_slice(&d.data);
74                buf
75            })
76            .unwrap_or_default();
77
78        Self {
79            key,
80            account: Account {
81                lamports: compressed.lamports,
82                data,
83                owner,
84                executable: false,
85                rent_epoch: 0,
86            },
87            cold: Some(ColdContext::Account(compressed)),
88        }
89    }
90
91    /// Create a cold account interface for a token account.
92    pub fn cold_token(
93        key: Pubkey,
94        compressed: CompressedTokenAccount,
95        wallet_owner: Pubkey,
96    ) -> Self {
97        use light_token::compat::AccountState as LightAccountState;
98        let token = &compressed.token;
99        let parsed = PodAccount {
100            mint: token.mint,
101            owner: wallet_owner,
102            amount: PodU64::from(token.amount),
103            delegate: match token.delegate {
104                Some(pk) => PodCOption::some(pk),
105                None => PodCOption::none(),
106            },
107            state: match token.state {
108                LightAccountState::Frozen => AccountState::Frozen as u8,
109                _ => AccountState::Initialized as u8,
110            },
111            is_native: PodCOption::none(),
112            delegated_amount: PodU64::from(0u64),
113            close_authority: PodCOption::none(),
114        };
115        let data = pod_bytes_of(&parsed).to_vec();
116
117        Self {
118            key,
119            account: Account {
120                lamports: compressed.account.lamports,
121                data,
122                owner: light_token::instruction::LIGHT_TOKEN_PROGRAM_ID,
123                executable: false,
124                rent_epoch: 0,
125            },
126            cold: Some(ColdContext::Token(compressed)),
127        }
128    }
129
130    /// Whether this account is cold.
131    #[inline]
132    pub fn is_cold(&self) -> bool {
133        self.cold.is_some()
134    }
135
136    /// Whether this account is hot.
137    #[inline]
138    pub fn is_hot(&self) -> bool {
139        self.cold.is_none()
140    }
141
142    /// Get data bytes.
143    #[inline]
144    pub fn data(&self) -> &[u8] {
145        &self.account.data
146    }
147
148    /// Get the account hash if cold.
149    pub fn hash(&self) -> Option<[u8; 32]> {
150        match &self.cold {
151            Some(ColdContext::Account(c)) => Some(c.hash),
152            Some(ColdContext::Token(c)) => Some(c.account.hash),
153            None => None,
154        }
155    }
156
157    /// Get tree info if cold.
158    pub fn tree_info(&self) -> Option<&TreeInfo> {
159        match &self.cold {
160            Some(ColdContext::Account(c)) => Some(&c.tree_info),
161            Some(ColdContext::Token(c)) => Some(&c.account.tree_info),
162            None => None,
163        }
164    }
165
166    /// Get leaf index if cold.
167    pub fn leaf_index(&self) -> Option<u32> {
168        match &self.cold {
169            Some(ColdContext::Account(c)) => Some(c.leaf_index),
170            Some(ColdContext::Token(c)) => Some(c.account.leaf_index),
171            None => None,
172        }
173    }
174
175    /// Get as CompressedAccount if cold account type.
176    pub fn as_compressed_account(&self) -> Option<&CompressedAccount> {
177        match &self.cold {
178            Some(ColdContext::Account(c)) => Some(c),
179            _ => None,
180        }
181    }
182
183    /// Get as CompressedTokenAccount if cold token type.
184    pub fn as_compressed_token(&self) -> Option<&CompressedTokenAccount> {
185        match &self.cold {
186            Some(ColdContext::Token(c)) => Some(c),
187            _ => None,
188        }
189    }
190
191    /// Try to parse as Mint. Returns None if not a mint or parse fails.
192    pub fn as_mint(&self) -> Option<light_token_interface::state::Mint> {
193        match &self.cold {
194            Some(ColdContext::Account(ca)) => {
195                let data = ca.data.as_ref()?;
196                borsh::BorshDeserialize::deserialize(&mut data.data.as_slice()).ok()
197            }
198            _ => None,
199        }
200    }
201
202    /// Get mint signer if this is a cold mint.
203    pub fn mint_signer(&self) -> Option<[u8; 32]> {
204        self.as_mint().map(|m| m.metadata.mint_signer)
205    }
206
207    /// Get mint address if this is a cold mint.
208    pub fn mint_compressed_address(&self) -> Option<[u8; 32]> {
209        self.as_mint().map(|m| m.metadata.compressed_address())
210    }
211}
212
213/// Token account interface with both raw and parsed data.
214///
215/// Uses standard types:
216/// - `solana_account::Account` for raw bytes
217/// - `spl_token_2022_interface::pod::PodAccount` for parsed token data
218///
219/// For ATAs: `parsed.owner` is the wallet owner (set from fetch params).
220/// For program-owned: `parsed.owner` is the PDA.
221#[derive(Debug, Clone)]
222pub struct TokenAccountInterface {
223    /// The token account's public key.
224    pub key: Pubkey,
225    /// Standard Solana Account (lamports, data, owner, executable, rent_epoch).
226    pub account: Account,
227    /// Parsed SPL Token Account (POD format).
228    pub parsed: PodAccount,
229    /// Cold context (only present when cold).
230    pub cold: Option<ColdContext>,
231    /// Optional TLV extension data.
232    pub extensions: Option<Vec<ExtensionStruct>>,
233}
234
235impl TokenAccountInterface {
236    /// Create a hot (on-chain) token account interface.
237    pub fn hot(key: Pubkey, account: Account) -> Result<Self, AccountInterfaceError> {
238        let pod_len = pod_get_packed_len::<PodAccount>();
239        if account.data.len() < pod_len {
240            return Err(AccountInterfaceError::InvalidData);
241        }
242
243        let parsed: &PodAccount = pod_from_bytes(&account.data[..pod_len])
244            .map_err(|e| AccountInterfaceError::ParseError(e.to_string()))?;
245
246        Ok(Self {
247            key,
248            parsed: *parsed,
249            account,
250            cold: None,
251            extensions: None,
252        })
253    }
254
255    /// Create a cold token account interface.
256    ///
257    /// # Arguments
258    /// * `key` - The token account address
259    /// * `compressed` - The cold token account from indexer
260    /// * `owner_override` - For ATAs, pass the wallet owner. For program-owned, pass the PDA.
261    /// * `program_owner` - The program that owns this account (usually LIGHT_TOKEN_PROGRAM_ID)
262    pub fn cold(
263        key: Pubkey,
264        compressed: CompressedTokenAccount,
265        owner_override: Pubkey,
266        program_owner: Pubkey,
267    ) -> Self {
268        use light_token::compat::AccountState as LightAccountState;
269
270        let token = &compressed.token;
271
272        let parsed = PodAccount {
273            mint: token.mint,
274            owner: owner_override,
275            amount: PodU64::from(token.amount),
276            delegate: match token.delegate {
277                Some(pk) => PodCOption::some(pk),
278                None => PodCOption::none(),
279            },
280            state: match token.state {
281                LightAccountState::Frozen => AccountState::Frozen as u8,
282                _ => AccountState::Initialized as u8,
283            },
284            is_native: PodCOption::none(),
285            delegated_amount: PodU64::from(0u64),
286            close_authority: PodCOption::none(),
287        };
288
289        let data = pod_bytes_of(&parsed).to_vec();
290
291        let extensions = token.tlv.clone();
292
293        let account = Account {
294            lamports: compressed.account.lamports,
295            data,
296            owner: program_owner,
297            executable: false,
298            rent_epoch: 0,
299        };
300
301        Self {
302            key,
303            account,
304            parsed,
305            cold: Some(ColdContext::Token(compressed)),
306            extensions,
307        }
308    }
309
310    /// Whether this account is cold.
311    #[inline]
312    pub fn is_cold(&self) -> bool {
313        self.cold.is_some()
314    }
315
316    /// Whether this account is hot.
317    #[inline]
318    pub fn is_hot(&self) -> bool {
319        self.cold.is_none()
320    }
321
322    /// Get the CompressedTokenAccount if cold.
323    pub fn compressed(&self) -> Option<&CompressedTokenAccount> {
324        match &self.cold {
325            Some(ColdContext::Token(c)) => Some(c),
326            _ => None,
327        }
328    }
329
330    /// Get amount.
331    #[inline]
332    pub fn amount(&self) -> u64 {
333        u64::from(self.parsed.amount)
334    }
335
336    /// Get delegate.
337    #[inline]
338    pub fn delegate(&self) -> Option<Pubkey> {
339        if self.parsed.delegate.is_some() {
340            Some(self.parsed.delegate.value)
341        } else {
342            None
343        }
344    }
345
346    /// Get mint.
347    #[inline]
348    pub fn mint(&self) -> Pubkey {
349        self.parsed.mint
350    }
351
352    /// Get owner (wallet for ATAs, PDA for program-owned).
353    #[inline]
354    pub fn owner(&self) -> Pubkey {
355        self.parsed.owner
356    }
357
358    /// Check if frozen.
359    #[inline]
360    pub fn is_frozen(&self) -> bool {
361        self.parsed.state == AccountState::Frozen as u8
362    }
363
364    /// Get the account hash if cold.
365    #[inline]
366    pub fn hash(&self) -> Option<[u8; 32]> {
367        self.compressed().map(|c| c.account.hash)
368    }
369
370    /// Get tree info if cold.
371    #[inline]
372    pub fn tree_info(&self) -> Option<&TreeInfo> {
373        self.compressed().map(|c| &c.account.tree_info)
374    }
375
376    /// Get leaf index if cold.
377    #[inline]
378    pub fn leaf_index(&self) -> Option<u32> {
379        self.compressed().map(|c| c.account.leaf_index)
380    }
381
382    /// Get ATA bump if this is an ATA. Returns None if not a valid ATA derivation.
383    pub fn ata_bump(&self) -> Option<u8> {
384        let (derived_ata, bump) = derive_token_ata(&self.parsed.owner, &self.parsed.mint);
385        (derived_ata == self.key).then_some(bump)
386    }
387
388    /// Check if this token account is an ATA (derivation matches).
389    pub fn is_ata(&self) -> bool {
390        self.ata_bump().is_some()
391    }
392}