From 6d42d5d6bca332b56795d520cc2db3e81a4ed7ae Mon Sep 17 00:00:00 2001 From: Sergey Timoshin Date: Wed, 11 Feb 2026 18:44:50 +0000 Subject: [PATCH 1/5] fix: correctly identify truly cold mint accounts --- sdk-libs/client/src/rpc/client.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/sdk-libs/client/src/rpc/client.rs b/sdk-libs/client/src/rpc/client.rs index 82d543c002..c3e089cffd 100644 --- a/sdk-libs/client/src/rpc/client.rs +++ b/sdk-libs/client/src/rpc/client.rs @@ -1172,7 +1172,8 @@ impl Rpc for LightClient { let value = match resp.value { Some(ai) => { - let state = if ai.is_cold() { + let is_truly_cold = ai.is_cold() && ai.account.lamports == 0; + let state = if is_truly_cold { let cold = ai.cold.as_ref().ok_or_else(|| { RpcError::CustomError("Cold mint missing cold context".into()) })?; From 2e6671bf109be75706dd0d5c8bdafb5d734013d5 Mon Sep 17 00:00:00 2001 From: Sergey Timoshin Date: Wed, 11 Feb 2026 19:49:26 +0000 Subject: [PATCH 2/5] fix: refine cold account identification logic in AccountInterface --- sdk-libs/client/src/indexer/types/interface.rs | 13 +++++++++---- sdk-libs/client/src/rpc/client.rs | 3 +-- 2 files changed, 10 insertions(+), 6 deletions(-) diff --git a/sdk-libs/client/src/indexer/types/interface.rs b/sdk-libs/client/src/indexer/types/interface.rs index 61d8d109aa..be241a90e5 100644 --- a/sdk-libs/client/src/indexer/types/interface.rs +++ b/sdk-libs/client/src/indexer/types/interface.rs @@ -113,14 +113,19 @@ pub struct AccountInterface { } impl AccountInterface { - /// Returns true if this account is on-chain (hot) + /// Returns true if this account is on-chain (hot). pub fn is_hot(&self) -> bool { - self.cold.is_none() + !self.is_cold() } - /// Returns true if this account is compressed (cold) + /// Returns true if this account is compressed (cold). + /// + /// An account is truly cold only when compressed data exists AND the + /// on-chain account is closed (lamports == 0). Decompressed accounts + /// keep a compressed placeholder (DECOMPRESSED_PDA_DISCRIMINATOR) but + /// are still on-chain with lamports > 0 — those are hot. pub fn is_cold(&self) -> bool { - self.cold.is_some() + self.cold.is_some() && self.account.lamports == 0 } } diff --git a/sdk-libs/client/src/rpc/client.rs b/sdk-libs/client/src/rpc/client.rs index c3e089cffd..82d543c002 100644 --- a/sdk-libs/client/src/rpc/client.rs +++ b/sdk-libs/client/src/rpc/client.rs @@ -1172,8 +1172,7 @@ impl Rpc for LightClient { let value = match resp.value { Some(ai) => { - let is_truly_cold = ai.is_cold() && ai.account.lamports == 0; - let state = if is_truly_cold { + let state = if ai.is_cold() { let cold = ai.cold.as_ref().ok_or_else(|| { RpcError::CustomError("Cold mint missing cold context".into()) })?; From ac01e82d21b6b9e3f4d949a20ad11e82bfb9394a Mon Sep 17 00:00:00 2001 From: Sergey Timoshin Date: Wed, 11 Feb 2026 21:13:15 +0000 Subject: [PATCH 3/5] fix: improve cold account handling in AccountInterface and RPC client --- .../client/src/indexer/types/interface.rs | 161 +++++++++++++++++- sdk-libs/client/src/rpc/client.rs | 104 +++++------ 2 files changed, 206 insertions(+), 59 deletions(-) diff --git a/sdk-libs/client/src/indexer/types/interface.rs b/sdk-libs/client/src/indexer/types/interface.rs index be241a90e5..89fa24f53a 100644 --- a/sdk-libs/client/src/indexer/types/interface.rs +++ b/sdk-libs/client/src/indexer/types/interface.rs @@ -1,4 +1,5 @@ use light_compressed_account::TreeType; +use light_compressible::DECOMPRESSED_PDA_DISCRIMINATOR; use light_token::compat::TokenData; use solana_account::Account; use solana_pubkey::Pubkey; @@ -120,12 +121,14 @@ impl AccountInterface { /// Returns true if this account is compressed (cold). /// - /// An account is truly cold only when compressed data exists AND the - /// on-chain account is closed (lamports == 0). Decompressed accounts - /// keep a compressed placeholder (DECOMPRESSED_PDA_DISCRIMINATOR) but - /// are still on-chain with lamports > 0 — those are hot. + /// An account is cold when compressed data exists AND the discriminator + /// is NOT `DECOMPRESSED_PDA_DISCRIMINATOR`. Decompressed accounts keep a + /// compressed placeholder with that discriminator but are on-chain (hot). pub fn is_cold(&self) -> bool { - self.cold.is_some() && self.account.lamports == 0 + match &self.cold { + Some(cold) => cold.data.discriminator != DECOMPRESSED_PDA_DISCRIMINATOR, + None => false, + } } } @@ -133,11 +136,19 @@ impl AccountInterface { fn convert_account_interface( ai: &photon_api::types::AccountInterface, ) -> Result { - // Take the first compressed account entry if present + // Photon can return multiple cold entries for the same pubkey (e.g. a + // decompressed placeholder alongside the active compressed account, or + // multiple compressed token accounts for the same owner). Skip decompressed + // placeholders and take the first truly cold entry. let cold = ai .cold .as_ref() - .and_then(|entries| entries.first()) + .and_then(|entries| { + entries.iter().find(|e| match &e.data { + Some(d) => (*d.discriminator).to_le_bytes() != DECOMPRESSED_PDA_DISCRIMINATOR, + None => true, + }) + }) .map(convert_account_v2) .transpose()?; @@ -173,3 +184,139 @@ pub struct TokenAccountInterface { /// Parsed token data (same as CompressedTokenAccount.token) pub token: TokenData, } + +#[cfg(test)] +mod tests { + use super::*; + + fn default_tree_info() -> InterfaceTreeInfo { + InterfaceTreeInfo { + tree: Pubkey::default(), + queue: Pubkey::default(), + tree_type: TreeType::StateV2, + seq: Some(1), + slot_created: 100, + } + } + + fn make_cold_context(discriminator: [u8; 8]) -> ColdContext { + ColdContext { + hash: [1u8; 32], + leaf_index: 0, + tree_info: default_tree_info(), + data: ColdData { + discriminator, + data: vec![1, 2, 3], + data_hash: [2u8; 32], + }, + address: Some([3u8; 32]), + prove_by_index: false, + } + } + + fn make_account(lamports: u64) -> SolanaAccountData { + Account { + lamports, + data: vec![], + owner: Pubkey::default(), + executable: false, + rent_epoch: 0, + } + } + + #[test] + fn test_pure_on_chain_is_hot() { + let ai = AccountInterface { + key: Pubkey::new_unique(), + account: make_account(1_000_000), + cold: None, + }; + assert!(ai.is_hot()); + assert!(!ai.is_cold()); + } + + #[test] + fn test_compressed_is_cold() { + let ai = AccountInterface { + key: Pubkey::new_unique(), + account: make_account(0), + cold: Some(make_cold_context([1, 2, 3, 4, 5, 6, 7, 8])), + }; + assert!(ai.is_cold()); + assert!(!ai.is_hot()); + } + + #[test] + fn test_decompressed_is_hot() { + let ai = AccountInterface { + key: Pubkey::new_unique(), + account: make_account(1_000_000), + cold: Some(make_cold_context(DECOMPRESSED_PDA_DISCRIMINATOR)), + }; + assert!(ai.is_hot()); + assert!(!ai.is_cold()); + } + + #[test] + fn test_compressed_with_lamports_sent_to_closed_account_is_still_cold() { + // Someone sent lamports to the closed on-chain account — old check + // would wrongly say is_hot() because lamports > 0. + let ai = AccountInterface { + key: Pubkey::new_unique(), + account: make_account(500_000), + cold: Some(make_cold_context([10, 20, 30, 40, 50, 60, 70, 80])), + }; + assert!(ai.is_cold()); + assert!(!ai.is_hot()); + } + + #[test] + fn test_zero_discriminator_is_cold() { + // Default/zero discriminator means compressed (no data case). + let ai = AccountInterface { + key: Pubkey::new_unique(), + account: make_account(0), + cold: Some(make_cold_context([0u8; 8])), + }; + assert!(ai.is_cold()); + assert!(!ai.is_hot()); + } + + #[test] + fn test_decompressed_with_zero_lamports_is_hot() { + // Discriminator wins over lamports — decompressed placeholder with + // zero lamports is still hot. + let ai = AccountInterface { + key: Pubkey::new_unique(), + account: make_account(0), + cold: Some(make_cold_context(DECOMPRESSED_PDA_DISCRIMINATOR)), + }; + assert!(ai.is_hot()); + assert!(!ai.is_cold()); + } + + #[test] + fn test_token_account_interface_delegates_is_cold() { + let token = TokenData::default(); + + let cold_tai = TokenAccountInterface { + account: AccountInterface { + key: Pubkey::new_unique(), + account: make_account(0), + cold: Some(make_cold_context([1, 2, 3, 4, 5, 6, 7, 8])), + }, + token: token.clone(), + }; + assert!(cold_tai.account.is_cold()); + + let decompressed_tai = TokenAccountInterface { + account: AccountInterface { + key: Pubkey::new_unique(), + account: make_account(1_000_000), + cold: Some(make_cold_context(DECOMPRESSED_PDA_DISCRIMINATOR)), + }, + token, + }; + assert!(decompressed_tai.account.is_hot()); + } +} diff --git a/sdk-libs/client/src/rpc/client.rs b/sdk-libs/client/src/rpc/client.rs index 82d543c002..0c606da300 100644 --- a/sdk-libs/client/src/rpc/client.rs +++ b/sdk-libs/client/src/rpc/client.rs @@ -540,28 +540,29 @@ fn cold_context_to_compressed_account( fn convert_account_interface( indexer_ai: IndexerAccountInterface, ) -> Result { - let account = Account { - lamports: indexer_ai.account.lamports, - data: indexer_ai.account.data, - owner: indexer_ai.account.owner, - executable: indexer_ai.account.executable, - rent_epoch: indexer_ai.account.rent_epoch, - }; - - match indexer_ai.cold { - None => Ok(AccountInterface::hot(indexer_ai.key, account)), - Some(cold) => { - let compressed = cold_context_to_compressed_account( - &cold, - indexer_ai.account.lamports, - indexer_ai.account.owner, - ); - Ok(AccountInterface::cold( - indexer_ai.key, - compressed, - indexer_ai.account.owner, - )) - } + let is_cold = indexer_ai.is_cold(); + + if is_cold { + let cold = indexer_ai.cold.unwrap(); + let compressed = cold_context_to_compressed_account( + &cold, + indexer_ai.account.lamports, + indexer_ai.account.owner, + ); + Ok(AccountInterface::cold( + indexer_ai.key, + compressed, + indexer_ai.account.owner, + )) + } else { + let account = Account { + lamports: indexer_ai.account.lamports, + data: indexer_ai.account.data, + owner: indexer_ai.account.owner, + executable: indexer_ai.account.executable, + rent_epoch: indexer_ai.account.rent_epoch, + }; + Ok(AccountInterface::hot(indexer_ai.key, account)) } } @@ -570,36 +571,35 @@ fn convert_token_account_interface( ) -> Result { use crate::indexer::CompressedTokenAccount; - let account = Account { - lamports: indexer_tai.account.account.lamports, - data: indexer_tai.account.account.data.clone(), - owner: indexer_tai.account.account.owner, - executable: indexer_tai.account.account.executable, - rent_epoch: indexer_tai.account.account.rent_epoch, - }; - - match indexer_tai.account.cold { - None => TokenAccountInterface::hot(indexer_tai.account.key, account) - .map_err(|e| RpcError::CustomError(format!("parse error: {}", e))), - Some(cold) => { - let compressed_account = cold_context_to_compressed_account( - &cold, - indexer_tai.account.account.lamports, - indexer_tai.account.account.owner, - ); - // Extract token owner before moving token into CompressedTokenAccount - let token_owner = indexer_tai.token.owner; - let compressed_token = CompressedTokenAccount { - token: indexer_tai.token, - account: compressed_account, - }; - Ok(TokenAccountInterface::cold( - indexer_tai.account.key, - compressed_token, - token_owner, // owner_override: use token owner, not account key - indexer_tai.account.account.owner, - )) - } + if indexer_tai.account.is_cold() { + let cold = indexer_tai.account.cold.unwrap(); + let compressed_account = cold_context_to_compressed_account( + &cold, + indexer_tai.account.account.lamports, + indexer_tai.account.account.owner, + ); + // Extract token owner before moving token into CompressedTokenAccount + let token_owner = indexer_tai.token.owner; + let compressed_token = CompressedTokenAccount { + token: indexer_tai.token, + account: compressed_account, + }; + Ok(TokenAccountInterface::cold( + indexer_tai.account.key, + compressed_token, + token_owner, // owner_override: use token owner, not account key + indexer_tai.account.account.owner, + )) + } else { + let account = Account { + lamports: indexer_tai.account.account.lamports, + data: indexer_tai.account.account.data, + owner: indexer_tai.account.account.owner, + executable: indexer_tai.account.account.executable, + rent_epoch: indexer_tai.account.account.rent_epoch, + }; + TokenAccountInterface::hot(indexer_tai.account.key, account) + .map_err(|e| RpcError::CustomError(format!("parse error: {}", e))) } } From 1fde92581ace50cb76a91f4fe1acbf28c80d6290 Mon Sep 17 00:00:00 2001 From: Sergey Timoshin Date: Wed, 11 Feb 2026 22:00:08 +0000 Subject: [PATCH 4/5] fix: clarify cold account identification logic in AccountInterface --- sdk-libs/client/src/interface/account_interface.rs | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/sdk-libs/client/src/interface/account_interface.rs b/sdk-libs/client/src/interface/account_interface.rs index a7f30891fa..aede672257 100644 --- a/sdk-libs/client/src/interface/account_interface.rs +++ b/sdk-libs/client/src/interface/account_interface.rs @@ -88,6 +88,13 @@ impl AccountInterface { } /// Whether this account is cold. + /// + /// This simple `cold.is_some()` check intentionally differs from + /// `IndexerAccountInterface::is_cold()` (which inspects + /// `DECOMPRESSED_PDA_DISCRIMINATOR`). `AccountInterface` is produced from + /// already-filtered client-side data where decompressed placeholders have + /// been removed; decompressed accounts will have `cold: None` here, so + /// the presence check is correct. #[inline] pub fn is_cold(&self) -> bool { self.cold.is_some() From 63392002f1d4748f72afd960c735fe17a880d281 Mon Sep 17 00:00:00 2001 From: Sergey Timoshin Date: Fri, 13 Feb 2026 15:43:40 +0000 Subject: [PATCH 5/5] fix: simplify cold account checks in AccountInterface and RPC client --- external/photon | 2 +- .../client/src/indexer/types/interface.rs | 67 +---------- .../client/src/interface/account_interface.rs | 7 -- sdk-libs/client/src/rpc/client.rs | 106 +++++++++--------- 4 files changed, 61 insertions(+), 121 deletions(-) diff --git a/external/photon b/external/photon index 0df2397c2c..84ddfc0f58 160000 --- a/external/photon +++ b/external/photon @@ -1 +1 @@ -Subproject commit 0df2397c2c7d8458f45df9279e999a730ba56482 +Subproject commit 84ddfc0f586806373567faf75f45158076a4f133 diff --git a/sdk-libs/client/src/indexer/types/interface.rs b/sdk-libs/client/src/indexer/types/interface.rs index 89fa24f53a..49b658cb4c 100644 --- a/sdk-libs/client/src/indexer/types/interface.rs +++ b/sdk-libs/client/src/indexer/types/interface.rs @@ -1,5 +1,4 @@ use light_compressed_account::TreeType; -use light_compressible::DECOMPRESSED_PDA_DISCRIMINATOR; use light_token::compat::TokenData; use solana_account::Account; use solana_pubkey::Pubkey; @@ -116,19 +115,12 @@ pub struct AccountInterface { impl AccountInterface { /// Returns true if this account is on-chain (hot). pub fn is_hot(&self) -> bool { - !self.is_cold() + self.cold.is_none() } /// Returns true if this account is compressed (cold). - /// - /// An account is cold when compressed data exists AND the discriminator - /// is NOT `DECOMPRESSED_PDA_DISCRIMINATOR`. Decompressed accounts keep a - /// compressed placeholder with that discriminator but are on-chain (hot). pub fn is_cold(&self) -> bool { - match &self.cold { - Some(cold) => cold.data.discriminator != DECOMPRESSED_PDA_DISCRIMINATOR, - None => false, - } + self.cold.is_some() } } @@ -136,19 +128,10 @@ impl AccountInterface { fn convert_account_interface( ai: &photon_api::types::AccountInterface, ) -> Result { - // Photon can return multiple cold entries for the same pubkey (e.g. a - // decompressed placeholder alongside the active compressed account, or - // multiple compressed token accounts for the same owner). Skip decompressed - // placeholders and take the first truly cold entry. let cold = ai .cold .as_ref() - .and_then(|entries| { - entries.iter().find(|e| match &e.data { - Some(d) => (*d.discriminator).to_le_bytes() != DECOMPRESSED_PDA_DISCRIMINATOR, - None => true, - }) - }) + .and_then(|entries| entries.first()) .map(convert_account_v2) .transpose()?; @@ -246,33 +229,8 @@ mod tests { assert!(!ai.is_hot()); } - #[test] - fn test_decompressed_is_hot() { - let ai = AccountInterface { - key: Pubkey::new_unique(), - account: make_account(1_000_000), - cold: Some(make_cold_context(DECOMPRESSED_PDA_DISCRIMINATOR)), - }; - assert!(ai.is_hot()); - assert!(!ai.is_cold()); - } - - #[test] - fn test_compressed_with_lamports_sent_to_closed_account_is_still_cold() { - // Someone sent lamports to the closed on-chain account — old check - // would wrongly say is_hot() because lamports > 0. - let ai = AccountInterface { - key: Pubkey::new_unique(), - account: make_account(500_000), - cold: Some(make_cold_context([10, 20, 30, 40, 50, 60, 70, 80])), - }; - assert!(ai.is_cold()); - assert!(!ai.is_hot()); - } - #[test] fn test_zero_discriminator_is_cold() { - // Default/zero discriminator means compressed (no data case). let ai = AccountInterface { key: Pubkey::new_unique(), account: make_account(0), @@ -282,19 +240,6 @@ mod tests { assert!(!ai.is_hot()); } - #[test] - fn test_decompressed_with_zero_lamports_is_hot() { - // Discriminator wins over lamports — decompressed placeholder with - // zero lamports is still hot. - let ai = AccountInterface { - key: Pubkey::new_unique(), - account: make_account(0), - cold: Some(make_cold_context(DECOMPRESSED_PDA_DISCRIMINATOR)), - }; - assert!(ai.is_hot()); - assert!(!ai.is_cold()); - } - #[test] fn test_token_account_interface_delegates_is_cold() { let token = TokenData::default(); @@ -309,14 +254,14 @@ mod tests { }; assert!(cold_tai.account.is_cold()); - let decompressed_tai = TokenAccountInterface { + let hot_tai = TokenAccountInterface { account: AccountInterface { key: Pubkey::new_unique(), account: make_account(1_000_000), - cold: Some(make_cold_context(DECOMPRESSED_PDA_DISCRIMINATOR)), + cold: None, }, token, }; - assert!(decompressed_tai.account.is_hot()); + assert!(hot_tai.account.is_hot()); } } diff --git a/sdk-libs/client/src/interface/account_interface.rs b/sdk-libs/client/src/interface/account_interface.rs index aede672257..a7f30891fa 100644 --- a/sdk-libs/client/src/interface/account_interface.rs +++ b/sdk-libs/client/src/interface/account_interface.rs @@ -88,13 +88,6 @@ impl AccountInterface { } /// Whether this account is cold. - /// - /// This simple `cold.is_some()` check intentionally differs from - /// `IndexerAccountInterface::is_cold()` (which inspects - /// `DECOMPRESSED_PDA_DISCRIMINATOR`). `AccountInterface` is produced from - /// already-filtered client-side data where decompressed placeholders have - /// been removed; decompressed accounts will have `cold: None` here, so - /// the presence check is correct. #[inline] pub fn is_cold(&self) -> bool { self.cold.is_some() diff --git a/sdk-libs/client/src/rpc/client.rs b/sdk-libs/client/src/rpc/client.rs index 0c606da300..53f08dad05 100644 --- a/sdk-libs/client/src/rpc/client.rs +++ b/sdk-libs/client/src/rpc/client.rs @@ -540,29 +540,29 @@ fn cold_context_to_compressed_account( fn convert_account_interface( indexer_ai: IndexerAccountInterface, ) -> Result { - let is_cold = indexer_ai.is_cold(); - - if is_cold { - let cold = indexer_ai.cold.unwrap(); - let compressed = cold_context_to_compressed_account( - &cold, - indexer_ai.account.lamports, - indexer_ai.account.owner, - ); - Ok(AccountInterface::cold( - indexer_ai.key, - compressed, - indexer_ai.account.owner, - )) - } else { - let account = Account { - lamports: indexer_ai.account.lamports, - data: indexer_ai.account.data, - owner: indexer_ai.account.owner, - executable: indexer_ai.account.executable, - rent_epoch: indexer_ai.account.rent_epoch, - }; - Ok(AccountInterface::hot(indexer_ai.key, account)) + match indexer_ai.cold { + Some(cold) => { + let compressed = cold_context_to_compressed_account( + &cold, + indexer_ai.account.lamports, + indexer_ai.account.owner, + ); + Ok(AccountInterface::cold( + indexer_ai.key, + compressed, + indexer_ai.account.owner, + )) + } + None => { + let account = Account { + lamports: indexer_ai.account.lamports, + data: indexer_ai.account.data, + owner: indexer_ai.account.owner, + executable: indexer_ai.account.executable, + rent_epoch: indexer_ai.account.rent_epoch, + }; + Ok(AccountInterface::hot(indexer_ai.key, account)) + } } } @@ -571,35 +571,37 @@ fn convert_token_account_interface( ) -> Result { use crate::indexer::CompressedTokenAccount; - if indexer_tai.account.is_cold() { - let cold = indexer_tai.account.cold.unwrap(); - let compressed_account = cold_context_to_compressed_account( - &cold, - indexer_tai.account.account.lamports, - indexer_tai.account.account.owner, - ); - // Extract token owner before moving token into CompressedTokenAccount - let token_owner = indexer_tai.token.owner; - let compressed_token = CompressedTokenAccount { - token: indexer_tai.token, - account: compressed_account, - }; - Ok(TokenAccountInterface::cold( - indexer_tai.account.key, - compressed_token, - token_owner, // owner_override: use token owner, not account key - indexer_tai.account.account.owner, - )) - } else { - let account = Account { - lamports: indexer_tai.account.account.lamports, - data: indexer_tai.account.account.data, - owner: indexer_tai.account.account.owner, - executable: indexer_tai.account.account.executable, - rent_epoch: indexer_tai.account.account.rent_epoch, - }; - TokenAccountInterface::hot(indexer_tai.account.key, account) - .map_err(|e| RpcError::CustomError(format!("parse error: {}", e))) + match indexer_tai.account.cold { + Some(cold) => { + let compressed_account = cold_context_to_compressed_account( + &cold, + indexer_tai.account.account.lamports, + indexer_tai.account.account.owner, + ); + // Extract token owner before moving token into CompressedTokenAccount + let token_owner = indexer_tai.token.owner; + let compressed_token = CompressedTokenAccount { + token: indexer_tai.token, + account: compressed_account, + }; + Ok(TokenAccountInterface::cold( + indexer_tai.account.key, + compressed_token, + token_owner, // owner_override: use token owner, not account key + indexer_tai.account.account.owner, + )) + } + None => { + let account = Account { + lamports: indexer_tai.account.account.lamports, + data: indexer_tai.account.account.data, + owner: indexer_tai.account.account.owner, + executable: indexer_tai.account.account.executable, + rent_epoch: indexer_tai.account.account.rent_epoch, + }; + TokenAccountInterface::hot(indexer_tai.account.key, account) + .map_err(|e| RpcError::CustomError(format!("parse error: {}", e))) + } } }