From f403b82c572743689e0be4da53a59820bdbf11d8 Mon Sep 17 00:00:00 2001 From: Sergey Timoshin Date: Wed, 7 Jan 2026 11:23:07 +0000 Subject: [PATCH 01/38] feat: forester: pda & mint compression --- forester/package.json | 23 +++++++++++++++++++++++ forester/src/compressible/subscriber.rs | 1 + 2 files changed, 24 insertions(+) create mode 100644 forester/package.json diff --git a/forester/package.json b/forester/package.json new file mode 100644 index 0000000000..cc0173c735 --- /dev/null +++ b/forester/package.json @@ -0,0 +1,23 @@ +{ + "name": "@lightprotocol/forester", + "version": "0.3.0", + "license": "GPL-3.0", + "scripts": { + "build": "cargo build", + "test": "redis-start && TEST_MODE=local TEST_V1_STATE=true TEST_V2_STATE=true TEST_V1_ADDRESS=true TEST_V2_ADDRESS=true RUST_LOG=forester=debug,forester_utils=debug,light_prover_client=debug cargo test --package forester e2e_test -- --nocapture", + "test:compressible": "cargo build-sbf -- -p csdk-anchor-full-derived-test && RUST_LOG=forester=debug,light_client=debug cargo test --package forester --test test_compressible_pda --test test_compressible_mint --test test_compressible_ctoken -- --nocapture", + "docker:build": "docker build --tag forester -f Dockerfile .." + }, + "devDependencies": { + "@lightprotocol/zk-compression-cli": "workspace:*" + }, + "nx": { + "targets": { + "build": { + "outputs": [ + "{workspaceRoot}/target/release" + ] + } + } + } +} diff --git a/forester/src/compressible/subscriber.rs b/forester/src/compressible/subscriber.rs index 096f457bb4..bc5ffce4c4 100644 --- a/forester/src/compressible/subscriber.rs +++ b/forester/src/compressible/subscriber.rs @@ -67,6 +67,7 @@ enum ConnectionResult { StreamClosed, } + impl SubscriptionConfig { /// Create subscription config for Light Token accounts (ctokens) pub fn ctoken() -> Self { From 9b6ae17401f532ed747753663773145b294cc9cc Mon Sep 17 00:00:00 2001 From: Sergey Timoshin Date: Wed, 21 Jan 2026 14:36:29 +0000 Subject: [PATCH 02/38] refactor rent exemption calculations --- forester/src/compressible/mint/state.rs | 4 +--- forester/src/compressible/pda/state.rs | 4 +--- 2 files changed, 2 insertions(+), 6 deletions(-) diff --git a/forester/src/compressible/mint/state.rs b/forester/src/compressible/mint/state.rs index db19e6dd10..fbb19c4ae1 100644 --- a/forester/src/compressible/mint/state.rs +++ b/forester/src/compressible/mint/state.rs @@ -1,8 +1,6 @@ use borsh::BorshDeserialize; use dashmap::DashMap; -use light_compressible::rent::{ - get_last_funded_epoch, get_rent_exemption_lamports, SLOTS_PER_EPOCH, -}; +use light_compressible::rent::{get_last_funded_epoch, get_rent_exemption_lamports, SLOTS_PER_EPOCH}; use light_token_interface::state::{Mint, ACCOUNT_TYPE_MINT}; use solana_sdk::pubkey::Pubkey; use tracing::{debug, warn}; diff --git a/forester/src/compressible/pda/state.rs b/forester/src/compressible/pda/state.rs index 6f96bb4f20..3ead0d37fe 100644 --- a/forester/src/compressible/pda/state.rs +++ b/forester/src/compressible/pda/state.rs @@ -1,8 +1,6 @@ use borsh::BorshDeserialize; use dashmap::DashMap; -use light_compressible::rent::{ - get_last_funded_epoch, get_rent_exemption_lamports, SLOTS_PER_EPOCH, -}; +use light_compressible::rent::{get_last_funded_epoch, get_rent_exemption_lamports, SLOTS_PER_EPOCH}; use light_sdk::compressible::compression_info::CompressionInfo; use solana_sdk::pubkey::Pubkey; use tracing::{debug, warn}; From 342640c61275da640ab9cf9da487b4e598ae9eb4 Mon Sep 17 00:00:00 2001 From: Sergey Timoshin Date: Wed, 21 Jan 2026 14:46:47 +0000 Subject: [PATCH 03/38] format --- forester/src/compressible/mint/state.rs | 4 +++- forester/src/compressible/pda/state.rs | 4 +++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/forester/src/compressible/mint/state.rs b/forester/src/compressible/mint/state.rs index fbb19c4ae1..db19e6dd10 100644 --- a/forester/src/compressible/mint/state.rs +++ b/forester/src/compressible/mint/state.rs @@ -1,6 +1,8 @@ use borsh::BorshDeserialize; use dashmap::DashMap; -use light_compressible::rent::{get_last_funded_epoch, get_rent_exemption_lamports, SLOTS_PER_EPOCH}; +use light_compressible::rent::{ + get_last_funded_epoch, get_rent_exemption_lamports, SLOTS_PER_EPOCH, +}; use light_token_interface::state::{Mint, ACCOUNT_TYPE_MINT}; use solana_sdk::pubkey::Pubkey; use tracing::{debug, warn}; diff --git a/forester/src/compressible/pda/state.rs b/forester/src/compressible/pda/state.rs index 3ead0d37fe..6f96bb4f20 100644 --- a/forester/src/compressible/pda/state.rs +++ b/forester/src/compressible/pda/state.rs @@ -1,6 +1,8 @@ use borsh::BorshDeserialize; use dashmap::DashMap; -use light_compressible::rent::{get_last_funded_epoch, get_rent_exemption_lamports, SLOTS_PER_EPOCH}; +use light_compressible::rent::{ + get_last_funded_epoch, get_rent_exemption_lamports, SLOTS_PER_EPOCH, +}; use light_sdk::compressible::compression_info::CompressionInfo; use solana_sdk::pubkey::Pubkey; use tracing::{debug, warn}; From 66deaec8d0bbcfda99eee91c927a2331fa549db9 Mon Sep 17 00:00:00 2001 From: Sergey Timoshin Date: Wed, 21 Jan 2026 18:15:35 +0000 Subject: [PATCH 04/38] feat: add support for compressed mint retrieval in the indexer - Implemented `get_compressed_mint` and `get_compressed_mint_by_pda` methods in the PhotonIndexer. - Added `get_compressed_mints_by_authority` method to retrieve compressed mints based on authority. - Introduced new data structures: `CompressedMint`, `MintData`, and `CompressedMintList` to handle compressed mint data. - Updated Photon API with new request and response models for compressed mint operations. - Enhanced error handling for API responses related to compressed mints. - Updated tests to cover new functionality for compressed mint retrieval. --- forester/tests/test_compressible_mint.rs | 120 +++++++++++++ scripts/devenv/versions.sh | 2 +- sdk-libs/client/src/indexer/indexer_trait.rs | 29 ++- sdk-libs/client/src/indexer/mod.rs | 10 +- sdk-libs/client/src/indexer/options.rs | 44 +++++ sdk-libs/client/src/indexer/photon_indexer.rs | 167 +++++++++++++++++- sdk-libs/client/src/indexer/types.rs | 76 ++++++++ sdk-libs/client/src/rpc/indexer.rs | 52 +++++- sdk-libs/photon-api/src/apis/default_api.rs | 103 +++++++++++ .../_get_compressed_mint_post_200_response.rs | 62 +++++++ ...ompressed_mint_post_200_response_result.rs | 28 +++ .../_get_compressed_mint_post_request.rs | 76 ++++++++ ...get_compressed_mint_post_request_params.rs | 30 ++++ ...ed_mints_by_authority_post_200_response.rs | 62 +++++++ ...s_by_authority_post_200_response_result.rs | 31 ++++ ...pressed_mints_by_authority_post_request.rs | 78 ++++++++ ..._mints_by_authority_post_request_params.rs | 57 ++++++ .../photon-api/src/models/compressed_mint.rs | 28 +++ .../src/models/compressed_mint_list.rs | 28 +++ sdk-libs/photon-api/src/models/mint_data.rs | 61 +++++++ sdk-libs/photon-api/src/models/mod.rs | 24 +++ .../program-test/src/indexer/test_indexer.rs | 38 +++- .../program-test/src/program_test/indexer.rs | 52 +++++- 23 files changed, 1228 insertions(+), 30 deletions(-) create mode 100644 sdk-libs/photon-api/src/models/_get_compressed_mint_post_200_response.rs create mode 100644 sdk-libs/photon-api/src/models/_get_compressed_mint_post_200_response_result.rs create mode 100644 sdk-libs/photon-api/src/models/_get_compressed_mint_post_request.rs create mode 100644 sdk-libs/photon-api/src/models/_get_compressed_mint_post_request_params.rs create mode 100644 sdk-libs/photon-api/src/models/_get_compressed_mints_by_authority_post_200_response.rs create mode 100644 sdk-libs/photon-api/src/models/_get_compressed_mints_by_authority_post_200_response_result.rs create mode 100644 sdk-libs/photon-api/src/models/_get_compressed_mints_by_authority_post_request.rs create mode 100644 sdk-libs/photon-api/src/models/_get_compressed_mints_by_authority_post_request_params.rs create mode 100644 sdk-libs/photon-api/src/models/compressed_mint.rs create mode 100644 sdk-libs/photon-api/src/models/compressed_mint_list.rs create mode 100644 sdk-libs/photon-api/src/models/mint_data.rs diff --git a/forester/tests/test_compressible_mint.rs b/forester/tests/test_compressible_mint.rs index 9ace18f774..5ff87afec3 100644 --- a/forester/tests/test_compressible_mint.rs +++ b/forester/tests/test_compressible_mint.rs @@ -398,6 +398,10 @@ async fn test_compressible_mint_compression() { "Mint account should be closed after compression" ); + wait_for_indexer(&rpc) + .await + .expect("Failed to wait for indexer"); + // Verify compressed mint still exists in the merkle tree let compressed_after = rpc .get_compressed_account(compression_address, None) @@ -409,6 +413,39 @@ async fn test_compressible_mint_compression() { "Compressed mint should still exist after compression" ); + // Test Photon API: get_compressed_mint + println!("Testing Photon get_compressed_mint API..."); + let mint_response = rpc + .get_compressed_mint(compression_address, None) + .await + .expect("get_compressed_mint should succeed"); + + let compressed_mint = mint_response + .value + .expect("Compressed mint should be returned by get_compressed_mint"); + + assert_eq!(compressed_mint.mint.decimals, 9, "Decimals should match"); + assert_eq!( + compressed_mint.mint.mint_authority, + Some(payer.pubkey()), + "Mint authority should be payer" + ); + println!( + "Photon get_compressed_mint verified: decimals={}, supply={}", + compressed_mint.mint.decimals, compressed_mint.mint.supply + ); + + // Test Photon API: get_compressed_mint_by_pda + let mint_by_pda = rpc + .get_compressed_mint_by_pda(&mint_pda, None) + .await + .expect("get_compressed_mint_by_pda should succeed"); + assert!( + mint_by_pda.value.is_some(), + "Should find compressed mint by PDA" + ); + println!("Photon get_compressed_mint_by_pda verified!"); + println!("Mint compression test completed successfully!"); } else { panic!("Mint should be ready to compress with rent_payment=0"); @@ -622,6 +659,89 @@ async fn test_compressible_mint_subscription() { "Compressed mint should still exist after compression" ); + wait_for_indexer(&rpc) + .await + .expect("Failed to wait for indexer"); + + // Test Photon API: get_compressed_mint by address + println!("Testing Photon get_compressed_mint API..."); + let mint_response = rpc + .get_compressed_mint(compression_address_1, None) + .await + .expect("get_compressed_mint should succeed"); + + let compressed_mint = mint_response + .value + .expect("Compressed mint should be returned by get_compressed_mint"); + + // Verify mint data matches what we created + assert_eq!( + compressed_mint.mint.decimals, 9, + "Decimals should match what we created" + ); + assert_eq!( + compressed_mint.mint.mint_authority, + Some(payer.pubkey()), + "Mint authority should be payer" + ); + assert!( + !compressed_mint.mint.mint_decompressed, + "Mint should NOT be marked as decompressed after compression" + ); + println!( + "get_compressed_mint verified: decimals={}, supply={}", + compressed_mint.mint.decimals, compressed_mint.mint.supply + ); + + // Test Photon API: get_compressed_mint_by_pda + println!("Testing Photon get_compressed_mint_by_pda API..."); + let mint_by_pda = rpc + .get_compressed_mint_by_pda(&mint_pda_1, None) + .await + .expect("get_compressed_mint_by_pda should succeed"); + + assert!( + mint_by_pda.value.is_some(), + "Compressed mint should be found by PDA" + ); + assert_eq!( + mint_by_pda.value.as_ref().unwrap().mint.decimals, + compressed_mint.mint.decimals, + "Mint found by PDA should match mint found by address" + ); + println!("get_compressed_mint_by_pda verified!"); + + // Test Photon API: get_compressed_mints_by_authority + println!("Testing Photon get_compressed_mints_by_authority API..."); + let mints_by_authority = rpc + .get_compressed_mints_by_authority(&payer.pubkey(), None, None) + .await + .expect("get_compressed_mints_by_authority should succeed"); + + // We compressed mint_pda_1 (payer is authority), and mint_pda_2 is still decompressed + // So we should have exactly 1 compressed mint with payer as authority + assert!( + !mints_by_authority.value.items.is_empty(), + "Should find at least 1 compressed mint by authority" + ); + println!( + "get_compressed_mints_by_authority found {} mints for authority {}", + mints_by_authority.value.items.len(), + payer.pubkey() + ); + + // Verify the first mint in the list is the one we compressed + let found_mint = mints_by_authority + .value + .items + .iter() + .find(|m| m.mint.decimals == 9); + assert!( + found_mint.is_some(), + "Should find the mint with 9 decimals in authority query results" + ); + println!("Photon API tests completed successfully!"); + // Shutdown subscribers shutdown_tx .send(()) diff --git a/scripts/devenv/versions.sh b/scripts/devenv/versions.sh index c578cacb05..0d58d9194d 100755 --- a/scripts/devenv/versions.sh +++ b/scripts/devenv/versions.sh @@ -13,7 +13,7 @@ export SOLANA_VERSION="2.2.15" export ANCHOR_VERSION="0.31.1" export JQ_VERSION="1.8.0" export PHOTON_VERSION="0.51.2" -export PHOTON_COMMIT="83b46c9aef58a134edef2eb8e506c1bc6604e876" +export PHOTON_COMMIT="c207ff649f05c5ef6d0820f762f7d464ca8b24c0" export REDIS_VERSION="8.0.1" export ANCHOR_TAG="anchor-v${ANCHOR_VERSION}" diff --git a/sdk-libs/client/src/indexer/indexer_trait.rs b/sdk-libs/client/src/indexer/indexer_trait.rs index b051ab3c1d..0c9df6f8f3 100644 --- a/sdk-libs/client/src/indexer/indexer_trait.rs +++ b/sdk-libs/client/src/indexer/indexer_trait.rs @@ -4,12 +4,13 @@ use solana_pubkey::Pubkey; use super::{ response::{Items, ItemsWithCursor, Response}, types::{ - CompressedAccount, CompressedTokenAccount, OwnerBalance, QueueInfoResult, + CompressedAccount, CompressedMint, CompressedTokenAccount, OwnerBalance, QueueInfoResult, SignatureWithMetadata, TokenBalance, ValidityProofWithContext, }, Address, AddressWithTree, GetCompressedAccountsByOwnerConfig, - GetCompressedTokenAccountsByOwnerOrDelegateOptions, Hash, IndexerError, IndexerRpcConfig, - MerkleProof, NewAddressProofWithContext, PaginatedOptions, QueueElementsV2Options, RetryConfig, + GetCompressedMintsByAuthorityOptions, GetCompressedTokenAccountsByOwnerOrDelegateOptions, Hash, + IndexerError, IndexerRpcConfig, MerkleProof, NewAddressProofWithContext, PaginatedOptions, + QueueElementsV2Options, RetryConfig, }; use crate::indexer::QueueElementsResult; // TODO: remove all references in input types. @@ -193,4 +194,26 @@ pub trait Indexer: std::marker::Send + std::marker::Sync { merkle_tree_pubkey: [u8; 32], config: Option, ) -> Result>, IndexerError>; + + /// Returns the compressed mint with the given address. + async fn get_compressed_mint( + &self, + address: Address, + config: Option, + ) -> Result>, IndexerError>; + + /// Returns the compressed mint with the given PDA (decompressed account address). + async fn get_compressed_mint_by_pda( + &self, + mint_pda: &Pubkey, + config: Option, + ) -> Result>, IndexerError>; + + /// Returns compressed mints controlled by the given authority. + async fn get_compressed_mints_by_authority( + &self, + authority: &Pubkey, + options: Option, + config: Option, + ) -> Result>, IndexerError>; } diff --git a/sdk-libs/client/src/indexer/mod.rs b/sdk-libs/client/src/indexer/mod.rs index fa03606dfe..48b92ef7fb 100644 --- a/sdk-libs/client/src/indexer/mod.rs +++ b/sdk-libs/client/src/indexer/mod.rs @@ -15,11 +15,11 @@ pub use indexer_trait::Indexer; pub use response::{Context, Items, ItemsWithCursor, Response}; pub use types::{ AccountProofInputs, Address, AddressMerkleTreeAccounts, AddressProofInputs, AddressQueueData, - AddressWithTree, CompressedAccount, CompressedTokenAccount, Hash, InputQueueData, MerkleProof, - MerkleProofWithContext, NewAddressProofWithContext, NextTreeInfo, OutputQueueData, - OwnerBalance, ProofOfLeaf, QueueElementsResult, QueueInfo, QueueInfoResult, RootIndex, - SignatureWithMetadata, StateMerkleTreeAccounts, StateQueueData, TokenBalance, TreeInfo, - ValidityProofWithContext, + AddressWithTree, CompressedAccount, CompressedMint, CompressedTokenAccount, Hash, + InputQueueData, MerkleProof, MerkleProofWithContext, MintData, NewAddressProofWithContext, + NextTreeInfo, OutputQueueData, OwnerBalance, ProofOfLeaf, QueueElementsResult, QueueInfo, + QueueInfoResult, RootIndex, SignatureWithMetadata, StateMerkleTreeAccounts, StateQueueData, + TokenBalance, TreeInfo, ValidityProofWithContext, }; mod options; pub use options::*; diff --git a/sdk-libs/client/src/indexer/options.rs b/sdk-libs/client/src/indexer/options.rs index dbbf699fb5..bd8418e764 100644 --- a/sdk-libs/client/src/indexer/options.rs +++ b/sdk-libs/client/src/indexer/options.rs @@ -112,3 +112,47 @@ impl QueueElementsV2Options { self } } + +/// Authority type for querying compressed mints +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub enum MintAuthorityType { + /// Query by mint authority only + MintAuthority, + /// Query by freeze authority only + FreezeAuthority, + /// Query by both mint and freeze authority (default) + #[default] + Both, +} + +/// Options for fetching compressed mints by authority. +#[derive(Debug, Clone, Default)] +pub struct GetCompressedMintsByAuthorityOptions { + /// Which authority type to filter by (default: Both) + pub authority_type: Option, + /// Cursor for pagination + pub cursor: Option, + /// Maximum number of results to return + pub limit: Option, +} + +impl GetCompressedMintsByAuthorityOptions { + pub fn new() -> Self { + Self::default() + } + + pub fn with_authority_type(mut self, authority_type: MintAuthorityType) -> Self { + self.authority_type = Some(authority_type); + self + } + + pub fn with_cursor(mut self, cursor: String) -> Self { + self.cursor = Some(cursor); + self + } + + pub fn with_limit(mut self, limit: u16) -> Self { + self.limit = Some(limit); + self + } +} diff --git a/sdk-libs/client/src/indexer/photon_indexer.rs b/sdk-libs/client/src/indexer/photon_indexer.rs index a220c16554..5d8c27fce2 100644 --- a/sdk-libs/client/src/indexer/photon_indexer.rs +++ b/sdk-libs/client/src/indexer/photon_indexer.rs @@ -10,15 +10,17 @@ use solana_pubkey::Pubkey; use tracing::{error, trace, warn}; use super::types::{ - CompressedAccount, CompressedTokenAccount, OwnerBalance, SignatureWithMetadata, TokenBalance, + CompressedAccount, CompressedMint, CompressedTokenAccount, OwnerBalance, SignatureWithMetadata, + TokenBalance, }; use crate::indexer::{ base58::Base58Conversions, config::RetryConfig, response::{Context, Items, ItemsWithCursor, Response}, Address, AddressWithTree, GetCompressedAccountsByOwnerConfig, - GetCompressedTokenAccountsByOwnerOrDelegateOptions, Hash, Indexer, IndexerError, - IndexerRpcConfig, MerkleProof, NewAddressProofWithContext, PaginatedOptions, + GetCompressedMintsByAuthorityOptions, GetCompressedTokenAccountsByOwnerOrDelegateOptions, Hash, + Indexer, IndexerError, IndexerRpcConfig, MerkleProof, MintAuthorityType, + NewAddressProofWithContext, PaginatedOptions, }; // Tests are in program-tests/client-test/tests/light-client.rs @@ -1777,4 +1779,163 @@ impl Indexer for PhotonIndexer { todo!(); } } + + async fn get_compressed_mint( + &self, + address: Address, + config: Option, + ) -> Result>, IndexerError> { + let config = config.unwrap_or_default(); + self.retry(config.retry_config, || async { + let request = photon_api::models::GetCompressedMintPostRequest::new( + photon_api::models::GetCompressedMintPostRequestParams { + address: Some(bs58::encode(address).into_string()), + mint_pda: None, + }, + ); + + let result = photon_api::apis::default_api::get_compressed_mint_post( + &self.configuration, + request, + ) + .await?; + + let api_response = Self::extract_result_with_error_check( + "get_compressed_mint", + result.error, + result.result.map(|r| *r), + )?; + + if api_response.context.slot < config.slot { + return Err(IndexerError::IndexerNotSyncedToSlot); + } + + let mint = match api_response.value { + Some(boxed) => Some(CompressedMint::try_from(&*boxed)?), + None => None, + }; + + Ok(Response { + context: Context { + slot: api_response.context.slot, + }, + value: mint, + }) + }) + .await + } + + async fn get_compressed_mint_by_pda( + &self, + mint_pda: &Pubkey, + config: Option, + ) -> Result>, IndexerError> { + let config = config.unwrap_or_default(); + self.retry(config.retry_config, || async { + let request = photon_api::models::GetCompressedMintPostRequest::new( + photon_api::models::GetCompressedMintPostRequestParams { + address: None, + mint_pda: Some(mint_pda.to_string()), + }, + ); + + let result = photon_api::apis::default_api::get_compressed_mint_post( + &self.configuration, + request, + ) + .await?; + + let api_response = Self::extract_result_with_error_check( + "get_compressed_mint_by_pda", + result.error, + result.result.map(|r| *r), + )?; + + if api_response.context.slot < config.slot { + return Err(IndexerError::IndexerNotSyncedToSlot); + } + + let mint = match api_response.value { + Some(boxed) => Some(CompressedMint::try_from(&*boxed)?), + None => None, + }; + + Ok(Response { + context: Context { + slot: api_response.context.slot, + }, + value: mint, + }) + }) + .await + } + + async fn get_compressed_mints_by_authority( + &self, + authority: &Pubkey, + options: Option, + config: Option, + ) -> Result>, IndexerError> { + let config = config.unwrap_or_default(); + self.retry(config.retry_config, || async { + let authority_type = + options + .as_ref() + .and_then(|o| o.authority_type) + .map(|at| match at { + MintAuthorityType::MintAuthority => { + photon_api::models::AuthorityType::MintAuthority + } + MintAuthorityType::FreezeAuthority => { + photon_api::models::AuthorityType::FreezeAuthority + } + MintAuthorityType::Both => photon_api::models::AuthorityType::Both, + }); + + let request = photon_api::models::GetCompressedMintsByAuthorityPostRequest::new( + photon_api::models::GetCompressedMintsByAuthorityPostRequestParams { + authority: authority.to_string(), + authority_type, + cursor: options.as_ref().and_then(|o| o.cursor.clone()), + limit: options.as_ref().and_then(|o| o.limit), + }, + ); + + let result = photon_api::apis::default_api::get_compressed_mints_by_authority_post( + &self.configuration, + request, + ) + .await?; + + let api_response = Self::extract_result_with_error_check( + "get_compressed_mints_by_authority", + result.error, + result.result.map(|r| *r), + )?; + + if api_response.context.slot < config.slot { + return Err(IndexerError::IndexerNotSyncedToSlot); + } + + let mints: Result, _> = api_response + .value + .items + .iter() + .map(CompressedMint::try_from) + .collect(); + + let cursor = api_response.value.cursor; + + Ok(Response { + context: Context { + slot: api_response.context.slot, + }, + value: ItemsWithCursor { + items: mints?, + cursor, + }, + }) + }) + .await + } } diff --git a/sdk-libs/client/src/indexer/types.rs b/sdk-libs/client/src/indexer/types.rs index 3f653db274..744d24d317 100644 --- a/sdk-libs/client/src/indexer/types.rs +++ b/sdk-libs/client/src/indexer/types.rs @@ -1037,3 +1037,79 @@ impl TryFrom<&photon_api::models::OwnerBalance> for OwnerBalance { }) } } + +/// Mint-specific data for compressed mints +#[derive(Clone, Default, Debug, PartialEq)] +pub struct MintData { + /// The PDA (decompressed account address) for this mint + pub mint_pda: Pubkey, + /// The signer/seed used for PDA derivation + pub mint_signer: [u8; 32], + /// Authority that can mint new tokens + pub mint_authority: Option, + /// Authority that can freeze accounts + pub freeze_authority: Option, + /// Total supply of tokens + pub supply: u64, + /// Number of decimals + pub decimals: u8, + /// Version of the mint + pub version: u8, + /// Whether the mint has been decompressed + pub mint_decompressed: bool, + /// Serialized extensions (base64 encoded) + pub extensions: Option>, +} + +impl TryFrom<&photon_api::models::MintData> for MintData { + type Error = IndexerError; + + fn try_from(mint_data: &photon_api::models::MintData) -> Result { + Ok(MintData { + mint_pda: Pubkey::new_from_array(decode_base58_to_fixed_array(&mint_data.mint_pda)?), + mint_signer: decode_base58_to_fixed_array(&mint_data.mint_signer)?, + mint_authority: mint_data + .mint_authority + .as_ref() + .map(|a| decode_base58_to_fixed_array(a).map(Pubkey::new_from_array)) + .transpose()?, + freeze_authority: mint_data + .freeze_authority + .as_ref() + .map(|a| decode_base58_to_fixed_array(a).map(Pubkey::new_from_array)) + .transpose()?, + supply: mint_data.supply, + decimals: mint_data.decimals, + version: mint_data.version, + mint_decompressed: mint_data.mint_decompressed, + extensions: mint_data + .extensions + .as_ref() + .map(|ext| { + base64::decode_config(ext, base64::STANDARD_NO_PAD) + .map_err(|_| IndexerError::InvalidResponseData) + }) + .transpose()?, + }) + } +} + +/// A compressed mint with its account data +#[derive(Clone, Default, Debug, PartialEq)] +pub struct CompressedMint { + /// Mint-specific data (mint_pda, authorities, supply, decimals, etc.) + pub mint: MintData, + /// General account information (address, hash, lamports, merkle context, etc.) + pub account: CompressedAccount, +} + +impl TryFrom<&photon_api::models::CompressedMint> for CompressedMint { + type Error = IndexerError; + + fn try_from(compressed_mint: &photon_api::models::CompressedMint) -> Result { + let account = CompressedAccount::try_from(compressed_mint.account.as_ref())?; + let mint = MintData::try_from(compressed_mint.mint_data.as_ref())?; + + Ok(CompressedMint { mint, account }) + } +} diff --git a/sdk-libs/client/src/rpc/indexer.rs b/sdk-libs/client/src/rpc/indexer.rs index 55c6b069e0..5b1980534a 100644 --- a/sdk-libs/client/src/rpc/indexer.rs +++ b/sdk-libs/client/src/rpc/indexer.rs @@ -3,12 +3,12 @@ use solana_pubkey::Pubkey; use super::LightClient; use crate::indexer::{ - Address, AddressWithTree, CompressedAccount, CompressedTokenAccount, - GetCompressedAccountsByOwnerConfig, GetCompressedTokenAccountsByOwnerOrDelegateOptions, Hash, - Indexer, IndexerError, IndexerRpcConfig, Items, ItemsWithCursor, MerkleProof, - NewAddressProofWithContext, OwnerBalance, PaginatedOptions, QueueElementsResult, - QueueElementsV2Options, QueueInfoResult, Response, RetryConfig, SignatureWithMetadata, - TokenBalance, ValidityProofWithContext, + Address, AddressWithTree, CompressedAccount, CompressedMint, CompressedTokenAccount, + GetCompressedAccountsByOwnerConfig, GetCompressedMintsByAuthorityOptions, + GetCompressedTokenAccountsByOwnerOrDelegateOptions, Hash, Indexer, IndexerError, + IndexerRpcConfig, Items, ItemsWithCursor, MerkleProof, NewAddressProofWithContext, + OwnerBalance, PaginatedOptions, QueueElementsResult, QueueElementsV2Options, QueueInfoResult, + Response, RetryConfig, SignatureWithMetadata, TokenBalance, ValidityProofWithContext, }; #[async_trait] @@ -316,4 +316,44 @@ impl Indexer for LightClient { .get_indexer_health(config) .await?) } + + async fn get_compressed_mint( + &self, + address: Address, + config: Option, + ) -> Result>, IndexerError> { + Ok(self + .indexer + .as_ref() + .ok_or(IndexerError::NotInitialized)? + .get_compressed_mint(address, config) + .await?) + } + + async fn get_compressed_mint_by_pda( + &self, + mint_pda: &Pubkey, + config: Option, + ) -> Result>, IndexerError> { + Ok(self + .indexer + .as_ref() + .ok_or(IndexerError::NotInitialized)? + .get_compressed_mint_by_pda(mint_pda, config) + .await?) + } + + async fn get_compressed_mints_by_authority( + &self, + authority: &Pubkey, + options: Option, + config: Option, + ) -> Result>, IndexerError> { + Ok(self + .indexer + .as_ref() + .ok_or(IndexerError::NotInitialized)? + .get_compressed_mints_by_authority(authority, options, config) + .await?) + } } diff --git a/sdk-libs/photon-api/src/apis/default_api.rs b/sdk-libs/photon-api/src/apis/default_api.rs index d0dd52fa51..b601e87590 100644 --- a/sdk-libs/photon-api/src/apis/default_api.rs +++ b/sdk-libs/photon-api/src/apis/default_api.rs @@ -349,6 +349,24 @@ pub enum GetValidityProofV2PostError { UnknownValue(serde_json::Value), } +/// struct for typed errors of method [`get_compressed_mint_post`] +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(untagged)] +pub enum GetCompressedMintPostError { + Status429(models::GetBatchAddressUpdateInfoPost429Response), + Status500(models::GetBatchAddressUpdateInfoPost429Response), + UnknownValue(serde_json::Value), +} + +/// struct for typed errors of method [`get_compressed_mints_by_authority_post`] +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(untagged)] +pub enum GetCompressedMintsByAuthorityPostError { + Status429(models::GetBatchAddressUpdateInfoPost429Response), + Status500(models::GetBatchAddressUpdateInfoPost429Response), + UnknownValue(serde_json::Value), +} + pub async fn get_batch_address_update_info_post( configuration: &configuration::Configuration, get_batch_address_update_info_post_request: models::GetBatchAddressUpdateInfoPostRequest, @@ -1997,6 +2015,91 @@ pub async fn get_validity_proof_v2_post( } } +pub async fn get_compressed_mint_post( + configuration: &configuration::Configuration, + get_compressed_mint_post_request: models::GetCompressedMintPostRequest, +) -> Result> { + let local_var_configuration = configuration; + + let local_var_client = &local_var_configuration.client; + + let local_var_uri_str = format!("{}/getCompressedMint", local_var_configuration.base_path); + let local_var_uri_str = append_api_key(local_var_configuration, &local_var_uri_str); + let mut local_var_req_builder = + local_var_client.request(reqwest::Method::POST, local_var_uri_str.as_str()); + + if let Some(ref local_var_user_agent) = local_var_configuration.user_agent { + local_var_req_builder = + local_var_req_builder.header(reqwest::header::USER_AGENT, local_var_user_agent.clone()); + } + local_var_req_builder = local_var_req_builder.json(&get_compressed_mint_post_request); + + let local_var_req = local_var_req_builder.build()?; + let local_var_resp = local_var_client.execute(local_var_req).await?; + + let local_var_status = local_var_resp.status(); + let local_var_content = local_var_resp.text().await?; + + if !local_var_status.is_client_error() && !local_var_status.is_server_error() { + serde_json::from_str(&local_var_content).map_err(Error::from) + } else { + let local_var_entity: Option = + serde_json::from_str(&local_var_content).ok(); + let local_var_error = ResponseContent { + status: local_var_status, + content: local_var_content, + entity: local_var_entity, + }; + Err(Error::ResponseError(local_var_error)) + } +} + +pub async fn get_compressed_mints_by_authority_post( + configuration: &configuration::Configuration, + get_compressed_mints_by_authority_post_request: models::GetCompressedMintsByAuthorityPostRequest, +) -> Result< + models::GetCompressedMintsByAuthorityPost200Response, + Error, +> { + let local_var_configuration = configuration; + + let local_var_client = &local_var_configuration.client; + + let local_var_uri_str = format!( + "{}/getCompressedMintsByAuthority", + local_var_configuration.base_path + ); + let local_var_uri_str = append_api_key(local_var_configuration, &local_var_uri_str); + let mut local_var_req_builder = + local_var_client.request(reqwest::Method::POST, local_var_uri_str.as_str()); + + if let Some(ref local_var_user_agent) = local_var_configuration.user_agent { + local_var_req_builder = + local_var_req_builder.header(reqwest::header::USER_AGENT, local_var_user_agent.clone()); + } + local_var_req_builder = + local_var_req_builder.json(&get_compressed_mints_by_authority_post_request); + + let local_var_req = local_var_req_builder.build()?; + let local_var_resp = local_var_client.execute(local_var_req).await?; + + let local_var_status = local_var_resp.status(); + let local_var_content = local_var_resp.text().await?; + + if !local_var_status.is_client_error() && !local_var_status.is_server_error() { + serde_json::from_str(&local_var_content).map_err(Error::from) + } else { + let local_var_entity: Option = + serde_json::from_str(&local_var_content).ok(); + let local_var_error = ResponseContent { + status: local_var_status, + content: local_var_content, + entity: local_var_entity, + }; + Err(Error::ResponseError(local_var_error)) + } +} + fn append_api_key(configuration: &Configuration, uri_str: &str) -> String { let mut uri_str = uri_str.to_string(); if let Some(ref api_key) = configuration.api_key { diff --git a/sdk-libs/photon-api/src/models/_get_compressed_mint_post_200_response.rs b/sdk-libs/photon-api/src/models/_get_compressed_mint_post_200_response.rs new file mode 100644 index 0000000000..d21727e4c4 --- /dev/null +++ b/sdk-libs/photon-api/src/models/_get_compressed_mint_post_200_response.rs @@ -0,0 +1,62 @@ +/* + * photon-indexer + * + * Solana indexer for general compression + * + * The version of the OpenAPI document: 0.50.0 + * + * Generated by: https://openapi-generator.tech + */ + +use crate::models; + +#[derive(Clone, Default, Debug, PartialEq, Serialize, Deserialize)] +pub struct GetCompressedMintPost200Response { + #[serde(rename = "error", skip_serializing_if = "Option::is_none")] + pub error: Option>, + /// An ID to identify the response. + #[serde(rename = "id")] + pub id: Id, + /// The version of the JSON-RPC protocol. + #[serde(rename = "jsonrpc")] + pub jsonrpc: Jsonrpc, + #[serde(rename = "result", skip_serializing_if = "Option::is_none")] + pub result: Option>, +} + +impl GetCompressedMintPost200Response { + pub fn new(id: Id, jsonrpc: Jsonrpc) -> GetCompressedMintPost200Response { + GetCompressedMintPost200Response { + error: None, + id, + jsonrpc, + result: None, + } + } +} + +/// An ID to identify the response. +#[derive(Clone, Copy, Debug, Eq, PartialEq, Ord, PartialOrd, Hash, Serialize, Deserialize)] +pub enum Id { + #[serde(rename = "test-account")] + TestAccount, +} + +impl Default for Id { + fn default() -> Id { + Self::TestAccount + } +} + +/// The version of the JSON-RPC protocol. +#[derive(Clone, Copy, Debug, Eq, PartialEq, Ord, PartialOrd, Hash, Serialize, Deserialize)] +pub enum Jsonrpc { + #[serde(rename = "2.0")] + Variant2Period0, +} + +impl Default for Jsonrpc { + fn default() -> Jsonrpc { + Self::Variant2Period0 + } +} diff --git a/sdk-libs/photon-api/src/models/_get_compressed_mint_post_200_response_result.rs b/sdk-libs/photon-api/src/models/_get_compressed_mint_post_200_response_result.rs new file mode 100644 index 0000000000..d7a36cfe23 --- /dev/null +++ b/sdk-libs/photon-api/src/models/_get_compressed_mint_post_200_response_result.rs @@ -0,0 +1,28 @@ +/* + * photon-indexer + * + * Solana indexer for general compression + * + * The version of the OpenAPI document: 0.50.0 + * + * Generated by: https://openapi-generator.tech + */ + +use crate::models; + +#[derive(Clone, Default, Debug, PartialEq, Serialize, Deserialize)] +pub struct GetCompressedMintPost200ResponseResult { + #[serde(rename = "context")] + pub context: Box, + #[serde(rename = "value", skip_serializing_if = "Option::is_none")] + pub value: Option>, +} + +impl GetCompressedMintPost200ResponseResult { + pub fn new(context: models::Context) -> GetCompressedMintPost200ResponseResult { + GetCompressedMintPost200ResponseResult { + context: Box::new(context), + value: None, + } + } +} diff --git a/sdk-libs/photon-api/src/models/_get_compressed_mint_post_request.rs b/sdk-libs/photon-api/src/models/_get_compressed_mint_post_request.rs new file mode 100644 index 0000000000..cf749d6d71 --- /dev/null +++ b/sdk-libs/photon-api/src/models/_get_compressed_mint_post_request.rs @@ -0,0 +1,76 @@ +/* + * photon-indexer + * + * Solana indexer for general compression + * + * The version of the OpenAPI document: 0.50.0 + * + * Generated by: https://openapi-generator.tech + */ + +use crate::models; + +#[derive(Clone, Default, Debug, PartialEq, Serialize, Deserialize)] +pub struct GetCompressedMintPostRequest { + /// An ID to identify the request. + #[serde(rename = "id")] + pub id: Id, + /// The version of the JSON-RPC protocol. + #[serde(rename = "jsonrpc")] + pub jsonrpc: Jsonrpc, + /// The name of the method to invoke. + #[serde(rename = "method")] + pub method: Method, + #[serde(rename = "params")] + pub params: Box, +} + +impl GetCompressedMintPostRequest { + pub fn new(params: models::GetCompressedMintPostRequestParams) -> GetCompressedMintPostRequest { + GetCompressedMintPostRequest { + id: Id::default(), + jsonrpc: Jsonrpc::default(), + method: Method::default(), + params: Box::new(params), + } + } +} + +/// An ID to identify the request. +#[derive(Clone, Copy, Debug, Eq, PartialEq, Ord, PartialOrd, Hash, Serialize, Deserialize)] +pub enum Id { + #[serde(rename = "test-account")] + TestAccount, +} + +impl Default for Id { + fn default() -> Id { + Self::TestAccount + } +} + +/// The version of the JSON-RPC protocol. +#[derive(Clone, Copy, Debug, Eq, PartialEq, Ord, PartialOrd, Hash, Serialize, Deserialize)] +pub enum Jsonrpc { + #[serde(rename = "2.0")] + Variant2Period0, +} + +impl Default for Jsonrpc { + fn default() -> Jsonrpc { + Self::Variant2Period0 + } +} + +/// The name of the method to invoke. +#[derive(Clone, Copy, Debug, Eq, PartialEq, Ord, PartialOrd, Hash, Serialize, Deserialize)] +pub enum Method { + #[serde(rename = "getCompressedMint")] + GetCompressedMint, +} + +impl Default for Method { + fn default() -> Method { + Self::GetCompressedMint + } +} diff --git a/sdk-libs/photon-api/src/models/_get_compressed_mint_post_request_params.rs b/sdk-libs/photon-api/src/models/_get_compressed_mint_post_request_params.rs new file mode 100644 index 0000000000..bd9401375f --- /dev/null +++ b/sdk-libs/photon-api/src/models/_get_compressed_mint_post_request_params.rs @@ -0,0 +1,30 @@ +/* + * photon-indexer + * + * Solana indexer for general compression + * + * The version of the OpenAPI document: 0.50.0 + * + * Generated by: https://openapi-generator.tech + */ + +/// GetCompressedMintPostRequestParams : Request for compressed mint data +#[derive(Clone, Default, Debug, PartialEq, Serialize, Deserialize)] +pub struct GetCompressedMintPostRequestParams { + /// A Solana public key represented as a base58 string (compressed address). + #[serde(rename = "address", default, skip_serializing_if = "Option::is_none")] + pub address: Option, + /// A Solana public key represented as a base58 string (mint PDA). + #[serde(rename = "mintPda", default, skip_serializing_if = "Option::is_none")] + pub mint_pda: Option, +} + +impl GetCompressedMintPostRequestParams { + /// Request for compressed mint data + pub fn new() -> GetCompressedMintPostRequestParams { + GetCompressedMintPostRequestParams { + address: None, + mint_pda: None, + } + } +} diff --git a/sdk-libs/photon-api/src/models/_get_compressed_mints_by_authority_post_200_response.rs b/sdk-libs/photon-api/src/models/_get_compressed_mints_by_authority_post_200_response.rs new file mode 100644 index 0000000000..1e45fec6f8 --- /dev/null +++ b/sdk-libs/photon-api/src/models/_get_compressed_mints_by_authority_post_200_response.rs @@ -0,0 +1,62 @@ +/* + * photon-indexer + * + * Solana indexer for general compression + * + * The version of the OpenAPI document: 0.50.0 + * + * Generated by: https://openapi-generator.tech + */ + +use crate::models; + +#[derive(Clone, Default, Debug, PartialEq, Serialize, Deserialize)] +pub struct GetCompressedMintsByAuthorityPost200Response { + #[serde(rename = "error", skip_serializing_if = "Option::is_none")] + pub error: Option>, + /// An ID to identify the response. + #[serde(rename = "id")] + pub id: Id, + /// The version of the JSON-RPC protocol. + #[serde(rename = "jsonrpc")] + pub jsonrpc: Jsonrpc, + #[serde(rename = "result", skip_serializing_if = "Option::is_none")] + pub result: Option>, +} + +impl GetCompressedMintsByAuthorityPost200Response { + pub fn new(id: Id, jsonrpc: Jsonrpc) -> GetCompressedMintsByAuthorityPost200Response { + GetCompressedMintsByAuthorityPost200Response { + error: None, + id, + jsonrpc, + result: None, + } + } +} + +/// An ID to identify the response. +#[derive(Clone, Copy, Debug, Eq, PartialEq, Ord, PartialOrd, Hash, Serialize, Deserialize)] +pub enum Id { + #[serde(rename = "test-account")] + TestAccount, +} + +impl Default for Id { + fn default() -> Id { + Self::TestAccount + } +} + +/// The version of the JSON-RPC protocol. +#[derive(Clone, Copy, Debug, Eq, PartialEq, Ord, PartialOrd, Hash, Serialize, Deserialize)] +pub enum Jsonrpc { + #[serde(rename = "2.0")] + Variant2Period0, +} + +impl Default for Jsonrpc { + fn default() -> Jsonrpc { + Self::Variant2Period0 + } +} diff --git a/sdk-libs/photon-api/src/models/_get_compressed_mints_by_authority_post_200_response_result.rs b/sdk-libs/photon-api/src/models/_get_compressed_mints_by_authority_post_200_response_result.rs new file mode 100644 index 0000000000..9414bb15e6 --- /dev/null +++ b/sdk-libs/photon-api/src/models/_get_compressed_mints_by_authority_post_200_response_result.rs @@ -0,0 +1,31 @@ +/* + * photon-indexer + * + * Solana indexer for general compression + * + * The version of the OpenAPI document: 0.50.0 + * + * Generated by: https://openapi-generator.tech + */ + +use crate::models; + +#[derive(Clone, Default, Debug, PartialEq, Serialize, Deserialize)] +pub struct GetCompressedMintsByAuthorityPost200ResponseResult { + #[serde(rename = "context")] + pub context: Box, + #[serde(rename = "value")] + pub value: Box, +} + +impl GetCompressedMintsByAuthorityPost200ResponseResult { + pub fn new( + context: models::Context, + value: models::CompressedMintList, + ) -> GetCompressedMintsByAuthorityPost200ResponseResult { + GetCompressedMintsByAuthorityPost200ResponseResult { + context: Box::new(context), + value: Box::new(value), + } + } +} diff --git a/sdk-libs/photon-api/src/models/_get_compressed_mints_by_authority_post_request.rs b/sdk-libs/photon-api/src/models/_get_compressed_mints_by_authority_post_request.rs new file mode 100644 index 0000000000..c208005e36 --- /dev/null +++ b/sdk-libs/photon-api/src/models/_get_compressed_mints_by_authority_post_request.rs @@ -0,0 +1,78 @@ +/* + * photon-indexer + * + * Solana indexer for general compression + * + * The version of the OpenAPI document: 0.50.0 + * + * Generated by: https://openapi-generator.tech + */ + +use crate::models; + +#[derive(Clone, Default, Debug, PartialEq, Serialize, Deserialize)] +pub struct GetCompressedMintsByAuthorityPostRequest { + /// An ID to identify the request. + #[serde(rename = "id")] + pub id: Id, + /// The version of the JSON-RPC protocol. + #[serde(rename = "jsonrpc")] + pub jsonrpc: Jsonrpc, + /// The name of the method to invoke. + #[serde(rename = "method")] + pub method: Method, + #[serde(rename = "params")] + pub params: Box, +} + +impl GetCompressedMintsByAuthorityPostRequest { + pub fn new( + params: models::GetCompressedMintsByAuthorityPostRequestParams, + ) -> GetCompressedMintsByAuthorityPostRequest { + GetCompressedMintsByAuthorityPostRequest { + id: Id::default(), + jsonrpc: Jsonrpc::default(), + method: Method::default(), + params: Box::new(params), + } + } +} + +/// An ID to identify the request. +#[derive(Clone, Copy, Debug, Eq, PartialEq, Ord, PartialOrd, Hash, Serialize, Deserialize)] +pub enum Id { + #[serde(rename = "test-account")] + TestAccount, +} + +impl Default for Id { + fn default() -> Id { + Self::TestAccount + } +} + +/// The version of the JSON-RPC protocol. +#[derive(Clone, Copy, Debug, Eq, PartialEq, Ord, PartialOrd, Hash, Serialize, Deserialize)] +pub enum Jsonrpc { + #[serde(rename = "2.0")] + Variant2Period0, +} + +impl Default for Jsonrpc { + fn default() -> Jsonrpc { + Self::Variant2Period0 + } +} + +/// The name of the method to invoke. +#[derive(Clone, Copy, Debug, Eq, PartialEq, Ord, PartialOrd, Hash, Serialize, Deserialize)] +pub enum Method { + #[serde(rename = "getCompressedMintsByAuthority")] + GetCompressedMintsByAuthority, +} + +impl Default for Method { + fn default() -> Method { + Self::GetCompressedMintsByAuthority + } +} diff --git a/sdk-libs/photon-api/src/models/_get_compressed_mints_by_authority_post_request_params.rs b/sdk-libs/photon-api/src/models/_get_compressed_mints_by_authority_post_request_params.rs new file mode 100644 index 0000000000..5e0e4fc131 --- /dev/null +++ b/sdk-libs/photon-api/src/models/_get_compressed_mints_by_authority_post_request_params.rs @@ -0,0 +1,57 @@ +/* + * photon-indexer + * + * Solana indexer for general compression + * + * The version of the OpenAPI document: 0.50.0 + * + * Generated by: https://openapi-generator.tech + */ + +/// GetCompressedMintsByAuthorityPostRequestParams : Request for compressed mints by authority +#[derive(Clone, Default, Debug, PartialEq, Serialize, Deserialize)] +pub struct GetCompressedMintsByAuthorityPostRequestParams { + /// A Solana public key represented as a base58 string. + #[serde(rename = "authority")] + pub authority: String, + /// Type of authority to filter by: mintAuthority, freezeAuthority, or both (default). + #[serde( + rename = "authorityType", + default, + skip_serializing_if = "Option::is_none" + )] + pub authority_type: Option, + /// A base 58 encoded string. + #[serde(rename = "cursor", default, skip_serializing_if = "Option::is_none")] + pub cursor: Option, + #[serde(rename = "limit", default, skip_serializing_if = "Option::is_none")] + pub limit: Option, +} + +impl GetCompressedMintsByAuthorityPostRequestParams { + pub fn new(authority: String) -> GetCompressedMintsByAuthorityPostRequestParams { + GetCompressedMintsByAuthorityPostRequestParams { + authority, + authority_type: None, + cursor: None, + limit: None, + } + } +} + +/// Type of authority to filter by. +#[derive(Clone, Copy, Debug, Eq, PartialEq, Ord, PartialOrd, Hash, Serialize, Deserialize)] +pub enum AuthorityType { + #[serde(rename = "mintAuthority")] + MintAuthority, + #[serde(rename = "freezeAuthority")] + FreezeAuthority, + #[serde(rename = "both")] + Both, +} + +impl Default for AuthorityType { + fn default() -> AuthorityType { + Self::Both + } +} diff --git a/sdk-libs/photon-api/src/models/compressed_mint.rs b/sdk-libs/photon-api/src/models/compressed_mint.rs new file mode 100644 index 0000000000..54552a62f1 --- /dev/null +++ b/sdk-libs/photon-api/src/models/compressed_mint.rs @@ -0,0 +1,28 @@ +/* + * photon-indexer + * + * Solana indexer for general compression + * + * The version of the OpenAPI document: 0.50.0 + * + * Generated by: https://openapi-generator.tech + */ + +use crate::models; + +#[derive(Clone, Default, Debug, PartialEq, Serialize, Deserialize)] +pub struct CompressedMint { + #[serde(rename = "account")] + pub account: Box, + #[serde(rename = "mintData")] + pub mint_data: Box, +} + +impl CompressedMint { + pub fn new(account: models::AccountV2, mint_data: models::MintData) -> CompressedMint { + CompressedMint { + account: Box::new(account), + mint_data: Box::new(mint_data), + } + } +} diff --git a/sdk-libs/photon-api/src/models/compressed_mint_list.rs b/sdk-libs/photon-api/src/models/compressed_mint_list.rs new file mode 100644 index 0000000000..452064a965 --- /dev/null +++ b/sdk-libs/photon-api/src/models/compressed_mint_list.rs @@ -0,0 +1,28 @@ +/* + * photon-indexer + * + * Solana indexer for general compression + * + * The version of the OpenAPI document: 0.50.0 + * + * Generated by: https://openapi-generator.tech + */ + +use crate::models; + +#[derive(Clone, Default, Debug, PartialEq, Serialize, Deserialize)] +pub struct CompressedMintList { + #[serde(rename = "items")] + pub items: Vec, + #[serde(rename = "cursor", skip_serializing_if = "Option::is_none")] + pub cursor: Option, +} + +impl CompressedMintList { + pub fn new(items: Vec) -> CompressedMintList { + CompressedMintList { + items, + cursor: None, + } + } +} diff --git a/sdk-libs/photon-api/src/models/mint_data.rs b/sdk-libs/photon-api/src/models/mint_data.rs new file mode 100644 index 0000000000..5c1029353f --- /dev/null +++ b/sdk-libs/photon-api/src/models/mint_data.rs @@ -0,0 +1,61 @@ +/* + * photon-indexer + * + * Solana indexer for general compression + * + * The version of the OpenAPI document: 0.50.0 + * + * Generated by: https://openapi-generator.tech + */ + +/// MintData : Compressed mint account data + +#[derive(Clone, Default, Debug, PartialEq, Serialize, Deserialize)] +pub struct MintData { + /// A Solana public key represented as a base58 string. + #[serde(rename = "mintPda")] + pub mint_pda: String, + /// A Solana public key represented as a base58 string. + #[serde(rename = "mintSigner")] + pub mint_signer: String, + /// A Solana public key represented as a base58 string. + #[serde(rename = "mintAuthority", skip_serializing_if = "Option::is_none")] + pub mint_authority: Option, + /// A Solana public key represented as a base58 string. + #[serde(rename = "freezeAuthority", skip_serializing_if = "Option::is_none")] + pub freeze_authority: Option, + #[serde(rename = "supply")] + pub supply: u64, + #[serde(rename = "decimals")] + pub decimals: u8, + #[serde(rename = "version")] + pub version: u8, + #[serde(rename = "mintDecompressed")] + pub mint_decompressed: bool, + /// A base 64 encoded string. + #[serde(rename = "extensions", skip_serializing_if = "Option::is_none")] + pub extensions: Option, +} + +impl MintData { + pub fn new( + mint_pda: String, + mint_signer: String, + supply: u64, + decimals: u8, + version: u8, + mint_decompressed: bool, + ) -> MintData { + MintData { + mint_pda, + mint_signer, + mint_authority: None, + freeze_authority: None, + supply, + decimals, + version, + mint_decompressed, + extensions: None, + } + } +} diff --git a/sdk-libs/photon-api/src/models/mod.rs b/sdk-libs/photon-api/src/models/mod.rs index 115861e99e..1b5ae2730e 100644 --- a/sdk-libs/photon-api/src/models/mod.rs +++ b/sdk-libs/photon-api/src/models/mod.rs @@ -342,3 +342,27 @@ pub mod node; pub use self::node::Node; pub mod queue_request; pub use self::queue_request::QueueRequest; +pub mod mint_data; +pub use self::mint_data::MintData; +pub mod compressed_mint; +pub use self::compressed_mint::CompressedMint; +pub mod compressed_mint_list; +pub use self::compressed_mint_list::CompressedMintList; +pub mod _get_compressed_mint_post_request_params; +pub use self::_get_compressed_mint_post_request_params::GetCompressedMintPostRequestParams; +pub mod _get_compressed_mint_post_request; +pub use self::_get_compressed_mint_post_request::GetCompressedMintPostRequest; +pub mod _get_compressed_mint_post_200_response_result; +pub use self::_get_compressed_mint_post_200_response_result::GetCompressedMintPost200ResponseResult; +pub mod _get_compressed_mint_post_200_response; +pub use self::_get_compressed_mint_post_200_response::GetCompressedMintPost200Response; +pub mod _get_compressed_mints_by_authority_post_request_params; +pub use self::_get_compressed_mints_by_authority_post_request_params::{ + AuthorityType, GetCompressedMintsByAuthorityPostRequestParams, +}; +pub mod _get_compressed_mints_by_authority_post_request; +pub use self::_get_compressed_mints_by_authority_post_request::GetCompressedMintsByAuthorityPostRequest; +pub mod _get_compressed_mints_by_authority_post_200_response_result; +pub use self::_get_compressed_mints_by_authority_post_200_response_result::GetCompressedMintsByAuthorityPost200ResponseResult; +pub mod _get_compressed_mints_by_authority_post_200_response; +pub use self::_get_compressed_mints_by_authority_post_200_response::GetCompressedMintsByAuthorityPost200Response; diff --git a/sdk-libs/program-test/src/indexer/test_indexer.rs b/sdk-libs/program-test/src/indexer/test_indexer.rs index a0691279b7..69d50d38fe 100644 --- a/sdk-libs/program-test/src/indexer/test_indexer.rs +++ b/sdk-libs/program-test/src/indexer/test_indexer.rs @@ -23,12 +23,13 @@ use light_client::{ fee::FeeConfig, indexer::{ AccountProofInputs, Address, AddressMerkleTreeAccounts, AddressProofInputs, - AddressWithTree, CompressedAccount, CompressedTokenAccount, Context, - GetCompressedAccountsByOwnerConfig, GetCompressedTokenAccountsByOwnerOrDelegateOptions, - Indexer, IndexerError, IndexerRpcConfig, Items, ItemsWithCursor, MerkleProof, - NewAddressProofWithContext, OwnerBalance, PaginatedOptions, QueueElementsResult, - QueueElementsV2Options, Response, RetryConfig, RootIndex, SignatureWithMetadata, - StateMerkleTreeAccounts, TokenBalance, ValidityProofWithContext, + AddressWithTree, CompressedAccount, CompressedMint, CompressedTokenAccount, Context, + GetCompressedAccountsByOwnerConfig, GetCompressedMintsByAuthorityOptions, + GetCompressedTokenAccountsByOwnerOrDelegateOptions, Indexer, IndexerError, + IndexerRpcConfig, Items, ItemsWithCursor, MerkleProof, NewAddressProofWithContext, + OwnerBalance, PaginatedOptions, QueueElementsResult, QueueElementsV2Options, Response, + RetryConfig, RootIndex, SignatureWithMetadata, StateMerkleTreeAccounts, TokenBalance, + ValidityProofWithContext, }, }; use light_compressed_account::{ @@ -994,6 +995,31 @@ impl Indexer for TestIndexer { async fn get_indexer_health(&self, _config: Option) -> Result { todo!("get_indexer_health not implemented") } + + async fn get_compressed_mint( + &self, + _address: Address, + _config: Option, + ) -> Result>, IndexerError> { + todo!("get_compressed_mint not implemented") + } + + async fn get_compressed_mint_by_pda( + &self, + _mint_pda: &Pubkey, + _config: Option, + ) -> Result>, IndexerError> { + todo!("get_compressed_mint_by_pda not implemented") + } + + async fn get_compressed_mints_by_authority( + &self, + _authority: &Pubkey, + _options: Option, + _config: Option, + ) -> Result>, IndexerError> { + todo!("get_compressed_mints_by_authority not implemented") + } } #[async_trait] diff --git a/sdk-libs/program-test/src/program_test/indexer.rs b/sdk-libs/program-test/src/program_test/indexer.rs index a1a80113ce..f227a6f788 100644 --- a/sdk-libs/program-test/src/program_test/indexer.rs +++ b/sdk-libs/program-test/src/program_test/indexer.rs @@ -1,11 +1,11 @@ use async_trait::async_trait; use light_client::indexer::{ - Address, AddressWithTree, CompressedAccount, CompressedTokenAccount, - GetCompressedAccountsByOwnerConfig, GetCompressedTokenAccountsByOwnerOrDelegateOptions, Hash, - Indexer, IndexerError, IndexerRpcConfig, Items, ItemsWithCursor, MerkleProof, - NewAddressProofWithContext, OwnerBalance, PaginatedOptions, QueueElementsResult, - QueueElementsV2Options, Response, RetryConfig, SignatureWithMetadata, TokenBalance, - ValidityProofWithContext, + Address, AddressWithTree, CompressedAccount, CompressedMint, CompressedTokenAccount, + GetCompressedAccountsByOwnerConfig, GetCompressedMintsByAuthorityOptions, + GetCompressedTokenAccountsByOwnerOrDelegateOptions, Hash, Indexer, IndexerError, + IndexerRpcConfig, Items, ItemsWithCursor, MerkleProof, NewAddressProofWithContext, + OwnerBalance, PaginatedOptions, QueueElementsResult, QueueElementsV2Options, Response, + RetryConfig, SignatureWithMetadata, TokenBalance, ValidityProofWithContext, }; use solana_sdk::pubkey::Pubkey; @@ -313,4 +313,44 @@ impl Indexer for LightProgramTest { .get_indexer_health(config) .await?) } + + async fn get_compressed_mint( + &self, + address: Address, + config: Option, + ) -> Result>, IndexerError> { + Ok(self + .indexer + .as_ref() + .ok_or(IndexerError::NotInitialized)? + .get_compressed_mint(address, config) + .await?) + } + + async fn get_compressed_mint_by_pda( + &self, + mint_pda: &Pubkey, + config: Option, + ) -> Result>, IndexerError> { + Ok(self + .indexer + .as_ref() + .ok_or(IndexerError::NotInitialized)? + .get_compressed_mint_by_pda(mint_pda, config) + .await?) + } + + async fn get_compressed_mints_by_authority( + &self, + authority: &Pubkey, + options: Option, + config: Option, + ) -> Result>, IndexerError> { + Ok(self + .indexer + .as_ref() + .ok_or(IndexerError::NotInitialized)? + .get_compressed_mints_by_authority(authority, options, config) + .await?) + } } From 12b236c41b55c5b031491ac94458cfcc5039943c Mon Sep 17 00:00:00 2001 From: Sergey Timoshin Date: Wed, 21 Jan 2026 18:58:31 +0000 Subject: [PATCH 05/38] get_compressed_mints_by_authority authority type filtering --- forester/tests/test_compressible_mint.rs | 7 +++++- sdk-libs/client/src/indexer/indexer_trait.rs | 5 ++-- sdk-libs/client/src/indexer/options.rs | 17 +++---------- sdk-libs/client/src/indexer/photon_indexer.rs | 25 ++++++++----------- sdk-libs/client/src/rpc/indexer.rs | 10 +++++--- ...pressed_mints_by_authority_post_request.rs | 2 +- ..._mints_by_authority_post_request_params.rs | 19 +++++++------- .../program-test/src/indexer/test_indexer.rs | 9 ++++--- .../program-test/src/program_test/indexer.rs | 10 +++++--- 9 files changed, 50 insertions(+), 54 deletions(-) diff --git a/forester/tests/test_compressible_mint.rs b/forester/tests/test_compressible_mint.rs index 5ff87afec3..5f4eca4e48 100644 --- a/forester/tests/test_compressible_mint.rs +++ b/forester/tests/test_compressible_mint.rs @@ -714,7 +714,12 @@ async fn test_compressible_mint_subscription() { // Test Photon API: get_compressed_mints_by_authority println!("Testing Photon get_compressed_mints_by_authority API..."); let mints_by_authority = rpc - .get_compressed_mints_by_authority(&payer.pubkey(), None, None) + .get_compressed_mints_by_authority( + &payer.pubkey(), + light_client::indexer::MintAuthorityType::Either, + None, + None, + ) .await .expect("get_compressed_mints_by_authority should succeed"); diff --git a/sdk-libs/client/src/indexer/indexer_trait.rs b/sdk-libs/client/src/indexer/indexer_trait.rs index 0c9df6f8f3..2c8ecd7ae2 100644 --- a/sdk-libs/client/src/indexer/indexer_trait.rs +++ b/sdk-libs/client/src/indexer/indexer_trait.rs @@ -9,8 +9,8 @@ use super::{ }, Address, AddressWithTree, GetCompressedAccountsByOwnerConfig, GetCompressedMintsByAuthorityOptions, GetCompressedTokenAccountsByOwnerOrDelegateOptions, Hash, - IndexerError, IndexerRpcConfig, MerkleProof, NewAddressProofWithContext, PaginatedOptions, - QueueElementsV2Options, RetryConfig, + IndexerError, IndexerRpcConfig, MerkleProof, MintAuthorityType, NewAddressProofWithContext, + PaginatedOptions, QueueElementsV2Options, RetryConfig, }; use crate::indexer::QueueElementsResult; // TODO: remove all references in input types. @@ -213,6 +213,7 @@ pub trait Indexer: std::marker::Send + std::marker::Sync { async fn get_compressed_mints_by_authority( &self, authority: &Pubkey, + authority_type: MintAuthorityType, options: Option, config: Option, ) -> Result>, IndexerError>; diff --git a/sdk-libs/client/src/indexer/options.rs b/sdk-libs/client/src/indexer/options.rs index bd8418e764..1826b63987 100644 --- a/sdk-libs/client/src/indexer/options.rs +++ b/sdk-libs/client/src/indexer/options.rs @@ -113,23 +113,17 @@ impl QueueElementsV2Options { } } -/// Authority type for querying compressed mints -#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +/// Authority type for querying compressed mints. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum MintAuthorityType { - /// Query by mint authority only MintAuthority, - /// Query by freeze authority only FreezeAuthority, - /// Query by both mint and freeze authority (default) - #[default] - Both, + Either, } /// Options for fetching compressed mints by authority. #[derive(Debug, Clone, Default)] pub struct GetCompressedMintsByAuthorityOptions { - /// Which authority type to filter by (default: Both) - pub authority_type: Option, /// Cursor for pagination pub cursor: Option, /// Maximum number of results to return @@ -141,11 +135,6 @@ impl GetCompressedMintsByAuthorityOptions { Self::default() } - pub fn with_authority_type(mut self, authority_type: MintAuthorityType) -> Self { - self.authority_type = Some(authority_type); - self - } - pub fn with_cursor(mut self, cursor: String) -> Self { self.cursor = Some(cursor); self diff --git a/sdk-libs/client/src/indexer/photon_indexer.rs b/sdk-libs/client/src/indexer/photon_indexer.rs index 5d8c27fce2..4feb8f6011 100644 --- a/sdk-libs/client/src/indexer/photon_indexer.rs +++ b/sdk-libs/client/src/indexer/photon_indexer.rs @@ -1873,29 +1873,26 @@ impl Indexer for PhotonIndexer { async fn get_compressed_mints_by_authority( &self, authority: &Pubkey, + authority_type: MintAuthorityType, options: Option, config: Option, ) -> Result>, IndexerError> { let config = config.unwrap_or_default(); self.retry(config.retry_config, || async { - let authority_type = - options - .as_ref() - .and_then(|o| o.authority_type) - .map(|at| match at { - MintAuthorityType::MintAuthority => { - photon_api::models::AuthorityType::MintAuthority - } - MintAuthorityType::FreezeAuthority => { - photon_api::models::AuthorityType::FreezeAuthority - } - MintAuthorityType::Both => photon_api::models::AuthorityType::Both, - }); + let api_authority_type = match authority_type { + MintAuthorityType::MintAuthority => { + photon_api::models::AuthorityType::MintAuthority + } + MintAuthorityType::FreezeAuthority => { + photon_api::models::AuthorityType::FreezeAuthority + } + MintAuthorityType::Either => photon_api::models::AuthorityType::Both, + }; let request = photon_api::models::GetCompressedMintsByAuthorityPostRequest::new( photon_api::models::GetCompressedMintsByAuthorityPostRequestParams { authority: authority.to_string(), - authority_type, + authority_type: api_authority_type, cursor: options.as_ref().and_then(|o| o.cursor.clone()), limit: options.as_ref().and_then(|o| o.limit), }, diff --git a/sdk-libs/client/src/rpc/indexer.rs b/sdk-libs/client/src/rpc/indexer.rs index 5b1980534a..0901bddb70 100644 --- a/sdk-libs/client/src/rpc/indexer.rs +++ b/sdk-libs/client/src/rpc/indexer.rs @@ -6,9 +6,10 @@ use crate::indexer::{ Address, AddressWithTree, CompressedAccount, CompressedMint, CompressedTokenAccount, GetCompressedAccountsByOwnerConfig, GetCompressedMintsByAuthorityOptions, GetCompressedTokenAccountsByOwnerOrDelegateOptions, Hash, Indexer, IndexerError, - IndexerRpcConfig, Items, ItemsWithCursor, MerkleProof, NewAddressProofWithContext, - OwnerBalance, PaginatedOptions, QueueElementsResult, QueueElementsV2Options, QueueInfoResult, - Response, RetryConfig, SignatureWithMetadata, TokenBalance, ValidityProofWithContext, + IndexerRpcConfig, Items, ItemsWithCursor, MerkleProof, MintAuthorityType, + NewAddressProofWithContext, OwnerBalance, PaginatedOptions, QueueElementsResult, + QueueElementsV2Options, QueueInfoResult, Response, RetryConfig, SignatureWithMetadata, + TokenBalance, ValidityProofWithContext, }; #[async_trait] @@ -346,6 +347,7 @@ impl Indexer for LightClient { async fn get_compressed_mints_by_authority( &self, authority: &Pubkey, + authority_type: MintAuthorityType, options: Option, config: Option, ) -> Result>, IndexerError> { @@ -353,7 +355,7 @@ impl Indexer for LightClient { .indexer .as_ref() .ok_or(IndexerError::NotInitialized)? - .get_compressed_mints_by_authority(authority, options, config) + .get_compressed_mints_by_authority(authority, authority_type, options, config) .await?) } } diff --git a/sdk-libs/photon-api/src/models/_get_compressed_mints_by_authority_post_request.rs b/sdk-libs/photon-api/src/models/_get_compressed_mints_by_authority_post_request.rs index c208005e36..b3eaecd939 100644 --- a/sdk-libs/photon-api/src/models/_get_compressed_mints_by_authority_post_request.rs +++ b/sdk-libs/photon-api/src/models/_get_compressed_mints_by_authority_post_request.rs @@ -10,7 +10,7 @@ use crate::models; -#[derive(Clone, Default, Debug, PartialEq, Serialize, Deserialize)] +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] pub struct GetCompressedMintsByAuthorityPostRequest { /// An ID to identify the request. #[serde(rename = "id")] diff --git a/sdk-libs/photon-api/src/models/_get_compressed_mints_by_authority_post_request_params.rs b/sdk-libs/photon-api/src/models/_get_compressed_mints_by_authority_post_request_params.rs index 5e0e4fc131..ffd1ec0b5e 100644 --- a/sdk-libs/photon-api/src/models/_get_compressed_mints_by_authority_post_request_params.rs +++ b/sdk-libs/photon-api/src/models/_get_compressed_mints_by_authority_post_request_params.rs @@ -9,18 +9,14 @@ */ /// GetCompressedMintsByAuthorityPostRequestParams : Request for compressed mints by authority -#[derive(Clone, Default, Debug, PartialEq, Serialize, Deserialize)] +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] pub struct GetCompressedMintsByAuthorityPostRequestParams { /// A Solana public key represented as a base58 string. #[serde(rename = "authority")] pub authority: String, - /// Type of authority to filter by: mintAuthority, freezeAuthority, or both (default). - #[serde( - rename = "authorityType", - default, - skip_serializing_if = "Option::is_none" - )] - pub authority_type: Option, + /// Type of authority to filter by: mintAuthority, freezeAuthority, or both. + #[serde(rename = "authorityType")] + pub authority_type: AuthorityType, /// A base 58 encoded string. #[serde(rename = "cursor", default, skip_serializing_if = "Option::is_none")] pub cursor: Option, @@ -29,10 +25,13 @@ pub struct GetCompressedMintsByAuthorityPostRequestParams { } impl GetCompressedMintsByAuthorityPostRequestParams { - pub fn new(authority: String) -> GetCompressedMintsByAuthorityPostRequestParams { + pub fn new( + authority: String, + authority_type: AuthorityType, + ) -> GetCompressedMintsByAuthorityPostRequestParams { GetCompressedMintsByAuthorityPostRequestParams { authority, - authority_type: None, + authority_type, cursor: None, limit: None, } diff --git a/sdk-libs/program-test/src/indexer/test_indexer.rs b/sdk-libs/program-test/src/indexer/test_indexer.rs index 69d50d38fe..0c622fee90 100644 --- a/sdk-libs/program-test/src/indexer/test_indexer.rs +++ b/sdk-libs/program-test/src/indexer/test_indexer.rs @@ -26,10 +26,10 @@ use light_client::{ AddressWithTree, CompressedAccount, CompressedMint, CompressedTokenAccount, Context, GetCompressedAccountsByOwnerConfig, GetCompressedMintsByAuthorityOptions, GetCompressedTokenAccountsByOwnerOrDelegateOptions, Indexer, IndexerError, - IndexerRpcConfig, Items, ItemsWithCursor, MerkleProof, NewAddressProofWithContext, - OwnerBalance, PaginatedOptions, QueueElementsResult, QueueElementsV2Options, Response, - RetryConfig, RootIndex, SignatureWithMetadata, StateMerkleTreeAccounts, TokenBalance, - ValidityProofWithContext, + IndexerRpcConfig, Items, ItemsWithCursor, MerkleProof, MintAuthorityType, + NewAddressProofWithContext, OwnerBalance, PaginatedOptions, QueueElementsResult, + QueueElementsV2Options, Response, RetryConfig, RootIndex, SignatureWithMetadata, + StateMerkleTreeAccounts, TokenBalance, ValidityProofWithContext, }, }; use light_compressed_account::{ @@ -1015,6 +1015,7 @@ impl Indexer for TestIndexer { async fn get_compressed_mints_by_authority( &self, _authority: &Pubkey, + _authority_type: MintAuthorityType, _options: Option, _config: Option, ) -> Result>, IndexerError> { diff --git a/sdk-libs/program-test/src/program_test/indexer.rs b/sdk-libs/program-test/src/program_test/indexer.rs index f227a6f788..5178e533db 100644 --- a/sdk-libs/program-test/src/program_test/indexer.rs +++ b/sdk-libs/program-test/src/program_test/indexer.rs @@ -3,9 +3,10 @@ use light_client::indexer::{ Address, AddressWithTree, CompressedAccount, CompressedMint, CompressedTokenAccount, GetCompressedAccountsByOwnerConfig, GetCompressedMintsByAuthorityOptions, GetCompressedTokenAccountsByOwnerOrDelegateOptions, Hash, Indexer, IndexerError, - IndexerRpcConfig, Items, ItemsWithCursor, MerkleProof, NewAddressProofWithContext, - OwnerBalance, PaginatedOptions, QueueElementsResult, QueueElementsV2Options, Response, - RetryConfig, SignatureWithMetadata, TokenBalance, ValidityProofWithContext, + IndexerRpcConfig, Items, ItemsWithCursor, MerkleProof, MintAuthorityType, + NewAddressProofWithContext, OwnerBalance, PaginatedOptions, QueueElementsResult, + QueueElementsV2Options, Response, RetryConfig, SignatureWithMetadata, TokenBalance, + ValidityProofWithContext, }; use solana_sdk::pubkey::Pubkey; @@ -343,6 +344,7 @@ impl Indexer for LightProgramTest { async fn get_compressed_mints_by_authority( &self, authority: &Pubkey, + authority_type: MintAuthorityType, options: Option, config: Option, ) -> Result>, IndexerError> { @@ -350,7 +352,7 @@ impl Indexer for LightProgramTest { .indexer .as_ref() .ok_or(IndexerError::NotInitialized)? - .get_compressed_mints_by_authority(authority, options, config) + .get_compressed_mints_by_authority(authority, authority_type, options, config) .await?) } } From 01911fbdfa0c546eb475efeda3f08be148174030 Mon Sep 17 00:00:00 2001 From: Sergey Timoshin Date: Wed, 21 Jan 2026 19:05:31 +0000 Subject: [PATCH 06/38] fix: update PHOTON_COMMIT version in versions.sh --- scripts/devenv/versions.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/devenv/versions.sh b/scripts/devenv/versions.sh index 0d58d9194d..8b6be524d5 100755 --- a/scripts/devenv/versions.sh +++ b/scripts/devenv/versions.sh @@ -13,7 +13,7 @@ export SOLANA_VERSION="2.2.15" export ANCHOR_VERSION="0.31.1" export JQ_VERSION="1.8.0" export PHOTON_VERSION="0.51.2" -export PHOTON_COMMIT="c207ff649f05c5ef6d0820f762f7d464ca8b24c0" +export PHOTON_COMMIT="2adfa97d10e2181632558158219878f455bfd95e" export REDIS_VERSION="8.0.1" export ANCHOR_TAG="anchor-v${ANCHOR_VERSION}" From 841a8c255c80f333095bf31620b5f5a565b5e2c5 Mon Sep 17 00:00:00 2001 From: Sergey Timoshin Date: Wed, 21 Jan 2026 19:05:54 +0000 Subject: [PATCH 07/38] docs: update CLI parameter descriptions for compressible PDA program --- forester/src/compressible/config.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/forester/src/compressible/config.rs b/forester/src/compressible/config.rs index 46b65e35b9..14668317a2 100644 --- a/forester/src/compressible/config.rs +++ b/forester/src/compressible/config.rs @@ -34,7 +34,7 @@ pub const DEFAULT_PAGINATION_DELAY_MS: u64 = 100; /// Configuration for a compressible PDA program. /// -/// Can be specified via CLI (using `program_id:discriminator_base58` format) +/// Can be specified via CLI `--compressible-pda-program` (using `program_id:discriminator_base58` format) /// or via config file using the serialized struct format. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct PdaProgramConfig { @@ -146,7 +146,7 @@ pub struct CompressibleConfig { #[serde(default = "default_max_concurrent_batches")] pub max_concurrent_batches: usize, /// Compressible PDA programs to track and compress. - /// Can be specified in config file or via CLI `--pda-program` flags. + /// Can be specified in config file or via CLI `--compressible-pda-program` flags. /// CLI values are merged with config file values. #[serde(default)] pub pda_programs: Vec, From 1dea574ebec6cb559043ce5d810941316afa9904 Mon Sep 17 00:00:00 2001 From: Sergey Timoshin Date: Wed, 21 Jan 2026 19:16:15 +0000 Subject: [PATCH 08/38] feat: add hex dependency and update existing hex usage in Cargo.toml files; refactor mint compression logic to handle batching and improve error handling; enhance test cases for mint creation and compression --- Cargo.toml | 1 + forester/Cargo.toml | 2 +- forester/src/compressible/ctoken/state.rs | 7 ++ forester/src/compressible/mint/compressor.rs | 16 ++-- forester/tests/test_compressible_mint.rs | 82 ++++++++++++++++---- forester/tests/test_compressible_pda.rs | 8 +- sdk-libs/client/src/indexer/options.rs | 19 ++++- sdk-libs/client/src/local_test_validator.rs | 31 +++++++- 8 files changed, 132 insertions(+), 34 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index b708d9c74d..34a1e540a4 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -140,6 +140,7 @@ pinocchio-pubkey = { version = "0.3.0" } pinocchio-system = { version = "0.3.0" } bs58 = "^0.5.1" sha2 = "0.10" +hex = "0.4" litesvm = "0.7" # Anchor anchor-lang = { version = "0.31.1" } diff --git a/forester/Cargo.toml b/forester/Cargo.toml index 5b23dc2855..5ce45b8d55 100644 --- a/forester/Cargo.toml +++ b/forester/Cargo.toml @@ -44,7 +44,7 @@ futures = { workspace = true } thiserror = { workspace = true } borsh = { workspace = true } bs58 = { workspace = true } -hex = "0.4" +hex = { workspace = true } env_logger = { workspace = true } async-trait = { workspace = true } tracing = { workspace = true } diff --git a/forester/src/compressible/ctoken/state.rs b/forester/src/compressible/ctoken/state.rs index eaf0272fac..5dbc5b9961 100644 --- a/forester/src/compressible/ctoken/state.rs +++ b/forester/src/compressible/ctoken/state.rs @@ -76,6 +76,7 @@ impl CTokenAccountTracker { /// Update tracker with an already-deserialized Token. /// Use this to avoid double deserialization when the Token is already available. + /// Skips mint accounts (only tracks actual token accounts). pub fn update_from_token( &self, pubkey: Pubkey, @@ -83,6 +84,12 @@ impl CTokenAccountTracker { lamports: u64, account_size: usize, ) -> Result<()> { + // Skip mint accounts - only track actual token accounts + if !ctoken.is_token_account() { + debug!("Skipping non-token account {}", pubkey); + return Ok(()); + } + let compressible_slot = match calculate_compressible_slot(&ctoken, lamports, account_size) { Ok(slot) => slot, Err(e) => { diff --git a/forester/src/compressible/mint/compressor.rs b/forester/src/compressible/mint/compressor.rs index 1c2cd6e317..ae063fb8fb 100644 --- a/forester/src/compressible/mint/compressor.rs +++ b/forester/src/compressible/mint/compressor.rs @@ -77,14 +77,14 @@ impl MintCompressor { mint_seed, true, // idempotent ) - .await - .map_err(|e| { - anyhow::anyhow!( + .await + .map_err(|e| { + anyhow::anyhow!( "Failed to build CompressAndCloseMint instruction for {}: {:?}", mint_pda, e ) - })?; + })?; Ok::(ix) } @@ -224,10 +224,10 @@ impl MintCompressor { *mint_seed, true, // idempotent ) - .await - .map_err(|e| { - anyhow::anyhow!("Failed to build CompressAndCloseMint instruction: {:?}", e) - })?; + .await + .map_err(|e| { + anyhow::anyhow!("Failed to build CompressAndCloseMint instruction: {:?}", e) + })?; debug!( "Built CompressAndCloseMint instruction for Mint {}", diff --git a/forester/tests/test_compressible_mint.rs b/forester/tests/test_compressible_mint.rs index 5f4eca4e48..4e73f2e1bc 100644 --- a/forester/tests/test_compressible_mint.rs +++ b/forester/tests/test_compressible_mint.rs @@ -23,13 +23,47 @@ use tokio::{ time::sleep, }; -/// Helper to create a compressed mint with decompression +/// Build an expected Mint for assertion comparison. +/// +/// Takes known values from test setup plus runtime values extracted from the on-chain account. +fn build_expected_mint( + mint_authority: &Pubkey, + decimals: u8, + mint_pda: &Pubkey, + mint_signer: &[u8; 32], + bump: u8, + compression: light_compressible::compression_info::CompressionInfo, +) -> Mint { + Mint { + base: BaseMint { + mint_authority: Some(*mint_authority), + supply: 0, + decimals, + is_initialized: true, + freeze_authority: None, + }, + metadata: MintMetadata { + version: 1, + mint_decompressed: true, + mint: *mint_pda, + mint_signer: *mint_signer, + bump, + }, + reserved: [0u8; 16], + account_type: ACCOUNT_TYPE_MINT, + compression, + extensions: None, + } +} + +/// Helper to create a compressed mint with decompression. +/// Returns (mint_pda, compression_address, mint_seed, bump). async fn create_decompressed_mint( rpc: &mut (impl Rpc + Indexer), payer: &Keypair, mint_authority: Pubkey, decimals: u8, -) -> (Pubkey, [u8; 32], Keypair) { +) -> (Pubkey, [u8; 32], Keypair, u8) { let mint_seed = Keypair::new(); let address_tree = rpc.get_address_tree_v2(); let output_queue = rpc.get_random_state_tree_info().unwrap().queue; @@ -84,7 +118,7 @@ async fn create_decompressed_mint( .await .expect("CreateMint should succeed"); - (mint_pda, compression_address, mint_seed) + (mint_pda, compression_address, mint_seed, bump) } /// Test that Mint bootstrap discovers decompressed mints @@ -129,13 +163,13 @@ async fn test_compressible_mint_bootstrap() { .expect("Failed to wait for indexer"); // Create a decompressed mint - let (mint_pda, compression_address, mint_seed) = + let (mint_pda, compression_address, mint_seed, bump) = create_decompressed_mint(&mut rpc, &payer, payer.pubkey(), 9).await; println!("Created decompressed mint at: {}", mint_pda); println!("Compression address: {:?}", compression_address); - // Verify mint exists on-chain + // Verify mint exists on-chain and matches expected structure let mint_account = rpc.get_account(mint_pda).await.unwrap(); assert!(mint_account.is_some(), "Mint should exist after creation"); @@ -147,9 +181,6 @@ async fn test_compressible_mint_bootstrap() { let compression = mint.compression; let metadata_version = mint.metadata.version; - // Derive the bump from mint_seed - let (_, bump) = find_mint_address(&mint_seed.pubkey()); - // Build expected Mint let expected_mint = Mint { base: BaseMint { @@ -173,6 +204,22 @@ async fn test_compressible_mint_bootstrap() { }; assert_eq!(mint, expected_mint, "Mint should match expected state"); +======= + let mint_data = mint_account.unwrap(); + let mint = Mint::deserialize(&mut &mint_data.data[..]).expect("Failed to deserialize Mint"); + + // Build expected mint using known values plus runtime compression info + let expected_mint = build_expected_mint( + &payer.pubkey(), + 9, + &mint_pda, + &mint_seed.pubkey().to_bytes(), + bump, + mint.compression.clone(), + ); +>>>>>>> d6299d718 (feat: add hex dependency and update existing hex usage in Cargo.toml files; refactor mint compression logic to handle batching and improve error handling; enhance test cases for mint creation and compression) + + assert_eq!(mint, expected_mint, "Mint should match expected structure"); // Wait for indexer wait_for_indexer(&rpc) @@ -284,7 +331,7 @@ async fn test_compressible_mint_compression() { .expect("Failed to wait for indexer"); // Create a decompressed mint - let (mint_pda, compression_address, mint_seed) = + let (mint_pda, compression_address, mint_seed, bump) = create_decompressed_mint(&mut rpc, &payer, payer.pubkey(), 9).await; println!("Created decompressed mint at: {}", mint_pda); @@ -301,9 +348,6 @@ async fn test_compressible_mint_compression() { let compression = mint.compression; let metadata_version = mint.metadata.version; - // Derive the bump from mint_seed - let (_, bump) = find_mint_address(&mint_seed.pubkey()); - // Build expected Mint let expected_mint = Mint { base: BaseMint { @@ -372,7 +416,10 @@ async fn test_compressible_mint_compression() { println!("Compressing Mint..."); let compress_result = compressor.compress_batch(&ready_accounts).await; - let signature = compress_result.expect("Compression should succeed"); + let signatures = compress_result.expect("Compression should succeed"); + let signature = signatures + .last() + .expect("Should have at least one signature"); println!("Compression transaction sent: {}", signature); // Wait for account to be closed @@ -518,7 +565,7 @@ async fn test_compressible_mint_subscription() { sleep(Duration::from_secs(2)).await; // Create first decompressed mint (immediately compressible with rent_payment=0) - let (mint_pda_1, compression_address_1, _mint_seed_1) = + let (mint_pda_1, compression_address_1, _mint_seed_1, _bump_1) = create_decompressed_mint(&mut rpc, &payer, payer.pubkey(), 9).await; println!("Created first decompressed mint at: {}", mint_pda_1); @@ -545,7 +592,7 @@ async fn test_compressible_mint_subscription() { println!("Tracker detected first mint via subscription"); // Create second decompressed mint - let (mint_pda_2, _compression_address_2, _mint_seed_2) = + let (mint_pda_2, _compression_address_2, _mint_seed_2, _bump_2) = create_decompressed_mint(&mut rpc, &payer, payer.pubkey(), 6).await; println!("Created second decompressed mint at: {}", mint_pda_2); @@ -608,10 +655,13 @@ async fn test_compressible_mint_subscription() { .clone(); println!("Compressing first mint: {}", mint_pda_1); - let signature = compressor + let signatures = compressor .compress_batch(&[first_mint_state]) .await .expect("Compression should succeed"); + let signature = signatures + .last() + .expect("Should have at least one signature"); println!("Compression tx sent: {}", signature); diff --git a/forester/tests/test_compressible_pda.rs b/forester/tests/test_compressible_pda.rs index 97d4095620..cf2553fc6f 100644 --- a/forester/tests/test_compressible_pda.rs +++ b/forester/tests/test_compressible_pda.rs @@ -16,7 +16,7 @@ use forester_utils::{ use light_client::{ indexer::Indexer, interface::{get_create_accounts_proof, CreateAccountsProofInput, InitializeRentFreeConfig}, - local_test_validator::{spawn_validator, LightValidatorConfig}, + local_test_validator::{spawn_validator, LightValidatorConfig, UpgradeableProgramConfig}, rpc::{LightClient, LightClientConfig, Rpc}, }; use light_compressed_account::address::derive_address; @@ -261,7 +261,7 @@ async fn test_compressible_pda_bootstrap() { enable_prover: true, wait_time: 60, sbf_programs: vec![], - upgradeable_programs: vec![( + upgradeable_programs: vec![UpgradeableProgramConfig::new( CSDK_TEST_PROGRAM_ID.to_string(), "../target/deploy/csdk_anchor_full_derived_test.so".to_string(), payer_pubkey_string(), @@ -452,7 +452,7 @@ async fn test_compressible_pda_compression() { enable_prover: true, wait_time: 60, sbf_programs: vec![], - upgradeable_programs: vec![( + upgradeable_programs: vec![UpgradeableProgramConfig::new( CSDK_TEST_PROGRAM_ID.to_string(), "../target/deploy/csdk_anchor_full_derived_test.so".to_string(), payer_pubkey_string(), @@ -687,7 +687,7 @@ async fn test_compressible_pda_subscription() { enable_prover: true, wait_time: 60, sbf_programs: vec![], - upgradeable_programs: vec![( + upgradeable_programs: vec![UpgradeableProgramConfig::new( CSDK_TEST_PROGRAM_ID.to_string(), "../target/deploy/csdk_anchor_full_derived_test.so".to_string(), payer_pubkey_string(), diff --git a/sdk-libs/client/src/indexer/options.rs b/sdk-libs/client/src/indexer/options.rs index 1826b63987..8e70bbb76e 100644 --- a/sdk-libs/client/src/indexer/options.rs +++ b/sdk-libs/client/src/indexer/options.rs @@ -122,12 +122,24 @@ pub enum MintAuthorityType { } /// Options for fetching compressed mints by authority. -#[derive(Debug, Clone, Default)] +#[derive(Debug, Clone)] pub struct GetCompressedMintsByAuthorityOptions { /// Cursor for pagination pub cursor: Option, /// Maximum number of results to return pub limit: Option, + /// Authority type filter. Defaults to `Some(MintAuthorityType::Either)` (both mint and freeze authorities). + pub authority_type: Option, +} + +impl Default for GetCompressedMintsByAuthorityOptions { + fn default() -> Self { + Self { + cursor: None, + limit: None, + authority_type: Some(MintAuthorityType::Either), + } + } } impl GetCompressedMintsByAuthorityOptions { @@ -144,4 +156,9 @@ impl GetCompressedMintsByAuthorityOptions { self.limit = Some(limit); self } + + pub fn with_authority_type(mut self, authority_type: MintAuthorityType) -> Self { + self.authority_type = Some(authority_type); + self + } } diff --git a/sdk-libs/client/src/local_test_validator.rs b/sdk-libs/client/src/local_test_validator.rs index b0b7dfbcbc..78559cd9c2 100644 --- a/sdk-libs/client/src/local_test_validator.rs +++ b/sdk-libs/client/src/local_test_validator.rs @@ -2,6 +2,27 @@ use std::process::{Command, Stdio}; use light_prover_client::helpers::get_project_root; +/// Configuration for an upgradeable program to deploy to the validator. +#[derive(Debug, Clone)] +pub struct UpgradeableProgramConfig { + /// The program ID (public key) of the program + pub program_id: String, + /// Path to the compiled program (.so file) + pub program_path: String, + /// The upgrade authority for the program + pub upgrade_authority: String, +} + +impl UpgradeableProgramConfig { + pub fn new(program_id: String, program_path: String, upgrade_authority: String) -> Self { + Self { + program_id, + program_path, + upgrade_authority, + } + } +} + #[derive(Debug)] pub struct LightValidatorConfig { pub enable_indexer: bool, @@ -9,9 +30,9 @@ pub struct LightValidatorConfig { pub wait_time: u64, /// Non-upgradeable programs: (program_id, program_path) pub sbf_programs: Vec<(String, String)>, - /// Upgradeable programs: (program_id, program_path, upgrade_authority) - /// Use this when the program needs a valid upgrade authority (e.g., for compression config) - pub upgradeable_programs: Vec<(String, String, String)>, + /// Upgradeable programs to deploy with a valid upgrade authority. + /// Use this when the program needs a valid upgrade authority (e.g., for compression config). + pub upgradeable_programs: Vec, pub limit_ledger_size: Option, } @@ -50,7 +71,9 @@ pub async fn spawn_validator(config: LightValidatorConfig) { for upgradeable_program in config.upgradeable_programs.iter() { path.push_str(&format!( " --upgradeable-program {} {} {}", - upgradeable_program.0, upgradeable_program.1, upgradeable_program.2 + upgradeable_program.program_id, + upgradeable_program.program_path, + upgradeable_program.upgrade_authority )); } From e748e411a444b2ced7e4abdd162657800e94434f Mon Sep 17 00:00:00 2001 From: Sergey Timoshin Date: Wed, 21 Jan 2026 19:27:58 +0000 Subject: [PATCH 09/38] fix: update authority_type field in GetCompressedMintsByAuthorityOptions to remove Option wrapper and set default value --- sdk-libs/client/src/indexer/options.rs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/sdk-libs/client/src/indexer/options.rs b/sdk-libs/client/src/indexer/options.rs index 8e70bbb76e..403ccf1402 100644 --- a/sdk-libs/client/src/indexer/options.rs +++ b/sdk-libs/client/src/indexer/options.rs @@ -128,8 +128,8 @@ pub struct GetCompressedMintsByAuthorityOptions { pub cursor: Option, /// Maximum number of results to return pub limit: Option, - /// Authority type filter. Defaults to `Some(MintAuthorityType::Either)` (both mint and freeze authorities). - pub authority_type: Option, + /// Authority type filter. Defaults to `MintAuthorityType::Either` (both mint and freeze authorities). + pub authority_type: MintAuthorityType, } impl Default for GetCompressedMintsByAuthorityOptions { @@ -137,7 +137,7 @@ impl Default for GetCompressedMintsByAuthorityOptions { Self { cursor: None, limit: None, - authority_type: Some(MintAuthorityType::Either), + authority_type: MintAuthorityType::Either, } } } @@ -158,7 +158,7 @@ impl GetCompressedMintsByAuthorityOptions { } pub fn with_authority_type(mut self, authority_type: MintAuthorityType) -> Self { - self.authority_type = Some(authority_type); + self.authority_type = authority_type; self } } From 93d61a7b3d0453eaecba7aa5e937d47ce8fbf07c Mon Sep 17 00:00:00 2001 From: Sergey Timoshin Date: Wed, 21 Jan 2026 19:46:54 +0000 Subject: [PATCH 10/38] fix: update mint_authority and mint fields in build_expected_mint function to use into() for conversion --- forester/tests/test_compressible_mint.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/forester/tests/test_compressible_mint.rs b/forester/tests/test_compressible_mint.rs index 4e73f2e1bc..a615020431 100644 --- a/forester/tests/test_compressible_mint.rs +++ b/forester/tests/test_compressible_mint.rs @@ -36,7 +36,7 @@ fn build_expected_mint( ) -> Mint { Mint { base: BaseMint { - mint_authority: Some(*mint_authority), + mint_authority: Some((*mint_authority).into()), supply: 0, decimals, is_initialized: true, @@ -45,7 +45,7 @@ fn build_expected_mint( metadata: MintMetadata { version: 1, mint_decompressed: true, - mint: *mint_pda, + mint: (*mint_pda).into(), mint_signer: *mint_signer, bump, }, From 9121a061d9bf000cb61d39a47e171ca7ea88aebc Mon Sep 17 00:00:00 2001 From: Sergey Timoshin Date: Wed, 21 Jan 2026 20:22:16 +0000 Subject: [PATCH 11/38] feat: refactor bootstrap logic to use run_bootstrap helper; enhance mint compression test with improved assertions and logging --- .../src/compressible/bootstrap_helpers.rs | 121 ++++++++++++- forester/src/compressible/mint/bootstrap.rs | 88 +++------ forester/src/compressible/mint/state.rs | 5 +- forester/src/compressible/pda/compressor.rs | 7 +- forester/tests/test_compressible_mint.rs | 170 +++++++++--------- 5 files changed, 234 insertions(+), 157 deletions(-) diff --git a/forester/src/compressible/bootstrap_helpers.rs b/forester/src/compressible/bootstrap_helpers.rs index c358bacbfc..1c3ecc84d9 100644 --- a/forester/src/compressible/bootstrap_helpers.rs +++ b/forester/src/compressible/bootstrap_helpers.rs @@ -5,12 +5,18 @@ //! - Account field extraction from JSON responses //! - Standard and V2 API patterns -use std::time::Duration; +use std::{ + sync::{ + atomic::{AtomicBool, Ordering}, + Arc, + }, + time::Duration, +}; use serde_json::json; use solana_sdk::pubkey::Pubkey; -use tokio::time::timeout; -use tracing::debug; +use tokio::{sync::oneshot, time::timeout}; +use tracing::{debug, info}; use super::config::{DEFAULT_PAGE_SIZE, DEFAULT_PAGINATION_DELAY_MS}; use crate::Result; @@ -344,3 +350,112 @@ where Ok((page_count, total_fetched, total_inserted)) } + +/// Result of a bootstrap operation +#[derive(Debug, Clone)] +pub struct BootstrapResult { + /// Number of pages fetched (1 for standard API) + pub pages: usize, + /// Total number of accounts fetched from RPC + pub fetched: usize, + /// Number of accounts successfully inserted/processed + pub inserted: usize, +} + +/// High-level bootstrap runner that handles common scaffolding. +/// +/// This helper encapsulates: +/// - Shutdown flag setup and listener spawning +/// - HTTP client creation +/// - Automatic selection between standard and V2 APIs based on localhost detection +/// - Consistent logging with the provided label +/// +/// # Arguments +/// * `rpc_url` - The RPC endpoint URL +/// * `program_id` - The program ID to fetch accounts from +/// * `filters` - Optional memcmp/dataSize filters for the query +/// * `shutdown_rx` - Optional shutdown receiver for graceful cancellation +/// * `process_fn` - Closure called for each fetched account; returns true if successfully processed +/// * `label` - Label for log messages (e.g., "Mint", "CToken", "PDA") +/// +/// # Returns +/// A `BootstrapResult` containing page count, fetched count, and inserted count. +pub async fn run_bootstrap( + rpc_url: &str, + program_id: &Pubkey, + filters: Option>, + shutdown_rx: Option>, + process_fn: F, + label: &str, +) -> Result +where + F: FnMut(RawAccountData) -> bool, +{ + info!("Starting bootstrap of {} accounts", label); + + // Set up shutdown flag + let shutdown_flag = Arc::new(AtomicBool::new(false)); + + if let Some(rx) = shutdown_rx { + let shutdown_flag_clone = shutdown_flag.clone(); + tokio::spawn(async move { + let _ = rx.await; + shutdown_flag_clone.store(true, Ordering::SeqCst); + }); + } + + let client = reqwest::Client::new(); + + info!( + "Bootstrapping {} accounts from program {}", + label, program_id + ); + + let result = if is_localhost(rpc_url) { + debug!("Detected localhost, using standard getProgramAccounts"); + let (fetched, inserted) = bootstrap_standard_api( + &client, + rpc_url, + program_id, + filters, + Some(&shutdown_flag), + process_fn, + ) + .await?; + + info!( + "{} bootstrap complete: {} fetched, {} inserted", + label, fetched, inserted + ); + + BootstrapResult { + pages: 1, + fetched, + inserted, + } + } else { + debug!("Using getProgramAccountsV2 with pagination"); + let (pages, fetched, inserted) = bootstrap_v2_api( + &client, + rpc_url, + program_id, + filters, + Some(&shutdown_flag), + process_fn, + ) + .await?; + + info!( + "{} bootstrap complete: {} pages, {} fetched, {} inserted", + label, pages, fetched, inserted + ); + + BootstrapResult { + pages, + fetched, + inserted, + } + }; + + Ok(result) +} diff --git a/forester/src/compressible/mint/bootstrap.rs b/forester/src/compressible/mint/bootstrap.rs index b20aebfe9d..7d0348a102 100644 --- a/forester/src/compressible/mint/bootstrap.rs +++ b/forester/src/compressible/mint/bootstrap.rs @@ -6,9 +6,7 @@ use tracing::{debug, info}; use super::state::MintAccountTracker; use crate::{ compressible::{ - bootstrap_helpers::{ - bootstrap_standard_api, bootstrap_v2_api, is_localhost, RawAccountData, - }, + bootstrap_helpers::{run_bootstrap, RawAccountData}, config::{ACCOUNT_TYPE_OFFSET, MINT_ACCOUNT_TYPE_FILTER}, traits::CompressibleTracker, }, @@ -21,31 +19,18 @@ pub async fn bootstrap_mint_accounts( tracker: Arc, shutdown_rx: Option>, ) -> Result<()> { - info!("Starting bootstrap of decompressed Mint accounts"); - - // Set up shutdown flag - let shutdown_flag = Arc::new(std::sync::atomic::AtomicBool::new(false)); - - if let Some(rx) = shutdown_rx { - let shutdown_flag_clone = shutdown_flag.clone(); - tokio::spawn(async move { - let _ = rx.await; - shutdown_flag_clone.store(true, std::sync::atomic::Ordering::SeqCst); - }); - } - - let client = reqwest::Client::builder() - .timeout(Duration::from_secs(30)) - .build()?; - // Light Token Program ID let program_id = solana_sdk::pubkey::Pubkey::new_from_array(light_token_interface::LIGHT_TOKEN_PROGRAM_ID); - info!( - "Bootstrapping decompressed Mint accounts from program {}", - program_id - ); + // Filter for decompressed Mint accounts (account_type = 1) + let filters = Some(vec![serde_json::json!({ + "memcmp": { + "offset": ACCOUNT_TYPE_OFFSET, + "bytes": MINT_ACCOUNT_TYPE_FILTER, + "encoding": "base58" + } + })]); // Process function that updates tracker let process_account = |raw_data: RawAccountData| -> bool { @@ -58,50 +43,21 @@ pub async fn bootstrap_mint_accounts( true }; - // Filter for decompressed Mint accounts (account_type = 1) - let filters = Some(vec![serde_json::json!({ - "memcmp": { - "offset": ACCOUNT_TYPE_OFFSET, - "bytes": MINT_ACCOUNT_TYPE_FILTER, - "encoding": "base58" - } - })]); - - if is_localhost(&rpc_url) { - let (total_fetched, total_inserted) = bootstrap_standard_api( - &client, - &rpc_url, - &program_id, - filters, - Some(&shutdown_flag), - process_account, - ) - .await?; - - info!( - "Mint bootstrap complete: {} fetched, {} decompressed mints tracked", - total_fetched, total_inserted - ); - } else { - let (page_count, total_fetched, total_inserted) = bootstrap_v2_api( - &client, - &rpc_url, - &program_id, - filters, - Some(&shutdown_flag), - process_account, - ) - .await?; - - info!( - "Mint bootstrap finished: {} pages, {} fetched, {} decompressed mints tracked", - page_count, total_fetched, total_inserted - ); - } + let result = run_bootstrap( + &rpc_url, + &program_id, + filters, + shutdown_rx, + process_account, + "Mint", + ) + .await?; info!( - "Mint bootstrap finished: {} total mints tracked", - tracker.len() + "Mint bootstrap finished: {} total mints tracked (fetched: {}, pages: {})", + tracker.len(), + result.fetched, + result.pages ); Ok(()) diff --git a/forester/src/compressible/mint/state.rs b/forester/src/compressible/mint/state.rs index db19e6dd10..8ea987099b 100644 --- a/forester/src/compressible/mint/state.rs +++ b/forester/src/compressible/mint/state.rs @@ -28,7 +28,10 @@ fn calculate_compressible_slot(mint: &Mint, lamports: u64, account_size: usize) rent_exemption, ); - Ok(last_funded_epoch * SLOTS_PER_EPOCH) + // Use the first unpaid epoch as the compressible boundary. + // is_ready_to_compress checks current_slot > compressible_slot, + // so we return the start of the first unpaid epoch. + Ok((last_funded_epoch + 1) * SLOTS_PER_EPOCH) } #[derive(Debug)] diff --git a/forester/src/compressible/pda/compressor.rs b/forester/src/compressible/pda/compressor.rs index 188057544a..f637822d50 100644 --- a/forester/src/compressible/pda/compressor.rs +++ b/forester/src/compressible/pda/compressor.rs @@ -306,14 +306,17 @@ impl PdaCompressor { "Batched compress_accounts_idempotent tx confirmed: {}", signature ); + Ok(signature) } else { tracing::warn!( "compress_accounts_idempotent tx not confirmed: {} - accounts kept in tracker for retry", signature ); + Err(anyhow::anyhow!( + "Batch transaction not confirmed: {}", + signature + )) } - - Ok(signature) } /// Compress a single PDA account using cached config diff --git a/forester/tests/test_compressible_mint.rs b/forester/tests/test_compressible_mint.rs index a615020431..8fb1ac8670 100644 --- a/forester/tests/test_compressible_mint.rs +++ b/forester/tests/test_compressible_mint.rs @@ -215,7 +215,7 @@ async fn test_compressible_mint_bootstrap() { &mint_pda, &mint_seed.pubkey().to_bytes(), bump, - mint.compression.clone(), + mint.compression, ); >>>>>>> d6299d718 (feat: add hex dependency and update existing hex usage in Cargo.toml files; refactor mint compression logic to handle batching and improve error handling; enhance test cases for mint creation and compression) @@ -408,95 +408,95 @@ async fn test_compressible_mint_compression() { let ready_accounts = tracker.get_ready_to_compress(current_slot); println!("Ready to compress: {} mints", ready_accounts.len()); - if !ready_accounts.is_empty() { - // Create compressor and compress - let compressor = - MintCompressor::new(rpc_pool.clone(), tracker.clone(), payer.insecure_clone()); - - println!("Compressing Mint..."); - let compress_result = compressor.compress_batch(&ready_accounts).await; - - let signatures = compress_result.expect("Compression should succeed"); - let signature = signatures - .last() - .expect("Should have at least one signature"); - println!("Compression transaction sent: {}", signature); - - // Wait for account to be closed - let start = tokio::time::Instant::now(); - let timeout = Duration::from_secs(30); - let mut account_closed = false; - - while start.elapsed() < timeout { - let mint_after = rpc - .get_account(mint_pda) - .await - .expect("Failed to query mint account"); - if mint_after.is_none() { - account_closed = true; - println!("Mint account closed successfully!"); - break; - } - sleep(Duration::from_millis(500)).await; - } + assert!( + !ready_accounts.is_empty(), + "Mint should be ready to compress with rent_payment=0" + ); - assert!( - account_closed, - "Mint account should be closed after compression" - ); + // Create compressor and compress + let compressor = MintCompressor::new(rpc_pool.clone(), tracker.clone(), payer.insecure_clone()); - wait_for_indexer(&rpc) - .await - .expect("Failed to wait for indexer"); + println!("Compressing Mint..."); + let compress_result = compressor.compress_batch(&ready_accounts).await; - // Verify compressed mint still exists in the merkle tree - let compressed_after = rpc - .get_compressed_account(compression_address, None) - .await - .unwrap() - .value; - assert!( - compressed_after.is_some(), - "Compressed mint should still exist after compression" - ); - - // Test Photon API: get_compressed_mint - println!("Testing Photon get_compressed_mint API..."); - let mint_response = rpc - .get_compressed_mint(compression_address, None) - .await - .expect("get_compressed_mint should succeed"); - - let compressed_mint = mint_response - .value - .expect("Compressed mint should be returned by get_compressed_mint"); - - assert_eq!(compressed_mint.mint.decimals, 9, "Decimals should match"); - assert_eq!( - compressed_mint.mint.mint_authority, - Some(payer.pubkey()), - "Mint authority should be payer" - ); - println!( - "Photon get_compressed_mint verified: decimals={}, supply={}", - compressed_mint.mint.decimals, compressed_mint.mint.supply - ); - - // Test Photon API: get_compressed_mint_by_pda - let mint_by_pda = rpc - .get_compressed_mint_by_pda(&mint_pda, None) + let signatures = compress_result.expect("Compression should succeed"); + let signature = signatures + .last() + .expect("Should have at least one signature"); + println!("Compression transaction sent: {}", signature); + + // Wait for account to be closed + let start = tokio::time::Instant::now(); + let timeout = Duration::from_secs(30); + let mut account_closed = false; + + while start.elapsed() < timeout { + let mint_after = rpc + .get_account(mint_pda) .await - .expect("get_compressed_mint_by_pda should succeed"); - assert!( - mint_by_pda.value.is_some(), - "Should find compressed mint by PDA" - ); - println!("Photon get_compressed_mint_by_pda verified!"); - - println!("Mint compression test completed successfully!"); - } else { - panic!("Mint should be ready to compress with rent_payment=0"); + .expect("Failed to query mint account"); + if mint_after.is_none() { + account_closed = true; + println!("Mint account closed successfully!"); + break; + } + sleep(Duration::from_millis(500)).await; } + + assert!( + account_closed, + "Mint account should be closed after compression" + ); + + wait_for_indexer(&rpc) + .await + .expect("Failed to wait for indexer"); + + // Verify compressed mint still exists in the merkle tree + let compressed_after = rpc + .get_compressed_account(compression_address, None) + .await + .unwrap() + .value; + assert!( + compressed_after.is_some(), + "Compressed mint should still exist after compression" + ); + + // Test Photon API: get_compressed_mint + println!("Testing Photon get_compressed_mint API..."); + let mint_response = rpc + .get_compressed_mint(compression_address, None) + .await + .expect("get_compressed_mint should succeed"); + + let compressed_mint = mint_response + .value + .expect("Compressed mint should be returned by get_compressed_mint"); + + assert_eq!(compressed_mint.mint.decimals, 9, "Decimals should match"); + assert_eq!( + compressed_mint.mint.mint_authority, + Some(payer.pubkey()), + "Mint authority should be payer" + ); + println!( + "Photon get_compressed_mint verified: decimals={}, supply={}", + compressed_mint.mint.decimals, compressed_mint.mint.supply + ); + + // Test Photon API: get_compressed_mint_by_pda + let mint_by_pda = rpc + .get_compressed_mint_by_pda(&mint_pda, None) + .await + .expect("get_compressed_mint_by_pda should succeed"); + assert!( + mint_by_pda.value.is_some(), + "Should find compressed mint by PDA" + ); + println!("Photon get_compressed_mint_by_pda verified!"); + + println!("Mint compression test completed successfully!"); } /// Test AccountSubscriber for Mint accounts From 0adcca50053a1372f1df653b71ad57fb8539b1f1 Mon Sep 17 00:00:00 2001 From: Sergey Timoshin Date: Wed, 21 Jan 2026 20:23:12 +0000 Subject: [PATCH 12/38] fix: update build_expected_mint function to accept version parameter and use it for mint metadata --- forester/tests/test_compressible_mint.rs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/forester/tests/test_compressible_mint.rs b/forester/tests/test_compressible_mint.rs index 8fb1ac8670..a81c1b5431 100644 --- a/forester/tests/test_compressible_mint.rs +++ b/forester/tests/test_compressible_mint.rs @@ -32,6 +32,7 @@ fn build_expected_mint( mint_pda: &Pubkey, mint_signer: &[u8; 32], bump: u8, + version: u8, compression: light_compressible::compression_info::CompressionInfo, ) -> Mint { Mint { @@ -43,7 +44,7 @@ fn build_expected_mint( freeze_authority: None, }, metadata: MintMetadata { - version: 1, + version, mint_decompressed: true, mint: (*mint_pda).into(), mint_signer: *mint_signer, @@ -215,6 +216,7 @@ async fn test_compressible_mint_bootstrap() { &mint_pda, &mint_seed.pubkey().to_bytes(), bump, + mint.metadata.version, mint.compression, ); >>>>>>> d6299d718 (feat: add hex dependency and update existing hex usage in Cargo.toml files; refactor mint compression logic to handle batching and improve error handling; enhance test cases for mint creation and compression) From 265dbaa17cf152c6ed9d4b3918d49369e35a1451 Mon Sep 17 00:00:00 2001 From: Sergey Timoshin Date: Wed, 21 Jan 2026 23:13:47 +0000 Subject: [PATCH 13/38] fix: adjust calculate_compressible_slot to correctly compute available balance after rent exemption and compression cost --- forester/src/compressible/mint/state.rs | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/forester/src/compressible/mint/state.rs b/forester/src/compressible/mint/state.rs index 8ea987099b..f84f5afda9 100644 --- a/forester/src/compressible/mint/state.rs +++ b/forester/src/compressible/mint/state.rs @@ -19,12 +19,24 @@ fn calculate_compressible_slot(mint: &Mint, lamports: u64, account_size: usize) let rent_exemption = get_rent_exemption_lamports(account_size as u64) .map_err(|e| anyhow::anyhow!("Failed to get rent exemption: {:?}", e))?; let compression_info = &mint.compression; + let config = &compression_info.rent_config; + + // Calculate available balance after rent exemption and compression cost + let available_balance = lamports + .saturating_sub(rent_exemption) + .saturating_sub(config.compression_cost as u64); + let rent_per_epoch = config.rent_curve_per_epoch(account_size as u64); + + // If no epochs are funded (rent_payment=0), the account is immediately compressible + if rent_per_epoch == 0 || available_balance / rent_per_epoch == 0 { + return Ok(0); + } let last_funded_epoch = get_last_funded_epoch( account_size as u64, lamports, compression_info.last_claimed_slot, - &compression_info.rent_config, + config, rent_exemption, ); From 10bae7c369fd9e19cb07fa68292f62e6b376d21e Mon Sep 17 00:00:00 2001 From: Sergey Timoshin Date: Wed, 21 Jan 2026 23:52:50 +0000 Subject: [PATCH 14/38] bump photon version --- scripts/devenv/versions.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/devenv/versions.sh b/scripts/devenv/versions.sh index 8b6be524d5..450a81bb34 100755 --- a/scripts/devenv/versions.sh +++ b/scripts/devenv/versions.sh @@ -13,7 +13,7 @@ export SOLANA_VERSION="2.2.15" export ANCHOR_VERSION="0.31.1" export JQ_VERSION="1.8.0" export PHOTON_VERSION="0.51.2" -export PHOTON_COMMIT="2adfa97d10e2181632558158219878f455bfd95e" +export PHOTON_COMMIT="254d66715876f39702d4ab9b5a518e78023fa27f" export REDIS_VERSION="8.0.1" export ANCHOR_TAG="anchor-v${ANCHOR_VERSION}" From 005ddfb26a49e468958c66bf719b1a2c97610d56 Mon Sep 17 00:00:00 2001 From: Sergey Timoshin Date: Fri, 23 Jan 2026 10:55:35 +0000 Subject: [PATCH 15/38] wip --- forester/src/compressible/mint/bootstrap.rs | 2 +- forester/src/compressible/subscriber.rs | 1 - forester/tests/test_compressible_ctoken.rs | 92 ++++++++++++++------- forester/tests/test_compressible_mint.rs | 32 ------- 4 files changed, 61 insertions(+), 66 deletions(-) diff --git a/forester/src/compressible/mint/bootstrap.rs b/forester/src/compressible/mint/bootstrap.rs index 7d0348a102..ba04d59cd9 100644 --- a/forester/src/compressible/mint/bootstrap.rs +++ b/forester/src/compressible/mint/bootstrap.rs @@ -1,4 +1,4 @@ -use std::{sync::Arc, time::Duration}; +use std::sync::Arc; use tokio::sync::oneshot; use tracing::{debug, info}; diff --git a/forester/src/compressible/subscriber.rs b/forester/src/compressible/subscriber.rs index bc5ffce4c4..096f457bb4 100644 --- a/forester/src/compressible/subscriber.rs +++ b/forester/src/compressible/subscriber.rs @@ -67,7 +67,6 @@ enum ConnectionResult { StreamClosed, } - impl SubscriptionConfig { /// Create subscription config for Light Token accounts (ctokens) pub fn ctoken() -> Self { diff --git a/forester/tests/test_compressible_ctoken.rs b/forester/tests/test_compressible_ctoken.rs index 4bd135b9b5..fc76bd9223 100644 --- a/forester/tests/test_compressible_ctoken.rs +++ b/forester/tests/test_compressible_ctoken.rs @@ -90,22 +90,52 @@ async fn register_forester( // Calculate epoch info let current_slot = rpc.get_slot().await?; let current_epoch = protocol_config.get_current_epoch(current_slot); - println!("current_epoch {:?}", current_epoch); let phases = get_epoch_phases(&protocol_config, current_epoch); - let register_phase_start = phases.registration.start; - let active_phase_start = phases.active.start; - println!("phases {:?}", phases); - println!("current_slot {}", current_slot); + + println!( + "Current slot: {}, current_epoch: {}, phases: {:?}", + current_slot, current_epoch, phases + ); + + // Determine which epoch to register for: + // If we're already past the registration phase start, we might be in active phase + // and need to wait for the next epoch's registration + let (target_epoch, target_phases) = if current_slot >= phases.active.start { + // Already in active phase, register for next epoch + let next_epoch = current_epoch + 1; + let next_phases = get_epoch_phases(&protocol_config, next_epoch); + println!( + "Already in active phase, registering for next epoch {}, phases: {:?}", + next_epoch, next_phases + ); + (next_epoch, next_phases) + } else if current_slot >= phases.registration.start { + // In registration phase, register for current epoch + println!("In registration phase for epoch {}", current_epoch); + (current_epoch, phases) + } else { + // Before registration phase, wait for it + println!( + "Waiting for registration phase (starts at slot {})", + phases.registration.start + ); + (current_epoch, phases) + }; + + let register_phase_start = target_phases.registration.start; + let active_phase_start = target_phases.active.start; // Wait for registration phase while rpc.get_slot().await? < register_phase_start { sleep(Duration::from_millis(400)).await; } - // Register for epoch 0 - let epoch = 0u64; - let register_epoch_ix = - create_register_forester_epoch_pda_instruction(&forester_pubkey, &forester_pubkey, epoch); + // Register for the target epoch + let register_epoch_ix = create_register_forester_epoch_pda_instruction( + &forester_pubkey, + &forester_pubkey, + target_epoch, + ); let (blockhash, _) = rpc.get_latest_blockhash().await?; let tx = Transaction::new_signed_with_payer( @@ -116,23 +146,21 @@ async fn register_forester( ); rpc.process_transaction(tx).await?; - println!("Registered for epoch {}", epoch); - - println!( - "Waiting for active phase (current slot: {}, active phase starts at: {})...", - current_slot, active_phase_start - ); + println!("Registered for epoch {}", target_epoch); // Wait for active phase while rpc.get_slot().await? < active_phase_start { sleep(Duration::from_millis(400)).await; } - println!("Active phase reached"); + println!("Active phase reached for epoch {}", target_epoch); // Finalize registration - let finalize_ix = - create_finalize_registration_instruction(&forester_pubkey, &forester_pubkey, epoch); + let finalize_ix = create_finalize_registration_instruction( + &forester_pubkey, + &forester_pubkey, + target_epoch, + ); let (blockhash, _) = rpc.get_latest_blockhash().await?; let tx = Transaction::new_signed_with_payer( @@ -160,10 +188,10 @@ async fn register_forester( use light_registry::protocol_config::state::EpochState; let epoch_struct = Epoch { - epoch, + epoch: target_epoch, epoch_pda: solana_sdk::pubkey::Pubkey::default(), forester_epoch_pda: solana_sdk::pubkey::Pubkey::default(), - phases, + phases: target_phases, state: EpochState::Active, merkle_trees: vec![], }; @@ -467,7 +495,7 @@ async fn run_bootstrap_test( }); if expected_count > 0 { - // Wait for bootstrap to find expected number of accounts (with timeout) + // Wait for bootstrap to find at least expected number of accounts (with timeout) let start = tokio::time::Instant::now(); let timeout = Duration::from_secs(60); @@ -479,12 +507,13 @@ async fn run_bootstrap_test( sleep(Duration::from_millis(500)).await; } - // Assert bootstrap picked up all accounts - assert_eq!( - tracker.len(), + // Assert bootstrap picked up at least the expected accounts + // (there may be more from previous tests sharing the validator) + assert!( + tracker.len() >= expected_count, + "Bootstrap should have found at least {} accounts, found {}", expected_count, - "Bootstrap should have found all {} accounts", - expected_count + tracker.len() ); } else { // Mainnet test: wait a bit for bootstrap to run @@ -498,14 +527,13 @@ async fn run_bootstrap_test( if let Some((expected_pubkeys, expected_mint)) = expected_data { // Verify specific accounts (localhost test) - // Verify all created accounts are in tracker + // Verify all created accounts are in tracker and have correct data for pubkey in &expected_pubkeys { - let found = accounts.iter().any(|acc| acc.pubkey == *pubkey); - assert!(found, "Bootstrap should have found account {}", pubkey); - } + let account_state = accounts + .iter() + .find(|acc| acc.pubkey == *pubkey) + .expect(&format!("Bootstrap should have found account {}", pubkey)); - // Verify account data is correct - for account_state in &accounts { println!( "Verifying account {}: mint={:?}, lamports={}", account_state.pubkey, account_state.account.mint, account_state.lamports diff --git a/forester/tests/test_compressible_mint.rs b/forester/tests/test_compressible_mint.rs index a81c1b5431..ad54f0daca 100644 --- a/forester/tests/test_compressible_mint.rs +++ b/forester/tests/test_compressible_mint.rs @@ -178,37 +178,6 @@ async fn test_compressible_mint_bootstrap() { let mint_data = mint_account.unwrap(); let mint = Mint::deserialize(&mut &mint_data.data[..]).expect("Failed to deserialize Mint"); - // Extract runtime-specific values from deserialized mint - let compression = mint.compression; - let metadata_version = mint.metadata.version; - - // Build expected Mint - let expected_mint = Mint { - base: BaseMint { - mint_authority: Some(payer.pubkey().to_bytes().into()), - supply: 0, - decimals: 9, - is_initialized: true, - freeze_authority: None, - }, - metadata: MintMetadata { - version: metadata_version, - mint_decompressed: true, - mint: mint_pda.to_bytes().into(), - mint_signer: mint_seed.pubkey().to_bytes(), - bump, - }, - reserved: [0u8; 16], - account_type: ACCOUNT_TYPE_MINT, - compression, - extensions: None, - }; - - assert_eq!(mint, expected_mint, "Mint should match expected state"); -======= - let mint_data = mint_account.unwrap(); - let mint = Mint::deserialize(&mut &mint_data.data[..]).expect("Failed to deserialize Mint"); - // Build expected mint using known values plus runtime compression info let expected_mint = build_expected_mint( &payer.pubkey(), @@ -219,7 +188,6 @@ async fn test_compressible_mint_bootstrap() { mint.metadata.version, mint.compression, ); ->>>>>>> d6299d718 (feat: add hex dependency and update existing hex usage in Cargo.toml files; refactor mint compression logic to handle batching and improve error handling; enhance test cases for mint creation and compression) assert_eq!(mint, expected_mint, "Mint should match expected structure"); From 4659bb49033dfcb2adfaa5b8fb0617680f40175b Mon Sep 17 00:00:00 2001 From: Sergey Timoshin Date: Fri, 23 Jan 2026 11:07:09 +0000 Subject: [PATCH 16/38] cleanup --- forester/tests/test_compressible_ctoken.rs | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/forester/tests/test_compressible_ctoken.rs b/forester/tests/test_compressible_ctoken.rs index fc76bd9223..4030dd2dd7 100644 --- a/forester/tests/test_compressible_ctoken.rs +++ b/forester/tests/test_compressible_ctoken.rs @@ -156,11 +156,8 @@ async fn register_forester( println!("Active phase reached for epoch {}", target_epoch); // Finalize registration - let finalize_ix = create_finalize_registration_instruction( - &forester_pubkey, - &forester_pubkey, - target_epoch, - ); + let finalize_ix = + create_finalize_registration_instruction(&forester_pubkey, &forester_pubkey, target_epoch); let (blockhash, _) = rpc.get_latest_blockhash().await?; let tx = Transaction::new_signed_with_payer( From 8e23a823715213f7729c93dfa1a86e8bcdca8721 Mon Sep 17 00:00:00 2001 From: Sergey Timoshin Date: Fri, 23 Jan 2026 11:23:31 +0000 Subject: [PATCH 17/38] cleanup # Conflicts: # forester/src/compressible/pda/compressor.rs --- forester/src/compressible/pda/compressor.rs | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/forester/src/compressible/pda/compressor.rs b/forester/src/compressible/pda/compressor.rs index f637822d50..98709294dc 100644 --- a/forester/src/compressible/pda/compressor.rs +++ b/forester/src/compressible/pda/compressor.rs @@ -105,6 +105,17 @@ impl PdaCompressor { ) })?; + /* + // Verify PDA derivation matches (mirrors LightConfig::load_checked) + let (expected_pda, _) = LightConfig::derive_pda(program_id, config.config_bump); + if expected_pda != config_pda { + return Err(anyhow::anyhow!( + "Config PDA derivation mismatch. Expected: {}. Found: {}", + expected_pda, + config_pda + )); + } + */ let rent_sponsor = config.rent_sponsor; let compression_authority = config.compression_authority; let address_tree = *config From a60962eb37614da835017fad6e8055d68c9d204c Mon Sep 17 00:00:00 2001 From: Sergey Timoshin Date: Fri, 23 Jan 2026 11:33:12 +0000 Subject: [PATCH 18/38] cleanup # Conflicts: # forester/src/compressible/mint/compressor.rs --- .../src/compressible/bootstrap_helpers.rs | 31 ++++++++++++++----- forester/tests/test_compressible_ctoken.rs | 2 +- program-libs/CLAUDE.md | 1 + 3 files changed, 25 insertions(+), 9 deletions(-) diff --git a/forester/src/compressible/bootstrap_helpers.rs b/forester/src/compressible/bootstrap_helpers.rs index 1c3ecc84d9..8ad43ec638 100644 --- a/forester/src/compressible/bootstrap_helpers.rs +++ b/forester/src/compressible/bootstrap_helpers.rs @@ -393,16 +393,17 @@ where { info!("Starting bootstrap of {} accounts", label); - // Set up shutdown flag + // Set up shutdown flag and listener task let shutdown_flag = Arc::new(AtomicBool::new(false)); - if let Some(rx) = shutdown_rx { + // Spawn shutdown listener and keep handle for cleanup + let shutdown_listener_handle = shutdown_rx.map(|rx| { let shutdown_flag_clone = shutdown_flag.clone(); tokio::spawn(async move { let _ = rx.await; shutdown_flag_clone.store(true, Ordering::SeqCst); - }); - } + }) + }); let client = reqwest::Client::new(); @@ -413,7 +414,7 @@ where let result = if is_localhost(rpc_url) { debug!("Detected localhost, using standard getProgramAccounts"); - let (fetched, inserted) = bootstrap_standard_api( + let api_result = bootstrap_standard_api( &client, rpc_url, program_id, @@ -421,7 +422,14 @@ where Some(&shutdown_flag), process_fn, ) - .await?; + .await; + + // Abort shutdown listener before returning (success or error) + if let Some(handle) = shutdown_listener_handle { + handle.abort(); + } + + let (fetched, inserted) = api_result?; info!( "{} bootstrap complete: {} fetched, {} inserted", @@ -435,7 +443,7 @@ where } } else { debug!("Using getProgramAccountsV2 with pagination"); - let (pages, fetched, inserted) = bootstrap_v2_api( + let api_result = bootstrap_v2_api( &client, rpc_url, program_id, @@ -443,7 +451,14 @@ where Some(&shutdown_flag), process_fn, ) - .await?; + .await; + + // Abort shutdown listener before returning (success or error) + if let Some(handle) = shutdown_listener_handle { + handle.abort(); + } + + let (pages, fetched, inserted) = api_result?; info!( "{} bootstrap complete: {} pages, {} fetched, {} inserted", diff --git a/forester/tests/test_compressible_ctoken.rs b/forester/tests/test_compressible_ctoken.rs index 4030dd2dd7..d974804352 100644 --- a/forester/tests/test_compressible_ctoken.rs +++ b/forester/tests/test_compressible_ctoken.rs @@ -529,7 +529,7 @@ async fn run_bootstrap_test( let account_state = accounts .iter() .find(|acc| acc.pubkey == *pubkey) - .expect(&format!("Bootstrap should have found account {}", pubkey)); + .unwrap_or_else(|| panic!("Bootstrap should have found account {}", pubkey)); println!( "Verifying account {}: mint={:?}, lamports={}", diff --git a/program-libs/CLAUDE.md b/program-libs/CLAUDE.md index 81359b5151..3803b58e81 100644 --- a/program-libs/CLAUDE.md +++ b/program-libs/CLAUDE.md @@ -63,6 +63,7 @@ Some crates depend on external Light Protocol crates not in program-libs: ## Testing Unit tests run with `cargo test`: + ```bash cargo test -p light-hasher --all-features cargo test -p light-compressed-account --all-features From 91f5d91d8abf1f4cef1f46527549c7a36b5dc076 Mon Sep 17 00:00:00 2001 From: Sergey Timoshin Date: Fri, 23 Jan 2026 13:20:19 +0000 Subject: [PATCH 19/38] cleanup --- forester/package.json | 23 ------------------- forester/tests/test_compressible_mint.rs | 4 ++-- sdk-libs/client/src/indexer/photon_indexer.rs | 14 +++++------ .../_get_compressed_mint_post_request.rs | 2 +- ...get_compressed_mint_post_request_params.rs | 21 ++++++++++++----- 5 files changed, 24 insertions(+), 40 deletions(-) delete mode 100644 forester/package.json diff --git a/forester/package.json b/forester/package.json deleted file mode 100644 index cc0173c735..0000000000 --- a/forester/package.json +++ /dev/null @@ -1,23 +0,0 @@ -{ - "name": "@lightprotocol/forester", - "version": "0.3.0", - "license": "GPL-3.0", - "scripts": { - "build": "cargo build", - "test": "redis-start && TEST_MODE=local TEST_V1_STATE=true TEST_V2_STATE=true TEST_V1_ADDRESS=true TEST_V2_ADDRESS=true RUST_LOG=forester=debug,forester_utils=debug,light_prover_client=debug cargo test --package forester e2e_test -- --nocapture", - "test:compressible": "cargo build-sbf -- -p csdk-anchor-full-derived-test && RUST_LOG=forester=debug,light_client=debug cargo test --package forester --test test_compressible_pda --test test_compressible_mint --test test_compressible_ctoken -- --nocapture", - "docker:build": "docker build --tag forester -f Dockerfile .." - }, - "devDependencies": { - "@lightprotocol/zk-compression-cli": "workspace:*" - }, - "nx": { - "targets": { - "build": { - "outputs": [ - "{workspaceRoot}/target/release" - ] - } - } - } -} diff --git a/forester/tests/test_compressible_mint.rs b/forester/tests/test_compressible_mint.rs index ad54f0daca..7b3cf0e04c 100644 --- a/forester/tests/test_compressible_mint.rs +++ b/forester/tests/test_compressible_mint.rs @@ -760,10 +760,10 @@ async fn test_compressible_mint_subscription() { .value .items .iter() - .find(|m| m.mint.decimals == 9); + .find(|m| m.account.address == Some(compression_address_1)); assert!( found_mint.is_some(), - "Should find the mint with 9 decimals in authority query results" + "Should find the mint with compression_address_1 in authority query results" ); println!("Photon API tests completed successfully!"); diff --git a/sdk-libs/client/src/indexer/photon_indexer.rs b/sdk-libs/client/src/indexer/photon_indexer.rs index 4feb8f6011..aecdd9b632 100644 --- a/sdk-libs/client/src/indexer/photon_indexer.rs +++ b/sdk-libs/client/src/indexer/photon_indexer.rs @@ -1788,10 +1788,9 @@ impl Indexer for PhotonIndexer { let config = config.unwrap_or_default(); self.retry(config.retry_config, || async { let request = photon_api::models::GetCompressedMintPostRequest::new( - photon_api::models::GetCompressedMintPostRequestParams { - address: Some(bs58::encode(address).into_string()), - mint_pda: None, - }, + photon_api::models::GetCompressedMintPostRequestParams::with_address( + bs58::encode(address).into_string(), + ), ); let result = photon_api::apis::default_api::get_compressed_mint_post( @@ -1833,10 +1832,9 @@ impl Indexer for PhotonIndexer { let config = config.unwrap_or_default(); self.retry(config.retry_config, || async { let request = photon_api::models::GetCompressedMintPostRequest::new( - photon_api::models::GetCompressedMintPostRequestParams { - address: None, - mint_pda: Some(mint_pda.to_string()), - }, + photon_api::models::GetCompressedMintPostRequestParams::with_mint_pda( + mint_pda.to_string(), + ), ); let result = photon_api::apis::default_api::get_compressed_mint_post( diff --git a/sdk-libs/photon-api/src/models/_get_compressed_mint_post_request.rs b/sdk-libs/photon-api/src/models/_get_compressed_mint_post_request.rs index cf749d6d71..e51b7eb533 100644 --- a/sdk-libs/photon-api/src/models/_get_compressed_mint_post_request.rs +++ b/sdk-libs/photon-api/src/models/_get_compressed_mint_post_request.rs @@ -10,7 +10,7 @@ use crate::models; -#[derive(Clone, Default, Debug, PartialEq, Serialize, Deserialize)] +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] pub struct GetCompressedMintPostRequest { /// An ID to identify the request. #[serde(rename = "id")] diff --git a/sdk-libs/photon-api/src/models/_get_compressed_mint_post_request_params.rs b/sdk-libs/photon-api/src/models/_get_compressed_mint_post_request_params.rs index bd9401375f..b040026fb2 100644 --- a/sdk-libs/photon-api/src/models/_get_compressed_mint_post_request_params.rs +++ b/sdk-libs/photon-api/src/models/_get_compressed_mint_post_request_params.rs @@ -8,8 +8,9 @@ * Generated by: https://openapi-generator.tech */ -/// GetCompressedMintPostRequestParams : Request for compressed mint data -#[derive(Clone, Default, Debug, PartialEq, Serialize, Deserialize)] +/// GetCompressedMintPostRequestParams : Request for compressed mint data. +/// Exactly one of `address` or `mint_pda` must be set. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] pub struct GetCompressedMintPostRequestParams { /// A Solana public key represented as a base58 string (compressed address). #[serde(rename = "address", default, skip_serializing_if = "Option::is_none")] @@ -20,11 +21,19 @@ pub struct GetCompressedMintPostRequestParams { } impl GetCompressedMintPostRequestParams { - /// Request for compressed mint data - pub fn new() -> GetCompressedMintPostRequestParams { - GetCompressedMintPostRequestParams { - address: None, + /// Create params to query by compressed address. + pub fn with_address(address: String) -> Self { + Self { + address: Some(address), mint_pda: None, } } + + /// Create params to query by mint PDA. + pub fn with_mint_pda(mint_pda: String) -> Self { + Self { + address: None, + mint_pda: Some(mint_pda), + } + } } From 32c6cba68be340c8512d0e321b267eef79a4a241 Mon Sep 17 00:00:00 2001 From: Sergey Timoshin Date: Tue, 27 Jan 2026 01:20:02 +0000 Subject: [PATCH 20/38] new apis --- .mise.toml | 4 + forester/justfile | 4 + forester/tests/test_indexer_interface.rs | 752 ++++++++++++++++++ scripts/devenv.sh | 5 +- scripts/devenv/versions.sh | 2 +- sdk-libs/client/src/indexer/mod.rs | 11 +- sdk-libs/client/src/indexer/photon_indexer.rs | 243 +++++- sdk-libs/client/src/indexer/types.rs | 226 ++++++ sdk-libs/photon-api/src/apis/default_api.rs | 251 ++++++ ...get_account_interface_post_200_response.rs | 35 + ...ount_interface_post_200_response_result.rs | 27 + .../_get_account_interface_post_request.rs | 36 + ...t_account_interface_post_request_params.rs | 22 + .../_get_ata_interface_post_200_response.rs | 35 + ..._ata_interface_post_200_response_result.rs | 27 + .../models/_get_ata_interface_post_request.rs | 36 + .../_get_ata_interface_post_request_params.rs | 25 + .../_get_mint_interface_post_200_response.rs | 35 + ...mint_interface_post_200_response_result.rs | 27 + .../_get_mint_interface_post_request.rs | 36 + ..._get_mint_interface_post_request_params.rs | 22 + ...le_account_interfaces_post_200_response.rs | 35 + ...unt_interfaces_post_200_response_result.rs | 28 + ...ultiple_account_interfaces_post_request.rs | 36 + ..._account_interfaces_post_request_params.rs | 22 + ...ken_account_interface_post_200_response.rs | 35 + ...ount_interface_post_200_response_result.rs | 27 + ...et_token_account_interface_post_request.rs | 36 + ...n_account_interface_post_request_params.rs | 22 + .../src/models/account_interface.rs | 68 ++ .../src/models/compressed_context.rs | 40 + .../photon-api/src/models/interface_result.rs | 31 + .../photon-api/src/models/mint_interface.rs | 27 + sdk-libs/photon-api/src/models/mod.rs | 58 ++ .../photon-api/src/models/resolved_from.rs | 23 + .../src/models/token_account_interface.rs | 30 + 36 files changed, 2370 insertions(+), 9 deletions(-) create mode 100644 .mise.toml create mode 100644 forester/tests/test_indexer_interface.rs create mode 100644 sdk-libs/photon-api/src/models/_get_account_interface_post_200_response.rs create mode 100644 sdk-libs/photon-api/src/models/_get_account_interface_post_200_response_result.rs create mode 100644 sdk-libs/photon-api/src/models/_get_account_interface_post_request.rs create mode 100644 sdk-libs/photon-api/src/models/_get_account_interface_post_request_params.rs create mode 100644 sdk-libs/photon-api/src/models/_get_ata_interface_post_200_response.rs create mode 100644 sdk-libs/photon-api/src/models/_get_ata_interface_post_200_response_result.rs create mode 100644 sdk-libs/photon-api/src/models/_get_ata_interface_post_request.rs create mode 100644 sdk-libs/photon-api/src/models/_get_ata_interface_post_request_params.rs create mode 100644 sdk-libs/photon-api/src/models/_get_mint_interface_post_200_response.rs create mode 100644 sdk-libs/photon-api/src/models/_get_mint_interface_post_200_response_result.rs create mode 100644 sdk-libs/photon-api/src/models/_get_mint_interface_post_request.rs create mode 100644 sdk-libs/photon-api/src/models/_get_mint_interface_post_request_params.rs create mode 100644 sdk-libs/photon-api/src/models/_get_multiple_account_interfaces_post_200_response.rs create mode 100644 sdk-libs/photon-api/src/models/_get_multiple_account_interfaces_post_200_response_result.rs create mode 100644 sdk-libs/photon-api/src/models/_get_multiple_account_interfaces_post_request.rs create mode 100644 sdk-libs/photon-api/src/models/_get_multiple_account_interfaces_post_request_params.rs create mode 100644 sdk-libs/photon-api/src/models/_get_token_account_interface_post_200_response.rs create mode 100644 sdk-libs/photon-api/src/models/_get_token_account_interface_post_200_response_result.rs create mode 100644 sdk-libs/photon-api/src/models/_get_token_account_interface_post_request.rs create mode 100644 sdk-libs/photon-api/src/models/_get_token_account_interface_post_request_params.rs create mode 100644 sdk-libs/photon-api/src/models/account_interface.rs create mode 100644 sdk-libs/photon-api/src/models/compressed_context.rs create mode 100644 sdk-libs/photon-api/src/models/interface_result.rs create mode 100644 sdk-libs/photon-api/src/models/mint_interface.rs create mode 100644 sdk-libs/photon-api/src/models/resolved_from.rs create mode 100644 sdk-libs/photon-api/src/models/token_account_interface.rs diff --git a/.mise.toml b/.mise.toml new file mode 100644 index 0000000000..c5492f4b9e --- /dev/null +++ b/.mise.toml @@ -0,0 +1,4 @@ +# Disable mise's Go management for this project. +# We use our own Go installation via devenv.sh. +[settings] +disable_tools = ["go"] diff --git a/forester/justfile b/forester/justfile index 430267c08f..1ae4223948 100644 --- a/forester/justfile +++ b/forester/justfile @@ -35,3 +35,7 @@ test-compressible-mint: build-compressible-test-deps test-compressible-ctoken: build-compressible-test-deps RUST_LOG=forester=debug,light_client=debug \ cargo test --package forester --test test_compressible_ctoken -- --nocapture + +# Test for indexer interface scenarios (creates test data for photon) +test-indexer-interface: build-test-deps + cargo test --package forester --test test_indexer_interface -- --nocapture diff --git a/forester/tests/test_indexer_interface.rs b/forester/tests/test_indexer_interface.rs new file mode 100644 index 0000000000..53c48c7cb3 --- /dev/null +++ b/forester/tests/test_indexer_interface.rs @@ -0,0 +1,752 @@ +/// Test scenarios for indexer interface endpoints. +/// +/// This test creates various account types for testing the indexer's interface racing logic. +/// After running, use `cargo xtask export-photon-test-data --test-name indexer_interface` +/// to export transactions to the indexer's test snapshot directory. +/// +/// Scenarios covered: +/// 1. SPL Mint (on-chain) - standard mint for token operations +/// 2. Compressed token accounts (via mint_to) - for getTokenAccountInterface +/// 3. Registered v2 address in batched address tree - for address tree verification +/// 4. Decompressed mint (via CreateMint with rent_payment=0) - for getMintInterface (on-chain CMint) +/// 5. Fully compressed mint (CreateMint + CompressAndCloseMint) - for getMintInterface (compressed DB) +/// 6. Compressible token accounts - on-chain accounts that can be compressed +use std::{collections::HashMap, time::Duration}; + +use anchor_lang::Discriminator; +use borsh::BorshSerialize; +use create_address_test_program::create_invoke_cpi_instruction; +use forester_utils::utils::wait_for_indexer; +use light_client::{ + indexer::{photon_indexer::PhotonIndexer, AddressWithTree, Indexer, ResolvedFrom}, + local_test_validator::{spawn_validator, LightValidatorConfig}, + rpc::{LightClient, LightClientConfig, Rpc}, +}; +use light_compressed_account::{ + address::derive_address, + instruction_data::{ + data::NewAddressParamsAssigned, with_readonly::InstructionDataInvokeCpiWithReadOnly, + }, +}; +use light_compressed_token::{ + process_mint::mint_sdk::create_mint_to_instruction, + process_transfer::transfer_sdk::to_account_metas, +}; +use light_test_utils::{ + pack::pack_new_address_params_assigned, spl::create_mint_helper_with_keypair, +}; +use light_token::instruction::{ + derive_mint_compressed_address, find_mint_address, CreateMint, CreateMintParams, +}; +use light_token_client::{ + actions::{create_compressible_token_account, CreateCompressibleTokenAccountInputs}, + instructions::mint_action::{create_mint_action_instruction, MintActionParams, MintActionType}, +}; +use light_token_interface::state::TokenDataVersion; +use serial_test::serial; +use solana_sdk::{ + pubkey::Pubkey, + signature::{Keypair, Signature}, + signer::Signer, + transaction::Transaction, +}; +use tokio::time::sleep; + +const COMPUTE_BUDGET_LIMIT: u32 = 1_000_000; + +/// Helper to mint compressed tokens +async fn mint_compressed_tokens( + rpc: &mut R, + merkle_tree_pubkey: &Pubkey, + payer: &Keypair, + mint_pubkey: &Pubkey, + recipients: Vec, + amounts: Vec, +) -> Signature { + let mint_to_ix = create_mint_to_instruction( + &payer.pubkey(), + &payer.pubkey(), + mint_pubkey, + merkle_tree_pubkey, + amounts, + recipients, + None, + false, + 0, + ); + let instructions = vec![ + solana_sdk::compute_budget::ComputeBudgetInstruction::set_compute_unit_limit( + COMPUTE_BUDGET_LIMIT, + ), + mint_to_ix, + ]; + rpc.create_and_send_transaction(&instructions, &payer.pubkey(), &[payer]) + .await + .unwrap() +} + +/// Test that creates scenarios for Photon interface testing +/// +/// Run with: cargo test -p forester --test test_indexer_interface -- --nocapture +/// Then export: cargo xtask export-photon-test-data --test-name indexer_interface +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +#[serial] +async fn test_indexer_interface_scenarios() { + // Start validator with indexer, prover, and create_address_test_program + spawn_validator(LightValidatorConfig { + enable_indexer: true, + enable_prover: true, + wait_time: 90, + sbf_programs: vec![( + "FNt7byTHev1k5x2cXZLBr8TdWiC3zoP5vcnZR4P682Uy".to_string(), + "../target/deploy/create_address_test_program.so".to_string(), + )], + upgradeable_programs: vec![], + limit_ledger_size: None, + }) + .await; + + let mut rpc = LightClient::new(LightClientConfig::local()) + .await + .expect("Failed to create LightClient"); + rpc.get_latest_active_state_trees() + .await + .expect("Failed to get state trees"); + + let payer = rpc.get_payer().insecure_clone(); + rpc.airdrop_lamports(&payer.pubkey(), 100_000_000_000) + .await + .expect("Failed to airdrop to payer"); + + // Give extra time for indexer to fully start + sleep(Duration::from_secs(5)).await; + + // Wait for indexer to be ready before making any requests + wait_for_indexer(&rpc) + .await + .expect("Failed to wait for indexer"); + + println!("\n========== PHOTON INTERFACE TEST ==========\n"); + println!("Payer: {}", payer.pubkey()); + + // ============ Scenario 1: Create SPL Mint ============ + println!("\n=== Creating SPL mint ==="); + + let mint_keypair = Keypair::new(); + let mint_pubkey = create_mint_helper_with_keypair(&mut rpc, &payer, &mint_keypair).await; + println!("SPL Mint: {}", mint_pubkey); + + // ============ Scenario 2: Create compressed token accounts ============ + println!("\n=== Creating compressed token accounts ==="); + + let bob = Keypair::new(); + let charlie = Keypair::new(); + + let state_tree_info = rpc.get_random_state_tree_info().unwrap(); + + // Mint compressed tokens to Bob and Charlie + let mint_sig = mint_compressed_tokens( + &mut rpc, + &state_tree_info.queue, + &payer, + &mint_pubkey, + vec![bob.pubkey(), charlie.pubkey()], + vec![1_000_000_000, 500_000_000], + ) + .await; + println!("Minted compressed tokens: {}", mint_sig); + println!("Bob pubkey: {}", bob.pubkey()); + println!("Charlie pubkey: {}", charlie.pubkey()); + + // Wait for indexer + sleep(Duration::from_secs(3)).await; + + // ============ Scenario 3: Register v2 Address (using create_address_test_program) ============ + println!("\n=== Registering v2 address in batched address tree ==="); + + // Use v2 (batched) address tree + let address_tree = rpc.get_address_tree_v2(); + + // Create a deterministic seed for the address + let address_seed: [u8; 32] = [42u8; 32]; + + // Derive address using v2 method (includes program ID) + let derived_address = derive_address( + &address_seed, + &address_tree.tree.to_bytes(), + &create_address_test_program::ID.to_bytes(), + ); + + println!("Derived v2 address: {:?}", derived_address); + + // Get validity proof for the new address + wait_for_indexer(&mut rpc).await.unwrap(); + let proof_result = rpc + .indexer() + .unwrap() + .get_validity_proof( + vec![], + vec![AddressWithTree { + address: derived_address, + tree: address_tree.tree, + }], + None, + ) + .await + .unwrap(); + + // Build new address params + let new_address_params = vec![NewAddressParamsAssigned { + seed: address_seed, + address_queue_pubkey: address_tree.tree.into(), // For batched trees, queue = tree + address_merkle_tree_pubkey: address_tree.tree.into(), + address_merkle_tree_root_index: proof_result.value.get_address_root_indices()[0], + assigned_account_index: None, + }]; + + // Pack the address params for the instruction + let mut remaining_accounts = HashMap::::new(); + let packed_new_address_params = + pack_new_address_params_assigned(&new_address_params, &mut remaining_accounts); + + // Build instruction data for create_address_test_program + let ix_data = InstructionDataInvokeCpiWithReadOnly { + mode: 0, + bump: 255, + with_cpi_context: false, + invoking_program_id: create_address_test_program::ID.into(), + proof: proof_result.value.proof.0, + new_address_params: packed_new_address_params, + is_compress: false, + compress_or_decompress_lamports: 0, + output_compressed_accounts: Default::default(), + input_compressed_accounts: Default::default(), + with_transaction_hash: true, + read_only_accounts: Vec::new(), + read_only_addresses: Vec::new(), + cpi_context: Default::default(), + }; + + let remaining_accounts_metas = to_account_metas(remaining_accounts); + + // Create the instruction using the test program + let instruction = create_invoke_cpi_instruction( + payer.pubkey(), + [ + light_system_program::instruction::InvokeCpiWithReadOnly::DISCRIMINATOR.to_vec(), + ix_data.try_to_vec().unwrap(), + ] + .concat(), + remaining_accounts_metas, + None, + ); + + let instructions = vec![ + solana_sdk::compute_budget::ComputeBudgetInstruction::set_compute_unit_limit( + COMPUTE_BUDGET_LIMIT, + ), + instruction, + ]; + let address_sig = rpc + .create_and_send_transaction(&instructions, &payer.pubkey(), &[&payer]) + .await + .unwrap(); + println!( + "Registered v2 address: {} (sig: {})", + hex::encode(derived_address), + address_sig + ); + + // ============ Scenario 4: Decompressed Mint (CreateMint with rent_payment=0) ============ + // This creates a compressed mint that is immediately decompressed to an on-chain CMint account. + // The compressed account only contains the 32-byte mint_pda reference (DECOMPRESSED_PDA_DISCRIMINATOR). + // Full mint data is on-chain in the CMint account owned by LIGHT_TOKEN_PROGRAM_ID. + println!("\n=== Creating decompressed mint (on-chain CMint) ==="); + + let decompressed_mint_seed = Keypair::new(); + let output_queue = rpc.get_random_state_tree_info().unwrap().queue; + + // Use v2 address tree for compressed mints + let mint_address_tree = rpc.get_address_tree_v2(); + + // Derive compression address for decompressed mint + let decompressed_mint_compression_address = + derive_mint_compressed_address(&decompressed_mint_seed.pubkey(), &mint_address_tree.tree); + + let (decompressed_mint_pda, decompressed_mint_bump) = + find_mint_address(&decompressed_mint_seed.pubkey()); + + // Get validity proof for the address + let rpc_result = rpc + .get_validity_proof( + vec![], + vec![AddressWithTree { + address: decompressed_mint_compression_address, + tree: mint_address_tree.tree, + }], + None, + ) + .await + .unwrap() + .value; + + // Create decompressed mint (CreateMint always creates both compressed + on-chain CMint) + let decompressed_mint_params = CreateMintParams { + decimals: 6, + address_merkle_tree_root_index: rpc_result.addresses[0].root_index, + mint_authority: payer.pubkey(), + proof: rpc_result.proof.0.unwrap(), + compression_address: decompressed_mint_compression_address, + mint: decompressed_mint_pda, + bump: decompressed_mint_bump, + freeze_authority: None, + extensions: None, + rent_payment: 0, // Immediately compressible + write_top_up: 0, + }; + + let create_decompressed_mint_builder = CreateMint::new( + decompressed_mint_params, + decompressed_mint_seed.pubkey(), + payer.pubkey(), + mint_address_tree.tree, + output_queue, + ); + let ix = create_decompressed_mint_builder.instruction().unwrap(); + + let (blockhash, _) = rpc.get_latest_blockhash().await.unwrap(); + let tx = Transaction::new_signed_with_payer( + &[ix], + Some(&payer.pubkey()), + &[&payer, &decompressed_mint_seed], + blockhash, + ); + let decompressed_mint_sig = rpc.process_transaction(tx).await.unwrap(); + println!( + "Created decompressed mint (CMint on-chain): {} (sig: {})", + decompressed_mint_pda, decompressed_mint_sig + ); + + // Wait for indexer to process + sleep(Duration::from_secs(3)).await; + + // ============ Scenario 5: Fully Compressed Mint (CreateMint + CompressAndCloseMint) ============ + // This creates a compressed mint and then compresses it, so full mint data is in the compressed DB. + // This is for testing getMintInterface cold path (no on-chain data needed). + println!("\n=== Creating fully compressed mint ==="); + + let compressed_mint_seed = Keypair::new(); + + // Derive compression address for fully compressed mint + let compressed_mint_compression_address = + derive_mint_compressed_address(&compressed_mint_seed.pubkey(), &mint_address_tree.tree); + + let (compressed_mint_pda, compressed_mint_bump) = + find_mint_address(&compressed_mint_seed.pubkey()); + + // Get validity proof for the new address + let rpc_result = rpc + .get_validity_proof( + vec![], + vec![AddressWithTree { + address: compressed_mint_compression_address, + tree: mint_address_tree.tree, + }], + None, + ) + .await + .unwrap() + .value; + + // Create compressed mint (will be decompressed initially) + let compressed_mint_params = CreateMintParams { + decimals: 9, + address_merkle_tree_root_index: rpc_result.addresses[0].root_index, + mint_authority: payer.pubkey(), + proof: rpc_result.proof.0.unwrap(), + compression_address: compressed_mint_compression_address, + mint: compressed_mint_pda, + bump: compressed_mint_bump, + freeze_authority: Some(payer.pubkey()), // Add freeze authority for variety + extensions: None, + rent_payment: 0, // Immediately compressible + write_top_up: 0, + }; + + let create_compressed_mint_builder = CreateMint::new( + compressed_mint_params, + compressed_mint_seed.pubkey(), + payer.pubkey(), + mint_address_tree.tree, + output_queue, + ); + let ix = create_compressed_mint_builder.instruction().unwrap(); + + let (blockhash, _) = rpc.get_latest_blockhash().await.unwrap(); + let tx = Transaction::new_signed_with_payer( + &[ix], + Some(&payer.pubkey()), + &[&payer, &compressed_mint_seed], + blockhash, + ); + let create_mint_sig = rpc.process_transaction(tx).await.unwrap(); + println!( + "Created mint (step 1/2): {} (sig: {})", + compressed_mint_pda, create_mint_sig + ); + + // Wait for indexer to process the CreateMint + sleep(Duration::from_secs(3)).await; + wait_for_indexer(&mut rpc).await.unwrap(); + + // Now compress and close the mint to make it fully compressed + println!("Compressing mint via CompressAndCloseMint..."); + + let compress_params = MintActionParams { + compressed_mint_address: compressed_mint_compression_address, + mint_seed: compressed_mint_seed.pubkey(), + authority: payer.pubkey(), + payer: payer.pubkey(), + actions: vec![MintActionType::CompressAndCloseMint { idempotent: false }], + new_mint: None, + }; + + let compress_ix = create_mint_action_instruction(&mut rpc, compress_params) + .await + .expect("Failed to create CompressAndCloseMint instruction"); + + let (blockhash, _) = rpc.get_latest_blockhash().await.unwrap(); + let tx = Transaction::new_signed_with_payer( + &[compress_ix], + Some(&payer.pubkey()), + &[&payer], + blockhash, + ); + let compress_mint_sig = rpc.process_transaction(tx).await.unwrap(); + println!( + "Compressed mint (step 2/2): {} (sig: {})", + compressed_mint_pda, compress_mint_sig + ); + + // Wait for indexer to process + sleep(Duration::from_secs(3)).await; + + // ============ Scenario 6: Compressible Token Account ============ + println!("\n=== Creating compressible token account ==="); + + let compressible_owner = Keypair::new(); + rpc.airdrop_lamports(&compressible_owner.pubkey(), 1_000_000_000) + .await + .expect("Failed to airdrop to compressible owner"); + + let compressible_token_account = create_compressible_token_account( + &mut rpc, + CreateCompressibleTokenAccountInputs { + owner: compressible_owner.pubkey(), + mint: mint_pubkey, + num_prepaid_epochs: 2, + payer: &payer, + token_account_keypair: None, + lamports_per_write: Some(100), + token_account_version: TokenDataVersion::ShaFlat, + }, + ) + .await + .expect("Failed to create compressible token account"); + println!( + "Created compressible token account: {}", + compressible_token_account + ); + println!("Compressible owner: {}", compressible_owner.pubkey()); + + // ============ Summary ============ + println!("\n========== ADDRESSES SUMMARY ==========\n"); + println!("SPL Mint: {}", mint_pubkey); + println!("Registered v2 Address: {}", hex::encode(derived_address)); + println!( + "Decompressed Mint PDA (on-chain CMint): {}", + decompressed_mint_pda + ); + println!( + "Decompressed Mint Address: {:?}", + decompressed_mint_compression_address + ); + println!( + "Fully Compressed Mint PDA (in compressed DB): {}", + compressed_mint_pda + ); + println!( + "Fully Compressed Mint Address: {:?}", + compressed_mint_compression_address + ); + println!("Bob (compressed token holder): {}", bob.pubkey()); + println!("Charlie (compressed token holder): {}", charlie.pubkey()); + println!("Compressible owner: {}", compressible_owner.pubkey()); + println!("Compressible token account: {}", compressible_token_account); + + // ============ Test Interface Endpoints ============ + println!("\n========== TESTING INTERFACE ENDPOINTS ==========\n"); + + // Create PhotonIndexer to test the interface endpoints + let photon_indexer = PhotonIndexer::new("http://localhost:8784".to_string(), None); + + // Wait for indexer to sync + sleep(Duration::from_secs(3)).await; + wait_for_indexer(&mut rpc).await.unwrap(); + + // ============ Test 1: getMintInterface with decompressed mint (on-chain CMint) ============ + println!("Test 1: getMintInterface with decompressed mint (on-chain CMint)..."); + let decompressed_mint_interface = photon_indexer + .get_mint_interface(&decompressed_mint_pda, None) + .await + .expect("getMintInterface should not error for decompressed mint") + .value + .expect("Decompressed mint should be found"); + + assert_eq!( + decompressed_mint_interface.account.resolved_from, + ResolvedFrom::Onchain, + "Decompressed mint should be resolved from on-chain" + ); + assert!( + decompressed_mint_interface + .account + .compressed_context + .is_none(), + "On-chain mint should not have compressed context" + ); + assert_eq!( + decompressed_mint_interface.mint_data.decimals, 6, + "Decompressed mint decimals should be 6" + ); + assert_eq!( + decompressed_mint_interface.mint_data.mint_pda, decompressed_mint_pda, + "Mint PDA should match the queried address" + ); + println!(" PASSED: Decompressed mint resolved from on-chain with correct data"); + + // ============ Test 2: getMintInterface with fully compressed mint (compressed DB) ============ + println!("\nTest 2: getMintInterface with fully compressed mint (compressed DB)..."); + let compressed_mint_interface = photon_indexer + .get_mint_interface(&compressed_mint_pda, None) + .await + .expect("getMintInterface should not error for compressed mint") + .value + .expect("Compressed mint should be found"); + + assert_eq!( + compressed_mint_interface.account.resolved_from, + ResolvedFrom::Compressed, + "Fully compressed mint should be resolved from compressed DB" + ); + assert!( + compressed_mint_interface + .account + .compressed_context + .is_some(), + "Compressed mint should have compressed context" + ); + assert_eq!( + compressed_mint_interface.mint_data.decimals, 9, + "Compressed mint decimals should be 9" + ); + assert_eq!( + compressed_mint_interface.mint_data.freeze_authority, + Some(payer.pubkey()), + "Compressed mint freeze authority should match" + ); + assert_eq!( + compressed_mint_interface.mint_data.mint_pda, compressed_mint_pda, + "Mint PDA should match the queried address" + ); + println!(" PASSED: Compressed mint resolved from DB with correct data"); + + // ============ Test 3: getAccountInterface with compressible token account (on-chain) ============ + println!("\nTest 3: getAccountInterface with compressible token account (on-chain)..."); + let compressible_account_interface = photon_indexer + .get_account_interface(&compressible_token_account, None) + .await + .expect("getAccountInterface should not error for compressible account") + .value + .expect("Compressible token account should be found"); + + assert_eq!( + compressible_account_interface.resolved_from, + ResolvedFrom::Onchain, + "Compressible account should be resolved from on-chain" + ); + assert!( + compressible_account_interface.compressed_context.is_none(), + "On-chain account should not have compressed context" + ); + assert_eq!( + compressible_account_interface.address, compressible_token_account, + "Address should match the queried address" + ); + println!(" PASSED: Compressible account resolved from on-chain"); + + // ============ Test 4: getTokenAccountInterface with compressible token account (on-chain) ============ + println!("\nTest 4: getTokenAccountInterface with compressible token account (on-chain)..."); + let compressible_token_interface = photon_indexer + .get_token_account_interface(&compressible_token_account, None) + .await + .expect("getTokenAccountInterface should not error") + .value + .expect("Compressible token account should be found via token interface"); + + assert_eq!( + compressible_token_interface.account.resolved_from, + ResolvedFrom::Onchain, + "Token account should be resolved from on-chain" + ); + assert_eq!( + compressible_token_interface.token.mint, mint_pubkey, + "Token mint should match SPL mint" + ); + assert_eq!( + compressible_token_interface.token.owner, + compressible_owner.pubkey(), + "Token owner should match compressible owner" + ); + println!(" PASSED: Token account interface resolved with correct token data"); + + // ============ Test 5: getAtaInterface (Light Protocol ATA derivation) ============ + // Note: This tests ATA derivation - we didn't create a Light ATA so it should return None + println!("\nTest 5: getAtaInterface (Light Protocol ATA derivation)..."); + let ata_result = photon_indexer + .get_ata_interface(&compressible_owner.pubkey(), &mint_pubkey, None) + .await + .expect("getAtaInterface should not error"); + + // We didn't create a Light ATA for this owner/mint combo, so it should be None + assert!( + ata_result.value.is_none(), + "ATA should not be found (no Light ATA was created for this owner/mint)" + ); + println!(" PASSED: ATA correctly returns None when not created"); + + // ============ Test 6: getMultipleAccountInterfaces batch lookup ============ + println!("\nTest 6: getMultipleAccountInterfaces batch lookup..."); + let batch_addresses = vec![&decompressed_mint_pda, &compressible_token_account]; + + let batch_response = photon_indexer + .get_multiple_account_interfaces(batch_addresses.clone(), None) + .await + .expect("getMultipleAccountInterfaces should not error"); + + assert_eq!( + batch_response.value.len(), + 2, + "Batch response should have exactly 2 results" + ); + + // First result: decompressed mint + let batch_mint = batch_response.value[0] + .as_ref() + .expect("Decompressed mint should be found in batch"); + assert_eq!( + batch_mint.resolved_from, + ResolvedFrom::Onchain, + "Batch mint should be resolved from on-chain" + ); + assert_eq!( + batch_mint.address, decompressed_mint_pda, + "Batch mint address should match" + ); + + // Second result: compressible token account + let batch_token = batch_response.value[1] + .as_ref() + .expect("Compressible account should be found in batch"); + assert_eq!( + batch_token.resolved_from, + ResolvedFrom::Onchain, + "Batch token account should be resolved from on-chain" + ); + assert_eq!( + batch_token.address, compressible_token_account, + "Batch token account address should match" + ); + println!(" PASSED: Batch lookup returned correct results"); + + // ============ Test 7: Consistency between getMintInterface and getAccountInterface ============ + println!("\nTest 7: Consistency between getMintInterface and getAccountInterface..."); + let mint_via_mint = photon_indexer + .get_mint_interface(&decompressed_mint_pda, None) + .await + .expect("getMintInterface should succeed") + .value + .expect("Mint should be found via getMintInterface"); + + let mint_via_account = photon_indexer + .get_account_interface(&decompressed_mint_pda, None) + .await + .expect("getAccountInterface should succeed") + .value + .expect("Mint should be found via getAccountInterface"); + + assert_eq!( + mint_via_mint.account.address, mint_via_account.address, + "Addresses should match between interfaces" + ); + assert_eq!( + mint_via_mint.account.lamports, mint_via_account.lamports, + "Lamports should match between interfaces" + ); + assert_eq!( + mint_via_mint.account.resolved_from, mint_via_account.resolved_from, + "Resolved source should match between interfaces" + ); + assert_eq!( + mint_via_mint.account.data, mint_via_account.data, + "Data should match between interfaces" + ); + println!(" PASSED: Consistency verified between getMintInterface and getAccountInterface"); + + // ============ Test 8: Verify fully compressed mint via getAccountInterface returns None ============ + // Fully compressed mints (after CompressAndCloseMint) have full mint data in the compressed DB. + // Their address column contains the compression_address, not the mint_pda. + // Since they don't have the [255; 8] discriminator, onchain_pubkey is not set. + // Therefore getAccountInterface by mint_pda should return None (use getMintInterface instead). + println!("\nTest 8: getAccountInterface with fully compressed mint PDA..."); + let compressed_via_account = photon_indexer + .get_account_interface(&compressed_mint_pda, None) + .await + .expect("getAccountInterface should not error"); + + assert!( + compressed_via_account.value.is_none(), + "Fully compressed mint should NOT be found via getAccountInterface (use getMintInterface)" + ); + println!(" PASSED: Fully compressed mint correctly returns None via getAccountInterface"); + + // ============ Test 9: Verify decompressed mint found via getAccountInterface (generic linking) ============ + // Decompressed mints have discriminator [255; 8] + 32-byte mint_pda in data. + // The generic linking feature extracts this as onchain_pubkey during ingestion. + // Therefore getAccountInterface(mint_pda) should find it via onchain_pubkey column. + println!("\nTest 9: getAccountInterface with decompressed mint PDA (generic linking)..."); + let decompressed_via_account = photon_indexer + .get_account_interface(&decompressed_mint_pda, None) + .await + .expect("getAccountInterface should not error"); + + let decompressed_account = decompressed_via_account + .value + .expect("Decompressed mint should be found via getAccountInterface (generic linking)"); + + // The decompressed mint should be found from on-chain (CMint account exists) + assert_eq!( + decompressed_account.resolved_from, + ResolvedFrom::Onchain, + "Decompressed mint via getAccountInterface should resolve from on-chain" + ); + assert_eq!( + decompressed_account.address, decompressed_mint_pda, + "Address should match the queried mint PDA" + ); + println!(" PASSED: Decompressed mint found via getAccountInterface with generic linking"); + + println!("\n========== ALL TESTS PASSED =========="); + println!("\nTo export transactions, run:"); + println!("cargo xtask export-photon-test-data --test-name indexer_interface"); +} diff --git a/scripts/devenv.sh b/scripts/devenv.sh index 605bce12b7..a656a8de25 100755 --- a/scripts/devenv.sh +++ b/scripts/devenv.sh @@ -74,8 +74,11 @@ if [ -z "${CI:-}" ]; then alias light="${LIGHT_PROTOCOL_TOPLEVEL}/cli/test_bin/run" fi -# Define GOROOT for Go. export GOROOT="${LIGHT_PROTOCOL_TOPLEVEL}/.local/go" +export GOTOOLCHAIN=local +unset GOBIN +# Disable mise entirely to prevent its hooks from overriding our paths. +export MISE_DISABLED=1 # Ensure Rust binaries are in PATH PATH="${CARGO_HOME}/bin:${PATH}" diff --git a/scripts/devenv/versions.sh b/scripts/devenv/versions.sh index 450a81bb34..ebfc690d7d 100755 --- a/scripts/devenv/versions.sh +++ b/scripts/devenv/versions.sh @@ -13,7 +13,7 @@ export SOLANA_VERSION="2.2.15" export ANCHOR_VERSION="0.31.1" export JQ_VERSION="1.8.0" export PHOTON_VERSION="0.51.2" -export PHOTON_COMMIT="254d66715876f39702d4ab9b5a518e78023fa27f" +export PHOTON_COMMIT="4c809c9feccdd737e72f55744d963e90fafbe45e" export REDIS_VERSION="8.0.1" export ANCHOR_TAG="anchor-v${ANCHOR_VERSION}" diff --git a/sdk-libs/client/src/indexer/mod.rs b/sdk-libs/client/src/indexer/mod.rs index 48b92ef7fb..3b37f3cd0a 100644 --- a/sdk-libs/client/src/indexer/mod.rs +++ b/sdk-libs/client/src/indexer/mod.rs @@ -14,11 +14,12 @@ pub use error::IndexerError; pub use indexer_trait::Indexer; pub use response::{Context, Items, ItemsWithCursor, Response}; pub use types::{ - AccountProofInputs, Address, AddressMerkleTreeAccounts, AddressProofInputs, AddressQueueData, - AddressWithTree, CompressedAccount, CompressedMint, CompressedTokenAccount, Hash, - InputQueueData, MerkleProof, MerkleProofWithContext, MintData, NewAddressProofWithContext, - NextTreeInfo, OutputQueueData, OwnerBalance, ProofOfLeaf, QueueElementsResult, QueueInfo, - QueueInfoResult, RootIndex, SignatureWithMetadata, StateMerkleTreeAccounts, StateQueueData, + AccountInterface, AccountProofInputs, Address, AddressMerkleTreeAccounts, AddressProofInputs, + AddressQueueData, AddressWithTree, CompressedAccount, CompressedContext, CompressedMint, + CompressedTokenAccount, Hash, InputQueueData, MerkleProof, MerkleProofWithContext, MintData, + MintInterface, NewAddressProofWithContext, NextTreeInfo, OutputQueueData, OwnerBalance, + ProofOfLeaf, QueueElementsResult, QueueInfo, QueueInfoResult, ResolvedFrom, RootIndex, + SignatureWithMetadata, StateMerkleTreeAccounts, StateQueueData, TokenAccountInterface, TokenBalance, TreeInfo, ValidityProofWithContext, }; mod options; diff --git a/sdk-libs/client/src/indexer/photon_indexer.rs b/sdk-libs/client/src/indexer/photon_indexer.rs index aecdd9b632..e815de8da8 100644 --- a/sdk-libs/client/src/indexer/photon_indexer.rs +++ b/sdk-libs/client/src/indexer/photon_indexer.rs @@ -10,8 +10,8 @@ use solana_pubkey::Pubkey; use tracing::{error, trace, warn}; use super::types::{ - CompressedAccount, CompressedMint, CompressedTokenAccount, OwnerBalance, SignatureWithMetadata, - TokenBalance, + AccountInterface, CompressedAccount, CompressedMint, CompressedTokenAccount, MintInterface, + OwnerBalance, SignatureWithMetadata, TokenAccountInterface, TokenBalance, }; use crate::indexer::{ base58::Base58Conversions, @@ -1934,3 +1934,242 @@ impl Indexer for PhotonIndexer { .await } } + +// ============ Interface Methods ============ +// These methods use the Interface endpoints that race hot (on-chain) and cold (compressed) lookups + +impl PhotonIndexer { + /// Get account data from either on-chain or compressed sources. + /// Races both lookups and returns the result with the higher slot. + pub async fn get_account_interface( + &self, + address: &Pubkey, + config: Option, + ) -> Result>, IndexerError> { + let config = config.unwrap_or_default(); + self.retry(config.retry_config, || async { + let request = photon_api::models::GetAccountInterfacePostRequest::new( + photon_api::models::GetAccountInterfacePostRequestParams::new(address.to_string()), + ); + + let result = photon_api::apis::default_api::get_account_interface_post( + &self.configuration, + request, + ) + .await?; + + let api_response = Self::extract_result_with_error_check( + "get_account_interface", + result.error, + result.result.map(|r| *r), + )?; + + if api_response.context.slot < config.slot { + return Err(IndexerError::IndexerNotSyncedToSlot); + } + + let account = match api_response.value { + Some(boxed) => Some(AccountInterface::try_from(boxed.as_ref())?), + None => None, + }; + + Ok(Response { + context: Context { + slot: api_response.context.slot, + }, + value: account, + }) + }) + .await + } + + /// Get token account data from either on-chain or compressed sources. + /// Races both lookups and returns the result with the higher slot. + pub async fn get_token_account_interface( + &self, + address: &Pubkey, + config: Option, + ) -> Result>, IndexerError> { + let config = config.unwrap_or_default(); + self.retry(config.retry_config, || async { + let request = photon_api::models::GetTokenAccountInterfacePostRequest::new( + photon_api::models::GetTokenAccountInterfacePostRequestParams::new( + address.to_string(), + ), + ); + + let result = photon_api::apis::default_api::get_token_account_interface_post( + &self.configuration, + request, + ) + .await?; + + let api_response = Self::extract_result_with_error_check( + "get_token_account_interface", + result.error, + result.result.map(|r| *r), + )?; + + if api_response.context.slot < config.slot { + return Err(IndexerError::IndexerNotSyncedToSlot); + } + + let account = match api_response.value { + Some(boxed) => Some(TokenAccountInterface::try_from(boxed.as_ref())?), + None => None, + }; + + Ok(Response { + context: Context { + slot: api_response.context.slot, + }, + value: account, + }) + }) + .await + } + + /// Get Associated Token Account data from either on-chain or compressed sources. + /// Derives the Light Protocol ATA address from owner+mint, then races hot/cold lookups. + pub async fn get_ata_interface( + &self, + owner: &Pubkey, + mint: &Pubkey, + config: Option, + ) -> Result>, IndexerError> { + let config = config.unwrap_or_default(); + self.retry(config.retry_config, || async { + let request = photon_api::models::GetAtaInterfacePostRequest::new( + photon_api::models::GetAtaInterfacePostRequestParams::new( + owner.to_string(), + mint.to_string(), + ), + ); + + let result = + photon_api::apis::default_api::get_ata_interface_post(&self.configuration, request) + .await?; + + let api_response = Self::extract_result_with_error_check( + "get_ata_interface", + result.error, + result.result.map(|r| *r), + )?; + + if api_response.context.slot < config.slot { + return Err(IndexerError::IndexerNotSyncedToSlot); + } + + let account = match api_response.value { + Some(boxed) => Some(TokenAccountInterface::try_from(boxed.as_ref())?), + None => None, + }; + + Ok(Response { + context: Context { + slot: api_response.context.slot, + }, + value: account, + }) + }) + .await + } + + /// Get mint data from either on-chain or compressed sources. + /// Races both lookups and returns the result with the higher slot. + pub async fn get_mint_interface( + &self, + address: &Pubkey, + config: Option, + ) -> Result>, IndexerError> { + let config = config.unwrap_or_default(); + self.retry(config.retry_config, || async { + let request = photon_api::models::GetMintInterfacePostRequest::new( + photon_api::models::GetMintInterfacePostRequestParams::new(address.to_string()), + ); + + let result = photon_api::apis::default_api::get_mint_interface_post( + &self.configuration, + request, + ) + .await?; + + let api_response = Self::extract_result_with_error_check( + "get_mint_interface", + result.error, + result.result.map(|r| *r), + )?; + + if api_response.context.slot < config.slot { + return Err(IndexerError::IndexerNotSyncedToSlot); + } + + let mint = match api_response.value { + Some(boxed) => Some(MintInterface::try_from(boxed.as_ref())?), + None => None, + }; + + Ok(Response { + context: Context { + slot: api_response.context.slot, + }, + value: mint, + }) + }) + .await + } + + /// Get multiple account interfaces in a batch. + /// Returns a vector where each element corresponds to an input address. + pub async fn get_multiple_account_interfaces( + &self, + addresses: Vec<&Pubkey>, + config: Option, + ) -> Result>>, IndexerError> { + let config = config.unwrap_or_default(); + self.retry(config.retry_config, || async { + let address_strings: Vec = + addresses.iter().map(|addr| addr.to_string()).collect(); + + let request = photon_api::models::GetMultipleAccountInterfacesPostRequest::new( + photon_api::models::GetMultipleAccountInterfacesPostRequestParams::new( + address_strings, + ), + ); + + let result = photon_api::apis::default_api::get_multiple_account_interfaces_post( + &self.configuration, + request, + ) + .await?; + + let api_response = Self::extract_result_with_error_check( + "get_multiple_account_interfaces", + result.error, + result.result.map(|r| *r), + )?; + + if api_response.context.slot < config.slot { + return Err(IndexerError::IndexerNotSyncedToSlot); + } + + let accounts: Result>, IndexerError> = api_response + .value + .into_iter() + .map(|maybe_acc| { + maybe_acc + .map(|ai| AccountInterface::try_from(&ai)) + .transpose() + }) + .collect(); + + Ok(Response { + context: Context { + slot: api_response.context.slot, + }, + value: accounts?, + }) + }) + .await + } +} diff --git a/sdk-libs/client/src/indexer/types.rs b/sdk-libs/client/src/indexer/types.rs index 744d24d317..cbff55dae4 100644 --- a/sdk-libs/client/src/indexer/types.rs +++ b/sdk-libs/client/src/indexer/types.rs @@ -1113,3 +1113,229 @@ impl TryFrom<&photon_api::models::CompressedMint> for CompressedMint { Ok(CompressedMint { mint, account }) } } + +// ============ Interface Types ============ +// These types are used by the Interface endpoints that race hot (on-chain) and cold (compressed) lookups + +/// Indicates the source of the resolved account data +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum ResolvedFrom { + /// Account data comes from on-chain (hot) lookup + Onchain, + /// Account data comes from compressed (cold) lookup + Compressed, +} + +impl TryFrom for ResolvedFrom { + type Error = IndexerError; + + fn try_from(value: photon_api::models::ResolvedFrom) -> Result { + match value { + photon_api::models::ResolvedFrom::Onchain => Ok(ResolvedFrom::Onchain), + photon_api::models::ResolvedFrom::Compressed => Ok(ResolvedFrom::Compressed), + } + } +} + +/// Context information for compressed accounts (only present when resolved_from = Compressed) +#[derive(Clone, Debug, PartialEq)] +pub struct CompressedContext { + /// The hash of the compressed account (leaf hash in Merkle tree) + pub hash: [u8; 32], + /// The Merkle tree address + pub tree: Pubkey, + /// The leaf index in the Merkle tree + pub leaf_index: u64, + /// Sequence number (None if in output queue, Some once inserted into Merkle tree) + pub seq: Option, + /// Whether the account can be proven by index (in output queue) + pub prove_by_index: bool, +} + +impl TryFrom<&photon_api::models::CompressedContext> for CompressedContext { + type Error = IndexerError; + + fn try_from(ctx: &photon_api::models::CompressedContext) -> Result { + Ok(CompressedContext { + hash: decode_base58_to_fixed_array(&ctx.hash)?, + tree: Pubkey::new_from_array(decode_base58_to_fixed_array(&ctx.tree)?), + leaf_index: ctx.leaf_index, + seq: ctx.seq, + prove_by_index: ctx.prove_by_index, + }) + } +} + +/// Unified account interface that represents either on-chain or compressed account data +#[derive(Clone, Debug, PartialEq)] +pub struct AccountInterface { + /// The account address + pub address: Pubkey, + /// Account lamports balance + pub lamports: u64, + /// The program owner of this account + pub owner: Pubkey, + /// Account data as bytes + pub data: Vec, + /// Whether the account is executable (always false for compressed) + pub executable: bool, + /// Rent epoch (always 0 for compressed) + pub rent_epoch: u64, + /// Source of the account data + pub resolved_from: ResolvedFrom, + /// Slot at which the account data was resolved + pub resolved_slot: u64, + /// Additional context for compressed accounts (None for on-chain) + pub compressed_context: Option, +} + +impl TryFrom<&photon_api::models::AccountInterface> for AccountInterface { + type Error = IndexerError; + + fn try_from(ai: &photon_api::models::AccountInterface) -> Result { + let compressed_context = ai + .compressed_context + .as_ref() + .map(|ctx| CompressedContext::try_from(ctx.as_ref())) + .transpose()?; + + let data = base64::decode_config(&ai.data, base64::STANDARD_NO_PAD) + .map_err(|_| IndexerError::InvalidResponseData)?; + + Ok(AccountInterface { + address: Pubkey::new_from_array(decode_base58_to_fixed_array(&ai.address)?), + lamports: ai.lamports, + owner: Pubkey::new_from_array(decode_base58_to_fixed_array(&ai.owner)?), + data, + executable: ai.executable, + rent_epoch: ai.rent_epoch, + resolved_from: ResolvedFrom::try_from(ai.resolved_from)?, + resolved_slot: ai.resolved_slot, + compressed_context, + }) + } +} + +impl TryFrom<&photon_api::models::InterfaceResult> for AccountInterface { + type Error = IndexerError; + + fn try_from(ir: &photon_api::models::InterfaceResult) -> Result { + match ir { + photon_api::models::InterfaceResult::Account(ai) => AccountInterface::try_from(ai), + photon_api::models::InterfaceResult::Token(tai) => { + AccountInterface::try_from(&tai.account) + } + photon_api::models::InterfaceResult::Mint(mi) => { + AccountInterface::try_from(&mi.account) + } + } + } +} + +/// Token account interface with parsed token data +#[derive(Clone, Debug, PartialEq)] +pub struct TokenAccountInterface { + /// Base account interface data + pub account: AccountInterface, + /// Parsed token data (same as CompressedTokenAccount.token) + pub token: TokenData, +} + +impl TryFrom<&photon_api::models::TokenAccountInterface> for TokenAccountInterface { + type Error = IndexerError; + + fn try_from(tai: &photon_api::models::TokenAccountInterface) -> Result { + let compressed_context = tai + .account + .compressed_context + .as_ref() + .map(|ctx| CompressedContext::try_from(ctx.as_ref())) + .transpose()?; + + let data = base64::decode_config(&tai.account.data, base64::STANDARD_NO_PAD) + .map_err(|_| IndexerError::InvalidResponseData)?; + + let account = AccountInterface { + address: Pubkey::new_from_array(decode_base58_to_fixed_array(&tai.account.address)?), + lamports: tai.account.lamports, + owner: Pubkey::new_from_array(decode_base58_to_fixed_array(&tai.account.owner)?), + data, + executable: tai.account.executable, + rent_epoch: tai.account.rent_epoch, + resolved_from: ResolvedFrom::try_from(tai.account.resolved_from)?, + resolved_slot: tai.account.resolved_slot, + compressed_context, + }; + + // Parse token data - same pattern as CompressedTokenAccount + let token = TokenData { + mint: Pubkey::new_from_array(decode_base58_to_fixed_array(&tai.token_data.mint)?), + owner: Pubkey::new_from_array(decode_base58_to_fixed_array(&tai.token_data.owner)?), + amount: tai.token_data.amount, + delegate: tai + .token_data + .delegate + .as_ref() + .map(|d| decode_base58_to_fixed_array(d).map(Pubkey::new_from_array)) + .transpose()?, + state: match tai.token_data.state { + photon_api::models::AccountState::Initialized => AccountState::Initialized, + photon_api::models::AccountState::Frozen => AccountState::Frozen, + }, + tlv: tai + .token_data + .tlv + .as_ref() + .map(|tlv| { + let bytes = base64::decode_config(tlv, base64::STANDARD_NO_PAD) + .map_err(|_| IndexerError::InvalidResponseData)?; + Vec::::deserialize(&mut bytes.as_slice()) + .map_err(|_| IndexerError::InvalidResponseData) + }) + .transpose()?, + }; + + Ok(TokenAccountInterface { account, token }) + } +} + +/// Mint account interface with parsed mint data +#[derive(Clone, Debug, PartialEq)] +pub struct MintInterface { + /// Base account interface data + pub account: AccountInterface, + /// Parsed mint data + pub mint_data: MintData, +} + +impl TryFrom<&photon_api::models::MintInterface> for MintInterface { + type Error = IndexerError; + + fn try_from(mi: &photon_api::models::MintInterface) -> Result { + let compressed_context = mi + .account + .compressed_context + .as_ref() + .map(|ctx| CompressedContext::try_from(ctx.as_ref())) + .transpose()?; + + let data = base64::decode_config(&mi.account.data, base64::STANDARD_NO_PAD) + .map_err(|_| IndexerError::InvalidResponseData)?; + + let account = AccountInterface { + address: Pubkey::new_from_array(decode_base58_to_fixed_array(&mi.account.address)?), + lamports: mi.account.lamports, + owner: Pubkey::new_from_array(decode_base58_to_fixed_array(&mi.account.owner)?), + data, + executable: mi.account.executable, + rent_epoch: mi.account.rent_epoch, + resolved_from: ResolvedFrom::try_from(mi.account.resolved_from)?, + resolved_slot: mi.account.resolved_slot, + compressed_context, + }; + + let mint_data = MintData::try_from(&mi.mint_data)?; + + Ok(MintInterface { account, mint_data }) + } +} diff --git a/sdk-libs/photon-api/src/apis/default_api.rs b/sdk-libs/photon-api/src/apis/default_api.rs index b601e87590..797e4a3473 100644 --- a/sdk-libs/photon-api/src/apis/default_api.rs +++ b/sdk-libs/photon-api/src/apis/default_api.rs @@ -367,6 +367,51 @@ pub enum GetCompressedMintsByAuthorityPostError { UnknownValue(serde_json::Value), } +/// struct for typed errors of method [`get_account_interface_post`] +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(untagged)] +pub enum GetAccountInterfacePostError { + Status429(models::GetBatchAddressUpdateInfoPost429Response), + Status500(models::GetBatchAddressUpdateInfoPost429Response), + UnknownValue(serde_json::Value), +} + +/// struct for typed errors of method [`get_token_account_interface_post`] +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(untagged)] +pub enum GetTokenAccountInterfacePostError { + Status429(models::GetBatchAddressUpdateInfoPost429Response), + Status500(models::GetBatchAddressUpdateInfoPost429Response), + UnknownValue(serde_json::Value), +} + +/// struct for typed errors of method [`get_ata_interface_post`] +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(untagged)] +pub enum GetAtaInterfacePostError { + Status429(models::GetBatchAddressUpdateInfoPost429Response), + Status500(models::GetBatchAddressUpdateInfoPost429Response), + UnknownValue(serde_json::Value), +} + +/// struct for typed errors of method [`get_mint_interface_post`] +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(untagged)] +pub enum GetMintInterfacePostError { + Status429(models::GetBatchAddressUpdateInfoPost429Response), + Status500(models::GetBatchAddressUpdateInfoPost429Response), + UnknownValue(serde_json::Value), +} + +/// struct for typed errors of method [`get_multiple_account_interfaces_post`] +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(untagged)] +pub enum GetMultipleAccountInterfacesPostError { + Status429(models::GetBatchAddressUpdateInfoPost429Response), + Status500(models::GetBatchAddressUpdateInfoPost429Response), + UnknownValue(serde_json::Value), +} + pub async fn get_batch_address_update_info_post( configuration: &configuration::Configuration, get_batch_address_update_info_post_request: models::GetBatchAddressUpdateInfoPostRequest, @@ -2100,6 +2145,212 @@ pub async fn get_compressed_mints_by_authority_post( } } +pub async fn get_account_interface_post( + configuration: &configuration::Configuration, + get_account_interface_post_request: models::GetAccountInterfacePostRequest, +) -> Result> { + let local_var_configuration = configuration; + + let local_var_client = &local_var_configuration.client; + + let local_var_uri_str = format!("{}/getAccountInterface", local_var_configuration.base_path); + let local_var_uri_str = append_api_key(local_var_configuration, &local_var_uri_str); + let mut local_var_req_builder = + local_var_client.request(reqwest::Method::POST, local_var_uri_str.as_str()); + + if let Some(ref local_var_user_agent) = local_var_configuration.user_agent { + local_var_req_builder = + local_var_req_builder.header(reqwest::header::USER_AGENT, local_var_user_agent.clone()); + } + local_var_req_builder = local_var_req_builder.json(&get_account_interface_post_request); + + let local_var_req = local_var_req_builder.build()?; + let local_var_resp = local_var_client.execute(local_var_req).await?; + + let local_var_status = local_var_resp.status(); + let local_var_content = local_var_resp.text().await?; + + if !local_var_status.is_client_error() && !local_var_status.is_server_error() { + serde_json::from_str(&local_var_content).map_err(Error::from) + } else { + let local_var_entity: Option = + serde_json::from_str(&local_var_content).ok(); + let local_var_error = ResponseContent { + status: local_var_status, + content: local_var_content, + entity: local_var_entity, + }; + Err(Error::ResponseError(local_var_error)) + } +} + +pub async fn get_token_account_interface_post( + configuration: &configuration::Configuration, + get_token_account_interface_post_request: models::GetTokenAccountInterfacePostRequest, +) -> Result> +{ + let local_var_configuration = configuration; + + let local_var_client = &local_var_configuration.client; + + let local_var_uri_str = format!( + "{}/getTokenAccountInterface", + local_var_configuration.base_path + ); + let local_var_uri_str = append_api_key(local_var_configuration, &local_var_uri_str); + let mut local_var_req_builder = + local_var_client.request(reqwest::Method::POST, local_var_uri_str.as_str()); + + if let Some(ref local_var_user_agent) = local_var_configuration.user_agent { + local_var_req_builder = + local_var_req_builder.header(reqwest::header::USER_AGENT, local_var_user_agent.clone()); + } + local_var_req_builder = local_var_req_builder.json(&get_token_account_interface_post_request); + + let local_var_req = local_var_req_builder.build()?; + let local_var_resp = local_var_client.execute(local_var_req).await?; + + let local_var_status = local_var_resp.status(); + let local_var_content = local_var_resp.text().await?; + + if !local_var_status.is_client_error() && !local_var_status.is_server_error() { + serde_json::from_str(&local_var_content).map_err(Error::from) + } else { + let local_var_entity: Option = + serde_json::from_str(&local_var_content).ok(); + let local_var_error = ResponseContent { + status: local_var_status, + content: local_var_content, + entity: local_var_entity, + }; + Err(Error::ResponseError(local_var_error)) + } +} + +pub async fn get_ata_interface_post( + configuration: &configuration::Configuration, + get_ata_interface_post_request: models::GetAtaInterfacePostRequest, +) -> Result> { + let local_var_configuration = configuration; + + let local_var_client = &local_var_configuration.client; + + let local_var_uri_str = format!("{}/getAtaInterface", local_var_configuration.base_path); + let local_var_uri_str = append_api_key(local_var_configuration, &local_var_uri_str); + let mut local_var_req_builder = + local_var_client.request(reqwest::Method::POST, local_var_uri_str.as_str()); + + if let Some(ref local_var_user_agent) = local_var_configuration.user_agent { + local_var_req_builder = + local_var_req_builder.header(reqwest::header::USER_AGENT, local_var_user_agent.clone()); + } + local_var_req_builder = local_var_req_builder.json(&get_ata_interface_post_request); + + let local_var_req = local_var_req_builder.build()?; + let local_var_resp = local_var_client.execute(local_var_req).await?; + + let local_var_status = local_var_resp.status(); + let local_var_content = local_var_resp.text().await?; + + if !local_var_status.is_client_error() && !local_var_status.is_server_error() { + serde_json::from_str(&local_var_content).map_err(Error::from) + } else { + let local_var_entity: Option = + serde_json::from_str(&local_var_content).ok(); + let local_var_error = ResponseContent { + status: local_var_status, + content: local_var_content, + entity: local_var_entity, + }; + Err(Error::ResponseError(local_var_error)) + } +} + +pub async fn get_mint_interface_post( + configuration: &configuration::Configuration, + get_mint_interface_post_request: models::GetMintInterfacePostRequest, +) -> Result> { + let local_var_configuration = configuration; + + let local_var_client = &local_var_configuration.client; + + let local_var_uri_str = format!("{}/getMintInterface", local_var_configuration.base_path); + let local_var_uri_str = append_api_key(local_var_configuration, &local_var_uri_str); + let mut local_var_req_builder = + local_var_client.request(reqwest::Method::POST, local_var_uri_str.as_str()); + + if let Some(ref local_var_user_agent) = local_var_configuration.user_agent { + local_var_req_builder = + local_var_req_builder.header(reqwest::header::USER_AGENT, local_var_user_agent.clone()); + } + local_var_req_builder = local_var_req_builder.json(&get_mint_interface_post_request); + + let local_var_req = local_var_req_builder.build()?; + let local_var_resp = local_var_client.execute(local_var_req).await?; + + let local_var_status = local_var_resp.status(); + let local_var_content = local_var_resp.text().await?; + + if !local_var_status.is_client_error() && !local_var_status.is_server_error() { + serde_json::from_str(&local_var_content).map_err(Error::from) + } else { + let local_var_entity: Option = + serde_json::from_str(&local_var_content).ok(); + let local_var_error = ResponseContent { + status: local_var_status, + content: local_var_content, + entity: local_var_entity, + }; + Err(Error::ResponseError(local_var_error)) + } +} + +pub async fn get_multiple_account_interfaces_post( + configuration: &configuration::Configuration, + get_multiple_account_interfaces_post_request: models::GetMultipleAccountInterfacesPostRequest, +) -> Result< + models::GetMultipleAccountInterfacesPost200Response, + Error, +> { + let local_var_configuration = configuration; + + let local_var_client = &local_var_configuration.client; + + let local_var_uri_str = format!( + "{}/getMultipleAccountInterfaces", + local_var_configuration.base_path + ); + let local_var_uri_str = append_api_key(local_var_configuration, &local_var_uri_str); + let mut local_var_req_builder = + local_var_client.request(reqwest::Method::POST, local_var_uri_str.as_str()); + + if let Some(ref local_var_user_agent) = local_var_configuration.user_agent { + local_var_req_builder = + local_var_req_builder.header(reqwest::header::USER_AGENT, local_var_user_agent.clone()); + } + local_var_req_builder = + local_var_req_builder.json(&get_multiple_account_interfaces_post_request); + + let local_var_req = local_var_req_builder.build()?; + let local_var_resp = local_var_client.execute(local_var_req).await?; + + let local_var_status = local_var_resp.status(); + let local_var_content = local_var_resp.text().await?; + + if !local_var_status.is_client_error() && !local_var_status.is_server_error() { + serde_json::from_str(&local_var_content).map_err(Error::from) + } else { + let local_var_entity: Option = + serde_json::from_str(&local_var_content).ok(); + let local_var_error = ResponseContent { + status: local_var_status, + content: local_var_content, + entity: local_var_entity, + }; + Err(Error::ResponseError(local_var_error)) + } +} + fn append_api_key(configuration: &Configuration, uri_str: &str) -> String { let mut uri_str = uri_str.to_string(); if let Some(ref api_key) = configuration.api_key { diff --git a/sdk-libs/photon-api/src/models/_get_account_interface_post_200_response.rs b/sdk-libs/photon-api/src/models/_get_account_interface_post_200_response.rs new file mode 100644 index 0000000000..1dc4692b4f --- /dev/null +++ b/sdk-libs/photon-api/src/models/_get_account_interface_post_200_response.rs @@ -0,0 +1,35 @@ +/* + * photon-indexer + * + * Solana indexer for general compression + * + * The version of the OpenAPI document: 0.50.0 + * + */ + +use crate::models; + +#[derive(Clone, Default, Debug, PartialEq, Serialize, Deserialize)] +pub struct GetAccountInterfacePost200Response { + #[serde(rename = "error", skip_serializing_if = "Option::is_none")] + pub error: Option>, + /// An ID to identify the response. + #[serde(rename = "id")] + pub id: String, + /// The version of the JSON-RPC protocol. + #[serde(rename = "jsonrpc")] + pub jsonrpc: String, + #[serde(rename = "result", skip_serializing_if = "Option::is_none")] + pub result: Option>, +} + +impl GetAccountInterfacePost200Response { + pub fn new(id: String, jsonrpc: String) -> Self { + Self { + error: None, + id, + jsonrpc, + result: None, + } + } +} diff --git a/sdk-libs/photon-api/src/models/_get_account_interface_post_200_response_result.rs b/sdk-libs/photon-api/src/models/_get_account_interface_post_200_response_result.rs new file mode 100644 index 0000000000..e59fae1529 --- /dev/null +++ b/sdk-libs/photon-api/src/models/_get_account_interface_post_200_response_result.rs @@ -0,0 +1,27 @@ +/* + * photon-indexer + * + * Solana indexer for general compression + * + * The version of the OpenAPI document: 0.50.0 + * + */ + +use crate::models; + +#[derive(Clone, Default, Debug, PartialEq, Serialize, Deserialize)] +pub struct GetAccountInterfacePost200ResponseResult { + #[serde(rename = "context")] + pub context: Box, + #[serde(rename = "value", skip_serializing_if = "Option::is_none")] + pub value: Option>, +} + +impl GetAccountInterfacePost200ResponseResult { + pub fn new(context: models::Context) -> Self { + Self { + context: Box::new(context), + value: None, + } + } +} diff --git a/sdk-libs/photon-api/src/models/_get_account_interface_post_request.rs b/sdk-libs/photon-api/src/models/_get_account_interface_post_request.rs new file mode 100644 index 0000000000..dd4cc0e843 --- /dev/null +++ b/sdk-libs/photon-api/src/models/_get_account_interface_post_request.rs @@ -0,0 +1,36 @@ +/* + * photon-indexer + * + * Solana indexer for general compression + * + * The version of the OpenAPI document: 0.50.0 + * + */ + +use crate::models; + +#[derive(Clone, Default, Debug, PartialEq, Serialize, Deserialize)] +pub struct GetAccountInterfacePostRequest { + /// An ID to identify the request. + #[serde(rename = "id")] + pub id: String, + /// The version of the JSON-RPC protocol. + #[serde(rename = "jsonrpc")] + pub jsonrpc: String, + /// The name of the method to invoke. + #[serde(rename = "method")] + pub method: String, + #[serde(rename = "params")] + pub params: Box, +} + +impl GetAccountInterfacePostRequest { + pub fn new(params: models::GetAccountInterfacePostRequestParams) -> Self { + Self { + id: "test-id".to_string(), + jsonrpc: "2.0".to_string(), + method: "getAccountInterface".to_string(), + params: Box::new(params), + } + } +} diff --git a/sdk-libs/photon-api/src/models/_get_account_interface_post_request_params.rs b/sdk-libs/photon-api/src/models/_get_account_interface_post_request_params.rs new file mode 100644 index 0000000000..d3b6238808 --- /dev/null +++ b/sdk-libs/photon-api/src/models/_get_account_interface_post_request_params.rs @@ -0,0 +1,22 @@ +/* + * photon-indexer + * + * Solana indexer for general compression + * + * The version of the OpenAPI document: 0.50.0 + * + */ + +/// GetAccountInterfacePostRequestParams : Request parameters for getAccountInterface +#[derive(Clone, Default, Debug, PartialEq, Serialize, Deserialize)] +pub struct GetAccountInterfacePostRequestParams { + /// Account address to look up + #[serde(rename = "address")] + pub address: String, +} + +impl GetAccountInterfacePostRequestParams { + pub fn new(address: String) -> Self { + Self { address } + } +} diff --git a/sdk-libs/photon-api/src/models/_get_ata_interface_post_200_response.rs b/sdk-libs/photon-api/src/models/_get_ata_interface_post_200_response.rs new file mode 100644 index 0000000000..5c7709912a --- /dev/null +++ b/sdk-libs/photon-api/src/models/_get_ata_interface_post_200_response.rs @@ -0,0 +1,35 @@ +/* + * photon-indexer + * + * Solana indexer for general compression + * + * The version of the OpenAPI document: 0.50.0 + * + */ + +use crate::models; + +#[derive(Clone, Default, Debug, PartialEq, Serialize, Deserialize)] +pub struct GetAtaInterfacePost200Response { + #[serde(rename = "error", skip_serializing_if = "Option::is_none")] + pub error: Option>, + /// An ID to identify the response. + #[serde(rename = "id")] + pub id: String, + /// The version of the JSON-RPC protocol. + #[serde(rename = "jsonrpc")] + pub jsonrpc: String, + #[serde(rename = "result", skip_serializing_if = "Option::is_none")] + pub result: Option>, +} + +impl GetAtaInterfacePost200Response { + pub fn new(id: String, jsonrpc: String) -> Self { + Self { + error: None, + id, + jsonrpc, + result: None, + } + } +} diff --git a/sdk-libs/photon-api/src/models/_get_ata_interface_post_200_response_result.rs b/sdk-libs/photon-api/src/models/_get_ata_interface_post_200_response_result.rs new file mode 100644 index 0000000000..f477f54d09 --- /dev/null +++ b/sdk-libs/photon-api/src/models/_get_ata_interface_post_200_response_result.rs @@ -0,0 +1,27 @@ +/* + * photon-indexer + * + * Solana indexer for general compression + * + * The version of the OpenAPI document: 0.50.0 + * + */ + +use crate::models; + +#[derive(Clone, Default, Debug, PartialEq, Serialize, Deserialize)] +pub struct GetAtaInterfacePost200ResponseResult { + #[serde(rename = "context")] + pub context: Box, + #[serde(rename = "value", skip_serializing_if = "Option::is_none")] + pub value: Option>, +} + +impl GetAtaInterfacePost200ResponseResult { + pub fn new(context: models::Context) -> Self { + Self { + context: Box::new(context), + value: None, + } + } +} diff --git a/sdk-libs/photon-api/src/models/_get_ata_interface_post_request.rs b/sdk-libs/photon-api/src/models/_get_ata_interface_post_request.rs new file mode 100644 index 0000000000..81c36f3981 --- /dev/null +++ b/sdk-libs/photon-api/src/models/_get_ata_interface_post_request.rs @@ -0,0 +1,36 @@ +/* + * photon-indexer + * + * Solana indexer for general compression + * + * The version of the OpenAPI document: 0.50.0 + * + */ + +use crate::models; + +#[derive(Clone, Default, Debug, PartialEq, Serialize, Deserialize)] +pub struct GetAtaInterfacePostRequest { + /// An ID to identify the request. + #[serde(rename = "id")] + pub id: String, + /// The version of the JSON-RPC protocol. + #[serde(rename = "jsonrpc")] + pub jsonrpc: String, + /// The name of the method to invoke. + #[serde(rename = "method")] + pub method: String, + #[serde(rename = "params")] + pub params: Box, +} + +impl GetAtaInterfacePostRequest { + pub fn new(params: models::GetAtaInterfacePostRequestParams) -> Self { + Self { + id: "test-id".to_string(), + jsonrpc: "2.0".to_string(), + method: "getAtaInterface".to_string(), + params: Box::new(params), + } + } +} diff --git a/sdk-libs/photon-api/src/models/_get_ata_interface_post_request_params.rs b/sdk-libs/photon-api/src/models/_get_ata_interface_post_request_params.rs new file mode 100644 index 0000000000..ea336353c5 --- /dev/null +++ b/sdk-libs/photon-api/src/models/_get_ata_interface_post_request_params.rs @@ -0,0 +1,25 @@ +/* + * photon-indexer + * + * Solana indexer for general compression + * + * The version of the OpenAPI document: 0.50.0 + * + */ + +/// GetAtaInterfacePostRequestParams : Request parameters for getAtaInterface +#[derive(Clone, Default, Debug, PartialEq, Serialize, Deserialize)] +pub struct GetAtaInterfacePostRequestParams { + /// Owner address + #[serde(rename = "owner")] + pub owner: String, + /// Mint address + #[serde(rename = "mint")] + pub mint: String, +} + +impl GetAtaInterfacePostRequestParams { + pub fn new(owner: String, mint: String) -> Self { + Self { owner, mint } + } +} diff --git a/sdk-libs/photon-api/src/models/_get_mint_interface_post_200_response.rs b/sdk-libs/photon-api/src/models/_get_mint_interface_post_200_response.rs new file mode 100644 index 0000000000..162277b4a0 --- /dev/null +++ b/sdk-libs/photon-api/src/models/_get_mint_interface_post_200_response.rs @@ -0,0 +1,35 @@ +/* + * photon-indexer + * + * Solana indexer for general compression + * + * The version of the OpenAPI document: 0.50.0 + * + */ + +use crate::models; + +#[derive(Clone, Default, Debug, PartialEq, Serialize, Deserialize)] +pub struct GetMintInterfacePost200Response { + #[serde(rename = "error", skip_serializing_if = "Option::is_none")] + pub error: Option>, + /// An ID to identify the response. + #[serde(rename = "id")] + pub id: String, + /// The version of the JSON-RPC protocol. + #[serde(rename = "jsonrpc")] + pub jsonrpc: String, + #[serde(rename = "result", skip_serializing_if = "Option::is_none")] + pub result: Option>, +} + +impl GetMintInterfacePost200Response { + pub fn new(id: String, jsonrpc: String) -> Self { + Self { + error: None, + id, + jsonrpc, + result: None, + } + } +} diff --git a/sdk-libs/photon-api/src/models/_get_mint_interface_post_200_response_result.rs b/sdk-libs/photon-api/src/models/_get_mint_interface_post_200_response_result.rs new file mode 100644 index 0000000000..2aa3042ef1 --- /dev/null +++ b/sdk-libs/photon-api/src/models/_get_mint_interface_post_200_response_result.rs @@ -0,0 +1,27 @@ +/* + * photon-indexer + * + * Solana indexer for general compression + * + * The version of the OpenAPI document: 0.50.0 + * + */ + +use crate::models; + +#[derive(Clone, Default, Debug, PartialEq, Serialize, Deserialize)] +pub struct GetMintInterfacePost200ResponseResult { + #[serde(rename = "context")] + pub context: Box, + #[serde(rename = "value", skip_serializing_if = "Option::is_none")] + pub value: Option>, +} + +impl GetMintInterfacePost200ResponseResult { + pub fn new(context: models::Context) -> Self { + Self { + context: Box::new(context), + value: None, + } + } +} diff --git a/sdk-libs/photon-api/src/models/_get_mint_interface_post_request.rs b/sdk-libs/photon-api/src/models/_get_mint_interface_post_request.rs new file mode 100644 index 0000000000..b0aedd8cd6 --- /dev/null +++ b/sdk-libs/photon-api/src/models/_get_mint_interface_post_request.rs @@ -0,0 +1,36 @@ +/* + * photon-indexer + * + * Solana indexer for general compression + * + * The version of the OpenAPI document: 0.50.0 + * + */ + +use crate::models; + +#[derive(Clone, Default, Debug, PartialEq, Serialize, Deserialize)] +pub struct GetMintInterfacePostRequest { + /// An ID to identify the request. + #[serde(rename = "id")] + pub id: String, + /// The version of the JSON-RPC protocol. + #[serde(rename = "jsonrpc")] + pub jsonrpc: String, + /// The name of the method to invoke. + #[serde(rename = "method")] + pub method: String, + #[serde(rename = "params")] + pub params: Box, +} + +impl GetMintInterfacePostRequest { + pub fn new(params: models::GetMintInterfacePostRequestParams) -> Self { + Self { + id: "test-id".to_string(), + jsonrpc: "2.0".to_string(), + method: "getMintInterface".to_string(), + params: Box::new(params), + } + } +} diff --git a/sdk-libs/photon-api/src/models/_get_mint_interface_post_request_params.rs b/sdk-libs/photon-api/src/models/_get_mint_interface_post_request_params.rs new file mode 100644 index 0000000000..1e38fdbd4a --- /dev/null +++ b/sdk-libs/photon-api/src/models/_get_mint_interface_post_request_params.rs @@ -0,0 +1,22 @@ +/* + * photon-indexer + * + * Solana indexer for general compression + * + * The version of the OpenAPI document: 0.50.0 + * + */ + +/// GetMintInterfacePostRequestParams : Request parameters for getMintInterface +#[derive(Clone, Default, Debug, PartialEq, Serialize, Deserialize)] +pub struct GetMintInterfacePostRequestParams { + /// Mint address to look up + #[serde(rename = "address")] + pub address: String, +} + +impl GetMintInterfacePostRequestParams { + pub fn new(address: String) -> Self { + Self { address } + } +} diff --git a/sdk-libs/photon-api/src/models/_get_multiple_account_interfaces_post_200_response.rs b/sdk-libs/photon-api/src/models/_get_multiple_account_interfaces_post_200_response.rs new file mode 100644 index 0000000000..d46d97e6e7 --- /dev/null +++ b/sdk-libs/photon-api/src/models/_get_multiple_account_interfaces_post_200_response.rs @@ -0,0 +1,35 @@ +/* + * photon-indexer + * + * Solana indexer for general compression + * + * The version of the OpenAPI document: 0.50.0 + * + */ + +use crate::models; + +#[derive(Clone, Default, Debug, PartialEq, Serialize, Deserialize)] +pub struct GetMultipleAccountInterfacesPost200Response { + #[serde(rename = "error", skip_serializing_if = "Option::is_none")] + pub error: Option>, + /// An ID to identify the response. + #[serde(rename = "id")] + pub id: String, + /// The version of the JSON-RPC protocol. + #[serde(rename = "jsonrpc")] + pub jsonrpc: String, + #[serde(rename = "result", skip_serializing_if = "Option::is_none")] + pub result: Option>, +} + +impl GetMultipleAccountInterfacesPost200Response { + pub fn new(id: String, jsonrpc: String) -> Self { + Self { + error: None, + id, + jsonrpc, + result: None, + } + } +} diff --git a/sdk-libs/photon-api/src/models/_get_multiple_account_interfaces_post_200_response_result.rs b/sdk-libs/photon-api/src/models/_get_multiple_account_interfaces_post_200_response_result.rs new file mode 100644 index 0000000000..31085786eb --- /dev/null +++ b/sdk-libs/photon-api/src/models/_get_multiple_account_interfaces_post_200_response_result.rs @@ -0,0 +1,28 @@ +/* + * photon-indexer + * + * Solana indexer for general compression + * + * The version of the OpenAPI document: 0.50.0 + * + */ + +use crate::models; + +#[derive(Clone, Default, Debug, PartialEq, Serialize, Deserialize)] +pub struct GetMultipleAccountInterfacesPost200ResponseResult { + #[serde(rename = "context")] + pub context: Box, + /// List of typed results (Some for found accounts, None for not found) + #[serde(rename = "value")] + pub value: Vec>, +} + +impl GetMultipleAccountInterfacesPost200ResponseResult { + pub fn new(context: models::Context, value: Vec>) -> Self { + Self { + context: Box::new(context), + value, + } + } +} diff --git a/sdk-libs/photon-api/src/models/_get_multiple_account_interfaces_post_request.rs b/sdk-libs/photon-api/src/models/_get_multiple_account_interfaces_post_request.rs new file mode 100644 index 0000000000..fb9308c13b --- /dev/null +++ b/sdk-libs/photon-api/src/models/_get_multiple_account_interfaces_post_request.rs @@ -0,0 +1,36 @@ +/* + * photon-indexer + * + * Solana indexer for general compression + * + * The version of the OpenAPI document: 0.50.0 + * + */ + +use crate::models; + +#[derive(Clone, Default, Debug, PartialEq, Serialize, Deserialize)] +pub struct GetMultipleAccountInterfacesPostRequest { + /// An ID to identify the request. + #[serde(rename = "id")] + pub id: String, + /// The version of the JSON-RPC protocol. + #[serde(rename = "jsonrpc")] + pub jsonrpc: String, + /// The name of the method to invoke. + #[serde(rename = "method")] + pub method: String, + #[serde(rename = "params")] + pub params: Box, +} + +impl GetMultipleAccountInterfacesPostRequest { + pub fn new(params: models::GetMultipleAccountInterfacesPostRequestParams) -> Self { + Self { + id: "test-id".to_string(), + jsonrpc: "2.0".to_string(), + method: "getMultipleAccountInterfaces".to_string(), + params: Box::new(params), + } + } +} diff --git a/sdk-libs/photon-api/src/models/_get_multiple_account_interfaces_post_request_params.rs b/sdk-libs/photon-api/src/models/_get_multiple_account_interfaces_post_request_params.rs new file mode 100644 index 0000000000..c20d8b1e42 --- /dev/null +++ b/sdk-libs/photon-api/src/models/_get_multiple_account_interfaces_post_request_params.rs @@ -0,0 +1,22 @@ +/* + * photon-indexer + * + * Solana indexer for general compression + * + * The version of the OpenAPI document: 0.50.0 + * + */ + +/// GetMultipleAccountInterfacesPostRequestParams : Request parameters for getMultipleAccountInterfaces +#[derive(Clone, Default, Debug, PartialEq, Serialize, Deserialize)] +pub struct GetMultipleAccountInterfacesPostRequestParams { + /// List of account addresses to look up (max 100) + #[serde(rename = "addresses")] + pub addresses: Vec, +} + +impl GetMultipleAccountInterfacesPostRequestParams { + pub fn new(addresses: Vec) -> Self { + Self { addresses } + } +} diff --git a/sdk-libs/photon-api/src/models/_get_token_account_interface_post_200_response.rs b/sdk-libs/photon-api/src/models/_get_token_account_interface_post_200_response.rs new file mode 100644 index 0000000000..7bd42eb7f7 --- /dev/null +++ b/sdk-libs/photon-api/src/models/_get_token_account_interface_post_200_response.rs @@ -0,0 +1,35 @@ +/* + * photon-indexer + * + * Solana indexer for general compression + * + * The version of the OpenAPI document: 0.50.0 + * + */ + +use crate::models; + +#[derive(Clone, Default, Debug, PartialEq, Serialize, Deserialize)] +pub struct GetTokenAccountInterfacePost200Response { + #[serde(rename = "error", skip_serializing_if = "Option::is_none")] + pub error: Option>, + /// An ID to identify the response. + #[serde(rename = "id")] + pub id: String, + /// The version of the JSON-RPC protocol. + #[serde(rename = "jsonrpc")] + pub jsonrpc: String, + #[serde(rename = "result", skip_serializing_if = "Option::is_none")] + pub result: Option>, +} + +impl GetTokenAccountInterfacePost200Response { + pub fn new(id: String, jsonrpc: String) -> Self { + Self { + error: None, + id, + jsonrpc, + result: None, + } + } +} diff --git a/sdk-libs/photon-api/src/models/_get_token_account_interface_post_200_response_result.rs b/sdk-libs/photon-api/src/models/_get_token_account_interface_post_200_response_result.rs new file mode 100644 index 0000000000..dadd19b281 --- /dev/null +++ b/sdk-libs/photon-api/src/models/_get_token_account_interface_post_200_response_result.rs @@ -0,0 +1,27 @@ +/* + * photon-indexer + * + * Solana indexer for general compression + * + * The version of the OpenAPI document: 0.50.0 + * + */ + +use crate::models; + +#[derive(Clone, Default, Debug, PartialEq, Serialize, Deserialize)] +pub struct GetTokenAccountInterfacePost200ResponseResult { + #[serde(rename = "context")] + pub context: Box, + #[serde(rename = "value", skip_serializing_if = "Option::is_none")] + pub value: Option>, +} + +impl GetTokenAccountInterfacePost200ResponseResult { + pub fn new(context: models::Context) -> Self { + Self { + context: Box::new(context), + value: None, + } + } +} diff --git a/sdk-libs/photon-api/src/models/_get_token_account_interface_post_request.rs b/sdk-libs/photon-api/src/models/_get_token_account_interface_post_request.rs new file mode 100644 index 0000000000..8255ac11f7 --- /dev/null +++ b/sdk-libs/photon-api/src/models/_get_token_account_interface_post_request.rs @@ -0,0 +1,36 @@ +/* + * photon-indexer + * + * Solana indexer for general compression + * + * The version of the OpenAPI document: 0.50.0 + * + */ + +use crate::models; + +#[derive(Clone, Default, Debug, PartialEq, Serialize, Deserialize)] +pub struct GetTokenAccountInterfacePostRequest { + /// An ID to identify the request. + #[serde(rename = "id")] + pub id: String, + /// The version of the JSON-RPC protocol. + #[serde(rename = "jsonrpc")] + pub jsonrpc: String, + /// The name of the method to invoke. + #[serde(rename = "method")] + pub method: String, + #[serde(rename = "params")] + pub params: Box, +} + +impl GetTokenAccountInterfacePostRequest { + pub fn new(params: models::GetTokenAccountInterfacePostRequestParams) -> Self { + Self { + id: "test-id".to_string(), + jsonrpc: "2.0".to_string(), + method: "getTokenAccountInterface".to_string(), + params: Box::new(params), + } + } +} diff --git a/sdk-libs/photon-api/src/models/_get_token_account_interface_post_request_params.rs b/sdk-libs/photon-api/src/models/_get_token_account_interface_post_request_params.rs new file mode 100644 index 0000000000..60f583e6af --- /dev/null +++ b/sdk-libs/photon-api/src/models/_get_token_account_interface_post_request_params.rs @@ -0,0 +1,22 @@ +/* + * photon-indexer + * + * Solana indexer for general compression + * + * The version of the OpenAPI document: 0.50.0 + * + */ + +/// GetTokenAccountInterfacePostRequestParams : Request parameters for getTokenAccountInterface +#[derive(Clone, Default, Debug, PartialEq, Serialize, Deserialize)] +pub struct GetTokenAccountInterfacePostRequestParams { + /// Token account address to look up + #[serde(rename = "address")] + pub address: String, +} + +impl GetTokenAccountInterfacePostRequestParams { + pub fn new(address: String) -> Self { + Self { address } + } +} diff --git a/sdk-libs/photon-api/src/models/account_interface.rs b/sdk-libs/photon-api/src/models/account_interface.rs new file mode 100644 index 0000000000..05f9c7ffe4 --- /dev/null +++ b/sdk-libs/photon-api/src/models/account_interface.rs @@ -0,0 +1,68 @@ +/* + * photon-indexer + * + * Solana indexer for general compression + * + * The version of the OpenAPI document: 0.50.0 + * + */ + +use crate::models; + +/// AccountInterface : Unified account interface representing either on-chain or compressed account data +#[derive(Clone, Default, Debug, PartialEq, Serialize, Deserialize)] +pub struct AccountInterface { + /// The account address (pubkey for on-chain, compressed address for compressed) + #[serde(rename = "address")] + pub address: String, + /// Account lamports balance + #[serde(rename = "lamports")] + pub lamports: u64, + /// The program owner of this account + #[serde(rename = "owner")] + pub owner: String, + /// Account data as base64 encoded bytes + #[serde(rename = "data")] + pub data: String, + /// Whether the account is executable (always false for compressed) + #[serde(rename = "executable")] + pub executable: bool, + /// Rent epoch (always 0 for compressed) + #[serde(rename = "rentEpoch")] + pub rent_epoch: u64, + /// Source of the account data + #[serde(rename = "resolvedFrom")] + pub resolved_from: models::ResolvedFrom, + /// Slot at which the account data was resolved + #[serde(rename = "resolvedSlot")] + pub resolved_slot: u64, + /// Additional context for compressed accounts (None for on-chain) + #[serde(rename = "compressedContext", skip_serializing_if = "Option::is_none")] + pub compressed_context: Option>, +} + +impl AccountInterface { + #[allow(clippy::too_many_arguments)] + pub fn new( + address: String, + lamports: u64, + owner: String, + data: String, + executable: bool, + rent_epoch: u64, + resolved_from: models::ResolvedFrom, + resolved_slot: u64, + ) -> Self { + Self { + address, + lamports, + owner, + data, + executable, + rent_epoch, + resolved_from, + resolved_slot, + compressed_context: None, + } + } +} diff --git a/sdk-libs/photon-api/src/models/compressed_context.rs b/sdk-libs/photon-api/src/models/compressed_context.rs new file mode 100644 index 0000000000..e664500503 --- /dev/null +++ b/sdk-libs/photon-api/src/models/compressed_context.rs @@ -0,0 +1,40 @@ +/* + * photon-indexer + * + * Solana indexer for general compression + * + * The version of the OpenAPI document: 0.50.0 + * + */ + +/// CompressedContext : Context information for compressed accounts +#[derive(Clone, Default, Debug, PartialEq, Serialize, Deserialize)] +pub struct CompressedContext { + /// The hash of the compressed account (leaf hash in Merkle tree) + #[serde(rename = "hash")] + pub hash: String, + /// The Merkle tree address + #[serde(rename = "tree")] + pub tree: String, + /// The leaf index in the Merkle tree + #[serde(rename = "leafIndex")] + pub leaf_index: u64, + /// Sequence number (None if in output queue, Some once inserted into Merkle tree) + #[serde(rename = "seq", skip_serializing_if = "Option::is_none")] + pub seq: Option, + /// Whether the account can be proven by index (in output queue) + #[serde(rename = "proveByIndex")] + pub prove_by_index: bool, +} + +impl CompressedContext { + pub fn new(hash: String, tree: String, leaf_index: u64, prove_by_index: bool) -> Self { + Self { + hash, + tree, + leaf_index, + seq: None, + prove_by_index, + } + } +} diff --git a/sdk-libs/photon-api/src/models/interface_result.rs b/sdk-libs/photon-api/src/models/interface_result.rs new file mode 100644 index 0000000000..a861594207 --- /dev/null +++ b/sdk-libs/photon-api/src/models/interface_result.rs @@ -0,0 +1,31 @@ +/* + * photon-indexer + * + * Solana indexer for general compression + * + * The version of the OpenAPI document: 0.50.0 + * + */ + +use crate::models; + +/// InterfaceResult : Heterogeneous result type for batch lookups +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(tag = "type")] +pub enum InterfaceResult { + /// Generic account result + #[serde(rename = "account")] + Account(models::AccountInterface), + /// Token account result with parsed token data + #[serde(rename = "token")] + Token(models::TokenAccountInterface), + /// Mint account result with parsed mint data + #[serde(rename = "mint")] + Mint(models::MintInterface), +} + +impl Default for InterfaceResult { + fn default() -> Self { + Self::Account(models::AccountInterface::default()) + } +} diff --git a/sdk-libs/photon-api/src/models/mint_interface.rs b/sdk-libs/photon-api/src/models/mint_interface.rs new file mode 100644 index 0000000000..d842de2edd --- /dev/null +++ b/sdk-libs/photon-api/src/models/mint_interface.rs @@ -0,0 +1,27 @@ +/* + * photon-indexer + * + * Solana indexer for general compression + * + * The version of the OpenAPI document: 0.50.0 + * + */ + +use crate::models; + +/// MintInterface : Mint account interface with parsed mint data +#[derive(Clone, Default, Debug, PartialEq, Serialize, Deserialize)] +pub struct MintInterface { + /// Base account interface data (flattened) + #[serde(flatten)] + pub account: models::AccountInterface, + /// Parsed mint data + #[serde(rename = "mintData")] + pub mint_data: models::MintData, +} + +impl MintInterface { + pub fn new(account: models::AccountInterface, mint_data: models::MintData) -> Self { + Self { account, mint_data } + } +} diff --git a/sdk-libs/photon-api/src/models/mod.rs b/sdk-libs/photon-api/src/models/mod.rs index 1b5ae2730e..952f379307 100644 --- a/sdk-libs/photon-api/src/models/mod.rs +++ b/sdk-libs/photon-api/src/models/mod.rs @@ -366,3 +366,61 @@ pub mod _get_compressed_mints_by_authority_post_200_response_result; pub use self::_get_compressed_mints_by_authority_post_200_response_result::GetCompressedMintsByAuthorityPost200ResponseResult; pub mod _get_compressed_mints_by_authority_post_200_response; pub use self::_get_compressed_mints_by_authority_post_200_response::GetCompressedMintsByAuthorityPost200Response; +// Interface types +pub mod resolved_from; +pub use self::resolved_from::ResolvedFrom; +pub mod compressed_context; +pub use self::compressed_context::CompressedContext; +pub mod account_interface; +pub use self::account_interface::AccountInterface; +pub mod token_account_interface; +pub use self::token_account_interface::TokenAccountInterface; +pub mod mint_interface; +pub use self::mint_interface::MintInterface; +pub mod interface_result; +pub use self::interface_result::InterfaceResult; +// getAccountInterface +pub mod _get_account_interface_post_request_params; +pub use self::_get_account_interface_post_request_params::GetAccountInterfacePostRequestParams; +pub mod _get_account_interface_post_request; +pub use self::_get_account_interface_post_request::GetAccountInterfacePostRequest; +pub mod _get_account_interface_post_200_response_result; +pub use self::_get_account_interface_post_200_response_result::GetAccountInterfacePost200ResponseResult; +pub mod _get_account_interface_post_200_response; +pub use self::_get_account_interface_post_200_response::GetAccountInterfacePost200Response; +// getTokenAccountInterface +pub mod _get_token_account_interface_post_request_params; +pub use self::_get_token_account_interface_post_request_params::GetTokenAccountInterfacePostRequestParams; +pub mod _get_token_account_interface_post_request; +pub use self::_get_token_account_interface_post_request::GetTokenAccountInterfacePostRequest; +pub mod _get_token_account_interface_post_200_response_result; +pub use self::_get_token_account_interface_post_200_response_result::GetTokenAccountInterfacePost200ResponseResult; +pub mod _get_token_account_interface_post_200_response; +pub use self::_get_token_account_interface_post_200_response::GetTokenAccountInterfacePost200Response; +// getAtaInterface +pub mod _get_ata_interface_post_request_params; +pub use self::_get_ata_interface_post_request_params::GetAtaInterfacePostRequestParams; +pub mod _get_ata_interface_post_request; +pub use self::_get_ata_interface_post_request::GetAtaInterfacePostRequest; +pub mod _get_ata_interface_post_200_response_result; +pub use self::_get_ata_interface_post_200_response_result::GetAtaInterfacePost200ResponseResult; +pub mod _get_ata_interface_post_200_response; +pub use self::_get_ata_interface_post_200_response::GetAtaInterfacePost200Response; +// getMintInterface +pub mod _get_mint_interface_post_request_params; +pub use self::_get_mint_interface_post_request_params::GetMintInterfacePostRequestParams; +pub mod _get_mint_interface_post_request; +pub use self::_get_mint_interface_post_request::GetMintInterfacePostRequest; +pub mod _get_mint_interface_post_200_response_result; +pub use self::_get_mint_interface_post_200_response_result::GetMintInterfacePost200ResponseResult; +pub mod _get_mint_interface_post_200_response; +pub use self::_get_mint_interface_post_200_response::GetMintInterfacePost200Response; +// getMultipleAccountInterfaces +pub mod _get_multiple_account_interfaces_post_request_params; +pub use self::_get_multiple_account_interfaces_post_request_params::GetMultipleAccountInterfacesPostRequestParams; +pub mod _get_multiple_account_interfaces_post_request; +pub use self::_get_multiple_account_interfaces_post_request::GetMultipleAccountInterfacesPostRequest; +pub mod _get_multiple_account_interfaces_post_200_response_result; +pub use self::_get_multiple_account_interfaces_post_200_response_result::GetMultipleAccountInterfacesPost200ResponseResult; +pub mod _get_multiple_account_interfaces_post_200_response; +pub use self::_get_multiple_account_interfaces_post_200_response::GetMultipleAccountInterfacesPost200Response; diff --git a/sdk-libs/photon-api/src/models/resolved_from.rs b/sdk-libs/photon-api/src/models/resolved_from.rs new file mode 100644 index 0000000000..c79935af7e --- /dev/null +++ b/sdk-libs/photon-api/src/models/resolved_from.rs @@ -0,0 +1,23 @@ +/* + * photon-indexer + * + * Solana indexer for general compression + * + * The version of the OpenAPI document: 0.50.0 + * + */ + +/// ResolvedFrom : Indicates the source of the resolved account data +#[derive(Clone, Copy, Debug, Eq, PartialEq, Ord, PartialOrd, Hash, Serialize, Deserialize)] +pub enum ResolvedFrom { + #[serde(rename = "onchain")] + Onchain, + #[serde(rename = "compressed")] + Compressed, +} + +impl Default for ResolvedFrom { + fn default() -> Self { + Self::Onchain + } +} diff --git a/sdk-libs/photon-api/src/models/token_account_interface.rs b/sdk-libs/photon-api/src/models/token_account_interface.rs new file mode 100644 index 0000000000..3e3080c100 --- /dev/null +++ b/sdk-libs/photon-api/src/models/token_account_interface.rs @@ -0,0 +1,30 @@ +/* + * photon-indexer + * + * Solana indexer for general compression + * + * The version of the OpenAPI document: 0.50.0 + * + */ + +use crate::models; + +/// TokenAccountInterface : Token account interface with parsed token data +#[derive(Clone, Default, Debug, PartialEq, Serialize, Deserialize)] +pub struct TokenAccountInterface { + /// Base account interface data (flattened) + #[serde(flatten)] + pub account: models::AccountInterface, + /// Parsed token account data + #[serde(rename = "tokenData")] + pub token_data: models::TokenData, +} + +impl TokenAccountInterface { + pub fn new(account: models::AccountInterface, token_data: models::TokenData) -> Self { + Self { + account, + token_data, + } + } +} From fb11ddd5c7cdf0fb31ce030cd369678793b9f56d Mon Sep 17 00:00:00 2001 From: Sergey Timoshin Date: Wed, 28 Jan 2026 00:05:44 +0000 Subject: [PATCH 21/38] feat: add support for unified account interface with hot/cold context handling and corresponding tests --- forester/tests/test_indexer_interface.rs | 174 +++++++----- sdk-libs/client/src/indexer/mod.rs | 12 +- sdk-libs/client/src/indexer/types.rs | 262 ++++++++++-------- .../src/interface/account_interface_ext.rs | 10 +- sdk-libs/client/src/rpc/client.rs | 81 +++++- sdk-libs/client/src/rpc/rpc_trait.rs | 44 ++- .../src/models/account_interface.rs | 65 ++--- .../photon-api/src/models/cold_context.rs | 46 +++ sdk-libs/photon-api/src/models/cold_data.rs | 27 ++ sdk-libs/photon-api/src/models/mod.rs | 12 +- .../src/models/solana_account_data.rs | 37 +++ sdk-libs/photon-api/src/models/tree_info.rs | 23 ++ sdk-libs/program-test/src/program_test/rpc.rs | 66 +++++ 13 files changed, 598 insertions(+), 261 deletions(-) create mode 100644 sdk-libs/photon-api/src/models/cold_context.rs create mode 100644 sdk-libs/photon-api/src/models/cold_data.rs create mode 100644 sdk-libs/photon-api/src/models/solana_account_data.rs create mode 100644 sdk-libs/photon-api/src/models/tree_info.rs diff --git a/forester/tests/test_indexer_interface.rs b/forester/tests/test_indexer_interface.rs index 53c48c7cb3..e5b6125890 100644 --- a/forester/tests/test_indexer_interface.rs +++ b/forester/tests/test_indexer_interface.rs @@ -18,7 +18,7 @@ use borsh::BorshSerialize; use create_address_test_program::create_invoke_cpi_instruction; use forester_utils::utils::wait_for_indexer; use light_client::{ - indexer::{photon_indexer::PhotonIndexer, AddressWithTree, Indexer, ResolvedFrom}, + indexer::{photon_indexer::PhotonIndexer, AddressWithTree, ColdContext, Indexer}, local_test_validator::{spawn_validator, LightValidatorConfig}, rpc::{LightClient, LightClientConfig, Rpc}, }; @@ -503,17 +503,21 @@ async fn test_indexer_interface_scenarios() { .value .expect("Decompressed mint should be found"); + assert!( + decompressed_mint_interface.account.is_hot(), + "Decompressed mint should be hot (on-chain)" + ); + assert!( + decompressed_mint_interface.account.cold.is_none(), + "On-chain mint should not have cold context" + ); assert_eq!( - decompressed_mint_interface.account.resolved_from, - ResolvedFrom::Onchain, - "Decompressed mint should be resolved from on-chain" + decompressed_mint_interface.account.key, decompressed_mint_pda, + "Key should match the queried address" ); assert!( - decompressed_mint_interface - .account - .compressed_context - .is_none(), - "On-chain mint should not have compressed context" + decompressed_mint_interface.account.account.lamports > 0, + "On-chain mint should have lamports > 0" ); assert_eq!( decompressed_mint_interface.mint_data.decimals, 6, @@ -534,17 +538,25 @@ async fn test_indexer_interface_scenarios() { .value .expect("Compressed mint should be found"); - assert_eq!( - compressed_mint_interface.account.resolved_from, - ResolvedFrom::Compressed, - "Fully compressed mint should be resolved from compressed DB" + assert!( + compressed_mint_interface.account.is_cold(), + "Fully compressed mint should be cold (from compressed DB)" ); assert!( - compressed_mint_interface - .account - .compressed_context - .is_some(), - "Compressed mint should have compressed context" + compressed_mint_interface.account.cold.is_some(), + "Compressed mint should have cold context" + ); + // Verify cold context is the Mint variant + assert!( + matches!( + compressed_mint_interface.account.cold, + Some(ColdContext::Mint { .. }) + ), + "Cold context should be the Mint variant" + ); + assert_eq!( + compressed_mint_interface.account.key, compressed_mint_pda, + "Key should match the queried address" ); assert_eq!( compressed_mint_interface.mint_data.decimals, 9, @@ -570,18 +582,21 @@ async fn test_indexer_interface_scenarios() { .value .expect("Compressible token account should be found"); - assert_eq!( - compressible_account_interface.resolved_from, - ResolvedFrom::Onchain, - "Compressible account should be resolved from on-chain" + assert!( + compressible_account_interface.is_hot(), + "Compressible account should be hot (on-chain)" ); assert!( - compressible_account_interface.compressed_context.is_none(), - "On-chain account should not have compressed context" + compressible_account_interface.cold.is_none(), + "On-chain account should not have cold context" ); assert_eq!( - compressible_account_interface.address, compressible_token_account, - "Address should match the queried address" + compressible_account_interface.key, compressible_token_account, + "Key should match the queried address" + ); + assert!( + compressible_account_interface.account.lamports > 0, + "On-chain account should have lamports > 0" ); println!(" PASSED: Compressible account resolved from on-chain"); @@ -594,10 +609,17 @@ async fn test_indexer_interface_scenarios() { .value .expect("Compressible token account should be found via token interface"); + assert!( + compressible_token_interface.account.is_hot(), + "Token account should be hot (on-chain)" + ); + assert!( + compressible_token_interface.account.cold.is_none(), + "On-chain token account should not have cold context" + ); assert_eq!( - compressible_token_interface.account.resolved_from, - ResolvedFrom::Onchain, - "Token account should be resolved from on-chain" + compressible_token_interface.account.key, compressible_token_account, + "Token account key should match" ); assert_eq!( compressible_token_interface.token.mint, mint_pubkey, @@ -610,23 +632,8 @@ async fn test_indexer_interface_scenarios() { ); println!(" PASSED: Token account interface resolved with correct token data"); - // ============ Test 5: getAtaInterface (Light Protocol ATA derivation) ============ - // Note: This tests ATA derivation - we didn't create a Light ATA so it should return None - println!("\nTest 5: getAtaInterface (Light Protocol ATA derivation)..."); - let ata_result = photon_indexer - .get_ata_interface(&compressible_owner.pubkey(), &mint_pubkey, None) - .await - .expect("getAtaInterface should not error"); - - // We didn't create a Light ATA for this owner/mint combo, so it should be None - assert!( - ata_result.value.is_none(), - "ATA should not be found (no Light ATA was created for this owner/mint)" - ); - println!(" PASSED: ATA correctly returns None when not created"); - - // ============ Test 6: getMultipleAccountInterfaces batch lookup ============ - println!("\nTest 6: getMultipleAccountInterfaces batch lookup..."); + // ============ Test 5: getMultipleAccountInterfaces batch lookup ============ + println!("\nTest 5: getMultipleAccountInterfaces batch lookup..."); let batch_addresses = vec![&decompressed_mint_pda, &compressible_token_account]; let batch_response = photon_indexer @@ -644,33 +651,36 @@ async fn test_indexer_interface_scenarios() { let batch_mint = batch_response.value[0] .as_ref() .expect("Decompressed mint should be found in batch"); + assert!(batch_mint.is_hot(), "Batch mint should be hot (on-chain)"); assert_eq!( - batch_mint.resolved_from, - ResolvedFrom::Onchain, - "Batch mint should be resolved from on-chain" + batch_mint.key, decompressed_mint_pda, + "Batch mint key should match" ); - assert_eq!( - batch_mint.address, decompressed_mint_pda, - "Batch mint address should match" + assert!( + batch_mint.account.lamports > 0, + "Batch mint should have lamports > 0" ); // Second result: compressible token account let batch_token = batch_response.value[1] .as_ref() .expect("Compressible account should be found in batch"); - assert_eq!( - batch_token.resolved_from, - ResolvedFrom::Onchain, - "Batch token account should be resolved from on-chain" + assert!( + batch_token.is_hot(), + "Batch token account should be hot (on-chain)" ); assert_eq!( - batch_token.address, compressible_token_account, - "Batch token account address should match" + batch_token.key, compressible_token_account, + "Batch token account key should match" + ); + assert!( + batch_token.account.lamports > 0, + "Batch token account should have lamports > 0" ); println!(" PASSED: Batch lookup returned correct results"); - // ============ Test 7: Consistency between getMintInterface and getAccountInterface ============ - println!("\nTest 7: Consistency between getMintInterface and getAccountInterface..."); + // ============ Test 6: Consistency between getMintInterface and getAccountInterface ============ + println!("\nTest 6: Consistency between getMintInterface and getAccountInterface..."); let mint_via_mint = photon_indexer .get_mint_interface(&decompressed_mint_pda, None) .await @@ -686,29 +696,34 @@ async fn test_indexer_interface_scenarios() { .expect("Mint should be found via getAccountInterface"); assert_eq!( - mint_via_mint.account.address, mint_via_account.address, - "Addresses should match between interfaces" + mint_via_mint.account.key, mint_via_account.key, + "Keys should match between interfaces" ); assert_eq!( - mint_via_mint.account.lamports, mint_via_account.lamports, + mint_via_mint.account.account.lamports, mint_via_account.account.lamports, "Lamports should match between interfaces" ); assert_eq!( - mint_via_mint.account.resolved_from, mint_via_account.resolved_from, - "Resolved source should match between interfaces" + mint_via_mint.account.cold.is_none(), + mint_via_account.cold.is_none(), + "Hot/cold status should match between interfaces" ); assert_eq!( - mint_via_mint.account.data, mint_via_account.data, + mint_via_mint.account.account.data, mint_via_account.account.data, "Data should match between interfaces" ); + assert_eq!( + mint_via_mint.account.account.owner, mint_via_account.account.owner, + "Owner should match between interfaces" + ); println!(" PASSED: Consistency verified between getMintInterface and getAccountInterface"); - // ============ Test 8: Verify fully compressed mint via getAccountInterface returns None ============ + // ============ Test 7: Verify fully compressed mint via getAccountInterface returns None ============ // Fully compressed mints (after CompressAndCloseMint) have full mint data in the compressed DB. // Their address column contains the compression_address, not the mint_pda. // Since they don't have the [255; 8] discriminator, onchain_pubkey is not set. // Therefore getAccountInterface by mint_pda should return None (use getMintInterface instead). - println!("\nTest 8: getAccountInterface with fully compressed mint PDA..."); + println!("\nTest 7: getAccountInterface with fully compressed mint PDA..."); let compressed_via_account = photon_indexer .get_account_interface(&compressed_mint_pda, None) .await @@ -720,11 +735,11 @@ async fn test_indexer_interface_scenarios() { ); println!(" PASSED: Fully compressed mint correctly returns None via getAccountInterface"); - // ============ Test 9: Verify decompressed mint found via getAccountInterface (generic linking) ============ + // ============ Test 8: Verify decompressed mint found via getAccountInterface (generic linking) ============ // Decompressed mints have discriminator [255; 8] + 32-byte mint_pda in data. // The generic linking feature extracts this as onchain_pubkey during ingestion. // Therefore getAccountInterface(mint_pda) should find it via onchain_pubkey column. - println!("\nTest 9: getAccountInterface with decompressed mint PDA (generic linking)..."); + println!("\nTest 8: getAccountInterface with decompressed mint PDA (generic linking)..."); let decompressed_via_account = photon_indexer .get_account_interface(&decompressed_mint_pda, None) .await @@ -735,14 +750,21 @@ async fn test_indexer_interface_scenarios() { .expect("Decompressed mint should be found via getAccountInterface (generic linking)"); // The decompressed mint should be found from on-chain (CMint account exists) - assert_eq!( - decompressed_account.resolved_from, - ResolvedFrom::Onchain, - "Decompressed mint via getAccountInterface should resolve from on-chain" + assert!( + decompressed_account.is_hot(), + "Decompressed mint via getAccountInterface should be hot (on-chain)" + ); + assert!( + decompressed_account.cold.is_none(), + "Decompressed mint via getAccountInterface should not have cold context" ); assert_eq!( - decompressed_account.address, decompressed_mint_pda, - "Address should match the queried mint PDA" + decompressed_account.key, decompressed_mint_pda, + "Key should match the queried mint PDA" + ); + assert!( + decompressed_account.account.lamports > 0, + "Decompressed mint should have lamports > 0" ); println!(" PASSED: Decompressed mint found via getAccountInterface with generic linking"); diff --git a/sdk-libs/client/src/indexer/mod.rs b/sdk-libs/client/src/indexer/mod.rs index 3b37f3cd0a..d9fa33c0e8 100644 --- a/sdk-libs/client/src/indexer/mod.rs +++ b/sdk-libs/client/src/indexer/mod.rs @@ -15,12 +15,12 @@ pub use indexer_trait::Indexer; pub use response::{Context, Items, ItemsWithCursor, Response}; pub use types::{ AccountInterface, AccountProofInputs, Address, AddressMerkleTreeAccounts, AddressProofInputs, - AddressQueueData, AddressWithTree, CompressedAccount, CompressedContext, CompressedMint, - CompressedTokenAccount, Hash, InputQueueData, MerkleProof, MerkleProofWithContext, MintData, - MintInterface, NewAddressProofWithContext, NextTreeInfo, OutputQueueData, OwnerBalance, - ProofOfLeaf, QueueElementsResult, QueueInfo, QueueInfoResult, ResolvedFrom, RootIndex, - SignatureWithMetadata, StateMerkleTreeAccounts, StateQueueData, TokenAccountInterface, - TokenBalance, TreeInfo, ValidityProofWithContext, + AddressQueueData, AddressWithTree, ColdContext, ColdData, CompressedAccount, CompressedMint, + CompressedTokenAccount, Hash, InputQueueData, InterfaceTreeInfo, MerkleProof, + MerkleProofWithContext, MintData, MintInterface, NewAddressProofWithContext, NextTreeInfo, + OutputQueueData, OwnerBalance, ProofOfLeaf, QueueElementsResult, QueueInfo, QueueInfoResult, + RootIndex, SignatureWithMetadata, SolanaAccountData, StateMerkleTreeAccounts, StateQueueData, + TokenAccountInterface, TokenBalance, TreeInfo, ValidityProofWithContext, }; mod options; pub use options::*; diff --git a/sdk-libs/client/src/indexer/types.rs b/sdk-libs/client/src/indexer/types.rs index cbff55dae4..088c96b93a 100644 --- a/sdk-libs/client/src/indexer/types.rs +++ b/sdk-libs/client/src/indexer/types.rs @@ -1117,102 +1117,165 @@ impl TryFrom<&photon_api::models::CompressedMint> for CompressedMint { // ============ Interface Types ============ // These types are used by the Interface endpoints that race hot (on-chain) and cold (compressed) lookups -/// Indicates the source of the resolved account data -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub enum ResolvedFrom { - /// Account data comes from on-chain (hot) lookup - Onchain, - /// Account data comes from compressed (cold) lookup - Compressed, -} - -impl TryFrom for ResolvedFrom { - type Error = IndexerError; - - fn try_from(value: photon_api::models::ResolvedFrom) -> Result { - match value { - photon_api::models::ResolvedFrom::Onchain => Ok(ResolvedFrom::Onchain), - photon_api::models::ResolvedFrom::Compressed => Ok(ResolvedFrom::Compressed), - } - } +/// Standard Solana account fields +#[derive(Clone, Debug, PartialEq)] +pub struct SolanaAccountData { + pub lamports: u64, + pub data: Vec, + pub owner: Pubkey, + pub executable: bool, + pub rent_epoch: u64, } -/// Context information for compressed accounts (only present when resolved_from = Compressed) +/// Merkle tree info for compressed accounts #[derive(Clone, Debug, PartialEq)] -pub struct CompressedContext { - /// The hash of the compressed account (leaf hash in Merkle tree) - pub hash: [u8; 32], - /// The Merkle tree address +pub struct InterfaceTreeInfo { pub tree: Pubkey, - /// The leaf index in the Merkle tree - pub leaf_index: u64, - /// Sequence number (None if in output queue, Some once inserted into Merkle tree) pub seq: Option, - /// Whether the account can be proven by index (in output queue) - pub prove_by_index: bool, } -impl TryFrom<&photon_api::models::CompressedContext> for CompressedContext { - type Error = IndexerError; +/// Structured compressed account data (discriminator separated) +#[derive(Clone, Debug, PartialEq)] +pub struct ColdData { + pub discriminator: Vec, + pub data: Vec, +} - fn try_from(ctx: &photon_api::models::CompressedContext) -> Result { - Ok(CompressedContext { - hash: decode_base58_to_fixed_array(&ctx.hash)?, - tree: Pubkey::new_from_array(decode_base58_to_fixed_array(&ctx.tree)?), - leaf_index: ctx.leaf_index, - seq: ctx.seq, - prove_by_index: ctx.prove_by_index, - }) +/// Compressed account context — present when account is in compressed state +#[derive(Clone, Debug, PartialEq)] +pub enum ColdContext { + Account { + hash: [u8; 32], + leaf_index: u64, + tree_info: InterfaceTreeInfo, + data: ColdData, + }, + Token { + hash: [u8; 32], + leaf_index: u64, + tree_info: InterfaceTreeInfo, + data: ColdData, + }, + Mint { + hash: [u8; 32], + leaf_index: u64, + tree_info: InterfaceTreeInfo, + data: ColdData, + }, +} + +/// Helper to convert photon_api ColdContext to client ColdContext +fn convert_cold_context( + cold: &photon_api::models::ColdContext, +) -> Result { + match cold { + photon_api::models::ColdContext::Account { + hash, + leaf_index, + tree_info, + data, + } => Ok(ColdContext::Account { + hash: decode_base58_to_fixed_array(hash)?, + leaf_index: *leaf_index, + tree_info: InterfaceTreeInfo { + tree: Pubkey::new_from_array(decode_base58_to_fixed_array(&tree_info.tree)?), + seq: tree_info.seq, + }, + data: ColdData { + discriminator: data.discriminator.clone(), + data: base64::decode_config(&data.data, base64::STANDARD_NO_PAD) + .map_err(|_| IndexerError::InvalidResponseData)?, + }, + }), + photon_api::models::ColdContext::Token { + hash, + leaf_index, + tree_info, + data, + } => Ok(ColdContext::Token { + hash: decode_base58_to_fixed_array(hash)?, + leaf_index: *leaf_index, + tree_info: InterfaceTreeInfo { + tree: Pubkey::new_from_array(decode_base58_to_fixed_array(&tree_info.tree)?), + seq: tree_info.seq, + }, + data: ColdData { + discriminator: data.discriminator.clone(), + data: base64::decode_config(&data.data, base64::STANDARD_NO_PAD) + .map_err(|_| IndexerError::InvalidResponseData)?, + }, + }), + photon_api::models::ColdContext::Mint { + hash, + leaf_index, + tree_info, + data, + } => Ok(ColdContext::Mint { + hash: decode_base58_to_fixed_array(hash)?, + leaf_index: *leaf_index, + tree_info: InterfaceTreeInfo { + tree: Pubkey::new_from_array(decode_base58_to_fixed_array(&tree_info.tree)?), + seq: tree_info.seq, + }, + data: ColdData { + discriminator: data.discriminator.clone(), + data: base64::decode_config(&data.data, base64::STANDARD_NO_PAD) + .map_err(|_| IndexerError::InvalidResponseData)?, + }, + }), } } -/// Unified account interface that represents either on-chain or compressed account data +/// Unified account interface — works for both on-chain and compressed accounts #[derive(Clone, Debug, PartialEq)] pub struct AccountInterface { - /// The account address - pub address: Pubkey, - /// Account lamports balance - pub lamports: u64, - /// The program owner of this account - pub owner: Pubkey, - /// Account data as bytes - pub data: Vec, - /// Whether the account is executable (always false for compressed) - pub executable: bool, - /// Rent epoch (always 0 for compressed) - pub rent_epoch: u64, - /// Source of the account data - pub resolved_from: ResolvedFrom, - /// Slot at which the account data was resolved - pub resolved_slot: u64, - /// Additional context for compressed accounts (None for on-chain) - pub compressed_context: Option, + /// The on-chain Solana pubkey + pub key: Pubkey, + /// Standard Solana account fields + pub account: SolanaAccountData, + /// Compressed context — None if on-chain, Some if compressed + pub cold: Option, +} + +impl AccountInterface { + /// Returns true if this account is on-chain (hot) + pub fn is_hot(&self) -> bool { + self.cold.is_none() + } + + /// Returns true if this account is compressed (cold) + pub fn is_cold(&self) -> bool { + self.cold.is_some() + } +} + +/// Helper to convert photon_api AccountInterface to client AccountInterface +fn convert_account_interface( + ai: &photon_api::models::AccountInterface, +) -> Result { + let cold = ai.cold.as_ref().map(convert_cold_context).transpose()?; + + let data = base64::decode_config(&ai.account.data, base64::STANDARD_NO_PAD) + .map_err(|_| IndexerError::InvalidResponseData)?; + + Ok(AccountInterface { + key: Pubkey::new_from_array(decode_base58_to_fixed_array(&ai.key)?), + account: SolanaAccountData { + lamports: ai.account.lamports, + data, + owner: Pubkey::new_from_array(decode_base58_to_fixed_array(&ai.account.owner)?), + executable: ai.account.executable, + rent_epoch: ai.account.rent_epoch, + }, + cold, + }) } impl TryFrom<&photon_api::models::AccountInterface> for AccountInterface { type Error = IndexerError; fn try_from(ai: &photon_api::models::AccountInterface) -> Result { - let compressed_context = ai - .compressed_context - .as_ref() - .map(|ctx| CompressedContext::try_from(ctx.as_ref())) - .transpose()?; - - let data = base64::decode_config(&ai.data, base64::STANDARD_NO_PAD) - .map_err(|_| IndexerError::InvalidResponseData)?; - - Ok(AccountInterface { - address: Pubkey::new_from_array(decode_base58_to_fixed_array(&ai.address)?), - lamports: ai.lamports, - owner: Pubkey::new_from_array(decode_base58_to_fixed_array(&ai.owner)?), - data, - executable: ai.executable, - rent_epoch: ai.rent_epoch, - resolved_from: ResolvedFrom::try_from(ai.resolved_from)?, - resolved_slot: ai.resolved_slot, - compressed_context, - }) + convert_account_interface(ai) } } @@ -1245,27 +1308,7 @@ impl TryFrom<&photon_api::models::TokenAccountInterface> for TokenAccountInterfa type Error = IndexerError; fn try_from(tai: &photon_api::models::TokenAccountInterface) -> Result { - let compressed_context = tai - .account - .compressed_context - .as_ref() - .map(|ctx| CompressedContext::try_from(ctx.as_ref())) - .transpose()?; - - let data = base64::decode_config(&tai.account.data, base64::STANDARD_NO_PAD) - .map_err(|_| IndexerError::InvalidResponseData)?; - - let account = AccountInterface { - address: Pubkey::new_from_array(decode_base58_to_fixed_array(&tai.account.address)?), - lamports: tai.account.lamports, - owner: Pubkey::new_from_array(decode_base58_to_fixed_array(&tai.account.owner)?), - data, - executable: tai.account.executable, - rent_epoch: tai.account.rent_epoch, - resolved_from: ResolvedFrom::try_from(tai.account.resolved_from)?, - resolved_slot: tai.account.resolved_slot, - compressed_context, - }; + let account = convert_account_interface(&tai.account)?; // Parse token data - same pattern as CompressedTokenAccount let token = TokenData { @@ -1312,28 +1355,7 @@ impl TryFrom<&photon_api::models::MintInterface> for MintInterface { type Error = IndexerError; fn try_from(mi: &photon_api::models::MintInterface) -> Result { - let compressed_context = mi - .account - .compressed_context - .as_ref() - .map(|ctx| CompressedContext::try_from(ctx.as_ref())) - .transpose()?; - - let data = base64::decode_config(&mi.account.data, base64::STANDARD_NO_PAD) - .map_err(|_| IndexerError::InvalidResponseData)?; - - let account = AccountInterface { - address: Pubkey::new_from_array(decode_base58_to_fixed_array(&mi.account.address)?), - lamports: mi.account.lamports, - owner: Pubkey::new_from_array(decode_base58_to_fixed_array(&mi.account.owner)?), - data, - executable: mi.account.executable, - rent_epoch: mi.account.rent_epoch, - resolved_from: ResolvedFrom::try_from(mi.account.resolved_from)?, - resolved_slot: mi.account.resolved_slot, - compressed_context, - }; - + let account = convert_account_interface(&mi.account)?; let mint_data = MintData::try_from(&mi.mint_data)?; Ok(MintInterface { account, mint_data }) diff --git a/sdk-libs/client/src/interface/account_interface_ext.rs b/sdk-libs/client/src/interface/account_interface_ext.rs index d6ae2237a5..0f326502c0 100644 --- a/sdk-libs/client/src/interface/account_interface_ext.rs +++ b/sdk-libs/client/src/interface/account_interface_ext.rs @@ -243,9 +243,10 @@ impl AccountInterfaceExt for T { AccountToFetch::Pda { address, program_id, - } => self.get_account_interface(address, program_id).await?, + } => AccountInterfaceExt::get_account_interface(self, address, program_id).await?, AccountToFetch::Token { address } => { - let token_iface = self.get_token_account_interface(address).await?; + let token_iface = + AccountInterfaceExt::get_token_account_interface(self, address).await?; AccountInterface { key: token_iface.key, account: token_iface.account, @@ -253,7 +254,8 @@ impl AccountInterfaceExt for T { } } AccountToFetch::Ata { wallet_owner, mint } => { - let token_iface = self.get_ata_interface(wallet_owner, mint).await?; + let token_iface = + AccountInterfaceExt::get_ata_interface(self, wallet_owner, mint).await?; AccountInterface { key: token_iface.key, account: token_iface.account, @@ -261,7 +263,7 @@ impl AccountInterfaceExt for T { } } AccountToFetch::Mint { address } => { - let mint_iface = self.get_mint_interface(address).await?; + let mint_iface = AccountInterfaceExt::get_mint_interface(self, address).await?; match mint_iface.state { MintState::Hot { account } => AccountInterface { key: mint_iface.mint, diff --git a/sdk-libs/client/src/rpc/client.rs b/sdk-libs/client/src/rpc/client.rs index 09dabfa7cb..ca620c19d0 100644 --- a/sdk-libs/client/src/rpc/client.rs +++ b/sdk-libs/client/src/rpc/client.rs @@ -31,7 +31,10 @@ use tracing::warn; use super::LightClientConfig; use crate::{ - indexer::{photon_indexer::PhotonIndexer, Indexer, TreeInfo}, + indexer::{ + photon_indexer::PhotonIndexer, AccountInterface, Indexer, IndexerRpcConfig, MintInterface, + Response, TokenAccountInterface, TreeInfo, + }, rpc::{ errors::RpcError, get_light_state_tree_infos::{ @@ -899,6 +902,82 @@ impl Rpc for LightClient { tree_type: TreeType::AddressV2, } } + + async fn get_account_interface( + &self, + address: &Pubkey, + config: Option, + ) -> Result>, RpcError> { + let indexer = self + .indexer + .as_ref() + .ok_or(RpcError::IndexerNotInitialized)?; + indexer + .get_account_interface(address, config) + .await + .map_err(|e| RpcError::CustomError(format!("Indexer error: {e}"))) + } + + async fn get_token_account_interface( + &self, + address: &Pubkey, + config: Option, + ) -> Result>, RpcError> { + let indexer = self + .indexer + .as_ref() + .ok_or(RpcError::IndexerNotInitialized)?; + indexer + .get_token_account_interface(address, config) + .await + .map_err(|e| RpcError::CustomError(format!("Indexer error: {e}"))) + } + + async fn get_ata_interface( + &self, + owner: &Pubkey, + mint: &Pubkey, + config: Option, + ) -> Result>, RpcError> { + let indexer = self + .indexer + .as_ref() + .ok_or(RpcError::IndexerNotInitialized)?; + indexer + .get_ata_interface(owner, mint, config) + .await + .map_err(|e| RpcError::CustomError(format!("Indexer error: {e}"))) + } + + async fn get_mint_interface( + &self, + address: &Pubkey, + config: Option, + ) -> Result>, RpcError> { + let indexer = self + .indexer + .as_ref() + .ok_or(RpcError::IndexerNotInitialized)?; + indexer + .get_mint_interface(address, config) + .await + .map_err(|e| RpcError::CustomError(format!("Indexer error: {e}"))) + } + + async fn get_multiple_account_interfaces( + &self, + addresses: Vec<&Pubkey>, + config: Option, + ) -> Result>>, RpcError> { + let indexer = self + .indexer + .as_ref() + .ok_or(RpcError::IndexerNotInitialized)?; + indexer + .get_multiple_account_interfaces(addresses, config) + .await + .map_err(|e| RpcError::CustomError(format!("Indexer error: {e}"))) + } } impl MerkleTreeExt for LightClient {} diff --git a/sdk-libs/client/src/rpc/rpc_trait.rs b/sdk-libs/client/src/rpc/rpc_trait.rs index 104c32d51e..a0ecc81c3f 100644 --- a/sdk-libs/client/src/rpc/rpc_trait.rs +++ b/sdk-libs/client/src/rpc/rpc_trait.rs @@ -18,7 +18,10 @@ use solana_transaction_status_client_types::TransactionStatus; use super::client::RpcUrl; use crate::{ - indexer::{Indexer, TreeInfo}, + indexer::{ + AccountInterface, Indexer, IndexerRpcConfig, MintInterface, Response, + TokenAccountInterface, TreeInfo, + }, rpc::errors::RpcError, }; @@ -234,4 +237,43 @@ pub trait Rpc: Send + Sync + Debug + 'static { fn get_address_tree_v1(&self) -> TreeInfo; fn get_address_tree_v2(&self) -> TreeInfo; + + // ============ Interface Methods ============ + // These race hot (on-chain) and cold (compressed) lookups in the indexer. + + /// Get account data from either on-chain or compressed sources. + async fn get_account_interface( + &self, + address: &Pubkey, + config: Option, + ) -> Result>, RpcError>; + + /// Get token account data from either on-chain or compressed sources. + async fn get_token_account_interface( + &self, + address: &Pubkey, + config: Option, + ) -> Result>, RpcError>; + + /// Get ATA data from either on-chain or compressed sources. + async fn get_ata_interface( + &self, + owner: &Pubkey, + mint: &Pubkey, + config: Option, + ) -> Result>, RpcError>; + + /// Get mint data from either on-chain or compressed sources. + async fn get_mint_interface( + &self, + address: &Pubkey, + config: Option, + ) -> Result>, RpcError>; + + /// Get multiple account interfaces in a batch. + async fn get_multiple_account_interfaces( + &self, + addresses: Vec<&Pubkey>, + config: Option, + ) -> Result>>, RpcError>; } diff --git a/sdk-libs/photon-api/src/models/account_interface.rs b/sdk-libs/photon-api/src/models/account_interface.rs index 05f9c7ffe4..efae2f6498 100644 --- a/sdk-libs/photon-api/src/models/account_interface.rs +++ b/sdk-libs/photon-api/src/models/account_interface.rs @@ -3,66 +3,33 @@ * * Solana indexer for general compression * - * The version of the OpenAPI document: 0.50.0 + * The version of the OpenAPI document: 0.51.0 * */ use crate::models; -/// AccountInterface : Unified account interface representing either on-chain or compressed account data +/// AccountInterface : Unified account interface — works for both on-chain and compressed accounts #[derive(Clone, Default, Debug, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] pub struct AccountInterface { - /// The account address (pubkey for on-chain, compressed address for compressed) - #[serde(rename = "address")] - pub address: String, - /// Account lamports balance - #[serde(rename = "lamports")] - pub lamports: u64, - /// The program owner of this account - #[serde(rename = "owner")] - pub owner: String, - /// Account data as base64 encoded bytes - #[serde(rename = "data")] - pub data: String, - /// Whether the account is executable (always false for compressed) - #[serde(rename = "executable")] - pub executable: bool, - /// Rent epoch (always 0 for compressed) - #[serde(rename = "rentEpoch")] - pub rent_epoch: u64, - /// Source of the account data - #[serde(rename = "resolvedFrom")] - pub resolved_from: models::ResolvedFrom, - /// Slot at which the account data was resolved - #[serde(rename = "resolvedSlot")] - pub resolved_slot: u64, - /// Additional context for compressed accounts (None for on-chain) - #[serde(rename = "compressedContext", skip_serializing_if = "Option::is_none")] - pub compressed_context: Option>, + /// The on-chain Solana pubkey + #[serde(rename = "key")] + pub key: String, + /// Standard Solana account fields + #[serde(rename = "account")] + pub account: models::SolanaAccountData, + /// Compressed context — null if on-chain, present if compressed + #[serde(rename = "cold", skip_serializing_if = "Option::is_none")] + pub cold: Option, } impl AccountInterface { - #[allow(clippy::too_many_arguments)] - pub fn new( - address: String, - lamports: u64, - owner: String, - data: String, - executable: bool, - rent_epoch: u64, - resolved_from: models::ResolvedFrom, - resolved_slot: u64, - ) -> Self { + pub fn new(key: String, account: models::SolanaAccountData) -> Self { Self { - address, - lamports, - owner, - data, - executable, - rent_epoch, - resolved_from, - resolved_slot, - compressed_context: None, + key, + account, + cold: None, } } } diff --git a/sdk-libs/photon-api/src/models/cold_context.rs b/sdk-libs/photon-api/src/models/cold_context.rs new file mode 100644 index 0000000000..5b8c3faa38 --- /dev/null +++ b/sdk-libs/photon-api/src/models/cold_context.rs @@ -0,0 +1,46 @@ +/* + * photon-indexer + * + * Solana indexer for general compression + * + * The version of the OpenAPI document: 0.51.0 + * + */ + +use crate::models; + +/// ColdContext : Compressed account context — present when account is in compressed state +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(tag = "type", rename_all = "camelCase")] +pub enum ColdContext { + /// Generic compressed account + #[serde(rename = "account")] + Account { + hash: String, + #[serde(rename = "leafIndex")] + leaf_index: u64, + #[serde(rename = "treeInfo")] + tree_info: models::InterfaceTreeInfo, + data: models::ColdData, + }, + /// Compressed token account + #[serde(rename = "token")] + Token { + hash: String, + #[serde(rename = "leafIndex")] + leaf_index: u64, + #[serde(rename = "treeInfo")] + tree_info: models::InterfaceTreeInfo, + data: models::ColdData, + }, + /// Compressed mint account + #[serde(rename = "mint")] + Mint { + hash: String, + #[serde(rename = "leafIndex")] + leaf_index: u64, + #[serde(rename = "treeInfo")] + tree_info: models::InterfaceTreeInfo, + data: models::ColdData, + }, +} diff --git a/sdk-libs/photon-api/src/models/cold_data.rs b/sdk-libs/photon-api/src/models/cold_data.rs new file mode 100644 index 0000000000..718f46bb70 --- /dev/null +++ b/sdk-libs/photon-api/src/models/cold_data.rs @@ -0,0 +1,27 @@ +/* + * photon-indexer + * + * Solana indexer for general compression + * + * The version of the OpenAPI document: 0.51.0 + * + */ + +/// ColdData : Structured compressed account data (discriminator separated) +#[derive(Clone, Default, Debug, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ColdData { + /// First 8 bytes of the account data (discriminator) + pub discriminator: Vec, + /// Remaining account data after discriminator, base64 encoded + pub data: String, +} + +impl ColdData { + pub fn new(discriminator: Vec, data: String) -> Self { + Self { + discriminator, + data, + } + } +} diff --git a/sdk-libs/photon-api/src/models/mod.rs b/sdk-libs/photon-api/src/models/mod.rs index 952f379307..e65c2df490 100644 --- a/sdk-libs/photon-api/src/models/mod.rs +++ b/sdk-libs/photon-api/src/models/mod.rs @@ -367,10 +367,14 @@ pub use self::_get_compressed_mints_by_authority_post_200_response_result::GetCo pub mod _get_compressed_mints_by_authority_post_200_response; pub use self::_get_compressed_mints_by_authority_post_200_response::GetCompressedMintsByAuthorityPost200Response; // Interface types -pub mod resolved_from; -pub use self::resolved_from::ResolvedFrom; -pub mod compressed_context; -pub use self::compressed_context::CompressedContext; +pub mod solana_account_data; +pub use self::solana_account_data::SolanaAccountData; +pub mod cold_data; +pub use self::cold_data::ColdData; +pub mod tree_info; +pub use self::tree_info::TreeInfo as InterfaceTreeInfo; +pub mod cold_context; +pub use self::cold_context::ColdContext; pub mod account_interface; pub use self::account_interface::AccountInterface; pub mod token_account_interface; diff --git a/sdk-libs/photon-api/src/models/solana_account_data.rs b/sdk-libs/photon-api/src/models/solana_account_data.rs new file mode 100644 index 0000000000..9bc29045ad --- /dev/null +++ b/sdk-libs/photon-api/src/models/solana_account_data.rs @@ -0,0 +1,37 @@ +/* + * photon-indexer + * + * Solana indexer for general compression + * + * The version of the OpenAPI document: 0.51.0 + * + */ + +/// SolanaAccountData : Standard Solana account fields (matches getAccountInfo shape) +#[derive(Clone, Default, Debug, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SolanaAccountData { + pub lamports: u64, + pub data: String, + pub owner: String, + pub executable: bool, + pub rent_epoch: u64, +} + +impl SolanaAccountData { + pub fn new( + lamports: u64, + data: String, + owner: String, + executable: bool, + rent_epoch: u64, + ) -> Self { + Self { + lamports, + data, + owner, + executable, + rent_epoch, + } + } +} diff --git a/sdk-libs/photon-api/src/models/tree_info.rs b/sdk-libs/photon-api/src/models/tree_info.rs new file mode 100644 index 0000000000..31984566ae --- /dev/null +++ b/sdk-libs/photon-api/src/models/tree_info.rs @@ -0,0 +1,23 @@ +/* + * photon-indexer + * + * Solana indexer for general compression + * + * The version of the OpenAPI document: 0.51.0 + * + */ + +/// TreeInfo : Merkle tree info for compressed accounts +#[derive(Clone, Default, Debug, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct TreeInfo { + pub tree: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub seq: Option, +} + +impl TreeInfo { + pub fn new(tree: String) -> Self { + Self { tree, seq: None } + } +} diff --git a/sdk-libs/program-test/src/program_test/rpc.rs b/sdk-libs/program-test/src/program_test/rpc.rs index a2f5d6981d..bd71c08d8e 100644 --- a/sdk-libs/program-test/src/program_test/rpc.rs +++ b/sdk-libs/program-test/src/program_test/rpc.rs @@ -366,6 +366,72 @@ impl Rpc for LightProgramTest { "create_and_send_versioned_transaction is unimplemented for LightProgramTest" ); } + + async fn get_account_interface( + &self, + _address: &Pubkey, + _config: Option, + ) -> Result< + light_client::indexer::Response>, + RpcError, + > { + Err(RpcError::CustomError( + "get_account_interface is not supported in program-test context".into(), + )) + } + + async fn get_token_account_interface( + &self, + _address: &Pubkey, + _config: Option, + ) -> Result< + light_client::indexer::Response>, + RpcError, + > { + Err(RpcError::CustomError( + "get_token_account_interface is not supported in program-test context".into(), + )) + } + + async fn get_ata_interface( + &self, + _owner: &Pubkey, + _mint: &Pubkey, + _config: Option, + ) -> Result< + light_client::indexer::Response>, + RpcError, + > { + Err(RpcError::CustomError( + "get_ata_interface is not supported in program-test context".into(), + )) + } + + async fn get_mint_interface( + &self, + _address: &Pubkey, + _config: Option, + ) -> Result< + light_client::indexer::Response>, + RpcError, + > { + Err(RpcError::CustomError( + "get_mint_interface is not supported in program-test context".into(), + )) + } + + async fn get_multiple_account_interfaces( + &self, + _addresses: Vec<&Pubkey>, + _config: Option, + ) -> Result< + light_client::indexer::Response>>, + RpcError, + > { + Err(RpcError::CustomError( + "get_multiple_account_interfaces is not supported in program-test context".into(), + )) + } } impl LightProgramTest { From 5f120247f5622e2a1a9b8552bfc9089477b7eb24 Mon Sep 17 00:00:00 2001 From: Sergey Timoshin Date: Wed, 28 Jan 2026 13:51:49 +0000 Subject: [PATCH 22/38] cleanup --- forester/src/compressible/mint/bootstrap.rs | 3 ++- forester/tests/test_compressible_mint.rs | 2 +- sdk-libs/client/src/indexer/types.rs | 10 +++++----- sdk-libs/photon-api/src/models/cold_data.rs | 4 ++-- sdk-libs/photon-api/src/models/compressed_mint_list.rs | 1 + 5 files changed, 11 insertions(+), 9 deletions(-) diff --git a/forester/src/compressible/mint/bootstrap.rs b/forester/src/compressible/mint/bootstrap.rs index ba04d59cd9..104c8dd00c 100644 --- a/forester/src/compressible/mint/bootstrap.rs +++ b/forester/src/compressible/mint/bootstrap.rs @@ -54,9 +54,10 @@ pub async fn bootstrap_mint_accounts( .await?; info!( - "Mint bootstrap finished: {} total mints tracked (fetched: {}, pages: {})", + "Mint bootstrap finished: {} total mints tracked (fetched: {}, inserted: {}, pages: {})", tracker.len(), result.fetched, + result.inserted, result.pages ); diff --git a/forester/tests/test_compressible_mint.rs b/forester/tests/test_compressible_mint.rs index 7b3cf0e04c..d3130ea525 100644 --- a/forester/tests/test_compressible_mint.rs +++ b/forester/tests/test_compressible_mint.rs @@ -405,7 +405,7 @@ async fn test_compressible_mint_compression() { .get_account(mint_pda) .await .expect("Failed to query mint account"); - if mint_after.is_none() { + if mint_after.is_none() || mint_after.as_ref().map(|a| a.lamports) == Some(0) { account_closed = true; println!("Mint account closed successfully!"); break; diff --git a/sdk-libs/client/src/indexer/types.rs b/sdk-libs/client/src/indexer/types.rs index 088c96b93a..b253ce4d68 100644 --- a/sdk-libs/client/src/indexer/types.rs +++ b/sdk-libs/client/src/indexer/types.rs @@ -1057,7 +1057,7 @@ pub struct MintData { pub version: u8, /// Whether the mint has been decompressed pub mint_decompressed: bool, - /// Serialized extensions (base64 encoded) + /// Serialized extensions (decoded bytes; base64 decoded in `TryFrom`) pub extensions: Option>, } @@ -1137,7 +1137,7 @@ pub struct InterfaceTreeInfo { /// Structured compressed account data (discriminator separated) #[derive(Clone, Debug, PartialEq)] pub struct ColdData { - pub discriminator: Vec, + pub discriminator: [u8; 8], pub data: Vec, } @@ -1182,7 +1182,7 @@ fn convert_cold_context( seq: tree_info.seq, }, data: ColdData { - discriminator: data.discriminator.clone(), + discriminator: data.discriminator, data: base64::decode_config(&data.data, base64::STANDARD_NO_PAD) .map_err(|_| IndexerError::InvalidResponseData)?, }, @@ -1200,7 +1200,7 @@ fn convert_cold_context( seq: tree_info.seq, }, data: ColdData { - discriminator: data.discriminator.clone(), + discriminator: data.discriminator, data: base64::decode_config(&data.data, base64::STANDARD_NO_PAD) .map_err(|_| IndexerError::InvalidResponseData)?, }, @@ -1218,7 +1218,7 @@ fn convert_cold_context( seq: tree_info.seq, }, data: ColdData { - discriminator: data.discriminator.clone(), + discriminator: data.discriminator, data: base64::decode_config(&data.data, base64::STANDARD_NO_PAD) .map_err(|_| IndexerError::InvalidResponseData)?, }, diff --git a/sdk-libs/photon-api/src/models/cold_data.rs b/sdk-libs/photon-api/src/models/cold_data.rs index 718f46bb70..318e7939f5 100644 --- a/sdk-libs/photon-api/src/models/cold_data.rs +++ b/sdk-libs/photon-api/src/models/cold_data.rs @@ -12,13 +12,13 @@ #[serde(rename_all = "camelCase")] pub struct ColdData { /// First 8 bytes of the account data (discriminator) - pub discriminator: Vec, + pub discriminator: [u8; 8], /// Remaining account data after discriminator, base64 encoded pub data: String, } impl ColdData { - pub fn new(discriminator: Vec, data: String) -> Self { + pub fn new(discriminator: [u8; 8], data: String) -> Self { Self { discriminator, data, diff --git a/sdk-libs/photon-api/src/models/compressed_mint_list.rs b/sdk-libs/photon-api/src/models/compressed_mint_list.rs index 452064a965..96ee8d3e06 100644 --- a/sdk-libs/photon-api/src/models/compressed_mint_list.rs +++ b/sdk-libs/photon-api/src/models/compressed_mint_list.rs @@ -9,6 +9,7 @@ */ use crate::models; +use serde::{Deserialize, Serialize}; #[derive(Clone, Default, Debug, PartialEq, Serialize, Deserialize)] pub struct CompressedMintList { From d2fbfa9bc2a1fc7a42758c910f2b846a2f7305b3 Mon Sep 17 00:00:00 2001 From: Sergey Timoshin Date: Wed, 28 Jan 2026 14:01:55 +0000 Subject: [PATCH 23/38] format --- forester/src/compressible/mint/compressor.rs | 16 ++++++++-------- .../src/models/compressed_mint_list.rs | 3 ++- 2 files changed, 10 insertions(+), 9 deletions(-) diff --git a/forester/src/compressible/mint/compressor.rs b/forester/src/compressible/mint/compressor.rs index ae063fb8fb..1c2cd6e317 100644 --- a/forester/src/compressible/mint/compressor.rs +++ b/forester/src/compressible/mint/compressor.rs @@ -77,14 +77,14 @@ impl MintCompressor { mint_seed, true, // idempotent ) - .await - .map_err(|e| { - anyhow::anyhow!( + .await + .map_err(|e| { + anyhow::anyhow!( "Failed to build CompressAndCloseMint instruction for {}: {:?}", mint_pda, e ) - })?; + })?; Ok::(ix) } @@ -224,10 +224,10 @@ impl MintCompressor { *mint_seed, true, // idempotent ) - .await - .map_err(|e| { - anyhow::anyhow!("Failed to build CompressAndCloseMint instruction: {:?}", e) - })?; + .await + .map_err(|e| { + anyhow::anyhow!("Failed to build CompressAndCloseMint instruction: {:?}", e) + })?; debug!( "Built CompressAndCloseMint instruction for Mint {}", diff --git a/sdk-libs/photon-api/src/models/compressed_mint_list.rs b/sdk-libs/photon-api/src/models/compressed_mint_list.rs index 96ee8d3e06..ffb9710d62 100644 --- a/sdk-libs/photon-api/src/models/compressed_mint_list.rs +++ b/sdk-libs/photon-api/src/models/compressed_mint_list.rs @@ -8,9 +8,10 @@ * Generated by: https://openapi-generator.tech */ -use crate::models; use serde::{Deserialize, Serialize}; +use crate::models; + #[derive(Clone, Default, Debug, PartialEq, Serialize, Deserialize)] pub struct CompressedMintList { #[serde(rename = "items")] From 11215b3e96f7fd4f4e2cc37ee0115d31be9938b2 Mon Sep 17 00:00:00 2001 From: Sergey Timoshin Date: Thu, 29 Jan 2026 16:34:33 +0000 Subject: [PATCH 24/38] refactor account interface --- forester/src/compressible/mint/state.rs | 11 - forester/tests/test_compressible_mint.rs | 10 +- forester/tests/test_indexer_interface.rs | 20 +- justfile | 2 +- sdk-libs/client/src/indexer/base58.rs | 7 +- sdk-libs/client/src/indexer/types.rs | 172 ++++++--- .../client/src/interface/account_interface.rs | 14 +- .../src/interface/account_interface_ext.rs | 290 --------------- .../client/src/interface/decompress_mint.rs | 51 ++- .../src/interface/light_program_interface.rs | 2 +- sdk-libs/client/src/interface/mod.rs | 2 - sdk-libs/client/src/rpc/client.rs | 341 +++++++++++++++++- sdk-libs/client/src/rpc/rpc_trait.rs | 70 +++- .../src/models/solana_account_data.rs | 3 + .../program-test/src/indexer/test_indexer.rs | 63 +++- sdk-libs/program-test/src/program_test/rpc.rs | 231 +++++++++--- .../tests/amm_test.rs | 20 +- .../tests/basic_test.rs | 33 +- .../tests/integration_tests.rs | 36 +- 19 files changed, 907 insertions(+), 471 deletions(-) delete mode 100644 sdk-libs/client/src/interface/account_interface_ext.rs diff --git a/forester/src/compressible/mint/state.rs b/forester/src/compressible/mint/state.rs index f84f5afda9..c08fc74f1a 100644 --- a/forester/src/compressible/mint/state.rs +++ b/forester/src/compressible/mint/state.rs @@ -21,17 +21,6 @@ fn calculate_compressible_slot(mint: &Mint, lamports: u64, account_size: usize) let compression_info = &mint.compression; let config = &compression_info.rent_config; - // Calculate available balance after rent exemption and compression cost - let available_balance = lamports - .saturating_sub(rent_exemption) - .saturating_sub(config.compression_cost as u64); - let rent_per_epoch = config.rent_curve_per_epoch(account_size as u64); - - // If no epochs are funded (rent_payment=0), the account is immediately compressible - if rent_per_epoch == 0 || available_balance / rent_per_epoch == 0 { - return Ok(0); - } - let last_funded_epoch = get_last_funded_epoch( account_size as u64, lamports, diff --git a/forester/tests/test_compressible_mint.rs b/forester/tests/test_compressible_mint.rs index d3130ea525..396aec0d04 100644 --- a/forester/tests/test_compressible_mint.rs +++ b/forester/tests/test_compressible_mint.rs @@ -389,10 +389,7 @@ async fn test_compressible_mint_compression() { println!("Compressing Mint..."); let compress_result = compressor.compress_batch(&ready_accounts).await; - let signatures = compress_result.expect("Compression should succeed"); - let signature = signatures - .last() - .expect("Should have at least one signature"); + let signature = compress_result.expect("Compression should succeed"); println!("Compression transaction sent: {}", signature); // Wait for account to be closed @@ -625,13 +622,10 @@ async fn test_compressible_mint_subscription() { .clone(); println!("Compressing first mint: {}", mint_pda_1); - let signatures = compressor + let signature = compressor .compress_batch(&[first_mint_state]) .await .expect("Compression should succeed"); - let signature = signatures - .last() - .expect("Should have at least one signature"); println!("Compression tx sent: {}", signature); diff --git a/forester/tests/test_indexer_interface.rs b/forester/tests/test_indexer_interface.rs index e5b6125890..e4033c08aa 100644 --- a/forester/tests/test_indexer_interface.rs +++ b/forester/tests/test_indexer_interface.rs @@ -33,15 +33,19 @@ use light_compressed_token::{ process_transfer::transfer_sdk::to_account_metas, }; use light_test_utils::{ - pack::pack_new_address_params_assigned, spl::create_mint_helper_with_keypair, + actions::legacy::{ + create_compressible_token_account, + instructions::mint_action::{ + create_mint_action_instruction, MintActionParams, MintActionType, + }, + CreateCompressibleTokenAccountInputs, + }, + pack::pack_new_address_params_assigned, + spl::create_mint_helper_with_keypair, }; use light_token::instruction::{ derive_mint_compressed_address, find_mint_address, CreateMint, CreateMintParams, }; -use light_token_client::{ - actions::{create_compressible_token_account, CreateCompressibleTokenAccountInputs}, - instructions::mint_action::{create_mint_action_instruction, MintActionParams, MintActionType}, -}; use light_token_interface::state::TokenDataVersion; use serial_test::serial; use solana_sdk::{ @@ -180,7 +184,7 @@ async fn test_indexer_interface_scenarios() { println!("Derived v2 address: {:?}", derived_address); // Get validity proof for the new address - wait_for_indexer(&mut rpc).await.unwrap(); + wait_for_indexer(&rpc).await.unwrap(); let proof_result = rpc .indexer() .unwrap() @@ -397,7 +401,7 @@ async fn test_indexer_interface_scenarios() { // Wait for indexer to process the CreateMint sleep(Duration::from_secs(3)).await; - wait_for_indexer(&mut rpc).await.unwrap(); + wait_for_indexer(&rpc).await.unwrap(); // Now compress and close the mint to make it fully compressed println!("Compressing mint via CompressAndCloseMint..."); @@ -492,7 +496,7 @@ async fn test_indexer_interface_scenarios() { // Wait for indexer to sync sleep(Duration::from_secs(3)).await; - wait_for_indexer(&mut rpc).await.unwrap(); + wait_for_indexer(&rpc).await.unwrap(); // ============ Test 1: getMintInterface with decompressed mint (on-chain CMint) ============ println!("Test 1: getMintInterface with decompressed mint (on-chain CMint)..."); diff --git a/justfile b/justfile index e3624721d5..325ac4c250 100644 --- a/justfile +++ b/justfile @@ -36,7 +36,7 @@ lint: lint-rust js::lint lint-rust: cargo +nightly fmt --all -- --check - cargo clippy --workspace --all-features -- -D warnings + cargo clippy --workspace --all-features --tests -- -D warnings format: cargo +nightly fmt --all diff --git a/sdk-libs/client/src/indexer/base58.rs b/sdk-libs/client/src/indexer/base58.rs index a2b66a123f..46b3953aa3 100644 --- a/sdk-libs/client/src/indexer/base58.rs +++ b/sdk-libs/client/src/indexer/base58.rs @@ -38,10 +38,13 @@ pub fn decode_base58_to_fixed_array(input: &str) -> Result<[u8; let mut buffer = [0u8; N]; let decoded_len = bs58::decode(input) .onto(&mut buffer) - .map_err(|_| IndexerError::InvalidResponseData)?; + .map_err(|e| IndexerError::base58_decode_error("base58", e))?; if decoded_len != N { - return Err(IndexerError::InvalidResponseData); + return Err(IndexerError::base58_decode_error( + "base58", + format!("expected {} bytes, got {}", N, decoded_len), + )); } Ok(buffer) diff --git a/sdk-libs/client/src/indexer/types.rs b/sdk-libs/client/src/indexer/types.rs index b253ce4d68..add7fb9403 100644 --- a/sdk-libs/client/src/indexer/types.rs +++ b/sdk-libs/client/src/indexer/types.rs @@ -11,8 +11,13 @@ use light_indexed_merkle_tree::array::IndexedElement; use light_sdk::instruction::{ PackedAccounts, PackedAddressTreeInfo, PackedStateTreeInfo, ValidityProof, }; -use light_token::compat::{AccountState, TokenData}; -use light_token_interface::state::ExtensionStruct; +use light_token::{ + compat::{AccountState, TokenData}, + instruction::find_mint_address, +}; +use light_token_interface::state::{ + BaseMint, CompressionInfo, ExtensionStruct, Mint as LightMint, MintMetadata, ACCOUNT_TYPE_MINT, +}; use num_bigint::BigUint; use solana_pubkey::Pubkey; use tracing::warn; @@ -408,17 +413,17 @@ impl ValidityProofWithContext { .compressed_proof .a .try_into() - .map_err(|_| IndexerError::InvalidResponseData)?, + .map_err(|_| IndexerError::decode_error("proof.a", "invalid length"))?, b: value .compressed_proof .b .try_into() - .map_err(|_| IndexerError::InvalidResponseData)?, + .map_err(|_| IndexerError::decode_error("proof.b", "invalid length"))?, c: value .compressed_proof .c .try_into() - .map_err(|_| IndexerError::InvalidResponseData)?, + .map_err(|_| IndexerError::decode_error("proof.c", "invalid length"))?, })); // Convert account data from V1 flat arrays to V2 structured format @@ -428,7 +433,10 @@ impl ValidityProofWithContext { Pubkey::new_from_array(decode_base58_to_fixed_array(&value.merkle_trees[i])?); let tree_info = super::tree_info::QUEUE_TREE_MAPPING .get(&value.merkle_trees[i]) - .ok_or(IndexerError::InvalidResponseData)?; + .ok_or(IndexerError::MissingResult { + context: "conversion".into(), + message: "expected value was None".into(), + })?; Ok(AccountProofInputs { hash: decode_base58_to_fixed_array(&value.leaves[i])?, @@ -455,7 +463,10 @@ impl ValidityProofWithContext { )?); let tree_info = super::tree_info::QUEUE_TREE_MAPPING .get(&value.merkle_trees[i]) - .ok_or(IndexerError::InvalidResponseData)?; + .ok_or(IndexerError::MissingResult { + context: "conversion".into(), + message: "expected value was None".into(), + })?; Ok(AddressProofInputs { address: decode_base58_to_fixed_array(&value.leaves[i])?, // Address is in leaves @@ -490,15 +501,15 @@ impl ValidityProofWithContext { a: proof .a .try_into() - .map_err(|_| IndexerError::InvalidResponseData)?, + .map_err(|_| IndexerError::decode_error("proof.a", "invalid length"))?, b: proof .b .try_into() - .map_err(|_| IndexerError::InvalidResponseData)?, + .map_err(|_| IndexerError::decode_error("proof.b", "invalid length"))?, c: proof .c .try_into() - .map_err(|_| IndexerError::InvalidResponseData)?, + .map_err(|_| IndexerError::decode_error("proof.c", "invalid length"))?, })) } else { ValidityProof::new(None) @@ -659,7 +670,7 @@ impl TryFrom for CompressedAccount { fn try_from(account: CompressedAccountWithMerkleContext) -> Result { let hash = account .hash() - .map_err(|_| IndexerError::InvalidResponseData)?; + .map_err(|e| IndexerError::decode_error("data", e))?; // Breaks light-program-test let tree_info = QUEUE_TREE_MAPPING.get( &Pubkey::new_from_array(account.merkle_context.merkle_tree_pubkey.to_bytes()) @@ -721,7 +732,7 @@ impl TryFrom<&photon_api::models::AccountV2> for CompressedAccount { Ok::, IndexerError>(Some(CompressedAccountData { discriminator: data.discriminator.to_le_bytes(), data: base64::decode_config(&data.data, base64::STANDARD_NO_PAD) - .map_err(|_| IndexerError::InvalidResponseData)?, + .map_err(|e| IndexerError::decode_error("data", e))?, data_hash: decode_base58_to_fixed_array(&data.data_hash)?, })) } else { @@ -776,7 +787,7 @@ impl TryFrom<&photon_api::models::Account> for CompressedAccount { Ok::, IndexerError>(Some(CompressedAccountData { discriminator: data.discriminator.to_le_bytes(), data: base64::decode_config(&data.data, base64::STANDARD_NO_PAD) - .map_err(|_| IndexerError::InvalidResponseData)?, + .map_err(|e| IndexerError::decode_error("data", e))?, data_hash: decode_base58_to_fixed_array(&data.data_hash)?, })) } else { @@ -794,9 +805,13 @@ impl TryFrom<&photon_api::models::Account> for CompressedAccount { let lamports = account.lamports; let leaf_index = account.leaf_index; - let tree_info = QUEUE_TREE_MAPPING - .get(&account.tree) - .ok_or(IndexerError::InvalidResponseData)?; + let tree_info = + QUEUE_TREE_MAPPING + .get(&account.tree) + .ok_or(IndexerError::MissingResult { + context: "conversion".into(), + message: "expected value was None".into(), + })?; let tree_info = TreeInfo { cpi_context: tree_info.cpi_context, @@ -886,9 +901,9 @@ impl TryFrom<&photon_api::models::TokenAccount> for CompressedTokenAccount { .as_ref() .map(|tlv| { let bytes = base64::decode_config(tlv, base64::STANDARD_NO_PAD) - .map_err(|_| IndexerError::InvalidResponseData)?; + .map_err(|e| IndexerError::decode_error("tlv", e))?; Vec::::deserialize(&mut bytes.as_slice()) - .map_err(|_| IndexerError::InvalidResponseData) + .map_err(|e| IndexerError::decode_error("extensions", e)) }) .transpose()?, }; @@ -927,9 +942,9 @@ impl TryFrom<&photon_api::models::TokenAccountV2> for CompressedTokenAccount { .as_ref() .map(|tlv| { let bytes = base64::decode_config(tlv, base64::STANDARD_NO_PAD) - .map_err(|_| IndexerError::InvalidResponseData)?; + .map_err(|e| IndexerError::decode_error("tlv", e))?; Vec::::deserialize(&mut bytes.as_slice()) - .map_err(|_| IndexerError::InvalidResponseData) + .map_err(|e| IndexerError::decode_error("extensions", e)) }) .transpose()?, }; @@ -1087,13 +1102,68 @@ impl TryFrom<&photon_api::models::MintData> for MintData { .as_ref() .map(|ext| { base64::decode_config(ext, base64::STANDARD_NO_PAD) - .map_err(|_| IndexerError::InvalidResponseData) + .map_err(|e| IndexerError::decode_error("extensions", e)) }) .transpose()?, }) } } +impl MintData { + /// Convert to `light_token_interface::state::Mint`. + /// + /// This reconstructs the full Mint struct from the indexed data. + /// Note: `CompressionInfo` is defaulted since it's not stored by the indexer. + pub fn to_light_mint(&self) -> Result { + // Derive bump from mint_signer + let mint_signer_pubkey = Pubkey::new_from_array(self.mint_signer); + let (derived_pda, bump) = find_mint_address(&mint_signer_pubkey); + + // Verify derived PDA matches stored mint_pda (sanity check) + if derived_pda != self.mint_pda { + warn!( + "Derived mint PDA {} does not match stored mint_pda {}", + derived_pda, self.mint_pda + ); + } + + // Parse extensions if present + let extensions = self + .extensions + .as_ref() + .map(|ext_bytes| { + Vec::::deserialize(&mut ext_bytes.as_slice()) + .map_err(|e| IndexerError::decode_error("extensions", e)) + }) + .transpose()?; + + Ok(LightMint { + base: BaseMint { + mint_authority: self + .mint_authority + .map(|p| light_compressed_account::Pubkey::new_from_array(p.to_bytes())), + supply: self.supply, + decimals: self.decimals, + is_initialized: true, // Always true for indexed mints + freeze_authority: self + .freeze_authority + .map(|p| light_compressed_account::Pubkey::new_from_array(p.to_bytes())), + }, + metadata: MintMetadata { + version: self.version, + mint_decompressed: self.mint_decompressed, + mint: light_compressed_account::Pubkey::new_from_array(self.mint_pda.to_bytes()), + mint_signer: self.mint_signer, + bump, + }, + reserved: [0u8; 16], + account_type: ACCOUNT_TYPE_MINT, + compression: CompressionInfo::default(), // Not stored by indexer + extensions, + }) + } +} + /// A compressed mint with its account data #[derive(Clone, Default, Debug, PartialEq)] pub struct CompressedMint { @@ -1125,6 +1195,7 @@ pub struct SolanaAccountData { pub owner: Pubkey, pub executable: bool, pub rent_epoch: u64, + pub space: u64, } /// Merkle tree info for compressed accounts @@ -1164,6 +1235,25 @@ pub enum ColdContext { }, } +/// Decode tree info from photon_api format +fn decode_tree_info( + tree_info: &photon_api::models::InterfaceTreeInfo, +) -> Result { + Ok(InterfaceTreeInfo { + tree: Pubkey::new_from_array(decode_base58_to_fixed_array(&tree_info.tree)?), + seq: tree_info.seq, + }) +} + +/// Decode cold data from photon_api format +fn decode_cold_data(data: &photon_api::models::ColdData) -> Result { + Ok(ColdData { + discriminator: data.discriminator, + data: base64::decode_config(&data.data, base64::STANDARD_NO_PAD) + .map_err(|e| IndexerError::decode_error("data", e))?, + }) +} + /// Helper to convert photon_api ColdContext to client ColdContext fn convert_cold_context( cold: &photon_api::models::ColdContext, @@ -1177,15 +1267,8 @@ fn convert_cold_context( } => Ok(ColdContext::Account { hash: decode_base58_to_fixed_array(hash)?, leaf_index: *leaf_index, - tree_info: InterfaceTreeInfo { - tree: Pubkey::new_from_array(decode_base58_to_fixed_array(&tree_info.tree)?), - seq: tree_info.seq, - }, - data: ColdData { - discriminator: data.discriminator, - data: base64::decode_config(&data.data, base64::STANDARD_NO_PAD) - .map_err(|_| IndexerError::InvalidResponseData)?, - }, + tree_info: decode_tree_info(tree_info)?, + data: decode_cold_data(data)?, }), photon_api::models::ColdContext::Token { hash, @@ -1195,15 +1278,8 @@ fn convert_cold_context( } => Ok(ColdContext::Token { hash: decode_base58_to_fixed_array(hash)?, leaf_index: *leaf_index, - tree_info: InterfaceTreeInfo { - tree: Pubkey::new_from_array(decode_base58_to_fixed_array(&tree_info.tree)?), - seq: tree_info.seq, - }, - data: ColdData { - discriminator: data.discriminator, - data: base64::decode_config(&data.data, base64::STANDARD_NO_PAD) - .map_err(|_| IndexerError::InvalidResponseData)?, - }, + tree_info: decode_tree_info(tree_info)?, + data: decode_cold_data(data)?, }), photon_api::models::ColdContext::Mint { hash, @@ -1213,15 +1289,8 @@ fn convert_cold_context( } => Ok(ColdContext::Mint { hash: decode_base58_to_fixed_array(hash)?, leaf_index: *leaf_index, - tree_info: InterfaceTreeInfo { - tree: Pubkey::new_from_array(decode_base58_to_fixed_array(&tree_info.tree)?), - seq: tree_info.seq, - }, - data: ColdData { - discriminator: data.discriminator, - data: base64::decode_config(&data.data, base64::STANDARD_NO_PAD) - .map_err(|_| IndexerError::InvalidResponseData)?, - }, + tree_info: decode_tree_info(tree_info)?, + data: decode_cold_data(data)?, }), } } @@ -1256,7 +1325,7 @@ fn convert_account_interface( let cold = ai.cold.as_ref().map(convert_cold_context).transpose()?; let data = base64::decode_config(&ai.account.data, base64::STANDARD_NO_PAD) - .map_err(|_| IndexerError::InvalidResponseData)?; + .map_err(|e| IndexerError::decode_error("account.data", e))?; Ok(AccountInterface { key: Pubkey::new_from_array(decode_base58_to_fixed_array(&ai.key)?), @@ -1266,6 +1335,7 @@ fn convert_account_interface( owner: Pubkey::new_from_array(decode_base58_to_fixed_array(&ai.account.owner)?), executable: ai.account.executable, rent_epoch: ai.account.rent_epoch, + space: ai.account.space, }, cold, }) @@ -1331,9 +1401,9 @@ impl TryFrom<&photon_api::models::TokenAccountInterface> for TokenAccountInterfa .as_ref() .map(|tlv| { let bytes = base64::decode_config(tlv, base64::STANDARD_NO_PAD) - .map_err(|_| IndexerError::InvalidResponseData)?; + .map_err(|e| IndexerError::decode_error("tlv", e))?; Vec::::deserialize(&mut bytes.as_slice()) - .map_err(|_| IndexerError::InvalidResponseData) + .map_err(|e| IndexerError::decode_error("extensions", e)) }) .transpose()?, }; diff --git a/sdk-libs/client/src/interface/account_interface.rs b/sdk-libs/client/src/interface/account_interface.rs index 4c04469b7f..2502d0e40f 100644 --- a/sdk-libs/client/src/interface/account_interface.rs +++ b/sdk-libs/client/src/interface/account_interface.rs @@ -43,7 +43,7 @@ pub enum AccountInterfaceError { /// Uses standard `solana_account::Account` for raw data. /// For hot accounts: actual on-chain bytes. /// For cold accounts: synthetic bytes from cold data. -#[derive(Debug, Clone)] +#[derive(Debug, Clone, PartialEq, Default)] pub struct AccountInterface { /// The account's public key. pub key: Pubkey, @@ -218,7 +218,7 @@ impl AccountInterface { /// /// For ATAs: `parsed.owner` is the wallet owner (set from fetch params). /// For program-owned: `parsed.owner` is the PDA. -#[derive(Debug, Clone)] +#[derive(Debug, Clone, PartialEq, Default)] pub struct TokenAccountInterface { /// The token account's public key. pub key: Pubkey, @@ -390,3 +390,13 @@ impl TokenAccountInterface { self.ata_bump().is_some() } } + +impl From for AccountInterface { + fn from(tai: TokenAccountInterface) -> Self { + Self { + key: tai.key, + account: tai.account, + cold: tai.cold, + } + } +} diff --git a/sdk-libs/client/src/interface/account_interface_ext.rs b/sdk-libs/client/src/interface/account_interface_ext.rs deleted file mode 100644 index 0f326502c0..0000000000 --- a/sdk-libs/client/src/interface/account_interface_ext.rs +++ /dev/null @@ -1,290 +0,0 @@ -use async_trait::async_trait; -use borsh::BorshDeserialize as _; -use light_compressed_account::address::derive_address; -use light_token::instruction::derive_token_ata; -use light_token_interface::{state::Mint, MINT_ADDRESS_TREE}; -use solana_pubkey::Pubkey; - -use super::{AccountInterface, AccountToFetch, MintInterface, MintState, TokenAccountInterface}; -use crate::{ - indexer::{GetCompressedTokenAccountsByOwnerOrDelegateOptions, Indexer}, - rpc::{Rpc, RpcError}, -}; - -fn indexer_err(e: impl std::fmt::Display) -> RpcError { - RpcError::CustomError(format!("IndexerError: {}", e)) -} - -/// Extension trait for fetching account interfaces (unified hot/cold handling). -#[async_trait] -pub trait AccountInterfaceExt: Rpc + Indexer { - /// Fetch MintInterface for a mint account. - /// - /// Use this instead of get_account + unpack_mint. - async fn get_mint_interface(&self, address: &Pubkey) -> Result; - - /// Fetch AccountInterface for an account. - /// - /// Use this instead of get_account. - async fn get_account_interface( - &self, - address: &Pubkey, - program_id: &Pubkey, - ) -> Result; - - /// Fetch TokenAccountInterface for a token account. - /// - /// Use this instead of get_token_account. - async fn get_token_account_interface( - &self, - address: &Pubkey, - ) -> Result; - - /// Fetch TokenAccountInterface for an associated token account. - /// - /// Use this for all ATAs. - async fn get_ata_interface( - &self, - owner: &Pubkey, - mint: &Pubkey, - ) -> Result; - - /// Fetch multiple accounts with automatic type dispatch. - /// - /// Use this instead of get_multiple_accounts. - async fn get_multiple_account_interfaces( - &self, - accounts: &[AccountToFetch], - ) -> Result, RpcError>; -} - -// TODO: move all these to native RPC methods with single roundtrip. -#[async_trait] -impl AccountInterfaceExt for T { - async fn get_mint_interface(&self, address: &Pubkey) -> Result { - let address_tree = Pubkey::new_from_array(MINT_ADDRESS_TREE); - let compressed_address = derive_address( - &address.to_bytes(), - &address_tree.to_bytes(), - &light_token_interface::LIGHT_TOKEN_PROGRAM_ID, - ); - - // Hot - if let Some(account) = self.get_account(*address).await? { - if account.lamports > 0 { - return Ok(MintInterface { - mint: *address, - address_tree, - compressed_address, - state: MintState::Hot { account }, - }); - } - } - - // Cold - let result = self - .get_compressed_account(compressed_address, None) - .await - .map_err(indexer_err)?; - - if let Some(compressed) = result.value { - if let Some(data) = compressed.data.as_ref() { - if !data.data.is_empty() { - let mint_data = Mint::try_from_slice(&data.data) - .map_err(|e| RpcError::CustomError(format!("mint parse error: {}", e)))?; - return Ok(MintInterface { - mint: *address, - address_tree, - compressed_address, - state: MintState::Cold { - compressed, - mint_data, - }, - }); - } - } - } - - Ok(MintInterface { - mint: *address, - address_tree, - compressed_address, - state: MintState::None, - }) - } - - async fn get_account_interface( - &self, - address: &Pubkey, - program_id: &Pubkey, - ) -> Result { - let address_tree = self.get_address_tree_v2().tree; - let compressed_address = derive_address( - &address.to_bytes(), - &address_tree.to_bytes(), - &program_id.to_bytes(), - ); - - // Hot - if let Some(account) = self.get_account(*address).await? { - if account.lamports > 0 { - return Ok(AccountInterface::hot(*address, account)); - } - } - - // Cold - let result = self - .get_compressed_account(compressed_address, None) - .await - .map_err(indexer_err)?; - - if let Some(compressed) = result.value { - if compressed.data.as_ref().is_some_and(|d| !d.data.is_empty()) { - return Ok(AccountInterface::cold(*address, compressed, *program_id)); - } - } - - // Doesn't exist. - let account = solana_account::Account { - lamports: 0, - data: vec![], - owner: *program_id, - executable: false, - rent_epoch: 0, - }; - Ok(AccountInterface::hot(*address, account)) - } - - async fn get_token_account_interface( - &self, - address: &Pubkey, - ) -> Result { - use light_sdk::constants::LIGHT_TOKEN_PROGRAM_ID; - - // Hot - if let Some(account) = self.get_account(*address).await? { - if account.lamports > 0 { - return TokenAccountInterface::hot(*address, account) - .map_err(|e| RpcError::CustomError(format!("parse error: {}", e))); - } - } - - // Cold (program-owned tokens: address = owner) - let result = self - .get_compressed_token_accounts_by_owner(address, None, None) - .await - .map_err(indexer_err)?; - - if let Some(compressed) = result.value.items.into_iter().next() { - return Ok(TokenAccountInterface::cold( - *address, - compressed, - *address, // owner = hot address - LIGHT_TOKEN_PROGRAM_ID.into(), - )); - } - - Err(RpcError::CustomError(format!( - "token account not found: {}", - address - ))) - } - - async fn get_ata_interface( - &self, - owner: &Pubkey, - mint: &Pubkey, - ) -> Result { - use light_sdk::constants::LIGHT_TOKEN_PROGRAM_ID; - - let (ata, _bump) = derive_token_ata(owner, mint); - - // Hot - if let Some(account) = self.get_account(ata).await? { - if account.lamports > 0 { - return TokenAccountInterface::hot(ata, account) - .map_err(|e| RpcError::CustomError(format!("parse error: {}", e))); - } - } - - // Cold (ATA query by address) - let options = Some(GetCompressedTokenAccountsByOwnerOrDelegateOptions::new( - Some(*mint), - )); - let result = self - .get_compressed_token_accounts_by_owner(&ata, options, None) - .await - .map_err(indexer_err)?; - - if let Some(compressed) = result.value.items.into_iter().next() { - return Ok(TokenAccountInterface::cold( - ata, - compressed, - *owner, // owner_override = wallet owner - LIGHT_TOKEN_PROGRAM_ID.into(), - )); - } - - Err(RpcError::CustomError(format!( - "ATA not found: owner={} mint={}", - owner, mint - ))) - } - - async fn get_multiple_account_interfaces( - &self, - accounts: &[AccountToFetch], - ) -> Result, RpcError> { - // TODO: concurrent with futures - let mut result = Vec::with_capacity(accounts.len()); - - for account in accounts { - let iface = match account { - AccountToFetch::Pda { - address, - program_id, - } => AccountInterfaceExt::get_account_interface(self, address, program_id).await?, - AccountToFetch::Token { address } => { - let token_iface = - AccountInterfaceExt::get_token_account_interface(self, address).await?; - AccountInterface { - key: token_iface.key, - account: token_iface.account, - cold: token_iface.cold, - } - } - AccountToFetch::Ata { wallet_owner, mint } => { - let token_iface = - AccountInterfaceExt::get_ata_interface(self, wallet_owner, mint).await?; - AccountInterface { - key: token_iface.key, - account: token_iface.account, - cold: token_iface.cold, - } - } - AccountToFetch::Mint { address } => { - let mint_iface = AccountInterfaceExt::get_mint_interface(self, address).await?; - match mint_iface.state { - MintState::Hot { account } => AccountInterface { - key: mint_iface.mint, - account, - cold: None, - }, - MintState::Cold { compressed, .. } => { - let owner = compressed.owner; - AccountInterface::cold(mint_iface.mint, compressed, owner) - } - MintState::None => AccountInterface { - key: mint_iface.mint, - account: Default::default(), - cold: None, - }, - } - } - }; - result.push(iface); - } - - Ok(result) - } -} diff --git a/sdk-libs/client/src/interface/decompress_mint.rs b/sdk-libs/client/src/interface/decompress_mint.rs index d877c1ce71..09690f6af2 100644 --- a/sdk-libs/client/src/interface/decompress_mint.rs +++ b/sdk-libs/client/src/interface/decompress_mint.rs @@ -13,6 +13,7 @@ use solana_instruction::Instruction; use solana_pubkey::Pubkey; use thiserror::Error; +use super::{AccountInterface, ColdContext}; use crate::indexer::{CompressedAccount, Indexer, ValidityProofWithContext}; /// Error type for mint load operations. @@ -38,7 +39,7 @@ pub enum DecompressMintError { } /// Mint state: hot (on-chain), cold (compressed), or none. -#[derive(Debug, Clone)] +#[derive(Debug, Clone, PartialEq, Default)] #[allow(clippy::large_enum_variant)] pub enum MintState { /// On-chain. @@ -49,11 +50,12 @@ pub enum MintState { mint_data: Mint, }, /// Doesn't exist. + #[default] None, } /// Mint interface for hot/cold handling. -#[derive(Debug, Clone)] +#[derive(Debug, Clone, PartialEq, Default)] pub struct MintInterface { pub mint: Pubkey, pub address_tree: Pubkey, @@ -97,6 +99,51 @@ impl MintInterface { } } +impl From for AccountInterface { + fn from(mi: MintInterface) -> Self { + match mi.state { + MintState::Hot { account } => Self { + key: mi.mint, + account, + cold: None, + }, + MintState::Cold { + compressed, + mint_data: _, + } => { + let data = compressed + .data + .as_ref() + .map(|d| { + let mut buf = d.discriminator.to_vec(); + buf.extend_from_slice(&d.data); + buf + }) + .unwrap_or_default(); + + Self { + key: mi.mint, + account: Account { + lamports: compressed.lamports, + data, + owner: Pubkey::new_from_array( + light_token_interface::LIGHT_TOKEN_PROGRAM_ID, + ), + executable: false, + rent_epoch: 0, + }, + cold: Some(ColdContext::Account(compressed)), + } + } + MintState::None => Self { + key: mi.mint, + account: Account::default(), + cold: None, + }, + } + } +} + pub const DEFAULT_RENT_PAYMENT: u8 = 2; pub const DEFAULT_WRITE_TOP_UP: u32 = 0; diff --git a/sdk-libs/client/src/interface/light_program_interface.rs b/sdk-libs/client/src/interface/light_program_interface.rs index 3817140a23..1987db929b 100644 --- a/sdk-libs/client/src/interface/light_program_interface.rs +++ b/sdk-libs/client/src/interface/light_program_interface.rs @@ -64,7 +64,7 @@ impl AccountToFetch { /// Two variants based on data structure, not account type: /// - `Account` - PDA /// - `Token` - Token account -#[derive(Clone, Debug)] +#[derive(Clone, Debug, PartialEq)] pub enum ColdContext { /// PDA Account(CompressedAccount), diff --git a/sdk-libs/client/src/interface/mod.rs b/sdk-libs/client/src/interface/mod.rs index b8847c6e98..e7b2f15c90 100644 --- a/sdk-libs/client/src/interface/mod.rs +++ b/sdk-libs/client/src/interface/mod.rs @@ -1,7 +1,6 @@ //! Client utilities for hot/cold account handling. pub mod account_interface; -pub mod account_interface_ext; pub mod create_accounts_proof; pub mod decompress_mint; pub mod initialize_config; @@ -12,7 +11,6 @@ pub mod pack; pub mod tx_size; pub use account_interface::{AccountInterface, AccountInterfaceError, TokenAccountInterface}; -pub use account_interface_ext::AccountInterfaceExt; pub use create_accounts_proof::{ get_create_accounts_proof, CreateAccountsProofError, CreateAccountsProofInput, CreateAccountsProofResult, diff --git a/sdk-libs/client/src/rpc/client.rs b/sdk-libs/client/src/rpc/client.rs index ca620c19d0..6ee66d8591 100644 --- a/sdk-libs/client/src/rpc/client.rs +++ b/sdk-libs/client/src/rpc/client.rs @@ -32,9 +32,11 @@ use tracing::warn; use super::LightClientConfig; use crate::{ indexer::{ - photon_indexer::PhotonIndexer, AccountInterface, Indexer, IndexerRpcConfig, MintInterface, - Response, TokenAccountInterface, TreeInfo, + photon_indexer::PhotonIndexer, AccountInterface as IndexerAccountInterface, Indexer, + IndexerRpcConfig, MintInterface as IndexerMintInterface, Response, + TokenAccountInterface as IndexerTokenAccountInterface, TreeInfo, }, + interface::{AccountInterface, MintInterface, MintState, TokenAccountInterface}, rpc::{ errors::RpcError, get_light_state_tree_infos::{ @@ -435,6 +437,277 @@ impl LightClient { } } +// Conversion helpers from indexer types to interface types + +fn convert_account_interface(indexer_ai: IndexerAccountInterface) -> AccountInterface { + use light_compressed_account::compressed_account::CompressedAccountData; + + use crate::indexer::{ColdContext as IndexerColdContext, CompressedAccount}; + + 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 => AccountInterface::hot(indexer_ai.key, account), + Some(IndexerColdContext::Account { + hash, + leaf_index, + tree_info, + data, + }) => { + let compressed = CompressedAccount { + address: None, + data: Some(CompressedAccountData { + discriminator: data.discriminator, + data: data.data, + data_hash: [0u8; 32], // Computed on demand if needed + }), + hash, + lamports: indexer_ai.account.lamports, + leaf_index: leaf_index as u32, + owner: indexer_ai.account.owner, + prove_by_index: false, + seq: tree_info.seq, + slot_created: 0, + tree_info: TreeInfo { + tree: tree_info.tree, + queue: tree_info.tree, // TODO: proper queue mapping + cpi_context: None, + next_tree_info: None, + tree_type: TreeType::StateV1, + }, + }; + AccountInterface::cold(indexer_ai.key, compressed, indexer_ai.account.owner) + } + Some(IndexerColdContext::Token { + hash, + leaf_index, + tree_info, + data, + }) => { + use light_token::compat::TokenData; + + use crate::indexer::CompressedTokenAccount; + + // Parse token data from the cold data + let token_data: TokenData = + borsh::BorshDeserialize::deserialize(&mut data.data.as_slice()).unwrap_or_default(); + + let compressed_account = CompressedAccount { + address: None, + data: Some(CompressedAccountData { + discriminator: data.discriminator, + data: data.data, + data_hash: [0u8; 32], + }), + hash, + lamports: indexer_ai.account.lamports, + leaf_index: leaf_index as u32, + owner: indexer_ai.account.owner, + prove_by_index: false, + seq: tree_info.seq, + slot_created: 0, + tree_info: TreeInfo { + tree: tree_info.tree, + queue: tree_info.tree, + cpi_context: None, + next_tree_info: None, + tree_type: TreeType::StateV1, + }, + }; + let compressed_token = CompressedTokenAccount { + token: token_data, + account: compressed_account.clone(), + }; + AccountInterface::cold_token(indexer_ai.key, compressed_token, indexer_ai.account.owner) + } + Some(IndexerColdContext::Mint { + hash, + leaf_index, + tree_info, + data, + }) => { + let compressed = CompressedAccount { + address: None, + data: Some(CompressedAccountData { + discriminator: data.discriminator, + data: data.data, + data_hash: [0u8; 32], + }), + hash, + lamports: indexer_ai.account.lamports, + leaf_index: leaf_index as u32, + owner: indexer_ai.account.owner, + prove_by_index: false, + seq: tree_info.seq, + slot_created: 0, + tree_info: TreeInfo { + tree: tree_info.tree, + queue: tree_info.tree, + cpi_context: None, + next_tree_info: None, + tree_type: TreeType::StateV1, + }, + }; + AccountInterface::cold(indexer_ai.key, compressed, indexer_ai.account.owner) + } + } +} + +fn convert_token_account_interface( + indexer_tai: IndexerTokenAccountInterface, +) -> Result { + use light_compressed_account::compressed_account::CompressedAccountData; + + use crate::indexer::{ + ColdContext as IndexerColdContext, CompressedAccount, 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(IndexerColdContext::Token { + hash, + leaf_index, + tree_info, + data, + }) => { + let compressed_account = CompressedAccount { + address: None, + data: Some(CompressedAccountData { + discriminator: data.discriminator, + data: data.data, + data_hash: [0u8; 32], + }), + hash, + lamports: indexer_tai.account.account.lamports, + leaf_index: leaf_index as u32, + owner: indexer_tai.account.account.owner, + prove_by_index: false, + seq: tree_info.seq, + slot_created: 0, + tree_info: TreeInfo { + tree: tree_info.tree, + queue: tree_info.tree, + cpi_context: None, + next_tree_info: None, + tree_type: TreeType::StateV1, + }, + }; + let compressed_token = CompressedTokenAccount { + token: indexer_tai.token, + account: compressed_account, + }; + Ok(TokenAccountInterface::cold( + indexer_tai.account.key, + compressed_token, + indexer_tai.account.key, // owner_override + indexer_tai.account.account.owner, + )) + } + _ => Err(RpcError::CustomError( + "unexpected cold context type for token account".into(), + )), + } +} + +fn convert_mint_interface(indexer_mi: IndexerMintInterface) -> Result { + use light_compressed_account::{ + address::derive_address, compressed_account::CompressedAccountData, + }; + use light_token_interface::{state::Mint, MINT_ADDRESS_TREE}; + + use crate::indexer::{ColdContext as IndexerColdContext, CompressedAccount}; + + let address_tree = Pubkey::new_from_array(MINT_ADDRESS_TREE); + let compressed_address = derive_address( + &indexer_mi.account.key.to_bytes(), + &address_tree.to_bytes(), + &light_token_interface::LIGHT_TOKEN_PROGRAM_ID, + ); + + let account = Account { + lamports: indexer_mi.account.account.lamports, + data: indexer_mi.account.account.data.clone(), + owner: indexer_mi.account.account.owner, + executable: indexer_mi.account.account.executable, + rent_epoch: indexer_mi.account.account.rent_epoch, + }; + + match indexer_mi.account.cold { + None => Ok(MintInterface { + mint: indexer_mi.account.key, + address_tree, + compressed_address, + state: MintState::Hot { account }, + }), + Some(IndexerColdContext::Mint { + hash, + leaf_index, + tree_info, + data, + }) + | Some(IndexerColdContext::Account { + hash, + leaf_index, + tree_info, + data, + }) => { + let mint_data = Mint::try_from_slice(&data.data) + .map_err(|e| RpcError::CustomError(format!("mint parse error: {}", e)))?; + + let compressed = CompressedAccount { + address: None, + data: Some(CompressedAccountData { + discriminator: data.discriminator, + data: data.data, + data_hash: [0u8; 32], + }), + hash, + lamports: indexer_mi.account.account.lamports, + leaf_index: leaf_index as u32, + owner: indexer_mi.account.account.owner, + prove_by_index: false, + seq: tree_info.seq, + slot_created: 0, + tree_info: TreeInfo { + tree: tree_info.tree, + queue: tree_info.tree, + cpi_context: None, + next_tree_info: None, + tree_type: TreeType::StateV1, + }, + }; + + Ok(MintInterface { + mint: indexer_mi.account.key, + address_tree, + compressed_address, + state: MintState::Cold { + compressed, + mint_data, + }, + }) + } + _ => Err(RpcError::CustomError( + "unexpected cold context type for mint".into(), + )), + } +} + #[async_trait] impl Rpc for LightClient { async fn new(config: LightClientConfig) -> Result @@ -912,10 +1185,15 @@ impl Rpc for LightClient { .indexer .as_ref() .ok_or(RpcError::IndexerNotInitialized)?; - indexer + let resp = indexer .get_account_interface(address, config) .await - .map_err(|e| RpcError::CustomError(format!("Indexer error: {e}"))) + .map_err(|e| RpcError::CustomError(format!("Indexer error: {e}")))?; + + Ok(Response { + context: resp.context, + value: resp.value.map(convert_account_interface), + }) } async fn get_token_account_interface( @@ -927,10 +1205,20 @@ impl Rpc for LightClient { .indexer .as_ref() .ok_or(RpcError::IndexerNotInitialized)?; - indexer + let resp = indexer .get_token_account_interface(address, config) .await - .map_err(|e| RpcError::CustomError(format!("Indexer error: {e}"))) + .map_err(|e| RpcError::CustomError(format!("Indexer error: {e}")))?; + + let value = match resp.value { + Some(tai) => Some(convert_token_account_interface(tai)?), + None => None, + }; + + Ok(Response { + context: resp.context, + value, + }) } async fn get_ata_interface( @@ -943,10 +1231,20 @@ impl Rpc for LightClient { .indexer .as_ref() .ok_or(RpcError::IndexerNotInitialized)?; - indexer + let resp = indexer .get_ata_interface(owner, mint, config) .await - .map_err(|e| RpcError::CustomError(format!("Indexer error: {e}"))) + .map_err(|e| RpcError::CustomError(format!("Indexer error: {e}")))?; + + let value = match resp.value { + Some(tai) => Some(convert_token_account_interface(tai)?), + None => None, + }; + + Ok(Response { + context: resp.context, + value, + }) } async fn get_mint_interface( @@ -958,10 +1256,20 @@ impl Rpc for LightClient { .indexer .as_ref() .ok_or(RpcError::IndexerNotInitialized)?; - indexer + let resp = indexer .get_mint_interface(address, config) .await - .map_err(|e| RpcError::CustomError(format!("Indexer error: {e}"))) + .map_err(|e| RpcError::CustomError(format!("Indexer error: {e}")))?; + + let value = match resp.value { + Some(mi) => Some(convert_mint_interface(mi)?), + None => None, + }; + + Ok(Response { + context: resp.context, + value, + }) } async fn get_multiple_account_interfaces( @@ -973,10 +1281,19 @@ impl Rpc for LightClient { .indexer .as_ref() .ok_or(RpcError::IndexerNotInitialized)?; - indexer + let resp = indexer .get_multiple_account_interfaces(addresses, config) .await - .map_err(|e| RpcError::CustomError(format!("Indexer error: {e}"))) + .map_err(|e| RpcError::CustomError(format!("Indexer error: {e}")))?; + + Ok(Response { + context: resp.context, + value: resp + .value + .into_iter() + .map(|opt| opt.map(convert_account_interface)) + .collect(), + }) } } diff --git a/sdk-libs/client/src/rpc/rpc_trait.rs b/sdk-libs/client/src/rpc/rpc_trait.rs index a0ecc81c3f..a5fadf9334 100644 --- a/sdk-libs/client/src/rpc/rpc_trait.rs +++ b/sdk-libs/client/src/rpc/rpc_trait.rs @@ -18,10 +18,8 @@ use solana_transaction_status_client_types::TransactionStatus; use super::client::RpcUrl; use crate::{ - indexer::{ - AccountInterface, Indexer, IndexerRpcConfig, MintInterface, Response, - TokenAccountInterface, TreeInfo, - }, + indexer::{Indexer, IndexerRpcConfig, Response, TreeInfo}, + interface::{AccountInterface, AccountToFetch, MintInterface, TokenAccountInterface}, rpc::errors::RpcError, }; @@ -242,6 +240,9 @@ pub trait Rpc: Send + Sync + Debug + 'static { // These race hot (on-chain) and cold (compressed) lookups in the indexer. /// Get account data from either on-chain or compressed sources. + /// + /// Looks up by on-chain Solana pubkey. For cold accounts, searches by + /// onchain_pubkey stored in the compressed account data. async fn get_account_interface( &self, address: &Pubkey, @@ -276,4 +277,65 @@ pub trait Rpc: Send + Sync + Debug + 'static { addresses: Vec<&Pubkey>, config: Option, ) -> Result>>, RpcError>; + + /// Fetch multiple accounts using `AccountToFetch` descriptors. + /// + /// Routes each account to the correct method based on its variant: + /// - `Pda` -> `get_account_interface` + /// - `Token` -> `get_token_account_interface` + /// - `Ata` -> `get_ata_interface` + /// - `Mint` -> `get_mint_interface` + async fn fetch_accounts( + &self, + accounts: &[AccountToFetch], + config: Option, + ) -> Result, RpcError> { + let mut results = Vec::with_capacity(accounts.len()); + for account in accounts { + let interface = match account { + AccountToFetch::Pda { address, .. } => self + .get_account_interface(address, config.clone()) + .await? + .value + .ok_or_else(|| { + RpcError::CustomError(format!("PDA account not found: {}", address)) + })?, + AccountToFetch::Token { address } => { + let tai = self + .get_token_account_interface(address, config.clone()) + .await? + .value + .ok_or_else(|| { + RpcError::CustomError(format!("Token account not found: {}", address)) + })?; + tai.into() + } + AccountToFetch::Ata { wallet_owner, mint } => { + let tai = self + .get_ata_interface(wallet_owner, mint, config.clone()) + .await? + .value + .ok_or_else(|| { + RpcError::CustomError(format!( + "ATA not found for owner {} mint {}", + wallet_owner, mint + )) + })?; + tai.into() + } + AccountToFetch::Mint { address } => { + let mi = self + .get_mint_interface(address, config.clone()) + .await? + .value + .ok_or_else(|| { + RpcError::CustomError(format!("Mint not found: {}", address)) + })?; + mi.into() + } + }; + results.push(interface); + } + Ok(results) + } } diff --git a/sdk-libs/photon-api/src/models/solana_account_data.rs b/sdk-libs/photon-api/src/models/solana_account_data.rs index 9bc29045ad..5d4142b840 100644 --- a/sdk-libs/photon-api/src/models/solana_account_data.rs +++ b/sdk-libs/photon-api/src/models/solana_account_data.rs @@ -16,6 +16,7 @@ pub struct SolanaAccountData { pub owner: String, pub executable: bool, pub rent_epoch: u64, + pub space: u64, } impl SolanaAccountData { @@ -25,6 +26,7 @@ impl SolanaAccountData { owner: String, executable: bool, rent_epoch: u64, + space: u64, ) -> Self { Self { lamports, @@ -32,6 +34,7 @@ impl SolanaAccountData { owner, executable, rent_epoch, + space, } } } diff --git a/sdk-libs/program-test/src/indexer/test_indexer.rs b/sdk-libs/program-test/src/indexer/test_indexer.rs index 0c622fee90..963d10ebeb 100644 --- a/sdk-libs/program-test/src/indexer/test_indexer.rs +++ b/sdk-libs/program-test/src/indexer/test_indexer.rs @@ -1,4 +1,4 @@ -use std::{fmt::Debug, time::Duration}; +use std::{collections::HashMap, fmt::Debug, time::Duration}; #[cfg(feature = "devenv")] use account_compression::{ @@ -13,6 +13,10 @@ pub(crate) const STATE_MERKLE_TREE_ROOTS: u64 = 2400; pub(crate) const DEFAULT_BATCH_STATE_TREE_HEIGHT: usize = 32; pub(crate) const DEFAULT_BATCH_ADDRESS_TREE_HEIGHT: usize = 40; pub(crate) const DEFAULT_BATCH_ROOT_HISTORY_LEN: usize = 200; + +/// Discriminator for compressible accounts that store onchain_pubkey in the first 32 bytes of data. +/// This matches Photon's DECOMPRESSED_ACCOUNT_DISCRIMINATOR. +pub const DECOMPRESSED_ACCOUNT_DISCRIMINATOR: u64 = 0x00FFFFFFFFFFFFFF; use async_trait::async_trait; use borsh::BorshDeserialize; #[cfg(feature = "devenv")] @@ -103,6 +107,9 @@ pub struct TestIndexer { pub token_compressed_accounts: Vec, pub token_nullified_compressed_accounts: Vec, pub events: Vec, + /// Index mapping onchain_pubkey to compressed account index. + /// Used for interface lookups (like Photon's onchain_pubkey column). + pub onchain_pubkey_index: HashMap<[u8; 32], usize>, } impl Clone for TestIndexer { @@ -117,6 +124,7 @@ impl Clone for TestIndexer { token_compressed_accounts: self.token_compressed_accounts.clone(), token_nullified_compressed_accounts: self.token_nullified_compressed_accounts.clone(), events: self.events.clone(), + onchain_pubkey_index: self.onchain_pubkey_index.clone(), } } } @@ -993,7 +1001,7 @@ impl Indexer for TestIndexer { } async fn get_indexer_health(&self, _config: Option) -> Result { - todo!("get_indexer_health not implemented") + Ok(true) // Test indexer is always healthy } async fn get_compressed_mint( @@ -1372,9 +1380,60 @@ impl TestIndexer { token_compressed_accounts: vec![], token_nullified_compressed_accounts: vec![], group_pda, + onchain_pubkey_index: HashMap::new(), } } + /// Extract onchain_pubkey from compressed account data if it has the decompressed discriminator. + /// Compressible accounts store the on-chain PDA pubkey in the first 32 bytes of data. + fn extract_onchain_pubkey_from_data( + data: Option<&light_compressed_account::compressed_account::CompressedAccountData>, + ) -> Option<[u8; 32]> { + let data = data?; + // Check discriminator (as little-endian u64) + let discriminator = u64::from_le_bytes(data.discriminator); + if discriminator == DECOMPRESSED_ACCOUNT_DISCRIMINATOR && data.data.len() >= 32 { + // onchain_pubkey is stored in the first 32 bytes of data (after discriminator) + data.data[..32].try_into().ok() + } else { + None + } + } + + /// Find a compressed account by its on-chain pubkey. + /// This mirrors Photon's lookup by onchain_pubkey column. + pub fn find_compressed_account_by_onchain_pubkey( + &self, + onchain_pubkey: &[u8; 32], + ) -> Option<&CompressedAccountWithMerkleContext> { + self.compressed_accounts.iter().find(|acc| { + Self::extract_onchain_pubkey_from_data(acc.compressed_account.data.as_ref()).as_ref() + == Some(onchain_pubkey) + }) + } + + /// Find a token compressed account by its on-chain pubkey. + pub fn find_token_account_by_onchain_pubkey( + &self, + onchain_pubkey: &[u8; 32], + ) -> Option<&TokenDataWithMerkleContext> { + self.token_compressed_accounts.iter().find(|acc| { + Self::extract_onchain_pubkey_from_data( + acc.compressed_account.compressed_account.data.as_ref(), + ) + .as_ref() + == Some(onchain_pubkey) + }) + } + + /// Get the sequence number for a state merkle tree by its pubkey. + pub fn get_state_tree_seq(&self, tree_pubkey: &Pubkey) -> Option { + self.state_merkle_trees + .iter() + .find(|tree| tree.accounts.merkle_tree == *tree_pubkey) + .map(|tree| tree.merkle_tree.sequence_number as u64) + } + pub fn add_address_merkle_tree_bundle( address_merkle_tree_accounts: AddressMerkleTreeAccounts, // TODO: add config here diff --git a/sdk-libs/program-test/src/program_test/rpc.rs b/sdk-libs/program-test/src/program_test/rpc.rs index bd71c08d8e..093c19e13d 100644 --- a/sdk-libs/program-test/src/program_test/rpc.rs +++ b/sdk-libs/program-test/src/program_test/rpc.rs @@ -4,7 +4,8 @@ use anchor_lang::pubkey; use async_trait::async_trait; use borsh::BorshDeserialize; use light_client::{ - indexer::{Indexer, TreeInfo}, + indexer::{CompressedAccount, CompressedTokenAccount, Context, Indexer, Response, TreeInfo}, + interface::{AccountInterface, MintInterface, MintState, TokenAccountInterface}, rpc::{LightClientConfig, Rpc, RpcError}, }; use light_compressed_account::TreeType; @@ -369,68 +370,210 @@ impl Rpc for LightProgramTest { async fn get_account_interface( &self, - _address: &Pubkey, + address: &Pubkey, _config: Option, - ) -> Result< - light_client::indexer::Response>, - RpcError, - > { - Err(RpcError::CustomError( - "get_account_interface is not supported in program-test context".into(), - )) + ) -> Result>, RpcError> { + let slot = self.context.get_sysvar::().slot; + + // Hot: check on-chain first + if let Some(account) = self.context.get_account(address) { + if account.lamports > 0 { + return Ok(Response { + context: Context { slot }, + value: Some(AccountInterface::hot(*address, account)), + }); + } + } + + // Cold: check TestIndexer by onchain pubkey (mirrors Photon behavior) + if let Some(indexer) = self.indexer.as_ref() { + if let Some(compressed_with_ctx) = + indexer.find_compressed_account_by_onchain_pubkey(&address.to_bytes()) + { + let owner: Pubkey = compressed_with_ctx.compressed_account.owner.into(); + let compressed: CompressedAccount = compressed_with_ctx + .clone() + .try_into() + .map_err(|e| RpcError::CustomError(format!("conversion error: {:?}", e)))?; + + return Ok(Response { + context: Context { slot }, + value: Some(AccountInterface::cold(*address, compressed, owner)), + }); + } + } + + Ok(Response { + context: Context { slot }, + value: None, + }) } async fn get_token_account_interface( &self, - _address: &Pubkey, + address: &Pubkey, _config: Option, - ) -> Result< - light_client::indexer::Response>, - RpcError, - > { - Err(RpcError::CustomError( - "get_token_account_interface is not supported in program-test context".into(), - )) + ) -> Result>, RpcError> { + use light_sdk::constants::LIGHT_TOKEN_PROGRAM_ID; + + let light_token_program_id: Pubkey = LIGHT_TOKEN_PROGRAM_ID.into(); + let slot = self.context.get_sysvar::().slot; + + // Hot: check on-chain first (must be owned by LIGHT_TOKEN_PROGRAM_ID) + if let Some(account) = self.context.get_account(address) { + if account.lamports > 0 && account.owner == light_token_program_id { + match TokenAccountInterface::hot(*address, account) { + Ok(iface) => { + return Ok(Response { + context: Context { slot }, + value: Some(iface), + }); + } + Err(_) => { + // Fall through to cold lookup if parsing failed + } + } + } + } + + // Cold: check TestIndexer by onchain_pubkey + if let Some(indexer) = self.indexer.as_ref() { + if let Some(token_acc) = + indexer.find_token_account_by_onchain_pubkey(&address.to_bytes()) + { + // Convert to CompressedTokenAccount + let compressed_account: CompressedAccount = token_acc + .compressed_account + .clone() + .try_into() + .map_err(|e| RpcError::CustomError(format!("conversion error: {:?}", e)))?; + + let compressed_token = CompressedTokenAccount { + token: token_acc.token_data.clone(), + account: compressed_account, + }; + + return Ok(Response { + context: Context { slot }, + value: Some(TokenAccountInterface::cold( + *address, + compressed_token, + *address, // owner = hot address for program-owned tokens + light_token_program_id, + )), + }); + } + } + + Ok(Response { + context: Context { slot }, + value: None, + }) } async fn get_ata_interface( &self, - _owner: &Pubkey, - _mint: &Pubkey, - _config: Option, - ) -> Result< - light_client::indexer::Response>, - RpcError, - > { - Err(RpcError::CustomError( - "get_ata_interface is not supported in program-test context".into(), - )) + owner: &Pubkey, + mint: &Pubkey, + config: Option, + ) -> Result>, RpcError> { + use light_token::instruction::derive_token_ata; + + let (ata, _bump) = derive_token_ata(owner, mint); + self.get_token_account_interface(&ata, config).await } async fn get_mint_interface( &self, - _address: &Pubkey, + address: &Pubkey, _config: Option, - ) -> Result< - light_client::indexer::Response>, - RpcError, - > { - Err(RpcError::CustomError( - "get_mint_interface is not supported in program-test context".into(), - )) + ) -> Result>, RpcError> { + use borsh::BorshDeserialize as _; + use light_compressed_account::address::derive_address; + use light_token_interface::{state::Mint, MINT_ADDRESS_TREE}; + + let slot = self.context.get_sysvar::().slot; + let address_tree = Pubkey::new_from_array(MINT_ADDRESS_TREE); + let compressed_address = derive_address( + &address.to_bytes(), + &address_tree.to_bytes(), + &light_token_interface::LIGHT_TOKEN_PROGRAM_ID, + ); + + // Hot: check on-chain first + if let Some(account) = self.context.get_account(address) { + if account.lamports > 0 { + return Ok(Response { + context: Context { slot }, + value: Some(MintInterface { + mint: *address, + address_tree, + compressed_address, + state: MintState::Hot { account }, + }), + }); + } + } + + // Cold: check indexer by compressed address + if let Some(indexer) = self.indexer.as_ref() { + let result = indexer + .get_compressed_account(compressed_address, None) + .await + .map_err(|e| RpcError::CustomError(format!("indexer error: {}", e)))?; + + if let Some(compressed) = result.value { + if let Some(data) = compressed.data.as_ref() { + if !data.data.is_empty() { + let mint_data = Mint::try_from_slice(&data.data).map_err(|e| { + RpcError::CustomError(format!("mint parse error: {}", e)) + })?; + return Ok(Response { + context: Context { slot }, + value: Some(MintInterface { + mint: *address, + address_tree, + compressed_address, + state: MintState::Cold { + compressed, + mint_data, + }, + }), + }); + } + } + } + } + + // Not found + Ok(Response { + context: Context { slot }, + value: Some(MintInterface { + mint: *address, + address_tree, + compressed_address, + state: MintState::None, + }), + }) } async fn get_multiple_account_interfaces( &self, - _addresses: Vec<&Pubkey>, - _config: Option, - ) -> Result< - light_client::indexer::Response>>, - RpcError, - > { - Err(RpcError::CustomError( - "get_multiple_account_interfaces is not supported in program-test context".into(), - )) + addresses: Vec<&Pubkey>, + config: Option, + ) -> Result>>, RpcError> { + let slot = self.context.get_sysvar::().slot; + let mut results = Vec::with_capacity(addresses.len()); + + for address in addresses { + let result = self.get_account_interface(address, config.clone()).await?; + results.push(result.value); + } + + Ok(Response { + context: Context { slot }, + value: results, + }) } } diff --git a/sdk-tests/csdk-anchor-full-derived-test/tests/amm_test.rs b/sdk-tests/csdk-anchor-full-derived-test/tests/amm_test.rs index 844022ac64..2fa278c9d7 100644 --- a/sdk-tests/csdk-anchor-full-derived-test/tests/amm_test.rs +++ b/sdk-tests/csdk-anchor-full-derived-test/tests/amm_test.rs @@ -17,8 +17,8 @@ use csdk_anchor_full_derived_test::amm_test::{ // SDK for AmmSdk-based approach use csdk_anchor_full_derived_test_sdk::{AmmInstruction, AmmSdk}; use light_client::interface::{ - create_load_instructions, get_create_accounts_proof, AccountInterfaceExt, - CreateAccountsProofInput, InitializeRentFreeConfig, LightProgramInterface, + create_load_instructions, get_create_accounts_proof, CreateAccountsProofInput, + InitializeRentFreeConfig, LightProgramInterface, }; use light_compressible::rent::SLOTS_PER_EPOCH; use light_program_test::{ @@ -631,9 +631,11 @@ async fn test_amm_full_lifecycle() { let pool_interface = ctx .rpc - .get_account_interface(&pdas.pool_state, &ctx.program_id) + .get_account_interface(&pdas.pool_state, None) .await - .expect("failed to get pool_state"); + .expect("failed to get pool_state") + .value + .expect("pool_state should exist"); assert!(pool_interface.is_cold(), "pool_state should be cold"); // Create Program Interface SDK. @@ -644,9 +646,9 @@ async fn test_amm_full_lifecycle() { let keyed_accounts = ctx .rpc - .get_multiple_account_interfaces(&accounts_to_fetch) + .fetch_accounts(&accounts_to_fetch, None) .await - .expect("get_multiple_account_interfaces should succeed"); + .expect("fetch_accounts should succeed"); sdk.update(&keyed_accounts) .expect("sdk.update should succeed"); @@ -655,9 +657,11 @@ async fn test_amm_full_lifecycle() { let creator_lp_interface = ctx .rpc - .get_ata_interface(&ctx.creator.pubkey(), &pdas.lp_mint) + .get_ata_interface(&ctx.creator.pubkey(), &pdas.lp_mint, None) .await - .expect("failed to get creator_lp_token"); + .expect("failed to get creator_lp_token") + .value + .expect("creator_lp_token should exist"); // add ata use light_client::interface::AccountSpec; diff --git a/sdk-tests/csdk-anchor-full-derived-test/tests/basic_test.rs b/sdk-tests/csdk-anchor-full-derived-test/tests/basic_test.rs index ba5cc3128c..f81a7d5210 100644 --- a/sdk-tests/csdk-anchor-full-derived-test/tests/basic_test.rs +++ b/sdk-tests/csdk-anchor-full-derived-test/tests/basic_test.rs @@ -2,8 +2,7 @@ mod shared; use anchor_lang::{InstructionData, ToAccountMetas}; use light_client::interface::{ - get_create_accounts_proof, AccountInterfaceExt, CreateAccountsProofInput, - InitializeRentFreeConfig, + get_create_accounts_proof, CreateAccountsProofInput, InitializeRentFreeConfig, }; use light_compressible::{rent::SLOTS_PER_EPOCH, DECOMPRESSED_PDA_DISCRIMINATOR}; use light_program_test::{ @@ -360,21 +359,27 @@ async fn test_create_pdas_and_mint_auto() { // Fetch unified interfaces (hot/cold transparent) let user_interface = rpc - .get_account_interface(&user_record_pda, &program_id) + .get_account_interface(&user_record_pda, None) .await - .expect("failed to get user"); + .expect("failed to get user") + .value + .expect("user should exist"); assert!(user_interface.is_cold(), "UserRecord should be cold"); let game_interface = rpc - .get_account_interface(&game_session_pda, &program_id) + .get_account_interface(&game_session_pda, None) .await - .expect("failed to get game"); + .expect("failed to get game") + .value + .expect("game should exist"); assert!(game_interface.is_cold(), "GameSession should be cold"); let vault_interface = rpc - .get_token_account_interface(&vault_pda) + .get_token_account_interface(&vault_pda, None) .await - .expect("failed to get vault"); + .expect("failed to get vault") + .value + .expect("vault should exist"); assert!(vault_interface.is_cold(), "Vault should be cold"); assert_eq!(vault_interface.amount(), vault_mint_amount); @@ -427,9 +432,11 @@ async fn test_create_pdas_and_mint_auto() { // get_ata_interface: fetches ATA with unified handling using standard SPL types let ata_interface = rpc - .get_ata_interface(&payer.pubkey(), &mint_pda) + .get_ata_interface(&payer.pubkey(), &mint_pda, None) .await - .expect("get_ata_interface should succeed"); + .expect("get_ata_interface should succeed") + .value + .expect("ATA should exist"); assert!(ata_interface.is_cold(), "ATA should be cold after warp"); assert_eq!(ata_interface.amount(), user_ata_mint_amount); assert_eq!(ata_interface.mint(), mint_pda); @@ -441,9 +448,11 @@ async fn test_create_pdas_and_mint_auto() { // Fetch mint interface let mint_interface = rpc - .get_mint_interface(&mint_pda) + .get_mint_interface(&mint_pda, None) .await - .expect("get_mint_interface should succeed"); + .expect("get_mint_interface should succeed") + .value + .expect("Mint should exist"); assert!(mint_interface.is_cold(), "Mint should be cold after warp"); // Convert MintInterface to AccountInterface for use in AccountSpec diff --git a/sdk-tests/csdk-anchor-full-derived-test/tests/integration_tests.rs b/sdk-tests/csdk-anchor-full-derived-test/tests/integration_tests.rs index 0363047387..b2c594c11d 100644 --- a/sdk-tests/csdk-anchor-full-derived-test/tests/integration_tests.rs +++ b/sdk-tests/csdk-anchor-full-derived-test/tests/integration_tests.rs @@ -10,8 +10,8 @@ mod shared; use anchor_lang::{AnchorDeserialize, InstructionData, ToAccountMetas}; use csdk_anchor_full_derived_test::csdk_anchor_full_derived_test::LightAccountVariant; use light_client::interface::{ - create_load_instructions, get_create_accounts_proof, AccountInterfaceExt, AccountSpec, - CreateAccountsProofInput, InitializeRentFreeConfig, PdaSpec, + create_load_instructions, get_create_accounts_proof, AccountSpec, CreateAccountsProofInput, + InitializeRentFreeConfig, PdaSpec, }; use light_compressible::rent::SLOTS_PER_EPOCH; use light_program_test::{ @@ -124,9 +124,11 @@ impl TestContext { // Get account interface let account_interface = self .rpc - .get_account_interface(pda, &self.program_id) + .get_account_interface(pda, None) .await - .expect("failed to get account interface"); + .expect("failed to get account interface") + .value + .expect("account should exist"); assert!( account_interface.is_cold(), "Account should be cold after compression" @@ -597,8 +599,10 @@ async fn test_d8_multi_rentfree() { // Decompress first account let interface1 = ctx .rpc - .get_account_interface(&pda1, &ctx.program_id) + .get_account_interface(&pda1, None) .await + .unwrap() + .value .unwrap(); let variant1 = D8MultiRecord1Seeds { owner, id1 } .into_variant(&interface1.account.data[8..]) @@ -620,8 +624,10 @@ async fn test_d8_multi_rentfree() { // Decompress second account let interface2 = ctx .rpc - .get_account_interface(&pda2, &ctx.program_id) + .get_account_interface(&pda2, None) .await + .unwrap() + .value .unwrap(); let variant2 = D8MultiRecord2Seeds { owner, id2 } .into_variant(&interface2.account.data[8..]) @@ -736,8 +742,10 @@ async fn test_d8_all() { // Decompress first account (single type) let interface_single = ctx .rpc - .get_account_interface(&pda_single, &ctx.program_id) + .get_account_interface(&pda_single, None) .await + .unwrap() + .value .unwrap(); let variant_single = D8AllSingleSeeds { owner } .into_variant(&interface_single.account.data[8..]) @@ -759,8 +767,10 @@ async fn test_d8_all() { // Decompress second account (multi type) let interface_multi = ctx .rpc - .get_account_interface(&pda_multi, &ctx.program_id) + .get_account_interface(&pda_multi, None) .await + .unwrap() + .value .unwrap(); let variant_multi = D8AllMultiSeeds { owner } .into_variant(&interface_multi.account.data[8..]) @@ -1485,8 +1495,10 @@ async fn test_d9_all() { ) { let interface = ctx .rpc - .get_account_interface(pda, &ctx.program_id) + .get_account_interface(pda, None) .await + .unwrap() + .value .unwrap(); let variant = seeds.into_variant(&interface.account.data[8..]).unwrap(); let spec = PdaSpec::new(interface.clone(), variant, ctx.program_id); @@ -1608,9 +1620,11 @@ async fn test_d8_pda_only_full_lifecycle() { // PHASE 3: Decompress account let account_interface = ctx .rpc - .get_account_interface(&pda, &ctx.program_id) + .get_account_interface(&pda, None) .await - .expect("failed to get account interface"); + .expect("failed to get account interface") + .value + .expect("account should exist"); assert!(account_interface.is_cold(), "Account should be cold"); let variant = D8PdaOnlyRecordSeeds { owner } From 00f85ab15e56049934677e4edc35bb1f54b15395 Mon Sep 17 00:00:00 2001 From: Sergey Timoshin Date: Thu, 29 Jan 2026 17:33:18 +0000 Subject: [PATCH 25/38] bump photon version --- scripts/devenv/versions.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/devenv/versions.sh b/scripts/devenv/versions.sh index ebfc690d7d..17c04bde55 100755 --- a/scripts/devenv/versions.sh +++ b/scripts/devenv/versions.sh @@ -13,7 +13,7 @@ export SOLANA_VERSION="2.2.15" export ANCHOR_VERSION="0.31.1" export JQ_VERSION="1.8.0" export PHOTON_VERSION="0.51.2" -export PHOTON_COMMIT="4c809c9feccdd737e72f55744d963e90fafbe45e" +export PHOTON_COMMIT="a42f7b74694706597c950e9407007cbfaba09b3d" export REDIS_VERSION="8.0.1" export ANCHOR_TAG="anchor-v${ANCHOR_VERSION}" From 27a78d7e1e1d867ad348506b65d074568fd6da3d Mon Sep 17 00:00:00 2001 From: Sergey Timoshin Date: Thu, 29 Jan 2026 17:49:44 +0000 Subject: [PATCH 26/38] feat: implement batch lookup for multiple compressed accounts in RPC --- .../program-test/src/indexer/test_indexer.rs | 11 ++++ sdk-libs/program-test/src/program_test/rpc.rs | 64 +++++++++++++++---- 2 files changed, 64 insertions(+), 11 deletions(-) diff --git a/sdk-libs/program-test/src/indexer/test_indexer.rs b/sdk-libs/program-test/src/indexer/test_indexer.rs index 963d10ebeb..6ca636d7d5 100644 --- a/sdk-libs/program-test/src/indexer/test_indexer.rs +++ b/sdk-libs/program-test/src/indexer/test_indexer.rs @@ -1412,6 +1412,17 @@ impl TestIndexer { }) } + /// Find multiple compressed accounts by their on-chain pubkeys. + pub fn find_multiple_compressed_accounts_by_onchain_pubkeys( + &self, + onchain_pubkeys: &[[u8; 32]], + ) -> Vec> { + onchain_pubkeys + .iter() + .map(|pubkey| self.find_compressed_account_by_onchain_pubkey(pubkey)) + .collect() + } + /// Find a token compressed account by its on-chain pubkey. pub fn find_token_account_by_onchain_pubkey( &self, diff --git a/sdk-libs/program-test/src/program_test/rpc.rs b/sdk-libs/program-test/src/program_test/rpc.rs index 093c19e13d..145bd34175 100644 --- a/sdk-libs/program-test/src/program_test/rpc.rs +++ b/sdk-libs/program-test/src/program_test/rpc.rs @@ -548,26 +548,68 @@ impl Rpc for LightProgramTest { // Not found Ok(Response { context: Context { slot }, - value: Some(MintInterface { - mint: *address, - address_tree, - compressed_address, - state: MintState::None, - }), + value: None, }) } async fn get_multiple_account_interfaces( &self, addresses: Vec<&Pubkey>, - config: Option, + _config: Option, ) -> Result>>, RpcError> { let slot = self.context.get_sysvar::().slot; - let mut results = Vec::with_capacity(addresses.len()); + let mut results: Vec> = vec![None; addresses.len()]; + + // Batch fetch on-chain accounts (hot path) + let owned_addresses: Vec = addresses.iter().map(|a| **a).collect(); + let on_chain_accounts: Vec> = owned_addresses + .iter() + .map(|addr| self.context.get_account(addr)) + .collect(); + + // Track which addresses still need cold lookup + let mut cold_lookup_indices: Vec = Vec::new(); + let mut cold_lookup_pubkeys: Vec<[u8; 32]> = Vec::new(); + + for (i, (address, maybe_account)) in addresses + .iter() + .zip(on_chain_accounts.into_iter()) + .enumerate() + { + if let Some(account) = maybe_account { + if account.lamports > 0 { + results[i] = Some(AccountInterface::hot(**address, account)); + continue; + } + } + // Not found on-chain or has 0 lamports, need cold lookup + cold_lookup_indices.push(i); + cold_lookup_pubkeys.push(address.to_bytes()); + } - for address in addresses { - let result = self.get_account_interface(address, config.clone()).await?; - results.push(result.value); + // Batch lookup cold accounts from TestIndexer + if !cold_lookup_pubkeys.is_empty() { + if let Some(indexer) = self.indexer.as_ref() { + let cold_results = indexer + .find_multiple_compressed_accounts_by_onchain_pubkeys(&cold_lookup_pubkeys); + + for (lookup_idx, maybe_compressed) in cold_results.into_iter().enumerate() { + let original_idx = cold_lookup_indices[lookup_idx]; + if let Some(compressed_with_ctx) = maybe_compressed { + let owner: Pubkey = compressed_with_ctx.compressed_account.owner.into(); + let compressed: CompressedAccount = + compressed_with_ctx.clone().try_into().map_err(|e| { + RpcError::CustomError(format!("conversion error: {:?}", e)) + })?; + + results[original_idx] = Some(AccountInterface::cold( + *addresses[original_idx], + compressed, + owner, + )); + } + } + } } Ok(Response { From f43212b9beddf8aba2f9c09a6d6022cce850242a Mon Sep 17 00:00:00 2001 From: Sergey Timoshin Date: Thu, 29 Jan 2026 20:55:48 +0000 Subject: [PATCH 27/38] enhance account interface --- sdk-libs/client/src/indexer/options.rs | 2 +- sdk-libs/client/src/indexer/types.rs | 13 +- .../client/src/interface/account_interface.rs | 8 +- .../client/src/interface/decompress_mint.rs | 2 +- .../src/interface/light_program_interface.rs | 22 ++- sdk-libs/client/src/rpc/client.rs | 99 +++++++++--- sdk-libs/client/src/rpc/rpc_trait.rs | 21 +++ .../program-test/src/indexer/test_indexer.rs | 69 +++++++- sdk-libs/program-test/src/program_test/rpc.rs | 148 +++++++++++++++++- .../src/lib.rs | 52 ++++-- .../tests/basic_test.rs | 15 +- sdk-tests/justfile | 1 + 12 files changed, 384 insertions(+), 68 deletions(-) diff --git a/sdk-libs/client/src/indexer/options.rs b/sdk-libs/client/src/indexer/options.rs index 403ccf1402..25738a8ff4 100644 --- a/sdk-libs/client/src/indexer/options.rs +++ b/sdk-libs/client/src/indexer/options.rs @@ -2,7 +2,7 @@ use photon_api::models::{FilterSelector, Memcmp}; use solana_account_decoder_client_types::UiDataSliceConfig; use solana_pubkey::Pubkey; -#[derive(Debug, Clone)] +#[derive(Debug, Clone, Default)] pub struct GetCompressedTokenAccountsByOwnerOrDelegateOptions { pub mint: Option, pub cursor: Option, diff --git a/sdk-libs/client/src/indexer/types.rs b/sdk-libs/client/src/indexer/types.rs index add7fb9403..36e91ddcc2 100644 --- a/sdk-libs/client/src/indexer/types.rs +++ b/sdk-libs/client/src/indexer/types.rs @@ -1119,12 +1119,15 @@ impl MintData { let mint_signer_pubkey = Pubkey::new_from_array(self.mint_signer); let (derived_pda, bump) = find_mint_address(&mint_signer_pubkey); - // Verify derived PDA matches stored mint_pda (sanity check) + // Verify derived PDA matches stored mint_pda (fail fast on mismatch) if derived_pda != self.mint_pda { - warn!( - "Derived mint PDA {} does not match stored mint_pda {}", - derived_pda, self.mint_pda - ); + return Err(IndexerError::DataDecodeError { + field: "mint_pda".to_string(), + message: format!( + "Derived mint PDA {} (bump={}) does not match stored mint_pda {}", + derived_pda, bump, self.mint_pda + ), + }); } // Parse extensions if present diff --git a/sdk-libs/client/src/interface/account_interface.rs b/sdk-libs/client/src/interface/account_interface.rs index 2502d0e40f..8c96e84d13 100644 --- a/sdk-libs/client/src/interface/account_interface.rs +++ b/sdk-libs/client/src/interface/account_interface.rs @@ -150,6 +150,7 @@ impl AccountInterface { match &self.cold { Some(ColdContext::Account(c)) => Some(c.hash), Some(ColdContext::Token(c)) => Some(c.account.hash), + Some(ColdContext::Mint(c)) => Some(c.hash), None => None, } } @@ -159,6 +160,7 @@ impl AccountInterface { match &self.cold { Some(ColdContext::Account(c)) => Some(&c.tree_info), Some(ColdContext::Token(c)) => Some(&c.account.tree_info), + Some(ColdContext::Mint(c)) => Some(&c.tree_info), None => None, } } @@ -168,14 +170,16 @@ impl AccountInterface { match &self.cold { Some(ColdContext::Account(c)) => Some(c.leaf_index), Some(ColdContext::Token(c)) => Some(c.account.leaf_index), + Some(ColdContext::Mint(c)) => Some(c.leaf_index), None => None, } } - /// Get as CompressedAccount if cold account type. + /// Get as CompressedAccount if cold account or mint type. pub fn as_compressed_account(&self) -> Option<&CompressedAccount> { match &self.cold { Some(ColdContext::Account(c)) => Some(c), + Some(ColdContext::Mint(c)) => Some(c), _ => None, } } @@ -191,7 +195,7 @@ impl AccountInterface { /// Try to parse as Mint. Returns None if not a mint or parse fails. pub fn as_mint(&self) -> Option { match &self.cold { - Some(ColdContext::Account(ca)) => { + Some(ColdContext::Mint(ca)) => { let data = ca.data.as_ref()?; borsh::BorshDeserialize::deserialize(&mut data.data.as_slice()).ok() } diff --git a/sdk-libs/client/src/interface/decompress_mint.rs b/sdk-libs/client/src/interface/decompress_mint.rs index 09690f6af2..db09536a46 100644 --- a/sdk-libs/client/src/interface/decompress_mint.rs +++ b/sdk-libs/client/src/interface/decompress_mint.rs @@ -132,7 +132,7 @@ impl From for AccountInterface { executable: false, rent_epoch: 0, }, - cold: Some(ColdContext::Account(compressed)), + cold: Some(ColdContext::Mint(compressed)), } } MintState::None => Self { diff --git a/sdk-libs/client/src/interface/light_program_interface.rs b/sdk-libs/client/src/interface/light_program_interface.rs index 1987db929b..4017d35763 100644 --- a/sdk-libs/client/src/interface/light_program_interface.rs +++ b/sdk-libs/client/src/interface/light_program_interface.rs @@ -22,6 +22,9 @@ pub enum AccountToFetch { Pda { address: Pubkey, program_id: Pubkey }, /// Token account (program-owned) - uses `get_token_account_interface(address)` Token { address: Pubkey }, + /// Token account by owner and mint - uses `get_compressed_token_accounts_by_owner` with mint filter + /// This is for program-owned token accounts (like vaults) where the address can't be looked up directly + TokenByOwnerMint { owner: Pubkey, mint: Pubkey }, /// ATA - uses `get_ata_interface(wallet_owner, mint)` Ata { wallet_owner: Pubkey, mint: Pubkey }, /// Light mint - uses `get_mint_interface(address)` @@ -40,6 +43,13 @@ impl AccountToFetch { Self::Token { address } } + /// Create a token fetch request by owner and mint. + /// Use this for program-owned token accounts (like vaults) where the on-chain PDA + /// address isn't preserved in the compressed representation. + pub fn token_by_owner_mint(owner: Pubkey, mint: Pubkey) -> Self { + Self::TokenByOwnerMint { owner, mint } + } + pub fn ata(wallet_owner: Pubkey, mint: Pubkey) -> Self { Self::Ata { wallet_owner, mint } } @@ -48,11 +58,14 @@ impl AccountToFetch { Self::Mint { address } } + /// Returns the primary pubkey for this fetch request. + /// For `TokenByOwnerMint`, returns the owner since there's no single address. #[must_use] pub fn pubkey(&self) -> Pubkey { match self { Self::Pda { address, .. } => *address, Self::Token { address } => *address, + Self::TokenByOwnerMint { owner, .. } => *owner, Self::Ata { wallet_owner, mint } => derive_token_ata(wallet_owner, mint).0, Self::Mint { address } => *address, } @@ -61,15 +74,18 @@ impl AccountToFetch { /// Context for cold accounts. /// -/// Two variants based on data structure, not account type: -/// - `Account` - PDA +/// Three variants based on data structure: +/// - `Account` - Generic PDA /// - `Token` - Token account +/// - `Mint` - Compressed mint #[derive(Clone, Debug, PartialEq)] pub enum ColdContext { - /// PDA + /// Generic PDA Account(CompressedAccount), /// Token account Token(CompressedTokenAccount), + /// Compressed mint + Mint(CompressedAccount), } /// Specification for a program-owned PDA with typed variant. diff --git a/sdk-libs/client/src/rpc/client.rs b/sdk-libs/client/src/rpc/client.rs index 6ee66d8591..28e2dc3c39 100644 --- a/sdk-libs/client/src/rpc/client.rs +++ b/sdk-libs/client/src/rpc/client.rs @@ -439,7 +439,9 @@ impl LightClient { // Conversion helpers from indexer types to interface types -fn convert_account_interface(indexer_ai: IndexerAccountInterface) -> AccountInterface { +fn convert_account_interface( + indexer_ai: IndexerAccountInterface, +) -> Result { use light_compressed_account::compressed_account::CompressedAccountData; use crate::indexer::{ColdContext as IndexerColdContext, CompressedAccount}; @@ -453,7 +455,7 @@ fn convert_account_interface(indexer_ai: IndexerAccountInterface) -> AccountInte }; match indexer_ai.cold { - None => AccountInterface::hot(indexer_ai.key, account), + None => Ok(AccountInterface::hot(indexer_ai.key, account)), Some(IndexerColdContext::Account { hash, leaf_index, @@ -476,13 +478,17 @@ fn convert_account_interface(indexer_ai: IndexerAccountInterface) -> AccountInte slot_created: 0, tree_info: TreeInfo { tree: tree_info.tree, - queue: tree_info.tree, // TODO: proper queue mapping + queue: tree_info.tree, // TODO: proper queue mapping (requires indexer changes) cpi_context: None, next_tree_info: None, - tree_type: TreeType::StateV1, + tree_type: TreeType::StateV1, // TODO: proper tree_type mapping (requires indexer changes) }, }; - AccountInterface::cold(indexer_ai.key, compressed, indexer_ai.account.owner) + Ok(AccountInterface::cold( + indexer_ai.key, + compressed, + indexer_ai.account.owner, + )) } Some(IndexerColdContext::Token { hash, @@ -494,9 +500,11 @@ fn convert_account_interface(indexer_ai: IndexerAccountInterface) -> AccountInte use crate::indexer::CompressedTokenAccount; - // Parse token data from the cold data + // Parse token data from the cold data - propagate errors instead of using default let token_data: TokenData = - borsh::BorshDeserialize::deserialize(&mut data.data.as_slice()).unwrap_or_default(); + borsh::BorshDeserialize::deserialize(&mut data.data.as_slice()).map_err(|e| { + RpcError::CustomError(format!("Failed to deserialize token data: {}", e)) + })?; let compressed_account = CompressedAccount { address: None, @@ -514,17 +522,21 @@ fn convert_account_interface(indexer_ai: IndexerAccountInterface) -> AccountInte slot_created: 0, tree_info: TreeInfo { tree: tree_info.tree, - queue: tree_info.tree, + queue: tree_info.tree, // TODO: proper queue mapping (requires indexer changes) cpi_context: None, next_tree_info: None, - tree_type: TreeType::StateV1, + tree_type: TreeType::StateV1, // TODO: proper tree_type mapping (requires indexer changes) }, }; let compressed_token = CompressedTokenAccount { token: token_data, account: compressed_account.clone(), }; - AccountInterface::cold_token(indexer_ai.key, compressed_token, indexer_ai.account.owner) + Ok(AccountInterface::cold_token( + indexer_ai.key, + compressed_token, + indexer_ai.account.owner, + )) } Some(IndexerColdContext::Mint { hash, @@ -548,13 +560,17 @@ fn convert_account_interface(indexer_ai: IndexerAccountInterface) -> AccountInte slot_created: 0, tree_info: TreeInfo { tree: tree_info.tree, - queue: tree_info.tree, + queue: tree_info.tree, // TODO: proper queue mapping (requires indexer changes) cpi_context: None, next_tree_info: None, - tree_type: TreeType::StateV1, + tree_type: TreeType::StateV1, // TODO: proper tree_type mapping (requires indexer changes) }, }; - AccountInterface::cold(indexer_ai.key, compressed, indexer_ai.account.owner) + Ok(AccountInterface::cold( + indexer_ai.key, + compressed, + indexer_ai.account.owner, + )) } } } @@ -607,6 +623,8 @@ fn convert_token_account_interface( tree_type: TreeType::StateV1, }, }; + // 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, @@ -614,7 +632,7 @@ fn convert_token_account_interface( Ok(TokenAccountInterface::cold( indexer_tai.account.key, compressed_token, - indexer_tai.account.key, // owner_override + token_owner, // owner_override: use token owner, not account key indexer_tai.account.account.owner, )) } @@ -1190,9 +1208,10 @@ impl Rpc for LightClient { .await .map_err(|e| RpcError::CustomError(format!("Indexer error: {e}")))?; + let value = resp.value.map(convert_account_interface).transpose()?; Ok(Response { context: resp.context, - value: resp.value.map(convert_account_interface), + value, }) } @@ -1272,6 +1291,44 @@ impl Rpc for LightClient { }) } + async fn get_token_account_by_owner_mint( + &self, + owner: &Pubkey, + mint: &Pubkey, + config: Option, + ) -> Result>, RpcError> { + use crate::indexer::GetCompressedTokenAccountsByOwnerOrDelegateOptions; + + let indexer = self + .indexer + .as_ref() + .ok_or(RpcError::IndexerNotInitialized)?; + + let options = Some(GetCompressedTokenAccountsByOwnerOrDelegateOptions { + mint: Some(*mint), + ..Default::default() + }); + + let resp = indexer + .get_compressed_token_accounts_by_owner(owner, options, config) + .await + .map_err(|e| RpcError::CustomError(format!("Indexer error: {e}")))?; + + let value = resp.value.items.into_iter().next().map(|token_acc| { + TokenAccountInterface::cold( + *owner, + token_acc, + *owner, + light_sdk::constants::LIGHT_TOKEN_PROGRAM_ID.into(), + ) + }); + + Ok(Response { + context: resp.context, + value, + }) + } + async fn get_multiple_account_interfaces( &self, addresses: Vec<&Pubkey>, @@ -1286,13 +1343,15 @@ impl Rpc for LightClient { .await .map_err(|e| RpcError::CustomError(format!("Indexer error: {e}")))?; + let value: Result>, RpcError> = resp + .value + .into_iter() + .map(|opt| opt.map(convert_account_interface).transpose()) + .collect(); + Ok(Response { context: resp.context, - value: resp - .value - .into_iter() - .map(|opt| opt.map(convert_account_interface)) - .collect(), + value: value?, }) } } diff --git a/sdk-libs/client/src/rpc/rpc_trait.rs b/sdk-libs/client/src/rpc/rpc_trait.rs index a5fadf9334..56df386749 100644 --- a/sdk-libs/client/src/rpc/rpc_trait.rs +++ b/sdk-libs/client/src/rpc/rpc_trait.rs @@ -271,6 +271,14 @@ pub trait Rpc: Send + Sync + Debug + 'static { config: Option, ) -> Result>, RpcError>; + /// Get a token account interface by owner and mint. + async fn get_token_account_by_owner_mint( + &self, + owner: &Pubkey, + mint: &Pubkey, + config: Option, + ) -> Result>, RpcError>; + /// Get multiple account interfaces in a batch. async fn get_multiple_account_interfaces( &self, @@ -310,6 +318,19 @@ pub trait Rpc: Send + Sync + Debug + 'static { })?; tai.into() } + AccountToFetch::TokenByOwnerMint { owner, mint } => { + let tai = self + .get_token_account_by_owner_mint(owner, mint, config.clone()) + .await? + .value + .ok_or_else(|| { + RpcError::CustomError(format!( + "Token account not found for owner {} mint {}", + owner, mint + )) + })?; + tai.into() + } AccountToFetch::Ata { wallet_owner, mint } => { let tai = self .get_ata_interface(wallet_owner, mint, config.clone()) diff --git a/sdk-libs/program-test/src/indexer/test_indexer.rs b/sdk-libs/program-test/src/indexer/test_indexer.rs index 6ca636d7d5..ef2c6510de 100644 --- a/sdk-libs/program-test/src/indexer/test_indexer.rs +++ b/sdk-libs/program-test/src/indexer/test_indexer.rs @@ -14,9 +14,6 @@ pub(crate) const DEFAULT_BATCH_STATE_TREE_HEIGHT: usize = 32; pub(crate) const DEFAULT_BATCH_ADDRESS_TREE_HEIGHT: usize = 40; pub(crate) const DEFAULT_BATCH_ROOT_HISTORY_LEN: usize = 200; -/// Discriminator for compressible accounts that store onchain_pubkey in the first 32 bytes of data. -/// This matches Photon's DECOMPRESSED_ACCOUNT_DISCRIMINATOR. -pub const DECOMPRESSED_ACCOUNT_DISCRIMINATOR: u64 = 0x00FFFFFFFFFFFFFF; use async_trait::async_trait; use borsh::BorshDeserialize; #[cfg(feature = "devenv")] @@ -43,6 +40,9 @@ use light_compressed_account::{ tx_hash::create_tx_hash, TreeType, }; +/// Discriminator for compressible accounts that store onchain_pubkey in the first 32 bytes of data. +/// Re-exported from light_compressible for convenience. +pub use light_compressible::DECOMPRESSED_PDA_DISCRIMINATOR; use light_event::event::PublicTransactionEvent; use light_hasher::{bigint::bigint_to_be_bytes_array, Poseidon}; use light_merkle_tree_reference::MerkleTree; @@ -1390,9 +1390,8 @@ impl TestIndexer { data: Option<&light_compressed_account::compressed_account::CompressedAccountData>, ) -> Option<[u8; 32]> { let data = data?; - // Check discriminator (as little-endian u64) - let discriminator = u64::from_le_bytes(data.discriminator); - if discriminator == DECOMPRESSED_ACCOUNT_DISCRIMINATOR && data.data.len() >= 32 { + // Check discriminator matches DECOMPRESSED_PDA_DISCRIMINATOR + if data.discriminator == DECOMPRESSED_PDA_DISCRIMINATOR && data.data.len() >= 32 { // onchain_pubkey is stored in the first 32 bytes of data (after discriminator) data.data[..32].try_into().ok() } else { @@ -1437,6 +1436,64 @@ impl TestIndexer { }) } + /// Find a compressed account by its PDA pubkey + pub fn find_compressed_account_by_pda_seed( + &self, + pda_pubkey: &[u8; 32], + ) -> Option<&CompressedAccountWithMerkleContext> { + // Try each address tree to find an account whose address matches + for address_tree in &self.address_merkle_trees { + let tree_pubkey = address_tree.accounts.merkle_tree.to_bytes(); + + // For each compressed account with an address, check if it was derived from this seed + for acc in &self.compressed_accounts { + if let Some(address) = acc.compressed_account.address { + // Try deriving with this tree and the account's owner as program_id + let owner_bytes = acc.compressed_account.owner.to_bytes(); + let derived = light_compressed_account::address::derive_address( + pda_pubkey, + &tree_pubkey, + &owner_bytes, + ); + + if derived == address { + return Some(acc); + } + } + } + } + None + } + + /// Find a token compressed account by its PDA pubkey + pub fn find_token_account_by_pda_seed( + &self, + pda_pubkey: &[u8; 32], + ) -> Option<&TokenDataWithMerkleContext> { + // Try each address tree to find an account whose address matches + for address_tree in &self.address_merkle_trees { + let tree_pubkey = address_tree.accounts.merkle_tree.to_bytes(); + + // For each token compressed account with an address, check if it was derived from this seed + for acc in &self.token_compressed_accounts { + if let Some(address) = acc.compressed_account.compressed_account.address { + // Try deriving with this tree and the account's owner as program_id + let owner_bytes = acc.compressed_account.compressed_account.owner.to_bytes(); + let derived = light_compressed_account::address::derive_address( + pda_pubkey, + &tree_pubkey, + &owner_bytes, + ); + + if derived == address { + return Some(acc); + } + } + } + } + None + } + /// Get the sequence number for a state merkle tree by its pubkey. pub fn get_state_tree_seq(&self, tree_pubkey: &Pubkey) -> Option { self.state_merkle_trees diff --git a/sdk-libs/program-test/src/program_test/rpc.rs b/sdk-libs/program-test/src/program_test/rpc.rs index 145bd34175..3c3a1e975d 100644 --- a/sdk-libs/program-test/src/program_test/rpc.rs +++ b/sdk-libs/program-test/src/program_test/rpc.rs @@ -387,6 +387,7 @@ impl Rpc for LightProgramTest { // Cold: check TestIndexer by onchain pubkey (mirrors Photon behavior) if let Some(indexer) = self.indexer.as_ref() { + // First try: lookup by onchain_pubkey (for accounts with DECOMPRESSED_PDA_DISCRIMINATOR) if let Some(compressed_with_ctx) = indexer.find_compressed_account_by_onchain_pubkey(&address.to_bytes()) { @@ -401,6 +402,22 @@ impl Rpc for LightProgramTest { value: Some(AccountInterface::cold(*address, compressed, owner)), }); } + + // Second try: lookup by PDA seed (for accounts whose address was derived from this pubkey) + if let Some(compressed_with_ctx) = + indexer.find_compressed_account_by_pda_seed(&address.to_bytes()) + { + let owner: Pubkey = compressed_with_ctx.compressed_account.owner.into(); + let compressed: CompressedAccount = compressed_with_ctx + .clone() + .try_into() + .map_err(|e| RpcError::CustomError(format!("conversion error: {:?}", e)))?; + + return Ok(Response { + context: Context { slot }, + value: Some(AccountInterface::cold(*address, compressed, owner)), + }); + } } Ok(Response { @@ -436,11 +453,17 @@ impl Rpc for LightProgramTest { } } - // Cold: check TestIndexer by onchain_pubkey + // Cold: check TestIndexer by onchain_pubkey, PDA seed, or token_data.owner if let Some(indexer) = self.indexer.as_ref() { - if let Some(token_acc) = - indexer.find_token_account_by_onchain_pubkey(&address.to_bytes()) - { + // First try: lookup by onchain_pubkey (for accounts with DECOMPRESSED_PDA_DISCRIMINATOR) + let token_acc = indexer + .find_token_account_by_onchain_pubkey(&address.to_bytes()) + .or_else(|| { + // Second try: lookup by PDA seed (for accounts whose address was derived from this pubkey) + indexer.find_token_account_by_pda_seed(&address.to_bytes()) + }); + + if let Some(token_acc) = token_acc { // Convert to CompressedTokenAccount let compressed_account: CompressedAccount = token_acc .compressed_account @@ -463,6 +486,24 @@ impl Rpc for LightProgramTest { )), }); } + + // Third try: lookup by token_data.owner (for tokens where owner == address) + let result = indexer + .get_compressed_token_accounts_by_owner(address, None, None) + .await + .map_err(|e| RpcError::CustomError(format!("indexer error: {}", e)))?; + + if let Some(token_acc) = result.value.items.into_iter().next() { + return Ok(Response { + context: Context { slot }, + value: Some(TokenAccountInterface::cold( + *address, + token_acc, + *address, // owner = hot address for program-owned tokens + light_token_program_id, + )), + }); + } } Ok(Response { @@ -475,12 +516,107 @@ impl Rpc for LightProgramTest { &self, owner: &Pubkey, mint: &Pubkey, - config: Option, + _config: Option, ) -> Result>, RpcError> { + use light_client::indexer::GetCompressedTokenAccountsByOwnerOrDelegateOptions; + use light_sdk::constants::LIGHT_TOKEN_PROGRAM_ID; use light_token::instruction::derive_token_ata; let (ata, _bump) = derive_token_ata(owner, mint); - self.get_token_account_interface(&ata, config).await + let light_token_program_id: Pubkey = LIGHT_TOKEN_PROGRAM_ID.into(); + let slot = self.context.get_sysvar::().slot; + + // First try: on-chain (hot) lookup + // We handle this directly instead of using get_token_account_interface + // because we need to control owner_override for ata_bump() to work + if let Some(account) = self.context.get_account(&ata) { + if account.lamports > 0 && account.owner == light_token_program_id { + match TokenAccountInterface::hot(ata, account) { + Ok(iface) => { + return Ok(Response { + context: Context { slot }, + value: Some(iface), + }); + } + Err(_) => { + // Fall through to cold lookup if parsing failed + } + } + } + } + + // Cold: search compressed tokens by ata_pubkey + mint + // In Light Protocol, token_data.owner is the token account pubkey (ATA), not wallet owner + // But we need to pass the wallet owner for TokenAccountInterface::cold so ata_bump() works + if let Some(indexer) = self.indexer.as_ref() { + let options = Some(GetCompressedTokenAccountsByOwnerOrDelegateOptions { + mint: Some(*mint), + ..Default::default() + }); + let result = indexer + .get_compressed_token_accounts_by_owner(&ata, options, None) + .await + .map_err(|e| RpcError::CustomError(format!("indexer error: {}", e)))?; + + if let Some(token_acc) = result.value.items.into_iter().next() { + return Ok(Response { + context: Context { slot }, + value: Some(TokenAccountInterface::cold( + ata, // key = ATA pubkey + token_acc, + *owner, // owner_override = wallet owner (for ata_bump() to work) + light_token_program_id, + )), + }); + } + } + + Ok(Response { + context: Context { slot }, + value: None, + }) + } + + async fn get_token_account_by_owner_mint( + &self, + owner: &Pubkey, + mint: &Pubkey, + _config: Option, + ) -> Result>, RpcError> { + use light_client::indexer::GetCompressedTokenAccountsByOwnerOrDelegateOptions; + use light_sdk::constants::LIGHT_TOKEN_PROGRAM_ID; + + let light_token_program_id: Pubkey = LIGHT_TOKEN_PROGRAM_ID.into(); + let slot = self.context.get_sysvar::().slot; + + // Search in compressed token accounts by owner with mint filter + if let Some(indexer) = self.indexer.as_ref() { + let options = Some(GetCompressedTokenAccountsByOwnerOrDelegateOptions { + mint: Some(*mint), + ..Default::default() + }); + let result = indexer + .get_compressed_token_accounts_by_owner(owner, options, None) + .await + .map_err(|e| RpcError::CustomError(format!("indexer error: {}", e)))?; + + if let Some(token_acc) = result.value.items.into_iter().next() { + return Ok(Response { + context: Context { slot }, + value: Some(TokenAccountInterface::cold( + *owner, // key = owner for program-owned tokens + token_acc, + *owner, + light_token_program_id, + )), + }); + } + } + + Ok(Response { + context: Context { slot }, + value: None, + }) } async fn get_mint_interface( diff --git a/sdk-tests/csdk-anchor-full-derived-test-sdk/src/lib.rs b/sdk-tests/csdk-anchor-full-derived-test-sdk/src/lib.rs index 5cbe8e3521..d9d0a608b9 100644 --- a/sdk-tests/csdk-anchor-full-derived-test-sdk/src/lib.rs +++ b/sdk-tests/csdk-anchor-full-derived-test-sdk/src/lib.rs @@ -33,6 +33,11 @@ pub type MintInterfaceMap = HashMap, kind: AccountKind) -> Self { Self { pubkey, kind } } + + fn token_by_owner_mint(owner: Pubkey, mint: Pubkey) -> Self { + Self { + pubkey: None, // No direct pubkey for owner+mint lookup + kind: AccountKind::TokenByOwnerMint { owner, mint }, + } + } } #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -229,6 +241,9 @@ impl AmmSdk { let compressed_account = match &account.cold { Some(ColdContext::Token(ct)) => ct.account.clone(), Some(ColdContext::Account(ca)) => ca.clone(), + Some(ColdContext::Mint(_)) => { + return Err(AmmSdkError::MissingField("unexpected Mint cold context")) + } None => return Err(AmmSdkError::MissingField("cold_context")), }; AccountInterface { @@ -290,20 +305,29 @@ impl AmmSdk { } fn account_requirements(&self, ix: &AmmInstruction) -> Vec { + let vault_0_req = match (self.token_0_vault, self.token_0_mint) { + (Some(owner), Some(mint)) => AccountRequirement::token_by_owner_mint(owner, mint), + _ => AccountRequirement::new(self.token_0_vault, AccountKind::Token), + }; + let vault_1_req = match (self.token_1_vault, self.token_1_mint) { + (Some(owner), Some(mint)) => AccountRequirement::token_by_owner_mint(owner, mint), + _ => AccountRequirement::new(self.token_1_vault, AccountKind::Token), + }; + match ix { AmmInstruction::Swap => { vec![ AccountRequirement::new(self.pool_state_pubkey, AccountKind::Pda), - AccountRequirement::new(self.token_0_vault, AccountKind::Token), - AccountRequirement::new(self.token_1_vault, AccountKind::Token), + vault_0_req, + vault_1_req, AccountRequirement::new(self.observation_key, AccountKind::Pda), ] } AmmInstruction::Deposit | AmmInstruction::Withdraw => { vec![ AccountRequirement::new(self.pool_state_pubkey, AccountKind::Pda), - AccountRequirement::new(self.token_0_vault, AccountKind::Token), - AccountRequirement::new(self.token_1_vault, AccountKind::Token), + vault_0_req, + vault_1_req, AccountRequirement::new(self.observation_key, AccountKind::Pda), AccountRequirement::new(self.lp_mint, AccountKind::Mint), ] @@ -339,12 +363,15 @@ impl LightProgramInterface for AmmSdk { fn get_accounts_to_update(&self, ix: &Self::Instruction) -> Vec { self.account_requirements(ix) .into_iter() - .filter_map(|req| { - req.pubkey.map(|pubkey| match req.kind { - AccountKind::Pda => AccountToFetch::pda(pubkey, PROGRAM_ID), - AccountKind::Token => AccountToFetch::token(pubkey), - AccountKind::Mint => AccountToFetch::mint(pubkey), - }) + .filter_map(|req| match req.kind { + AccountKind::Pda => req + .pubkey + .map(|pubkey| AccountToFetch::pda(pubkey, PROGRAM_ID)), + AccountKind::Token => req.pubkey.map(AccountToFetch::token), + AccountKind::TokenByOwnerMint { owner, mint } => { + Some(AccountToFetch::token_by_owner_mint(owner, mint)) + } + AccountKind::Mint => req.pubkey.map(AccountToFetch::mint), }) .collect() } @@ -381,6 +408,11 @@ impl LightProgramInterface for AmmSdk { } } } + AccountKind::TokenByOwnerMint { owner, mint: _ } => { + if let Some(spec) = self.program_owned_specs.get(&owner) { + specs.push(AccountSpec::Pda(spec.clone())); + } + } AccountKind::Mint => { if let Some(mint_pubkey) = req.pubkey { if let Some(spec) = self.mint_specs.get(&mint_pubkey) { diff --git a/sdk-tests/csdk-anchor-full-derived-test/tests/basic_test.rs b/sdk-tests/csdk-anchor-full-derived-test/tests/basic_test.rs index f81a7d5210..16ae2d6464 100644 --- a/sdk-tests/csdk-anchor-full-derived-test/tests/basic_test.rs +++ b/sdk-tests/csdk-anchor-full-derived-test/tests/basic_test.rs @@ -456,20 +456,7 @@ async fn test_create_pdas_and_mint_auto() { assert!(mint_interface.is_cold(), "Mint should be cold after warp"); // Convert MintInterface to AccountInterface for use in AccountSpec - let (compressed, _mint_data) = mint_interface - .compressed() - .expect("cold mint must have compressed data"); - let mint_account_interface = AccountInterface { - key: mint_pda, - account: solana_account::Account { - lamports: 0, - data: vec![], - owner: light_token::instruction::LIGHT_TOKEN_PROGRAM_ID, - executable: false, - rent_epoch: 0, - }, - cold: Some(ColdContext::Account(compressed.clone())), - }; + let mint_account_interface: AccountInterface = mint_interface.into(); // Build AccountSpec slice for all accounts let specs: Vec> = vec![ diff --git a/sdk-tests/justfile b/sdk-tests/justfile index 27c1d0a969..9cbf9ae952 100644 --- a/sdk-tests/justfile +++ b/sdk-tests/justfile @@ -10,3 +10,4 @@ test: RUSTFLAGS="-D warnings" cargo test-sbf -p sdk-native-test RUSTFLAGS="-D warnings" cargo test-sbf -p sdk-anchor-test RUSTFLAGS="-D warnings" cargo test-sbf -p sdk-token-test + RUSTFLAGS="-D warnings" cargo test-sbf -p csdk-anchor-full-derived-test From f46c6193ca645cc22e2e3d651f2ddb50af5dcae6 Mon Sep 17 00:00:00 2001 From: Sergey Timoshin Date: Thu, 29 Jan 2026 21:00:05 +0000 Subject: [PATCH 28/38] fix: update account types in get_accounts_to_update test --- .../csdk-anchor-full-derived-test-sdk/tests/trait_tests.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/sdk-tests/csdk-anchor-full-derived-test-sdk/tests/trait_tests.rs b/sdk-tests/csdk-anchor-full-derived-test-sdk/tests/trait_tests.rs index 3a9c8332e2..c5fa516125 100644 --- a/sdk-tests/csdk-anchor-full-derived-test-sdk/tests/trait_tests.rs +++ b/sdk-tests/csdk-anchor-full-derived-test-sdk/tests/trait_tests.rs @@ -495,11 +495,12 @@ fn test_get_accounts_to_update_categories() { let sdk = AmmSdk::new(); let typed = sdk.get_accounts_to_update(&AmmInstruction::Deposit); - // All should be one of Pda, Token, Ata, or Mint + // All should be one of Pda, Token, TokenByOwnerMint, Ata, or Mint for acc in &typed { match acc { AccountToFetch::Pda { .. } => {} AccountToFetch::Token { .. } => {} + AccountToFetch::TokenByOwnerMint { .. } => {} AccountToFetch::Ata { .. } => {} AccountToFetch::Mint { .. } => {} } From e52e51e5b223e21aba16ef7699df248a4b6225f3 Mon Sep 17 00:00:00 2001 From: Sergey Timoshin Date: Thu, 29 Jan 2026 22:00:12 +0000 Subject: [PATCH 29/38] cleanup --- forester/src/compressible/mint/state.rs | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/forester/src/compressible/mint/state.rs b/forester/src/compressible/mint/state.rs index c08fc74f1a..4ddebb4847 100644 --- a/forester/src/compressible/mint/state.rs +++ b/forester/src/compressible/mint/state.rs @@ -29,10 +29,7 @@ fn calculate_compressible_slot(mint: &Mint, lamports: u64, account_size: usize) rent_exemption, ); - // Use the first unpaid epoch as the compressible boundary. - // is_ready_to_compress checks current_slot > compressible_slot, - // so we return the start of the first unpaid epoch. - Ok((last_funded_epoch + 1) * SLOTS_PER_EPOCH) + Ok(last_funded_epoch * SLOTS_PER_EPOCH) } #[derive(Debug)] From 5dcf777a898839b82c2a14f71b4b265a6df02056 Mon Sep 17 00:00:00 2001 From: Sergey Timoshin Date: Thu, 29 Jan 2026 22:41:27 +0000 Subject: [PATCH 30/38] bump photon --- scripts/devenv/versions.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/devenv/versions.sh b/scripts/devenv/versions.sh index 17c04bde55..bf1bfa89eb 100755 --- a/scripts/devenv/versions.sh +++ b/scripts/devenv/versions.sh @@ -13,7 +13,7 @@ export SOLANA_VERSION="2.2.15" export ANCHOR_VERSION="0.31.1" export JQ_VERSION="1.8.0" export PHOTON_VERSION="0.51.2" -export PHOTON_COMMIT="a42f7b74694706597c950e9407007cbfaba09b3d" +export PHOTON_COMMIT="2503c2303f14e270cda166ef8a07fbb0de484e67" export REDIS_VERSION="8.0.1" export ANCHOR_TAG="anchor-v${ANCHOR_VERSION}" From 93ea0bb5f642e351d86e9eed2caa5a5046440f3e Mon Sep 17 00:00:00 2001 From: Sergey Timoshin Date: Thu, 29 Jan 2026 23:22:56 +0000 Subject: [PATCH 31/38] cleanup --- sdk-libs/client/src/indexer/types.rs | 5 +- sdk-libs/client/src/rpc/client.rs | 24 +++++- .../program-test/src/indexer/test_indexer.rs | 48 ++++++++--- sdk-libs/program-test/src/program_test/rpc.rs | 84 +++++++++++++++---- 4 files changed, 129 insertions(+), 32 deletions(-) diff --git a/sdk-libs/client/src/indexer/types.rs b/sdk-libs/client/src/indexer/types.rs index 36e91ddcc2..48cad89ea5 100644 --- a/sdk-libs/client/src/indexer/types.rs +++ b/sdk-libs/client/src/indexer/types.rs @@ -435,7 +435,10 @@ impl ValidityProofWithContext { .get(&value.merkle_trees[i]) .ok_or(IndexerError::MissingResult { context: "conversion".into(), - message: "expected value was None".into(), + message: format!( + "tree not found in QUEUE_TREE_MAPPING: {}", + &value.merkle_trees[i] + ), })?; Ok(AccountProofInputs { diff --git a/sdk-libs/client/src/rpc/client.rs b/sdk-libs/client/src/rpc/client.rs index 28e2dc3c39..5c00f32f7c 100644 --- a/sdk-libs/client/src/rpc/client.rs +++ b/sdk-libs/client/src/rpc/client.rs @@ -506,6 +506,8 @@ fn convert_account_interface( RpcError::CustomError(format!("Failed to deserialize token data: {}", e)) })?; + let wallet_owner = token_data.owner; + let compressed_account = CompressedAccount { address: None, data: Some(CompressedAccountData { @@ -535,7 +537,7 @@ fn convert_account_interface( Ok(AccountInterface::cold_token( indexer_ai.key, compressed_token, - indexer_ai.account.owner, + wallet_owner, )) } Some(IndexerColdContext::Mint { @@ -1314,9 +1316,25 @@ impl Rpc for LightClient { .await .map_err(|e| RpcError::CustomError(format!("Indexer error: {e}")))?; - let value = resp.value.items.into_iter().next().map(|token_acc| { + let items = resp.value.items; + if items.len() > 1 { + return Err(RpcError::CustomError(format!( + "Ambiguous lookup: found {} compressed token accounts for owner {} and mint {}. \ + Use get_compressed_token_accounts_by_owner for multiple accounts.", + items.len(), + owner, + mint + ))); + } + + let value = items.into_iter().next().map(|token_acc| { + let key = token_acc + .account + .address + .map(Pubkey::new_from_array) + .unwrap_or(*owner); TokenAccountInterface::cold( - *owner, + key, token_acc, *owner, light_sdk::constants::LIGHT_TOKEN_PROGRAM_ID.into(), diff --git a/sdk-libs/program-test/src/indexer/test_indexer.rs b/sdk-libs/program-test/src/indexer/test_indexer.rs index ef2c6510de..a4ca21729c 100644 --- a/sdk-libs/program-test/src/indexer/test_indexer.rs +++ b/sdk-libs/program-test/src/indexer/test_indexer.rs @@ -108,7 +108,6 @@ pub struct TestIndexer { pub token_nullified_compressed_accounts: Vec, pub events: Vec, /// Index mapping onchain_pubkey to compressed account index. - /// Used for interface lookups (like Photon's onchain_pubkey column). pub onchain_pubkey_index: HashMap<[u8; 32], usize>, } @@ -1405,10 +1404,23 @@ impl TestIndexer { &self, onchain_pubkey: &[u8; 32], ) -> Option<&CompressedAccountWithMerkleContext> { - self.compressed_accounts.iter().find(|acc| { - Self::extract_onchain_pubkey_from_data(acc.compressed_account.data.as_ref()).as_ref() - == Some(onchain_pubkey) - }) + let matches: Vec<_> = self + .compressed_accounts + .iter() + .filter(|acc| { + Self::extract_onchain_pubkey_from_data(acc.compressed_account.data.as_ref()) + .as_ref() + == Some(onchain_pubkey) + }) + .collect(); + + debug_assert!( + matches.len() <= 1, + "find_compressed_account_by_onchain_pubkey: found {} matches, expected at most 1", + matches.len() + ); + + matches.into_iter().next() } /// Find multiple compressed accounts by their on-chain pubkeys. @@ -1427,13 +1439,25 @@ impl TestIndexer { &self, onchain_pubkey: &[u8; 32], ) -> Option<&TokenDataWithMerkleContext> { - self.token_compressed_accounts.iter().find(|acc| { - Self::extract_onchain_pubkey_from_data( - acc.compressed_account.compressed_account.data.as_ref(), - ) - .as_ref() - == Some(onchain_pubkey) - }) + let matches: Vec<_> = self + .token_compressed_accounts + .iter() + .filter(|acc| { + Self::extract_onchain_pubkey_from_data( + acc.compressed_account.compressed_account.data.as_ref(), + ) + .as_ref() + == Some(onchain_pubkey) + }) + .collect(); + + debug_assert!( + matches.len() <= 1, + "find_token_account_by_onchain_pubkey: found {} matches, expected at most 1", + matches.len() + ); + + matches.into_iter().next() } /// Find a compressed account by its PDA pubkey diff --git a/sdk-libs/program-test/src/program_test/rpc.rs b/sdk-libs/program-test/src/program_test/rpc.rs index 3c3a1e975d..2aded8111e 100644 --- a/sdk-libs/program-test/src/program_test/rpc.rs +++ b/sdk-libs/program-test/src/program_test/rpc.rs @@ -392,10 +392,14 @@ impl Rpc for LightProgramTest { indexer.find_compressed_account_by_onchain_pubkey(&address.to_bytes()) { let owner: Pubkey = compressed_with_ctx.compressed_account.owner.into(); - let compressed: CompressedAccount = compressed_with_ctx - .clone() - .try_into() - .map_err(|e| RpcError::CustomError(format!("conversion error: {:?}", e)))?; + let compressed: CompressedAccount = compressed_with_ctx.clone().try_into().map_err( + |e| { + RpcError::CustomError(format!( + "CompressedAccountWithMerkleContext conversion failed for address {}: {:?}", + address, e + )) + }, + )?; return Ok(Response { context: Context { slot }, @@ -408,10 +412,14 @@ impl Rpc for LightProgramTest { indexer.find_compressed_account_by_pda_seed(&address.to_bytes()) { let owner: Pubkey = compressed_with_ctx.compressed_account.owner.into(); - let compressed: CompressedAccount = compressed_with_ctx - .clone() - .try_into() - .map_err(|e| RpcError::CustomError(format!("conversion error: {:?}", e)))?; + let compressed: CompressedAccount = compressed_with_ctx.clone().try_into().map_err( + |e| { + RpcError::CustomError(format!( + "CompressedAccountWithMerkleContext conversion failed for PDA seed {}: {:?}", + address, e + )) + }, + )?; return Ok(Response { context: Context { slot }, @@ -493,11 +501,26 @@ impl Rpc for LightProgramTest { .await .map_err(|e| RpcError::CustomError(format!("indexer error: {}", e)))?; - if let Some(token_acc) = result.value.items.into_iter().next() { + let items = result.value.items; + if items.len() > 1 { + return Err(RpcError::CustomError(format!( + "Ambiguous lookup: found {} compressed token accounts for address {}. \ + Use get_compressed_token_accounts_by_owner for multiple accounts.", + items.len(), + address + ))); + } + + if let Some(token_acc) = items.into_iter().next() { + let key = token_acc + .account + .address + .map(Pubkey::new_from_array) + .unwrap_or(*address); return Ok(Response { context: Context { slot }, value: Some(TokenAccountInterface::cold( - *address, + key, token_acc, *address, // owner = hot address for program-owned tokens light_token_program_id, @@ -558,11 +581,23 @@ impl Rpc for LightProgramTest { .await .map_err(|e| RpcError::CustomError(format!("indexer error: {}", e)))?; - if let Some(token_acc) = result.value.items.into_iter().next() { + let items = result.value.items; + if items.len() > 1 { + return Err(RpcError::CustomError(format!( + "Ambiguous lookup: found {} compressed token accounts for ATA {} (owner: {}, mint: {}). \ + Use get_compressed_token_accounts_by_owner for multiple accounts.", + items.len(), + ata, + owner, + mint + ))); + } + + if let Some(token_acc) = items.into_iter().next() { return Ok(Response { context: Context { slot }, value: Some(TokenAccountInterface::cold( - ata, // key = ATA pubkey + ata, // key = ATA pubkey (derived, so we use it directly) token_acc, *owner, // owner_override = wallet owner (for ata_bump() to work) light_token_program_id, @@ -600,11 +635,27 @@ impl Rpc for LightProgramTest { .await .map_err(|e| RpcError::CustomError(format!("indexer error: {}", e)))?; - if let Some(token_acc) = result.value.items.into_iter().next() { + let items = result.value.items; + if items.len() > 1 { + return Err(RpcError::CustomError(format!( + "Ambiguous lookup: found {} compressed token accounts for owner {} and mint {}. \ + Use get_compressed_token_accounts_by_owner for multiple accounts.", + items.len(), + owner, + mint + ))); + } + + if let Some(token_acc) = items.into_iter().next() { + let key = token_acc + .account + .address + .map(Pubkey::new_from_array) + .unwrap_or(*owner); return Ok(Response { context: Context { slot }, value: Some(TokenAccountInterface::cold( - *owner, // key = owner for program-owned tokens + key, token_acc, *owner, light_token_program_id, @@ -630,15 +681,16 @@ impl Rpc for LightProgramTest { let slot = self.context.get_sysvar::().slot; let address_tree = Pubkey::new_from_array(MINT_ADDRESS_TREE); + let light_token_program_id: Pubkey = + Pubkey::new_from_array(light_token_interface::LIGHT_TOKEN_PROGRAM_ID); let compressed_address = derive_address( &address.to_bytes(), &address_tree.to_bytes(), &light_token_interface::LIGHT_TOKEN_PROGRAM_ID, ); - // Hot: check on-chain first if let Some(account) = self.context.get_account(address) { - if account.lamports > 0 { + if account.lamports > 0 && account.owner == light_token_program_id { return Ok(Response { context: Context { slot }, value: Some(MintInterface { From 1e4d9185f43b3d2ab2234df525ea126e486fd087 Mon Sep 17 00:00:00 2001 From: Sergey Timoshin Date: Thu, 29 Jan 2026 23:54:16 +0000 Subject: [PATCH 32/38] fix: update error assertion in test_create_ata_failing for invalid mint accounts --- .../compressed-token-test/tests/light_token/create_ata.rs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/program-tests/compressed-token-test/tests/light_token/create_ata.rs b/program-tests/compressed-token-test/tests/light_token/create_ata.rs index 78db3428d3..556b82c1ba 100644 --- a/program-tests/compressed-token-test/tests/light_token/create_ata.rs +++ b/program-tests/compressed-token-test/tests/light_token/create_ata.rs @@ -892,9 +892,9 @@ async fn test_create_ata_failing() { .create_and_send_transaction(&[create_ata_ix], &payer_pubkey, &[&context.payer]) .await; - // Should fail with InstructionError::InvalidAccountData (3) because is_valid_mint returns false - // for token accounts (AccountType=2 at offset 165) - light_program_test::utils::assert::assert_rpc_error(result, 0, 3).unwrap(); + // Should fail with InstructionError::UninitializedAccount (10) because the token account + // is not a valid mint + light_program_test::utils::assert::assert_rpc_error(result, 0, 10).unwrap(); } } From 8216d7e91cfd2525de9aeb30818778b959881dc6 Mon Sep 17 00:00:00 2001 From: Sergey Timoshin Date: Fri, 30 Jan 2026 09:42:38 +0000 Subject: [PATCH 33/38] fix: update error assertion in test_create_ata_failing for invalid mint accounts refactor: streamline CI workflow in programs.yml and justfile --- .github/workflows/programs.yml | 70 +++------- .../tests/light_token/create_ata.rs | 6 +- program-tests/justfile | 122 +++++++++++++++++- 3 files changed, 138 insertions(+), 60 deletions(-) diff --git a/.github/workflows/programs.yml b/.github/workflows/programs.yml index c84da7dfe3..a1213a6d63 100644 --- a/.github/workflows/programs.yml +++ b/.github/workflows/programs.yml @@ -7,7 +7,7 @@ on: - "program-tests/**" - "program-libs/**" - "prover/client/**" - - ".github/workflows/light-system-programs-tests.yml" + - ".github/workflows/programs.yml" pull_request: branches: - "*" @@ -16,7 +16,7 @@ on: - "program-tests/**" - "program-libs/**" - "prover/client/**" - - ".github/workflows/light-system-programs-tests.yml" + - ".github/workflows/programs.yml" types: - opened - synchronize @@ -31,7 +31,7 @@ concurrency: jobs: system-programs: - name: programs + name: ${{ matrix.test-group }} if: github.event.pull_request.draft == false runs-on: warp-ubuntu-latest-x64-4x timeout-minutes: 90 @@ -52,27 +52,16 @@ jobs: strategy: matrix: - include: - - program: account-compression-and-registry - sub-tests: '["cargo-test-sbf -p account-compression-test", "cargo-test-sbf -p registry-test"]' - - program: light-system-program-address - sub-tests: '["cargo-test-sbf -p system-test -- test_with_address", "cargo-test-sbf -p e2e-test", "cargo-test-sbf -p compressed-token-test --test light_token"]' - - program: light-system-program-compression - sub-tests: '["cargo-test-sbf -p system-test -- test_with_compression", "cargo-test-sbf -p system-test --test test_re_init_cpi_account"]' - - program: compressed-token-and-e2e - sub-tests: '["cargo test -p light-compressed-token", "cargo-test-sbf -p compressed-token-test --test v1", "cargo-test-sbf -p compressed-token-test --test mint"]' - - program: compressed-token-batched-tree - sub-tests: '["cargo-test-sbf -p compressed-token-test -- test_transfer_with_photon_and_batched_tree"]' - - program: system-cpi-test - sub-tests: - '["cargo-test-sbf -p system-cpi-test", "cargo test -p light-system-program-pinocchio", - "cargo-test-sbf -p system-cpi-v2-test -- --skip functional_ --skip event::parse", "cargo-test-sbf -p system-cpi-v2-test -- event::parse", - "cargo-test-sbf -p compressed-token-test --test transfer2" - ]' - - program: system-cpi-test-v2-functional-read-only - sub-tests: '["cargo-test-sbf -p system-cpi-v2-test -- functional_read_only"]' - - program: system-cpi-test-v2-functional-account-infos - sub-tests: '["cargo-test-sbf -p system-cpi-v2-test -- functional_account_infos"]' + test-group: + - account-compression-and-registry + - system-address + - system-compression + - compressed-token-and-e2e + - compressed-token-batched-tree + - system-cpi + - system-cpi-v2-functional-read-only + - system-cpi-v2-functional-account-infos + steps: - name: Checkout sources uses: actions/checkout@v6 @@ -87,34 +76,7 @@ jobs: run: | just cli build - - name: ${{ matrix.program }} + - name: Run tests + working-directory: program-tests run: | - - IFS=',' read -r -a sub_tests <<< "${{ join(fromJSON(matrix['sub-tests']), ', ') }}" - for subtest in "${sub_tests[@]}" - do - echo "$subtest" - - # Retry logic for flaky batched-tree test - if [[ "$subtest" == *"test_transfer_with_photon_and_batched_tree"* ]]; then - echo "Running flaky test with retry logic (max 3 attempts)..." - attempt=1 - max_attempts=3 - until RUSTFLAGS="-D warnings" eval "$subtest"; do - attempt=$((attempt + 1)) - if [ $attempt -gt $max_attempts ]; then - echo "Test failed after $max_attempts attempts" - exit 1 - fi - echo "Attempt $attempt/$max_attempts failed, retrying..." - sleep 5 - done - echo "Test passed on attempt $attempt" - else - RUSTFLAGS="-D warnings" eval "$subtest" - if [ "$subtest" == "cargo-test-sbf -p e2e-test" ]; then - just programs build-compressed-token-small - RUSTFLAGS="-D warnings" eval "$subtest -- --test test_10_all" - fi - fi - done + just ci-${{ matrix.test-group }} diff --git a/program-tests/compressed-token-test/tests/light_token/create_ata.rs b/program-tests/compressed-token-test/tests/light_token/create_ata.rs index 556b82c1ba..78db3428d3 100644 --- a/program-tests/compressed-token-test/tests/light_token/create_ata.rs +++ b/program-tests/compressed-token-test/tests/light_token/create_ata.rs @@ -892,9 +892,9 @@ async fn test_create_ata_failing() { .create_and_send_transaction(&[create_ata_ix], &payer_pubkey, &[&context.payer]) .await; - // Should fail with InstructionError::UninitializedAccount (10) because the token account - // is not a valid mint - light_program_test::utils::assert::assert_rpc_error(result, 0, 10).unwrap(); + // Should fail with InstructionError::InvalidAccountData (3) because is_valid_mint returns false + // for token accounts (AccountType=2 at offset 165) + light_program_test::utils::assert::assert_rpc_error(result, 0, 3).unwrap(); } } diff --git a/program-tests/justfile b/program-tests/justfile index 18454e4823..b63c5fcdaa 100644 --- a/program-tests/justfile +++ b/program-tests/justfile @@ -6,10 +6,126 @@ default: build: cd create-address-test-program && cargo build-sbf -test: build +# === Full test suite (mirrors CI) === + +test: build test-account-compression test-registry test-system test-system-cpi test-system-cpi-v2 test-compressed-token test-e2e + +# === Individual test packages === + +test-account-compression: RUSTFLAGS="-D warnings" cargo test-sbf -p account-compression-test + +test-registry: RUSTFLAGS="-D warnings" cargo test-sbf -p registry-test - RUSTFLAGS="-D warnings" cargo test-sbf -p system-test + +# System program tests +test-system: test-system-address test-system-compression test-system-re-init + +test-system-address: + RUSTFLAGS="-D warnings" cargo test-sbf -p system-test -- test_with_address + +test-system-compression: + RUSTFLAGS="-D warnings" cargo test-sbf -p system-test -- test_with_compression + +test-system-re-init: + RUSTFLAGS="-D warnings" cargo test-sbf -p system-test --test test_re_init_cpi_account + +# System CPI tests (v1) +test-system-cpi: RUSTFLAGS="-D warnings" cargo test-sbf -p system-cpi-test - RUSTFLAGS="-D warnings" cargo test-sbf -p compressed-token-test + +# System CPI tests (v2) +test-system-cpi-v2: test-system-cpi-v2-main test-system-cpi-v2-event-parse test-system-cpi-v2-functional + +test-system-cpi-v2-main: + RUSTFLAGS="-D warnings" cargo test-sbf -p system-cpi-v2-test -- --skip functional_ --skip event::parse + +test-system-cpi-v2-event-parse: + RUSTFLAGS="-D warnings" cargo test-sbf -p system-cpi-v2-test -- event::parse + +test-system-cpi-v2-functional: test-system-cpi-v2-functional-read-only test-system-cpi-v2-functional-account-infos + +test-system-cpi-v2-functional-read-only: + RUSTFLAGS="-D warnings" cargo test-sbf -p system-cpi-v2-test -- functional_read_only + +test-system-cpi-v2-functional-account-infos: + RUSTFLAGS="-D warnings" cargo test-sbf -p system-cpi-v2-test -- functional_account_infos + +# Compressed token tests +test-compressed-token: test-compressed-token-unit test-compressed-token-v1 test-compressed-token-mint test-compressed-token-light-token test-compressed-token-transfer2 + +test-compressed-token-unit: + RUSTFLAGS="-D warnings" cargo test -p light-compressed-token + +test-compressed-token-v1: + RUSTFLAGS="-D warnings" cargo test-sbf -p compressed-token-test --test v1 + +test-compressed-token-mint: + RUSTFLAGS="-D warnings" cargo test-sbf -p compressed-token-test --test mint + +test-compressed-token-light-token: + RUSTFLAGS="-D warnings" cargo test-sbf -p compressed-token-test --test light_token + +test-compressed-token-transfer2: + RUSTFLAGS="-D warnings" cargo test-sbf -p compressed-token-test --test transfer2 + +# Compressed token batched tree test (flaky, may need retries) +test-compressed-token-batched-tree: + RUSTFLAGS="-D warnings" cargo test-sbf -p compressed-token-test -- test_transfer_with_photon_and_batched_tree + +# E2E tests +test-e2e: RUSTFLAGS="-D warnings" cargo test-sbf -p e2e-test + +# E2E extended tests (requires building compressed-token-small first) +test-e2e-extended: build-compressed-token-small + RUSTFLAGS="-D warnings" cargo test-sbf -p e2e-test -- --test test_10_all + +# Pinocchio unit tests +test-pinocchio: + RUSTFLAGS="-D warnings" cargo test -p light-system-program-pinocchio + +# === Build targets === + +build-compressed-token-small: + pnpm --filter @lightprotocol/programs run build-compressed-token-small + +# === CI-equivalent grouped tests === + +# Matches CI: account-compression-and-registry +ci-account-compression-and-registry: test-account-compression test-registry + +# Matches CI: light-system-program-address +ci-system-address: test-system-address test-e2e test-e2e-extended test-compressed-token-light-token + +# Matches CI: light-system-program-compression +ci-system-compression: test-system-compression test-system-re-init + +# Matches CI: compressed-token-and-e2e +ci-compressed-token-and-e2e: test-compressed-token-unit test-compressed-token-v1 test-compressed-token-mint + +# Matches CI: compressed-token-batched-tree (with retry for flaky test) +ci-compressed-token-batched-tree: + #!/usr/bin/env bash + set -euo pipefail + attempt=1 + max_attempts=3 + until RUSTFLAGS="-D warnings" cargo test-sbf -p compressed-token-test -- test_transfer_with_photon_and_batched_tree; do + attempt=$((attempt + 1)) + if [ $attempt -gt $max_attempts ]; then + echo "Test failed after $max_attempts attempts" + exit 1 + fi + echo "Attempt $attempt/$max_attempts failed, retrying in 5s..." + sleep 5 + done + echo "Test passed on attempt $attempt" + +# Matches CI: system-cpi-test +ci-system-cpi: test-system-cpi test-pinocchio test-system-cpi-v2-main test-system-cpi-v2-event-parse test-compressed-token-transfer2 + +# Matches CI: system-cpi-test-v2-functional-read-only +ci-system-cpi-v2-functional-read-only: test-system-cpi-v2-functional-read-only + +# Matches CI: system-cpi-test-v2-functional-account-infos +ci-system-cpi-v2-functional-account-infos: test-system-cpi-v2-functional-account-infos From 44202c282f6f69e1822b4f21822bdf4b4fc9cea9 Mon Sep 17 00:00:00 2001 From: Sergey Timoshin Date: Fri, 30 Jan 2026 09:45:10 +0000 Subject: [PATCH 34/38] Potential fix for code scanning alert no. 143: Workflow does not contain permissions Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com> --- .github/workflows/programs.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/programs.yml b/.github/workflows/programs.yml index a1213a6d63..9cce8303e0 100644 --- a/.github/workflows/programs.yml +++ b/.github/workflows/programs.yml @@ -24,6 +24,8 @@ on: - ready_for_review name: programs +permissions: + contents: read concurrency: group: ${{ github.workflow }}-${{ github.ref }} From 5ca6ab959f8ca4d7ef99cab903826eecb17ab2a2 Mon Sep 17 00:00:00 2001 From: Sergey Timoshin Date: Fri, 30 Jan 2026 10:21:51 +0000 Subject: [PATCH 35/38] fix: update default version fallback to V2 in LIGHT_PROTOCOL_VERSION test --- js/stateless.js/tests/unit/version.test.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/js/stateless.js/tests/unit/version.test.ts b/js/stateless.js/tests/unit/version.test.ts index 97db06c8be..a0cc7100aa 100644 --- a/js/stateless.js/tests/unit/version.test.ts +++ b/js/stateless.js/tests/unit/version.test.ts @@ -20,8 +20,9 @@ describe('Version System', () => { }); it('should respect LIGHT_PROTOCOL_VERSION environment variable', () => { + // Default is V2 when no env var is set (see constants.ts line 31) const expectedVersion = - process.env.LIGHT_PROTOCOL_VERSION || VERSION.V1; + process.env.LIGHT_PROTOCOL_VERSION || VERSION.V2; expect(featureFlags.version).toBe(expectedVersion); }); From 28b7db0c2db9a49e43d70939fd5d72764f04ef1c Mon Sep 17 00:00:00 2001 From: Sergey Timoshin Date: Sat, 31 Jan 2026 17:58:30 +0000 Subject: [PATCH 36/38] cleanup --- sdk-libs/client/src/interface/light_program_interface.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/sdk-libs/client/src/interface/light_program_interface.rs b/sdk-libs/client/src/interface/light_program_interface.rs index 4017d35763..42d7cff8c9 100644 --- a/sdk-libs/client/src/interface/light_program_interface.rs +++ b/sdk-libs/client/src/interface/light_program_interface.rs @@ -146,6 +146,7 @@ impl PdaSpec { match &self.interface.cold { Some(ColdContext::Account(c)) => Some(c), Some(ColdContext::Token(c)) => Some(&c.account), + Some(ColdContext::Mint(c)) => Some(c), None => None, } } From 1723ce407b4fc677b60679fd298fb8a8032240c0 Mon Sep 17 00:00:00 2001 From: Sergey Timoshin Date: Sun, 1 Feb 2026 00:55:02 +0000 Subject: [PATCH 37/38] feat: add forester to cli (particularly useful for compression tests) and update tests with validator args --- cli/src/commands/test-validator/index.ts | 20 ++++ cli/src/utils/constants.ts | 1 + cli/src/utils/initTestEnv.ts | 34 ++++++ cli/src/utils/processForester.ts | 107 ++++++++++++++++++ forester/tests/e2e_test.rs | 1 + forester/tests/legacy/address_v2_test.rs | 1 + forester/tests/legacy/batched_address_test.rs | 1 + .../batched_state_async_indexer_test.rs | 1 + .../legacy/batched_state_indexer_test.rs | 1 + forester/tests/legacy/batched_state_test.rs | 1 + forester/tests/legacy/e2e_test.rs | 2 + forester/tests/legacy/e2e_v1_test.rs | 2 + forester/tests/test_batch_append_spent.rs | 1 + forester/tests/test_compressible_ctoken.rs | 2 + forester/tests/test_compressible_mint.rs | 3 + forester/tests/test_compressible_pda.rs | 3 + forester/tests/test_indexer_interface.rs | 1 + .../compressed-token-test/tests/v1.rs | 1 + .../system-cpi-v2-test/tests/event.rs | 1 + sdk-libs/client/src/local_test_validator.rs | 7 ++ sdk-tests/client-test/tests/light_client.rs | 1 + .../tests/amm_stress_test.rs | 44 ++++--- .../tests/d10_token_accounts_test.rs | 10 +- .../tests/d11_zero_copy_test.rs | 58 ++++++---- .../tests/failing_tests.rs | 58 ++++++---- .../tests/integration_tests.rs | 12 +- .../tests/mint/metadata_test.rs | 9 +- sdk-tests/manual-test/tests/account_loader.rs | 10 +- sdk-tests/manual-test/tests/test.rs | 10 +- .../single-account-loader-test/tests/test.rs | 10 +- 30 files changed, 332 insertions(+), 81 deletions(-) create mode 100644 cli/src/utils/processForester.ts diff --git a/cli/src/commands/test-validator/index.ts b/cli/src/commands/test-validator/index.ts index d668c90e20..b35398b97d 100644 --- a/cli/src/commands/test-validator/index.ts +++ b/cli/src/commands/test-validator/index.ts @@ -43,6 +43,22 @@ class SetupCommand extends Command { "Runs a test validator without starting a new prover service.", default: false, }), + forester: Flags.boolean({ + description: + "Start the forester service for auto-compression of compressible accounts.", + default: false, + }), + "forester-port": Flags.integer({ + description: "Port for the forester API server.", + required: false, + default: 8080, + }), + "compressible-pda-program": Flags.string({ + description: + "Compressible PDA programs to track. Format: 'program_id:discriminator_base58'. Can be specified multiple times.", + required: false, + multiple: true, + }), "skip-system-accounts": Flags.boolean({ description: "Runs a test validator without initialized light system accounts.", @@ -210,6 +226,7 @@ class SetupCommand extends Command { await stopTestEnv({ indexer: !flags["skip-indexer"], prover: !flags["skip-prover"], + forester: flags.forester, }); this.log("\nTest validator stopped successfully \x1b[32m✔\x1b[0m"); } else { @@ -262,6 +279,9 @@ class SetupCommand extends Command { indexerPort: flags["indexer-port"], proverPort: flags["prover-port"], prover: !flags["skip-prover"], + forester: flags.forester, + foresterPort: flags["forester-port"], + compressiblePdaPrograms: flags["compressible-pda-program"], skipSystemAccounts: flags["skip-system-accounts"], geyserConfig: flags["geyser-config"], validatorArgs: flags["validator-args"], diff --git a/cli/src/utils/constants.ts b/cli/src/utils/constants.ts index e3a9137737..e7943ce629 100644 --- a/cli/src/utils/constants.ts +++ b/cli/src/utils/constants.ts @@ -18,6 +18,7 @@ export const CARGO_GENERATE_TAG = "v0.18.4"; export const SOLANA_VALIDATOR_PROCESS_NAME = "solana-test-validator"; export const LIGHT_PROVER_PROCESS_NAME = "light-prover"; export const INDEXER_PROCESS_NAME = "photon"; +export const FORESTER_PROCESS_NAME = "forester"; export const PHOTON_VERSION = "0.51.2"; diff --git a/cli/src/utils/initTestEnv.ts b/cli/src/utils/initTestEnv.ts index c98c8c29bb..fde4ed82a6 100644 --- a/cli/src/utils/initTestEnv.ts +++ b/cli/src/utils/initTestEnv.ts @@ -19,6 +19,11 @@ import { } from "./process"; import { killProver, startProver } from "./processProverServer"; import { killIndexer, startIndexer } from "./processPhotonIndexer"; +import { + killForester, + startForester, + getPayerForForester, +} from "./processForester"; import { Connection, PublicKey } from "@solana/web3.js"; type Program = { id: string; name?: string; tag?: string; path?: string }; @@ -95,8 +100,10 @@ async function getProgramOwnedAccounts( export async function stopTestEnv(options: { indexer: boolean; prover: boolean; + forester?: boolean; }) { const processesToKill = [ + { name: "forester", condition: options.forester ?? false, killFunction: killForester }, { name: "photon", condition: options.indexer, killFunction: killIndexer }, { name: "prover", condition: options.prover, killFunction: killProver }, { @@ -129,9 +136,11 @@ export async function initTestEnv({ skipSystemAccounts, indexer = true, prover = true, + forester = false, rpcPort = 8899, indexerPort = 8784, proverPort = 3001, + foresterPort = 8080, gossipHost = "127.0.0.1", checkPhotonVersion = true, photonDatabaseUrl, @@ -141,6 +150,7 @@ export async function initTestEnv({ cloneNetwork, verbose, skipReset, + compressiblePdaPrograms, }: { additionalPrograms?: { address: string; path: string }[]; upgradeablePrograms?: { @@ -151,9 +161,11 @@ export async function initTestEnv({ skipSystemAccounts?: boolean; indexer: boolean; prover: boolean; + forester?: boolean; rpcPort?: number; indexerPort?: number; proverPort?: number; + foresterPort?: number; gossipHost?: string; checkPhotonVersion?: boolean; photonDatabaseUrl?: string; @@ -163,6 +175,7 @@ export async function initTestEnv({ cloneNetwork?: "devnet" | "mainnet"; verbose?: boolean; skipReset?: boolean; + compressiblePdaPrograms?: string[]; }) { // We cannot await this promise directly because it will hang the process startTestValidator({ @@ -209,6 +222,27 @@ export async function initTestEnv({ proverUrlForIndexer, ); } + + if (forester) { + if (!indexer || !prover) { + throw new Error("Forester requires both indexer and prover to be running"); + } + try { + const payer = getPayerForForester(); + await startForester({ + rpcUrl: `http://127.0.0.1:${rpcPort}`, + wsRpcUrl: `ws://127.0.0.1:${rpcPort + 1}`, + indexerUrl: `http://127.0.0.1:${indexerPort}`, + proverUrl: `http://127.0.0.1:${proverPort}`, + payer, + foresterPort, + compressiblePdaPrograms, + }); + } catch (error) { + console.error("Failed to start forester:", error); + throw error; + } + } } export async function initTestEnvIfNeeded({ diff --git a/cli/src/utils/processForester.ts b/cli/src/utils/processForester.ts new file mode 100644 index 0000000000..9bbc255e72 --- /dev/null +++ b/cli/src/utils/processForester.ts @@ -0,0 +1,107 @@ +import which from "which"; +import { killProcess, spawnBinary, waitForServers } from "./process"; +import { FORESTER_PROCESS_NAME } from "./constants"; +import { exec } from "node:child_process"; +import * as util from "node:util"; +import { exit } from "node:process"; +import * as fs from "fs"; +import * as path from "path"; + +const execAsync = util.promisify(exec); + +async function isForesterInstalled(): Promise { + try { + const resolvedOrNull = which.sync("forester", { nothrow: true }); + return resolvedOrNull !== null; + } catch (error) { + return false; + } +} + +function getForesterInstallMessage(): string { + return `\nForester not found. Please install it by running: "cargo install --git https://github.com/Lightprotocol/light-protocol forester --locked --force"`; +} + +export interface ForesterConfig { + rpcUrl: string; + wsRpcUrl: string; + indexerUrl: string; + proverUrl: string; + payer: string; + foresterPort: number; + compressiblePdaPrograms?: string[]; +} + +/** + * Starts the forester service for auto-compression of compressible accounts. + * + * @param config - Forester configuration + */ +export async function startForester(config: ForesterConfig) { + await killForester(); + + if (!(await isForesterInstalled())) { + console.log(getForesterInstallMessage()); + return exit(1); + } + + console.log("Starting forester..."); + + const args: string[] = [ + "start", + "--rpc-url", + config.rpcUrl, + "--ws-rpc-url", + config.wsRpcUrl, + "--indexer-url", + config.indexerUrl, + "--prover-url", + config.proverUrl, + "--payer", + config.payer, + "--api-server-port", + config.foresterPort.toString(), + "--enable-compressible", + ]; + + // Add compressible PDA programs if specified + if (config.compressiblePdaPrograms && config.compressiblePdaPrograms.length > 0) { + for (const program of config.compressiblePdaPrograms) { + args.push("--compressible-pda-program", program); + } + } + + spawnBinary(FORESTER_PROCESS_NAME, args); + await waitForServers([{ port: config.foresterPort, path: "/health" }]); + console.log("Forester started successfully!"); +} + +export async function killForester() { + await killProcess(FORESTER_PROCESS_NAME); +} + +/** + * Gets the payer keypair as a JSON array string for forester. + * Reads from ~/.config/solana/id.json or SOLANA_PAYER environment variable. + * + * @returns JSON array string of the keypair bytes + */ +export function getPayerForForester(): string { + // Check for SOLANA_PAYER environment variable first + if (process.env.SOLANA_PAYER) { + return process.env.SOLANA_PAYER; + } + + // Default to standard Solana keypair location + const homeDir = process.env.HOME || process.env.USERPROFILE || ""; + const keypairPath = path.join(homeDir, ".config", "solana", "id.json"); + + if (fs.existsSync(keypairPath)) { + const keypairData = fs.readFileSync(keypairPath, "utf-8"); + return keypairData.trim(); + } + + throw new Error( + "No payer keypair found. Set SOLANA_PAYER environment variable or create ~/.config/solana/id.json", + ); +} diff --git a/forester/tests/e2e_test.rs b/forester/tests/e2e_test.rs index 0f0387bb0c..f18f136f07 100644 --- a/forester/tests/e2e_test.rs +++ b/forester/tests/e2e_test.rs @@ -276,6 +276,7 @@ async fn e2e_test() { )], upgradeable_programs: vec![], limit_ledger_size: None, + validator_args: vec![], })) .await; spawn_prover().await; diff --git a/forester/tests/legacy/address_v2_test.rs b/forester/tests/legacy/address_v2_test.rs index aa3a821152..5dc063236a 100644 --- a/forester/tests/legacy/address_v2_test.rs +++ b/forester/tests/legacy/address_v2_test.rs @@ -62,6 +62,7 @@ async fn test_create_v2_address() { )], upgradeable_programs: vec![], limit_ledger_size: Some(500000), + validator_args: vec![], })) .await; diff --git a/forester/tests/legacy/batched_address_test.rs b/forester/tests/legacy/batched_address_test.rs index fe5fe363d0..72faee88fc 100644 --- a/forester/tests/legacy/batched_address_test.rs +++ b/forester/tests/legacy/batched_address_test.rs @@ -43,6 +43,7 @@ async fn test_address_batched() { )], upgradeable_programs: vec![], limit_ledger_size: None, + validator_args: vec![], })) .await; let tree_params = InitAddressTreeAccountsInstructionData::test_default(); diff --git a/forester/tests/legacy/batched_state_async_indexer_test.rs b/forester/tests/legacy/batched_state_async_indexer_test.rs index adc84a823c..b57dd3fce7 100644 --- a/forester/tests/legacy/batched_state_async_indexer_test.rs +++ b/forester/tests/legacy/batched_state_async_indexer_test.rs @@ -83,6 +83,7 @@ async fn test_state_indexer_async_batched() { sbf_programs: vec![], upgradeable_programs: vec![], limit_ledger_size: None, + validator_args: vec![], })) .await; spawn_prover().await; diff --git a/forester/tests/legacy/batched_state_indexer_test.rs b/forester/tests/legacy/batched_state_indexer_test.rs index 2b9600a7f8..dc1d69c12b 100644 --- a/forester/tests/legacy/batched_state_indexer_test.rs +++ b/forester/tests/legacy/batched_state_indexer_test.rs @@ -44,6 +44,7 @@ async fn test_state_indexer_batched() { sbf_programs: vec![], upgradeable_programs: vec![], limit_ledger_size: None, + validator_args: vec![], })) .await; diff --git a/forester/tests/legacy/batched_state_test.rs b/forester/tests/legacy/batched_state_test.rs index 3067ea3a3d..44790f59f0 100644 --- a/forester/tests/legacy/batched_state_test.rs +++ b/forester/tests/legacy/batched_state_test.rs @@ -48,6 +48,7 @@ async fn test_state_batched() { sbf_programs: vec![], upgradeable_programs: vec![], limit_ledger_size: None, + validator_args: vec![], })) .await; diff --git a/forester/tests/legacy/e2e_test.rs b/forester/tests/legacy/e2e_test.rs index 69dadc8b39..871b5c2f94 100644 --- a/forester/tests/legacy/e2e_test.rs +++ b/forester/tests/legacy/e2e_test.rs @@ -40,6 +40,7 @@ async fn test_epoch_monitor_with_2_foresters() { sbf_programs: vec![], upgradeable_programs: vec![], limit_ledger_size: None, + validator_args: vec![], })) .await; let forester_keypair1 = Keypair::new(); @@ -387,6 +388,7 @@ async fn test_epoch_double_registration() { sbf_programs: vec![], upgradeable_programs: vec![], limit_ledger_size: None, + validator_args: vec![], })) .await; diff --git a/forester/tests/legacy/e2e_v1_test.rs b/forester/tests/legacy/e2e_v1_test.rs index ffe207dbea..d8308bb3c5 100644 --- a/forester/tests/legacy/e2e_v1_test.rs +++ b/forester/tests/legacy/e2e_v1_test.rs @@ -41,6 +41,7 @@ async fn test_e2e_v1() { sbf_programs: vec![], upgradeable_programs: vec![], limit_ledger_size: None, + validator_args: vec![], })) .await; let forester_keypair1 = Keypair::new(); @@ -384,6 +385,7 @@ async fn test_epoch_double_registration() { sbf_programs: vec![], upgradeable_programs: vec![], limit_ledger_size: None, + validator_args: vec![], })) .await; diff --git a/forester/tests/test_batch_append_spent.rs b/forester/tests/test_batch_append_spent.rs index b923662ca5..058d8f1575 100644 --- a/forester/tests/test_batch_append_spent.rs +++ b/forester/tests/test_batch_append_spent.rs @@ -51,6 +51,7 @@ async fn test_batch_sequence() { sbf_programs: vec![], upgradeable_programs: vec![], limit_ledger_size: None, + validator_args: vec![], })) .await; diff --git a/forester/tests/test_compressible_ctoken.rs b/forester/tests/test_compressible_ctoken.rs index d974804352..bee072e87a 100644 --- a/forester/tests/test_compressible_ctoken.rs +++ b/forester/tests/test_compressible_ctoken.rs @@ -219,6 +219,7 @@ async fn test_compressible_ctoken_compression() { sbf_programs: vec![], upgradeable_programs: vec![], limit_ledger_size: None, + validator_args: vec![], }) .await; let mut rpc = LightClient::new(LightClientConfig::local()) @@ -390,6 +391,7 @@ async fn test_compressible_ctoken_bootstrap() { sbf_programs: vec![], upgradeable_programs: vec![], limit_ledger_size: None, + validator_args: vec![], }) .await; diff --git a/forester/tests/test_compressible_mint.rs b/forester/tests/test_compressible_mint.rs index 396aec0d04..714f1da2be 100644 --- a/forester/tests/test_compressible_mint.rs +++ b/forester/tests/test_compressible_mint.rs @@ -141,6 +141,7 @@ async fn test_compressible_mint_bootstrap() { sbf_programs: vec![], upgradeable_programs: vec![], limit_ledger_size: None, + validator_args: vec![], }) .await; @@ -278,6 +279,7 @@ async fn test_compressible_mint_compression() { sbf_programs: vec![], upgradeable_programs: vec![], limit_ledger_size: None, + validator_args: vec![], }) .await; @@ -487,6 +489,7 @@ async fn test_compressible_mint_subscription() { sbf_programs: vec![], upgradeable_programs: vec![], limit_ledger_size: None, + validator_args: vec![], }) .await; diff --git a/forester/tests/test_compressible_pda.rs b/forester/tests/test_compressible_pda.rs index cf2553fc6f..ae08b12ddb 100644 --- a/forester/tests/test_compressible_pda.rs +++ b/forester/tests/test_compressible_pda.rs @@ -267,6 +267,7 @@ async fn test_compressible_pda_bootstrap() { payer_pubkey_string(), )], limit_ledger_size: None, + validator_args: vec![], }) .await; @@ -458,6 +459,7 @@ async fn test_compressible_pda_compression() { payer_pubkey_string(), )], limit_ledger_size: None, + validator_args: vec![], }) .await; @@ -693,6 +695,7 @@ async fn test_compressible_pda_subscription() { payer_pubkey_string(), )], limit_ledger_size: None, + validator_args: vec![], }) .await; diff --git a/forester/tests/test_indexer_interface.rs b/forester/tests/test_indexer_interface.rs index e4033c08aa..d87b3a8b4a 100644 --- a/forester/tests/test_indexer_interface.rs +++ b/forester/tests/test_indexer_interface.rs @@ -107,6 +107,7 @@ async fn test_indexer_interface_scenarios() { )], upgradeable_programs: vec![], limit_ledger_size: None, + validator_args: vec![], }) .await; diff --git a/program-tests/compressed-token-test/tests/v1.rs b/program-tests/compressed-token-test/tests/v1.rs index 9c4f55d2af..3b8b493c16 100644 --- a/program-tests/compressed-token-test/tests/v1.rs +++ b/program-tests/compressed-token-test/tests/v1.rs @@ -4892,6 +4892,7 @@ async fn test_transfer_with_photon_and_batched_tree() { sbf_programs: vec![], upgradeable_programs: vec![], limit_ledger_size: None, + validator_args: vec![], }) .await; diff --git a/program-tests/system-cpi-v2-test/tests/event.rs b/program-tests/system-cpi-v2-test/tests/event.rs index 9ed2aae062..f482b0a7a1 100644 --- a/program-tests/system-cpi-v2-test/tests/event.rs +++ b/program-tests/system-cpi-v2-test/tests/event.rs @@ -539,6 +539,7 @@ async fn generate_photon_test_data_multiple_events() { )], upgradeable_programs: vec![], limit_ledger_size: None, + validator_args: vec![], }) .await; diff --git a/sdk-libs/client/src/local_test_validator.rs b/sdk-libs/client/src/local_test_validator.rs index 78559cd9c2..d2165ed403 100644 --- a/sdk-libs/client/src/local_test_validator.rs +++ b/sdk-libs/client/src/local_test_validator.rs @@ -34,6 +34,8 @@ pub struct LightValidatorConfig { /// Use this when the program needs a valid upgrade authority (e.g., for compression config). pub upgradeable_programs: Vec, pub limit_ledger_size: Option, + /// Additional arguments to pass to the validator (e.g., "--account
") + pub validator_args: Vec, } impl Default for LightValidatorConfig { @@ -45,6 +47,7 @@ impl Default for LightValidatorConfig { sbf_programs: vec![], upgradeable_programs: vec![], limit_ledger_size: None, + validator_args: vec![], } } } @@ -81,6 +84,10 @@ pub async fn spawn_validator(config: LightValidatorConfig) { path.push_str(" --skip-prover"); } + for arg in config.validator_args.iter() { + path.push_str(&format!(" {}", arg)); + } + println!("Starting validator with command: {}", path); let child = Command::new("sh") diff --git a/sdk-tests/client-test/tests/light_client.rs b/sdk-tests/client-test/tests/light_client.rs index 22d799c888..5f6bfb45f7 100644 --- a/sdk-tests/client-test/tests/light_client.rs +++ b/sdk-tests/client-test/tests/light_client.rs @@ -55,6 +55,7 @@ async fn test_all_endpoints() { sbf_programs: vec![], upgradeable_programs: vec![], limit_ledger_size: None, + validator_args: vec![], }; spawn_validator(config).await; diff --git a/sdk-tests/csdk-anchor-full-derived-test/tests/amm_stress_test.rs b/sdk-tests/csdk-anchor-full-derived-test/tests/amm_stress_test.rs index c89d9c1b3c..ec6d59d6e8 100644 --- a/sdk-tests/csdk-anchor-full-derived-test/tests/amm_stress_test.rs +++ b/sdk-tests/csdk-anchor-full-derived-test/tests/amm_stress_test.rs @@ -19,8 +19,8 @@ use light_batched_merkle_tree::{ initialize_state_tree::InitStateTreeAccountsInstructionData, }; use light_client::interface::{ - create_load_instructions, get_create_accounts_proof, AccountInterfaceExt, - CreateAccountsProofInput, InitializeRentFreeConfig, LightProgramInterface, + create_load_instructions, get_create_accounts_proof, CreateAccountsProofInput, + InitializeRentFreeConfig, LightProgramInterface, }; use light_compressible::rent::SLOTS_PER_EPOCH; use light_program_test::{ @@ -489,9 +489,11 @@ async fn refresh_cache(rpc: &mut LightProgramTest, pdas: &AmmPdas) -> CachedStat async fn decompress_all(ctx: &mut AmmTestContext, pdas: &AmmPdas) { let pool_interface = ctx .rpc - .get_account_interface(&pdas.pool_state, &ctx.program_id) + .get_account_interface(&pdas.pool_state, None) .await - .expect("failed to get pool_state"); + .expect("failed to get pool_state") + .value + .expect("pool_state should exist"); assert!(pool_interface.is_cold(), "pool_state should be cold"); let mut sdk = AmmSdk::from_keyed_accounts(&[pool_interface]) @@ -501,9 +503,9 @@ async fn decompress_all(ctx: &mut AmmTestContext, pdas: &AmmPdas) { let keyed_accounts = ctx .rpc - .get_multiple_account_interfaces(&accounts_to_fetch) + .fetch_accounts(&accounts_to_fetch, None) .await - .expect("get_multiple_account_interfaces should succeed"); + .expect("fetch_accounts should succeed"); sdk.update(&keyed_accounts) .expect("sdk.update should succeed"); @@ -512,31 +514,39 @@ async fn decompress_all(ctx: &mut AmmTestContext, pdas: &AmmPdas) { let creator_lp_interface = ctx .rpc - .get_ata_interface(&ctx.creator.pubkey(), &pdas.lp_mint) + .get_ata_interface(&ctx.creator.pubkey(), &pdas.lp_mint, None) .await - .expect("failed to get creator_lp_token"); + .expect("failed to get creator_lp_token") + .value + .expect("creator_lp_token should exist"); // Creator's token_0 and token_1 ATAs also get compressed during epoch warp let creator_token_0_interface = ctx .rpc - .get_ata_interface(&ctx.creator.pubkey(), &ctx.token_0_mint) + .get_ata_interface(&ctx.creator.pubkey(), &ctx.token_0_mint, None) .await - .expect("failed to get creator_token_0"); + .expect("failed to get creator_token_0") + .value + .expect("creator_token_0 should exist"); let creator_token_1_interface = ctx .rpc - .get_ata_interface(&ctx.creator.pubkey(), &ctx.token_1_mint) + .get_ata_interface(&ctx.creator.pubkey(), &ctx.token_1_mint, None) .await - .expect("failed to get creator_token_1"); + .expect("failed to get creator_token_1") + .value + .expect("creator_token_1 should exist"); // Underlying mints also get compressed -- convert MintInterface to AccountInterface use light_client::interface::{AccountInterface, AccountSpec, MintState}; let mint_0_iface = ctx .rpc - .get_mint_interface(&ctx.token_0_mint) + .get_mint_interface(&ctx.token_0_mint, None) .await - .expect("failed to get token_0_mint"); + .expect("failed to get token_0_mint") + .value + .expect("token_0_mint should exist"); let mint_0_account_iface = match mint_0_iface.state { MintState::Hot { account } => AccountInterface { key: mint_0_iface.mint, @@ -556,9 +566,11 @@ async fn decompress_all(ctx: &mut AmmTestContext, pdas: &AmmPdas) { let mint_1_iface = ctx .rpc - .get_mint_interface(&ctx.token_1_mint) + .get_mint_interface(&ctx.token_1_mint, None) .await - .expect("failed to get token_1_mint"); + .expect("failed to get token_1_mint") + .value + .expect("token_1_mint should exist"); let mint_1_account_iface = match mint_1_iface.state { MintState::Hot { account } => AccountInterface { key: mint_1_iface.mint, diff --git a/sdk-tests/csdk-anchor-full-derived-test/tests/d10_token_accounts_test.rs b/sdk-tests/csdk-anchor-full-derived-test/tests/d10_token_accounts_test.rs index cb80c58243..e505025eeb 100644 --- a/sdk-tests/csdk-anchor-full-derived-test/tests/d10_token_accounts_test.rs +++ b/sdk-tests/csdk-anchor-full-derived-test/tests/d10_token_accounts_test.rs @@ -10,9 +10,7 @@ use csdk_anchor_full_derived_test::d10_token_accounts::{ D10SingleAtaMarkonlyParams, D10SingleAtaParams, D10SingleVaultParams, D10_SINGLE_VAULT_AUTH_SEED, D10_SINGLE_VAULT_SEED, }; -use light_client::interface::{ - get_create_accounts_proof, AccountInterfaceExt, InitializeRentFreeConfig, -}; +use light_client::interface::{get_create_accounts_proof, InitializeRentFreeConfig}; use light_program_test::{ program_test::{setup_mock_program_data, LightProgramTest}, ProgramTestConfig, Rpc, @@ -537,9 +535,11 @@ async fn test_d10_single_ata_markonly_lifecycle() { // ATAs use get_ata_interface which fetches the compressed token data let ata_interface = ctx .rpc - .get_ata_interface(&ata_owner, &mint) + .get_ata_interface(&ata_owner, &mint, None) .await - .expect("get_ata_interface should succeed"); + .expect("get_ata_interface should succeed") + .value + .expect("ata interface should exist"); assert!( ata_interface.is_cold(), "ATA should be cold after compression" diff --git a/sdk-tests/csdk-anchor-full-derived-test/tests/d11_zero_copy_test.rs b/sdk-tests/csdk-anchor-full-derived-test/tests/d11_zero_copy_test.rs index 80497c3ff1..8a7991eb63 100644 --- a/sdk-tests/csdk-anchor-full-derived-test/tests/d11_zero_copy_test.rs +++ b/sdk-tests/csdk-anchor-full-derived-test/tests/d11_zero_copy_test.rs @@ -42,8 +42,8 @@ use csdk_anchor_full_derived_test::d11_zero_copy::{ D11_ZC_VAULT_SEED, }; use light_client::interface::{ - create_load_instructions, get_create_accounts_proof, AccountInterfaceExt, AccountSpec, - CreateAccountsProofInput, InitializeRentFreeConfig, PdaSpec, + create_load_instructions, get_create_accounts_proof, AccountSpec, CreateAccountsProofInput, + InitializeRentFreeConfig, PdaSpec, }; use light_compressed_account::address::derive_address; use light_compressible::rent::SLOTS_PER_EPOCH; @@ -256,9 +256,11 @@ async fn test_d11_zc_with_vault() { // PHASE 4: Decompress account let account_interface = ctx .rpc - .get_account_interface(&zc_pda, &ctx.program_id) + .get_account_interface(&zc_pda, None) .await - .expect("failed to get account interface"); + .expect("failed to get account interface") + .value + .expect("account interface should exist"); assert!( account_interface.is_cold(), "Account should be cold (compressed)" @@ -426,9 +428,11 @@ async fn test_d11_zc_with_ata() { // PHASE 4: Decompress account let account_interface = ctx .rpc - .get_account_interface(&zc_pda, &ctx.program_id) + .get_account_interface(&zc_pda, None) .await - .expect("failed to get account interface"); + .expect("failed to get account interface") + .value + .expect("account interface should exist"); assert!( account_interface.is_cold(), "Account should be cold (compressed)" @@ -584,9 +588,11 @@ async fn test_d11_multiple_zc() { // PHASE 4: Decompress first account let account_interface_1 = ctx .rpc - .get_account_interface(&zc_pda_1, &ctx.program_id) + .get_account_interface(&zc_pda_1, None) .await - .expect("failed to get account interface 1"); + .expect("failed to get account interface 1") + .value + .expect("account interface 1 should exist"); assert!(account_interface_1.is_cold(), "Account 1 should be cold"); let variant_1: LightAccountVariant = @@ -614,9 +620,11 @@ async fn test_d11_multiple_zc() { // Decompress second account let account_interface_2 = ctx .rpc - .get_account_interface(&zc_pda_2, &ctx.program_id) + .get_account_interface(&zc_pda_2, None) .await - .expect("failed to get account interface 2"); + .expect("failed to get account interface 2") + .value + .expect("account interface 2 should exist"); assert!(account_interface_2.is_cold(), "Account 2 should be cold"); let variant_2: LightAccountVariant = @@ -785,9 +793,11 @@ async fn test_d11_mixed_zc_borsh() { // PHASE 4: Decompress zero-copy account let account_interface_zc = ctx .rpc - .get_account_interface(&zc_pda, &ctx.program_id) + .get_account_interface(&zc_pda, None) .await - .expect("failed to get zc account interface"); + .expect("failed to get zc account interface") + .value + .expect("zc account interface should exist"); let variant_zc: LightAccountVariant = csdk_anchor_full_derived_test::csdk_anchor_full_derived_test::ZcMixedRecordSeeds { owner } @@ -814,9 +824,11 @@ async fn test_d11_mixed_zc_borsh() { // Decompress borsh account let account_interface_borsh = ctx .rpc - .get_account_interface(&borsh_pda, &ctx.program_id) + .get_account_interface(&borsh_pda, None) .await - .expect("failed to get borsh account interface"); + .expect("failed to get borsh account interface") + .value + .expect("borsh account interface should exist"); let variant_borsh: LightAccountVariant = csdk_anchor_full_derived_test::csdk_anchor_full_derived_test::BorshRecordSeeds { owner } @@ -975,9 +987,11 @@ async fn test_d11_zc_with_ctx_seeds() { // PHASE 4: Decompress account let account_interface = ctx .rpc - .get_account_interface(&zc_pda, &ctx.program_id) + .get_account_interface(&zc_pda, None) .await - .expect("failed to get account interface"); + .expect("failed to get account interface") + .value + .expect("account interface should exist"); assert!( account_interface.is_cold(), "Account should be cold (compressed)" @@ -1118,9 +1132,11 @@ async fn test_d11_zc_with_params_seeds() { // PHASE 4: Decompress account let account_interface = ctx .rpc - .get_account_interface(&zc_pda, &ctx.program_id) + .get_account_interface(&zc_pda, None) .await - .expect("failed to get account interface"); + .expect("failed to get account interface") + .value + .expect("account interface should exist"); assert!( account_interface.is_cold(), "Account should be cold (compressed)" @@ -1292,9 +1308,11 @@ async fn test_d11_zc_with_mint_to() { // PHASE 4: Decompress account let account_interface = ctx .rpc - .get_account_interface(&zc_pda, &ctx.program_id) + .get_account_interface(&zc_pda, None) .await - .expect("failed to get account interface"); + .expect("failed to get account interface") + .value + .expect("account interface should exist"); assert!( account_interface.is_cold(), "Account should be cold (compressed)" diff --git a/sdk-tests/csdk-anchor-full-derived-test/tests/failing_tests.rs b/sdk-tests/csdk-anchor-full-derived-test/tests/failing_tests.rs index 05e8ab04b7..28fda8af8d 100644 --- a/sdk-tests/csdk-anchor-full-derived-test/tests/failing_tests.rs +++ b/sdk-tests/csdk-anchor-full-derived-test/tests/failing_tests.rs @@ -20,8 +20,8 @@ use csdk_anchor_full_derived_test::{ }, }; use light_client::interface::{ - create_load_instructions, get_create_accounts_proof, AccountInterfaceExt, AccountSpec, - CreateAccountsProofInput, InitializeRentFreeConfig, PdaSpec, + create_load_instructions, get_create_accounts_proof, AccountSpec, CreateAccountsProofInput, + InitializeRentFreeConfig, PdaSpec, }; use light_compressible::rent::SLOTS_PER_EPOCH; use light_program_test::{ @@ -176,9 +176,11 @@ async fn test_pda_wrong_rent_sponsor() { // Get account interface let account_interface = ctx .rpc - .get_account_interface(&zc_pda, &ctx.program_id) + .get_account_interface(&zc_pda, None) .await - .expect("failed to get account interface"); + .expect("failed to get account interface") + .value + .expect("account interface should exist"); // Build valid variant let variant: LightAccountVariant = @@ -222,9 +224,11 @@ async fn test_pda_double_decompress_is_noop() { // Get account interface let account_interface = ctx .rpc - .get_account_interface(&zc_pda, &ctx.program_id) + .get_account_interface(&zc_pda, None) .await - .expect("failed to get account interface"); + .expect("failed to get account interface") + .value + .expect("account interface should exist"); let variant: LightAccountVariant = csdk_anchor_full_derived_test::csdk_anchor_full_derived_test::ZcVaultRecordSeeds { owner } @@ -259,9 +263,11 @@ async fn test_pda_double_decompress_is_noop() { // Since the account is now hot, create_load_instructions will return empty let account_interface_2 = ctx .rpc - .get_account_interface(&zc_pda, &ctx.program_id) + .get_account_interface(&zc_pda, None) .await - .expect("failed to get account interface"); + .expect("failed to get account interface") + .value + .expect("account interface should exist"); // Account should be hot now assert!( @@ -305,9 +311,11 @@ async fn test_pda_wrong_config() { // Get account interface let account_interface = ctx .rpc - .get_account_interface(&zc_pda, &ctx.program_id) + .get_account_interface(&zc_pda, None) .await - .expect("failed to get account interface"); + .expect("failed to get account interface") + .value + .expect("account interface should exist"); let variant: LightAccountVariant = csdk_anchor_full_derived_test::csdk_anchor_full_derived_test::ZcVaultRecordSeeds { owner } @@ -353,9 +361,11 @@ async fn test_system_accounts_offset_out_of_bounds() { let account_interface = ctx .rpc - .get_account_interface(&zc_pda, &ctx.program_id) + .get_account_interface(&zc_pda, None) .await - .expect("failed to get account interface"); + .expect("failed to get account interface") + .value + .expect("account interface should exist"); let variant: LightAccountVariant = csdk_anchor_full_derived_test::csdk_anchor_full_derived_test::ZcVaultRecordSeeds { owner } @@ -397,9 +407,11 @@ async fn test_token_accounts_offset_invalid() { let account_interface = ctx .rpc - .get_account_interface(&zc_pda, &ctx.program_id) + .get_account_interface(&zc_pda, None) .await - .expect("failed to get account interface"); + .expect("failed to get account interface") + .value + .expect("account interface should exist"); let variant: LightAccountVariant = csdk_anchor_full_derived_test::csdk_anchor_full_derived_test::ZcVaultRecordSeeds { owner } @@ -446,9 +458,11 @@ async fn test_missing_system_accounts() { let account_interface = ctx .rpc - .get_account_interface(&zc_pda, &ctx.program_id) + .get_account_interface(&zc_pda, None) .await - .expect("failed to get account interface"); + .expect("failed to get account interface") + .value + .expect("account interface should exist"); let variant: LightAccountVariant = csdk_anchor_full_derived_test::csdk_anchor_full_derived_test::ZcVaultRecordSeeds { owner } @@ -489,9 +503,11 @@ async fn test_pda_account_mismatch() { let account_interface = ctx .rpc - .get_account_interface(&zc_pda, &ctx.program_id) + .get_account_interface(&zc_pda, None) .await - .expect("failed to get account interface"); + .expect("failed to get account interface") + .value + .expect("account interface should exist"); let variant: LightAccountVariant = csdk_anchor_full_derived_test::csdk_anchor_full_derived_test::ZcVaultRecordSeeds { owner } @@ -532,9 +548,11 @@ async fn test_fee_payer_not_signer() { let account_interface = ctx .rpc - .get_account_interface(&zc_pda, &ctx.program_id) + .get_account_interface(&zc_pda, None) .await - .expect("failed to get account interface"); + .expect("failed to get account interface") + .value + .expect("account interface should exist"); let variant: LightAccountVariant = csdk_anchor_full_derived_test::csdk_anchor_full_derived_test::ZcVaultRecordSeeds { owner } diff --git a/sdk-tests/csdk-anchor-full-derived-test/tests/integration_tests.rs b/sdk-tests/csdk-anchor-full-derived-test/tests/integration_tests.rs index b2c594c11d..d1b8d82e0f 100644 --- a/sdk-tests/csdk-anchor-full-derived-test/tests/integration_tests.rs +++ b/sdk-tests/csdk-anchor-full-derived-test/tests/integration_tests.rs @@ -188,9 +188,11 @@ impl TestContext { // Get account interface let account_interface = self .rpc - .get_account_interface(pda, &self.program_id) + .get_account_interface(pda, None) .await - .expect("failed to get account interface"); + .expect("failed to get account interface") + .value + .expect("account interface should exist"); assert!( account_interface.is_cold(), "Account should be cold after compression" @@ -240,9 +242,11 @@ impl TestContext { // Fetch token account interface let vault_interface = self .rpc - .get_token_account_interface(vault_pda) + .get_token_account_interface(vault_pda, None) .await - .expect("get_token_account_interface should succeed"); + .expect("get_token_account_interface should succeed") + .value + .expect("token account interface should exist"); assert!(vault_interface.is_cold(), "Token vault should be cold"); // Deserialize token data diff --git a/sdk-tests/csdk-anchor-full-derived-test/tests/mint/metadata_test.rs b/sdk-tests/csdk-anchor-full-derived-test/tests/mint/metadata_test.rs index 18273f21df..47894ba5e1 100644 --- a/sdk-tests/csdk-anchor-full-derived-test/tests/mint/metadata_test.rs +++ b/sdk-tests/csdk-anchor-full-derived-test/tests/mint/metadata_test.rs @@ -5,8 +5,7 @@ mod shared; use anchor_lang::{InstructionData, ToAccountMetas}; use light_client::interface::{ - decompress_mint::decompress_mint, get_create_accounts_proof, AccountInterfaceExt, - CreateAccountsProofInput, + decompress_mint::decompress_mint, get_create_accounts_proof, CreateAccountsProofInput, }; use light_compressible::{rent::SLOTS_PER_EPOCH, DECOMPRESSED_PDA_DISCRIMINATOR}; use light_program_test::{program_test::TestRpc, Indexer, Rpc}; @@ -227,9 +226,11 @@ async fn test_create_mint_with_metadata() { // Fetch mint interface (unified hot/cold handling) // Note: pass the mint PDA (cmint_pda), not the mint signer seed let mint_interface = rpc - .get_mint_interface(&cmint_pda) + .get_mint_interface(&cmint_pda, None) .await - .expect("get_mint_interface should succeed"); + .expect("get_mint_interface should succeed") + .value + .expect("mint interface should exist"); assert!(mint_interface.is_cold(), "Mint should be cold after warp"); // Create decompression instruction using decompress_mint helper diff --git a/sdk-tests/manual-test/tests/account_loader.rs b/sdk-tests/manual-test/tests/account_loader.rs index bcb345011c..501217b161 100644 --- a/sdk-tests/manual-test/tests/account_loader.rs +++ b/sdk-tests/manual-test/tests/account_loader.rs @@ -7,8 +7,8 @@ mod shared; use anchor_lang::{Discriminator, InstructionData, ToAccountMetas}; use light_client::interface::{ - create_load_instructions, get_create_accounts_proof, AccountInterfaceExt, AccountSpec, - CreateAccountsProofInput, PdaSpec, + create_load_instructions, get_create_accounts_proof, AccountSpec, CreateAccountsProofInput, + PdaSpec, }; use light_compressible::rent::SLOTS_PER_EPOCH; use light_program_test::{program_test::TestRpc, Indexer, Rpc}; @@ -118,9 +118,11 @@ async fn test_zero_copy_create_compress_decompress() { // PHASE 4: Decompress account let account_interface = rpc - .get_account_interface(&record_pda, &program_id) + .get_account_interface(&record_pda, None) .await - .expect("failed to get account interface"); + .expect("failed to get account interface") + .value + .expect("account interface should exist"); assert!( account_interface.is_cold(), "Account should be cold (compressed)" diff --git a/sdk-tests/manual-test/tests/test.rs b/sdk-tests/manual-test/tests/test.rs index 6bf6314a94..fe94962b81 100644 --- a/sdk-tests/manual-test/tests/test.rs +++ b/sdk-tests/manual-test/tests/test.rs @@ -6,8 +6,8 @@ mod shared; use anchor_lang::{InstructionData, ToAccountMetas}; use light_client::interface::{ - create_load_instructions, get_create_accounts_proof, AccountInterfaceExt, AccountSpec, - CreateAccountsProofInput, PdaSpec, + create_load_instructions, get_create_accounts_proof, AccountSpec, CreateAccountsProofInput, + PdaSpec, }; use light_compressible::rent::SLOTS_PER_EPOCH; use light_program_test::{program_test::TestRpc, Indexer, Rpc}; @@ -116,9 +116,11 @@ async fn test_create_compress_decompress() { // PHASE 4: Decompress account let account_interface = rpc - .get_account_interface(&record_pda, &program_id) + .get_account_interface(&record_pda, None) .await - .expect("failed to get account interface"); + .expect("failed to get account interface") + .value + .expect("account interface should exist"); assert!( account_interface.is_cold(), "Account should be cold (compressed)" diff --git a/sdk-tests/single-account-loader-test/tests/test.rs b/sdk-tests/single-account-loader-test/tests/test.rs index bea7332de5..ea1314c0de 100644 --- a/sdk-tests/single-account-loader-test/tests/test.rs +++ b/sdk-tests/single-account-loader-test/tests/test.rs @@ -2,8 +2,8 @@ use anchor_lang::{InstructionData, ToAccountMetas}; use light_client::interface::{ - create_load_instructions, get_create_accounts_proof, AccountInterfaceExt, AccountSpec, - CreateAccountsProofInput, InitializeRentFreeConfig, PdaSpec, + create_load_instructions, get_create_accounts_proof, AccountSpec, CreateAccountsProofInput, + InitializeRentFreeConfig, PdaSpec, }; use light_compressible::rent::SLOTS_PER_EPOCH; use light_program_test::{ @@ -240,9 +240,11 @@ async fn test_zero_copy_record_full_lifecycle() { // PHASE 4: Decompress account let account_interface = rpc - .get_account_interface(&record_pda, &program_id) + .get_account_interface(&record_pda, None) .await - .expect("failed to get account interface"); + .expect("failed to get account interface") + .value + .expect("account interface should exist"); assert!( account_interface.is_cold(), "Account should be cold (compressed)" From 311564f791b7672b842e242fc80104757274a6d5 Mon Sep 17 00:00:00 2001 From: Sergey Timoshin Date: Sun, 1 Feb 2026 00:56:50 +0000 Subject: [PATCH 38/38] bump photon commit hash in versions.sh --- scripts/devenv/versions.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/devenv/versions.sh b/scripts/devenv/versions.sh index bf1bfa89eb..f46121af4b 100755 --- a/scripts/devenv/versions.sh +++ b/scripts/devenv/versions.sh @@ -13,7 +13,7 @@ export SOLANA_VERSION="2.2.15" export ANCHOR_VERSION="0.31.1" export JQ_VERSION="1.8.0" export PHOTON_VERSION="0.51.2" -export PHOTON_COMMIT="2503c2303f14e270cda166ef8a07fbb0de484e67" +export PHOTON_COMMIT="ab3583e9cc43389a780fd1165781820e2f748a87" export REDIS_VERSION="8.0.1" export ANCHOR_TAG="anchor-v${ANCHOR_VERSION}"