Skip to main content

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