From 15ca5f7f978adca678572ee11c19c228f0dbc0ae Mon Sep 17 00:00:00 2001 From: Sergey Timoshin Date: Tue, 3 Mar 2026 14:25:01 +0000 Subject: [PATCH 1/3] feat: add Account Interface APIs (getAccountInterface, getMultipleAccountInterfaces) - Add getAccountInterface and getMultipleAccountInterfaces RPC methods - Implement racing logic for compressed vs on-chain account resolution - Add interface types (AccountInterface, SolanaAccountData) - Register new methods in RPC server and OpenAPI spec - Add integration tests with snapshot testing - Add test transaction data for indexer interface --- src/api/api.rs | 31 + .../method/interface/get_account_interface.rs | 22 + .../get_multiple_account_interfaces.rs | 107 ++ src/api/method/interface/mod.rs | 8 + src/api/method/interface/racing.rs | 1424 +++++++++++++++++ src/api/method/interface/types.rs | 87 + src/api/method/mod.rs | 1 + src/api/rpc_server.rs | 21 + src/openapi/mod.rs | 4 + ...2eSZuEdjKrqw9ez2yhxJt5U7S8LYdrUdSq1KKZid4X | 158 ++ ...2hDxNxEWoiybvn4p7ua2nw3scYeNo6htYCSuBYviFd | 157 ++ ...iqFp6pXAB9fdrFHq66u6zvv3ddY5xLAzpmREASatnB | 114 ++ ...bufv4nTbqrp7ZQ5fpopHh7pU9RnimvvR31XeckFt9F | 158 ++ ...USeWpegrdfvrPkGA8qxfBFpu5ru9DjTFktRSB6w4s1 | 115 ++ tests/integration_tests/interface_tests.rs | 222 +++ tests/integration_tests/main.rs | 1 + ...ace_compressed_only-account-interface.snap | 11 + ...terface_nonexistent-account-interface.snap | 11 + ...nterfaces-multiple-account-interfaces.snap | 15 + ...face_by_owner-token-account-interface.snap | 11 + ...mpressed_only-token-account-interface.snap | 11 + 21 files changed, 2689 insertions(+) create mode 100644 src/api/method/interface/get_account_interface.rs create mode 100644 src/api/method/interface/get_multiple_account_interfaces.rs create mode 100644 src/api/method/interface/mod.rs create mode 100644 src/api/method/interface/racing.rs create mode 100644 src/api/method/interface/types.rs create mode 100644 tests/data/transactions/indexer_interface/2Q8KnAuf9TuPThbkEFZp6tfFC9bsGBVEpvoDJzLZAAEDKi2eSZuEdjKrqw9ez2yhxJt5U7S8LYdrUdSq1KKZid4X create mode 100644 tests/data/transactions/indexer_interface/2YTv5hjSmRAgfwoNHdc4DRDFWW7fqQb57f9s8Rxtu9u6jA2hDxNxEWoiybvn4p7ua2nw3scYeNo6htYCSuBYviFd create mode 100644 tests/data/transactions/indexer_interface/2afJTiZyNEMvrKJasbDFqPaTaLWMttjAHnBLgm7CizqzqsiqFp6pXAB9fdrFHq66u6zvv3ddY5xLAzpmREASatnB create mode 100644 tests/data/transactions/indexer_interface/5bLkzWasPYAivyrVvrt5UN3amycT3MSdNB2evCEMyuW1Ajbufv4nTbqrp7ZQ5fpopHh7pU9RnimvvR31XeckFt9F create mode 100644 tests/data/transactions/indexer_interface/628ZqqrNWuVUfHF2seH7XQEZqBJtWA8NaAjcVeH96XWaubUSeWpegrdfvrPkGA8qxfBFpu5ru9DjTFktRSB6w4s1 create mode 100644 tests/integration_tests/interface_tests.rs create mode 100644 tests/integration_tests/snapshots/integration_tests__interface_tests__get_account_interface_compressed_only-account-interface.snap create mode 100644 tests/integration_tests/snapshots/integration_tests__interface_tests__get_account_interface_nonexistent-account-interface.snap create mode 100644 tests/integration_tests/snapshots/integration_tests__interface_tests__get_multiple_account_interfaces-multiple-account-interfaces.snap create mode 100644 tests/integration_tests/snapshots/integration_tests__interface_tests__get_token_account_interface_by_owner-token-account-interface.snap create mode 100644 tests/integration_tests/snapshots/integration_tests__interface_tests__get_token_account_interface_compressed_only-token-account-interface.snap diff --git a/src/api/api.rs b/src/api/api.rs index e323c2cd..c382db1a 100644 --- a/src/api/api.rs +++ b/src/api/api.rs @@ -85,6 +85,11 @@ use crate::api::method::get_validity_proof::{ GetValidityProofRequestDocumentation, GetValidityProofRequestV2, GetValidityProofResponse, GetValidityProofResponseV2, }; +use crate::api::method::interface::{ + get_account_interface, get_multiple_account_interfaces, GetAccountInterfaceRequest, + GetAccountInterfaceResponse, GetMultipleAccountInterfacesRequest, + GetMultipleAccountInterfacesResponse, +}; use crate::api::method::utils::{ AccountBalanceResponse, GetLatestSignaturesRequest, GetNonPaginatedSignaturesResponse, GetNonPaginatedSignaturesResponseWithError, GetPaginatedSignaturesResponse, HashRequest, @@ -402,6 +407,21 @@ impl PhotonApi { get_latest_non_voting_signatures(self.db_conn.as_ref(), request).await } + // Interface endpoints - race hot (on-chain) and cold (compressed) lookups + pub async fn get_account_interface( + &self, + request: GetAccountInterfaceRequest, + ) -> Result { + get_account_interface(&self.db_conn, &self.rpc_client, request).await + } + + pub async fn get_multiple_account_interfaces( + &self, + request: GetMultipleAccountInterfacesRequest, + ) -> Result { + get_multiple_account_interfaces(&self.db_conn, &self.rpc_client, request).await + } + pub fn method_api_specs() -> Vec { vec![ OpenApiSpec { @@ -591,6 +611,17 @@ impl PhotonApi { request: None, response: UnsignedInteger::schema().1, }, + // Interface endpoints + OpenApiSpec { + name: "getAccountInterface".to_string(), + request: Some(GetAccountInterfaceRequest::schema().1), + response: GetAccountInterfaceResponse::schema().1, + }, + OpenApiSpec { + name: "getMultipleAccountInterfaces".to_string(), + request: Some(GetMultipleAccountInterfacesRequest::schema().1), + response: GetMultipleAccountInterfacesResponse::schema().1, + }, ] } } diff --git a/src/api/method/interface/get_account_interface.rs b/src/api/method/interface/get_account_interface.rs new file mode 100644 index 00000000..cc6aa514 --- /dev/null +++ b/src/api/method/interface/get_account_interface.rs @@ -0,0 +1,22 @@ +use sea_orm::DatabaseConnection; +use solana_client::nonblocking::rpc_client::RpcClient; + +use crate::api::error::PhotonApiError; +use crate::common::typedefs::context::Context; + +use super::racing::race_hot_cold; +use super::types::{GetAccountInterfaceRequest, GetAccountInterfaceResponse}; + +/// 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( + conn: &DatabaseConnection, + rpc_client: &RpcClient, + request: GetAccountInterfaceRequest, +) -> Result { + let context = Context::extract(conn).await?; + + let value = race_hot_cold(rpc_client, conn, &request.address, None).await?; + + Ok(GetAccountInterfaceResponse { context, value }) +} diff --git a/src/api/method/interface/get_multiple_account_interfaces.rs b/src/api/method/interface/get_multiple_account_interfaces.rs new file mode 100644 index 00000000..576b8cf9 --- /dev/null +++ b/src/api/method/interface/get_multiple_account_interfaces.rs @@ -0,0 +1,107 @@ +use sea_orm::DatabaseConnection; +use solana_client::nonblocking::rpc_client::RpcClient; +use tokio::sync::Semaphore; + +use crate::api::error::PhotonApiError; +use crate::common::typedefs::context::Context; +use crate::common::typedefs::serializable_pubkey::SerializablePubkey; + +use super::racing::{get_distinct_owners_with_addresses, race_hot_cold}; +use super::types::{ + AccountInterface, GetMultipleAccountInterfacesRequest, GetMultipleAccountInterfacesResponse, + MAX_BATCH_SIZE, +}; + +/// Maximum concurrent hot+cold lookups per batch request. +const MAX_CONCURRENT_LOOKUPS: usize = 20; + +/// Get multiple account data from either on-chain or compressed sources. +/// Returns one unified AccountInterface shape for every input pubkey. +pub async fn get_multiple_account_interfaces( + conn: &DatabaseConnection, + rpc_client: &RpcClient, + request: GetMultipleAccountInterfacesRequest, +) -> Result { + if request.addresses.len() > MAX_BATCH_SIZE { + return Err(PhotonApiError::ValidationError(format!( + "Batch size {} exceeds maximum of {}", + request.addresses.len(), + MAX_BATCH_SIZE + ))); + } + + if request.addresses.is_empty() { + return Err(PhotonApiError::ValidationError( + "At least one address must be provided".to_string(), + )); + } + + let context = Context::extract(conn).await?; + + let distinct_owners = get_distinct_owners_with_addresses(conn) + .await + .map_err(PhotonApiError::DatabaseError)?; + + let semaphore = Semaphore::new(MAX_CONCURRENT_LOOKUPS); + let futures: Vec<_> = request + .addresses + .iter() + .map(|address| async { + let _permit = semaphore.acquire().await.unwrap(); + race_hot_cold(rpc_client, conn, address, Some(&distinct_owners)).await + }) + .collect(); + + let results = futures::future::join_all(futures).await; + + let value = collect_batch_results(&request.addresses, results)?; + + Ok(GetMultipleAccountInterfacesResponse { context, value }) +} + +fn collect_batch_results( + addresses: &[SerializablePubkey], + results: Vec, PhotonApiError>>, +) -> Result>, PhotonApiError> { + let mut value = Vec::with_capacity(results.len()); + for (i, result) in results.into_iter().enumerate() { + match result { + // Includes Ok(None): account not found is returned as None. + Ok(account) => value.push(account), + // Only actual lookup failures abort the entire batch call. + Err(e) => { + log::error!( + "Failed to fetch interface for address {:?} (index {}): {:?}", + addresses.get(i), + i, + e + ); + return Err(e); + } + } + } + Ok(value) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn collect_batch_results_keeps_none_for_not_found_accounts() { + let addresses = vec![SerializablePubkey::default(), SerializablePubkey::default()]; + let results = vec![Ok(None), Ok(None)]; + + let value = collect_batch_results(&addresses, results).expect("expected success"); + assert_eq!(value, vec![None, None]); + } + + #[test] + fn collect_batch_results_returns_error_for_actual_failure() { + let addresses = vec![SerializablePubkey::default()]; + let results = vec![Err(PhotonApiError::UnexpectedError("boom".to_string()))]; + + let err = collect_batch_results(&addresses, results).expect_err("expected error"); + assert_eq!(err, PhotonApiError::UnexpectedError("boom".to_string())); + } +} diff --git a/src/api/method/interface/mod.rs b/src/api/method/interface/mod.rs new file mode 100644 index 00000000..0be4588f --- /dev/null +++ b/src/api/method/interface/mod.rs @@ -0,0 +1,8 @@ +pub mod get_account_interface; +pub mod get_multiple_account_interfaces; +pub mod racing; +pub mod types; + +pub use get_account_interface::get_account_interface; +pub use get_multiple_account_interfaces::get_multiple_account_interfaces; +pub use types::*; diff --git a/src/api/method/interface/racing.rs b/src/api/method/interface/racing.rs new file mode 100644 index 00000000..8ed98f2f --- /dev/null +++ b/src/api/method/interface/racing.rs @@ -0,0 +1,1424 @@ +use std::collections::HashMap; +use std::time::Duration; + +use crate::api::error::PhotonApiError; +use crate::common::typedefs::account::AccountV2; +use crate::common::typedefs::bs64_string::Base64String; +use crate::common::typedefs::hash::Hash; +use crate::common::typedefs::serializable_pubkey::SerializablePubkey; +use crate::common::typedefs::token_data::AccountState; +use crate::common::typedefs::unsigned_integer::UnsignedInteger; +use crate::dao::generated::{accounts, token_accounts}; +use light_compressed_account::address::derive_address; +use light_hasher::{sha256::Sha256BE, Hasher}; +use light_sdk_types::constants::ADDRESS_TREE_V2; +use sea_orm::prelude::Decimal; +use sea_orm::{ColumnTrait, Condition, DatabaseConnection, EntityTrait, QueryFilter}; +use solana_account::Account as SolanaAccount; +use solana_client::nonblocking::rpc_client::RpcClient; +use solana_commitment_config::CommitmentConfig; +use solana_program_option::COption; +use solana_program_pack::Pack; +use solana_pubkey::Pubkey; +use spl_token_interface::state::Account as SplTokenAccount; +use spl_token_interface::state::AccountState as SplAccountState; +use tokio::time::timeout; + +use crate::common::typedefs::token_data::TokenData; +use crate::ingester::persist::DECOMPRESSED_ACCOUNT_DISCRIMINATOR; + +use super::types::{AccountInterface, SolanaAccountData, DB_TIMEOUT_MS, RPC_TIMEOUT_MS}; + +/// Result from a hot (on-chain RPC) lookup. +#[derive(Debug)] +pub struct HotLookupResult { + pub account: Option, + pub slot: u64, +} + +/// Result from a cold (compressed DB) lookup. +#[derive(Debug)] +pub struct ColdLookupResult { + pub accounts: Vec, + /// Map from account hash → wallet owner bytes (from ata_owner in token_accounts table). + pub token_wallet_owners: HashMap, +} + +/// Perform a hot lookup via Solana RPC. +pub async fn hot_lookup( + rpc_client: &RpcClient, + address: &Pubkey, +) -> Result { + let result = timeout( + Duration::from_millis(RPC_TIMEOUT_MS), + rpc_client.get_account_with_commitment(address, CommitmentConfig::confirmed()), + ) + .await; + + match result { + Ok(Ok(response)) => Ok(HotLookupResult { + account: response.value, + slot: response.context.slot, + }), + Ok(Err(e)) => Err(PhotonApiError::UnexpectedError(format!("RPC error: {}", e))), + Err(_) => Err(PhotonApiError::UnexpectedError("RPC timeout".to_string())), + } +} + +/// Perform a cold lookup from the compressed accounts database. +/// +/// The lookup aggregates all compressed accounts associated with the queried pubkey: +/// 1) direct onchain pubkey matches (decompressed account linkage), +/// 2) derived compressed address matches (V2 address tree only), +/// 3) token account owner / ata_owner matches (can return multiple accounts). +pub async fn cold_lookup( + conn: &DatabaseConnection, + address: &SerializablePubkey, + distinct_owners: Option<&[Vec]>, +) -> Result { + let (models, token_wallet_owners) = find_cold_models(conn, address, distinct_owners).await?; + + let mut accounts_v2 = Vec::with_capacity(models.len()); + for model in models { + let account = AccountV2::try_from(model)?; + accounts_v2.push(account); + } + + Ok(ColdLookupResult { + accounts: accounts_v2, + token_wallet_owners, + }) +} + +fn has_decompressed_placeholder_shape(model: &accounts::Model) -> bool { + let Some(data) = model.data.as_ref() else { + return false; + }; + let Some(onchain_pubkey) = model.onchain_pubkey.as_ref() else { + return false; + }; + + data.len() == 32 && onchain_pubkey.len() == 32 && data.as_slice() == onchain_pubkey.as_slice() +} + +fn has_decompressed_placeholder_hash(model: &accounts::Model) -> bool { + let Some(onchain_pubkey) = model.onchain_pubkey.as_ref() else { + return false; + }; + let Some(data_hash) = model.data_hash.as_ref() else { + return false; + }; + + if onchain_pubkey.len() != 32 || data_hash.len() != 32 { + return false; + } + + match Sha256BE::hash(onchain_pubkey) { + Ok(expected_hash) => expected_hash.as_slice() == data_hash.as_slice(), + Err(_) => false, + } +} + +fn has_decompressed_placeholder_discriminator(model: &accounts::Model) -> bool { + let expected_disc = Decimal::from(DECOMPRESSED_ACCOUNT_DISCRIMINATOR); + let sqlite_rounded_disc = Decimal::from((DECOMPRESSED_ACCOUNT_DISCRIMINATOR as f64) as u64); + model.discriminator == Some(expected_disc) || model.discriminator == Some(sqlite_rounded_disc) +} + +fn is_decompressed_placeholder_model(model: &accounts::Model) -> bool { + if !has_decompressed_placeholder_shape(model) { + return false; + } + + if model.data_hash.is_some() && !has_decompressed_placeholder_hash(model) { + return false; + } + + has_decompressed_placeholder_discriminator(model) +} + +async fn find_cold_models( + conn: &DatabaseConnection, + address: &SerializablePubkey, + distinct_owners: Option<&[Vec]>, +) -> Result<(Vec, HashMap), PhotonApiError> { + let address_bytes: Vec = (*address).into(); + let pda_seed = address.0.to_bytes(); + + let mut by_hash: HashMap, accounts::Model> = HashMap::new(); + let mut token_wallet_owners: HashMap = HashMap::new(); + + // 1) Direct onchain pubkey linkage. + let onchain_result = timeout( + Duration::from_millis(DB_TIMEOUT_MS), + accounts::Entity::find() + .filter(accounts::Column::Spent.eq(false)) + .filter(accounts::Column::OnchainPubkey.eq(address_bytes.clone())) + .all(conn), + ) + .await; + + match onchain_result { + Ok(Ok(models)) => { + for model in models { + by_hash.insert(model.hash.clone(), model); + } + } + Ok(Err(e)) => return Err(PhotonApiError::DatabaseError(e)), + Err(_) => { + return Err(PhotonApiError::UnexpectedError( + "Database timeout".to_string(), + )) + } + } + + // 2) Derived-address fallback (V2 only). + // Use pre-fetched owners when available (batch path), otherwise query. + let owned_owners; + let owners: &[Vec] = match distinct_owners { + Some(cached) => cached, + None => { + let owners_result = timeout( + Duration::from_millis(DB_TIMEOUT_MS), + get_distinct_owners_with_addresses(conn), + ) + .await; + + owned_owners = match owners_result { + Ok(Ok(o)) => o, + Ok(Err(e)) => return Err(PhotonApiError::DatabaseError(e)), + Err(_) => { + return Err(PhotonApiError::UnexpectedError( + "Database timeout getting owners".to_string(), + )) + } + }; + &owned_owners + } + }; + + if !owners.is_empty() { + // Derived address lookups use V2 address tree only — compressible + // programs (the only users of this path) always create accounts + // with ADDRESS_TREE_V2. + let derived_addresses: Vec> = owners + .iter() + .filter_map(|owner| owner.as_slice().try_into().ok()) + .map(|owner_bytes: [u8; 32]| { + derive_address(&pda_seed, &ADDRESS_TREE_V2, &owner_bytes).to_vec() + }) + .collect(); + + if !derived_addresses.is_empty() { + let derived_result = timeout( + Duration::from_millis(DB_TIMEOUT_MS), + accounts::Entity::find() + .filter( + Condition::all() + .add(accounts::Column::Spent.eq(false)) + .add(accounts::Column::Address.is_in(derived_addresses)), + ) + .all(conn), + ) + .await; + + match derived_result { + Ok(Ok(models)) => { + for model in models { + by_hash.insert(model.hash.clone(), model); + } + } + Ok(Err(e)) => return Err(PhotonApiError::DatabaseError(e)), + Err(_) => { + return Err(PhotonApiError::UnexpectedError( + "Database timeout during derived address lookup".to_string(), + )) + } + } + } + } + + // 3) Token account linkage by token owner / ata_owner. + let token_result = timeout( + Duration::from_millis(DB_TIMEOUT_MS), + token_accounts::Entity::find() + .filter(token_accounts::Column::Spent.eq(false)) + .filter( + Condition::any() + .add(token_accounts::Column::AtaOwner.eq(address_bytes.clone())) + .add(token_accounts::Column::Owner.eq(address_bytes)), + ) + .find_also_related(accounts::Entity) + .all(conn), + ) + .await; + + match token_result { + Ok(Ok(rows)) => { + for (token, maybe_account) in rows { + if let Some(model) = maybe_account { + if !model.spent { + if let Some(ata_owner) = token.ata_owner { + match ( + Hash::try_from(model.hash.clone()), + <[u8; 32]>::try_from(ata_owner.as_slice()), + ) { + (Ok(hash), Ok(owner)) => { + token_wallet_owners.insert(hash, owner); + } + _ => log::warn!( + "Skipping invalid token wallet owner entry: hash_len={}, owner_len={}", + model.hash.len(), + ata_owner.len() + ), + } + } + by_hash.insert(model.hash.clone(), model); + } + } + } + } + Ok(Err(e)) => return Err(PhotonApiError::DatabaseError(e)), + Err(_) => { + return Err(PhotonApiError::UnexpectedError( + "Database timeout during token lookup".to_string(), + )) + } + } + + // Filter out decompressed PDA placeholders — they are bookmarks in the + // Merkle tree, not truly cold accounts. + // Sort by hash for deterministic ordering across identical queries. + let mut models: Vec<_> = by_hash + .into_values() + .filter(|m| !is_decompressed_placeholder_model(m)) + .collect(); + models.sort_by(|a, b| a.hash.cmp(&b.hash)); + Ok((models, token_wallet_owners)) +} + +/// Get distinct owners from accounts that have derived addresses. +/// These are accounts from compressible programs (their address is derived from PDA + tree + owner). +/// This is typically a small set since most programs aren't compressible. +pub async fn get_distinct_owners_with_addresses( + conn: &DatabaseConnection, +) -> Result>, sea_orm::DbErr> { + use sea_orm::{FromQueryResult, QuerySelect}; + + #[derive(FromQueryResult)] + struct OwnerResult { + owner: Vec, + } + + let owners: Vec = accounts::Entity::find() + .select_only() + .column(accounts::Column::Owner) + .distinct() + .filter(accounts::Column::Spent.eq(false)) + .filter(accounts::Column::Address.is_not_null()) + .into_model::() + .all(conn) + .await?; + + Ok(owners.into_iter().map(|o| o.owner).collect()) +} + +fn hot_to_solana_account_data(account: &SolanaAccount) -> SolanaAccountData { + SolanaAccountData { + lamports: UnsignedInteger(account.lamports), + data: Base64String(account.data.clone()), + owner: SerializablePubkey::from(account.owner.to_bytes()), + executable: account.executable, + rent_epoch: UnsignedInteger(account.rent_epoch), + space: UnsignedInteger(account.data.len() as u64), + } +} + +/// Build the 165-byte SPL Token Account layout from compressed TokenData + +/// corrected wallet owner. +fn build_spl_token_account_bytes(token_data: &TokenData, wallet_owner: &[u8; 32]) -> Vec { + let spl_state = match token_data.state { + AccountState::initialized => SplAccountState::Initialized, + AccountState::frozen => SplAccountState::Frozen, + }; + + let spl_account = SplTokenAccount { + mint: Pubkey::from(token_data.mint.0.to_bytes()), + owner: Pubkey::from(*wallet_owner), + amount: token_data.amount.0, + delegate: match &token_data.delegate { + Some(d) => COption::Some(Pubkey::from(d.0.to_bytes())), + None => COption::None, + }, + state: spl_state, + is_native: COption::None, + delegated_amount: 0, + close_authority: COption::None, + }; + + let mut buf = vec![0u8; SplTokenAccount::LEN]; + SplTokenAccount::pack(spl_account, &mut buf).expect("buffer is exactly LEN bytes"); + buf +} + +fn cold_to_synthetic_account_data( + account: &AccountV2, + wallet_owner: Option<&[u8]>, +) -> SolanaAccountData { + // For token accounts, always synthesize 165-byte SPL layout. + // Prefer ATA wallet owner when available, else fall back to compressed token owner. + if let Ok(Some(token_data)) = account.parse_token_data() { + let owner_arr = match wallet_owner.and_then(|bytes| <&[u8; 32]>::try_from(bytes).ok()) { + Some(owner) => *owner, + None => { + if let Some(owner) = wallet_owner { + log::debug!( + "Invalid ata_owner length for token account {}, using compressed owner: {}", + account.hash, + owner.len() + ); + } + token_data.owner.0.to_bytes() + } + }; + + let spl_bytes = build_spl_token_account_bytes(&token_data, &owner_arr); + let space = spl_bytes.len() as u64; + return SolanaAccountData { + lamports: account.lamports, + data: Base64String(spl_bytes), + // Preserve the original program owner from the cold account model. + // This can be LIGHT token program and, depending on account source, + // may also be SPL Token / Token-2022. + owner: account.owner, + executable: false, + rent_epoch: UnsignedInteger(0), + space: UnsignedInteger(space), + }; + } + + if wallet_owner + .map(|bytes| <&[u8; 32]>::try_from(bytes).is_err()) + .unwrap_or(false) + { + log::debug!( + "Ignoring invalid wallet owner length for non-token cold account: {:?}", + wallet_owner.map(|v| v.len()) + ); + } + + let full_data = account + .data + .as_ref() + .map(|d| d.data.0.clone()) + .unwrap_or_default(); + + let space = full_data.len() as u64; + SolanaAccountData { + lamports: account.lamports, + data: Base64String(full_data), + owner: account.owner, + executable: false, + rent_epoch: UnsignedInteger(0), + space: UnsignedInteger(space), + } +} + +fn parse_hot_spl_token(data: &[u8]) -> Option { + SplTokenAccount::unpack(data).ok() +} + +fn build_interface( + address: SerializablePubkey, + account_data: SolanaAccountData, + cold_accounts: Vec, +) -> AccountInterface { + AccountInterface { + key: address, + account: account_data, + cold: (!cold_accounts.is_empty()).then_some(cold_accounts), + } +} + +/// Race hot and cold lookups, returning a single unified interface shape. +/// +/// Account selection policy: +/// 1) If hot exists with lamports > 0 and hot slot >= newest cold slot, use hot account view. +/// 2) Otherwise, if cold exists, synthesize account view from newest cold account. +/// 3) Include all cold accounts in `cold` whenever they exist. +pub async fn race_hot_cold( + rpc_client: &RpcClient, + conn: &DatabaseConnection, + address: &SerializablePubkey, + distinct_owners: Option<&[Vec]>, +) -> Result, PhotonApiError> { + let pubkey = Pubkey::from(address.0.to_bytes()); + + let (hot_result, cold_result) = tokio::join!( + hot_lookup(rpc_client, &pubkey), + cold_lookup(conn, address, distinct_owners) + ); + + resolve_race_result(hot_result, cold_result, *address) +} + +fn resolve_race_result( + hot_result: Result, + cold_result: Result, + address: SerializablePubkey, +) -> Result, PhotonApiError> { + match (hot_result, cold_result) { + (Ok(hot), Ok(cold)) => Ok(resolve_single_race( + hot.account.as_ref(), + &cold.accounts, + hot.slot, + address, + &cold.token_wallet_owners, + )), + (Ok(hot), Err(e)) => { + log::debug!("Cold lookup failed, using hot result: {:?}", e); + Ok(hot + .account + .as_ref() + .filter(|account| account.lamports > 0) + .map(|account| { + build_interface(address, hot_to_solana_account_data(account), vec![]) + })) + } + (Err(e), Ok(cold)) => { + log::debug!("Hot lookup failed, using cold result: {:?}", e); + Ok(resolve_single_race( + None, + &cold.accounts, + 0, + address, + &cold.token_wallet_owners, + )) + } + (Err(hot_err), Err(cold_err)) => { + log::warn!( + "Both hot and cold lookups failed. Hot: {:?}, Cold: {:?}", + hot_err, + cold_err + ); + Err(hot_err) + } + } +} + +/// Build a synthetic `SolanaAccountData` from cold accounts, optionally +/// including a hot on-chain account's balance. +/// +/// For fungible tokens: if **all** cold accounts parse as token accounts with +/// the same mint (and, when provided, the hot account shares that mint) their +/// amounts are summed into a single SPL Token layout. When a hot account is +/// present it is used as the base for the synthetic view (it already carries the +/// correct wallet owner, delegate, state, etc.). Otherwise the newest cold +/// account (by slot) provides those fields. +/// +/// For everything else (non-token accounts, mixed mints, parse failures) the +/// function falls back to the hot account (if present and newer) or the newest +/// cold account. +fn cold_accounts_to_synthetic( + cold_accounts: &[AccountV2], + token_wallet_owners: &HashMap, + hot_account: Option<&SolanaAccount>, + hot_slot: u64, +) -> SolanaAccountData { + debug_assert!(!cold_accounts.is_empty()); + + // Try to parse every cold account as a token account. + let parsed: Vec<(&AccountV2, TokenData)> = cold_accounts + .iter() + .filter_map(|acc| acc.parse_token_data().ok().flatten().map(|td| (acc, td))) + .collect(); + + // All cold accounts must be token accounts with the same mint. + if !parsed.is_empty() && parsed.len() == cold_accounts.len() { + let first_mint = &parsed[0].1.mint; + let all_same_mint = parsed.iter().all(|(_, td)| td.mint == *first_mint); + + if all_same_mint { + // If a hot account is provided, it must also be an SPL token with + // the same mint; otherwise skip aggregation entirely. + let hot_contribution = match hot_account { + Some(hot) => match parse_hot_spl_token(&hot.data) { + Some(spl) if spl.mint.to_bytes() == first_mint.0.to_bytes() => Some(spl), + Some(_) => None, // different mint — can't aggregate + None => None, // not a token account + }, + None => None, + }; + + // When hot exists but doesn't match, fall through to fallback. + if hot_account.is_none() || hot_contribution.is_some() { + let cold_total: u64 = parsed.iter().map(|(_, td)| td.amount.0).sum(); + let hot_amount = hot_contribution.as_ref().map_or(0, |spl| spl.amount); + let total_amount = cold_total + hot_amount; + + // When a hot account matches, use it as the base for the + // synthetic view — it already has the correct wallet owner, + // delegate, state, etc. Unpack, set aggregated amount, repack. + if let (Some(hot), Some(mut spl)) = (hot_account, hot_contribution) { + spl.amount = total_amount; + let mut spl_bytes = vec![0u8; SplTokenAccount::LEN]; + SplTokenAccount::pack(spl, &mut spl_bytes) + .expect("buffer is exactly LEN bytes"); + let space = spl_bytes.len() as u64; + return SolanaAccountData { + lamports: UnsignedInteger(hot.lamports), + data: Base64String(spl_bytes), + owner: SerializablePubkey::from(hot.owner.to_bytes()), + executable: false, + rent_epoch: UnsignedInteger(0), + space: UnsignedInteger(space), + }; + } + + // Cold-only path: build from newest cold account. + let (newest_acc, newest_td) = parsed + .iter() + .max_by_key(|(acc, _)| acc.slot_created.0) + .unwrap(); + + let wallet_owner_bytes = token_wallet_owners.get(&newest_acc.hash); + let owner_arr = match wallet_owner_bytes + .and_then(|bytes| <&[u8; 32]>::try_from(bytes.as_slice()).ok()) + { + Some(owner) => *owner, + None => newest_td.owner.0.to_bytes(), + }; + + let aggregated = TokenData { + mint: newest_td.mint, + owner: newest_td.owner, + amount: UnsignedInteger(total_amount), + delegate: newest_td.delegate, + state: newest_td.state, + tlv: newest_td.tlv.clone(), + }; + + let spl_bytes = build_spl_token_account_bytes(&aggregated, &owner_arr); + let space = spl_bytes.len() as u64; + return SolanaAccountData { + lamports: newest_acc.lamports, + data: Base64String(spl_bytes), + owner: newest_acc.owner, + executable: false, + rent_epoch: UnsignedInteger(0), + space: UnsignedInteger(space), + }; + } + } + } + + // Fallback: compare hot slot vs newest cold slot to pick the fresher view. + let newest = cold_accounts + .iter() + .max_by_key(|acc| acc.slot_created.0) + .unwrap(); + + if let Some(hot) = hot_account { + if hot_slot >= newest.slot_created.0 { + return hot_to_solana_account_data(hot); + } + } + + let wallet_owner = token_wallet_owners.get(&newest.hash); + cold_to_synthetic_account_data(newest, wallet_owner.map(|v| v.as_slice())) +} + +fn resolve_single_race( + hot_account: Option<&SolanaAccount>, + cold_accounts: &[AccountV2], + hot_slot: u64, + address: SerializablePubkey, + token_wallet_owners: &HashMap, +) -> Option { + let hot = hot_account.filter(|account| account.lamports > 0); + let has_cold = !cold_accounts.is_empty(); + + let account_data = match (hot, has_cold) { + (Some(hot), true) => { + cold_accounts_to_synthetic(cold_accounts, token_wallet_owners, Some(hot), hot_slot) + } + (Some(hot), false) => hot_to_solana_account_data(hot), + (None, true) => cold_accounts_to_synthetic(cold_accounts, token_wallet_owners, None, 0), + (None, false) => return None, + }; + + Some(build_interface( + address, + account_data, + cold_accounts.to_vec(), + )) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::api::method::get_validity_proof::MerkleContextV2; + use crate::common::typedefs::account::AccountData; + use crate::common::typedefs::account::C_TOKEN_DISCRIMINATOR_V2; + use crate::common::typedefs::hash::Hash; + use crate::common::typedefs::token_data::AccountState; + use crate::ingester::persist::LIGHT_TOKEN_PROGRAM_ID; + use solana_program_pack::Pack; + use spl_token_interface::state::Account as SplTokenAccount; + + fn sample_cold(slot_created: u64, lamports: u64) -> AccountV2 { + AccountV2 { + hash: Hash::default(), + address: Some(SerializablePubkey::default()), + data: Some(AccountData { + discriminator: UnsignedInteger(0x0807060504030201), + data: Base64String(vec![100, 200]), + data_hash: Hash::default(), + }), + owner: SerializablePubkey::default(), + lamports: UnsignedInteger(lamports), + leaf_index: UnsignedInteger(0), + seq: Some(UnsignedInteger(1)), + slot_created: UnsignedInteger(slot_created), + prove_by_index: false, + merkle_context: MerkleContextV2 { + tree_type: 3, + tree: SerializablePubkey::default(), + queue: SerializablePubkey::default(), + cpi_context: None, + next_tree_context: None, + }, + } + } + + /// Build a cold AccountV2 that looks like a compressed token account. + /// Uses LIGHT_TOKEN_PROGRAM_ID as owner and a c_token discriminator. + fn sample_token_cold(slot_created: u64, lamports: u64, token_data: &TokenData) -> AccountV2 { + sample_token_cold_with_hash(slot_created, lamports, token_data, Hash::default()) + } + + fn sample_token_cold_with_hash( + slot_created: u64, + lamports: u64, + token_data: &TokenData, + hash: Hash, + ) -> AccountV2 { + use borsh::BorshSerialize; + let mut data_bytes = Vec::new(); + token_data.serialize(&mut data_bytes).unwrap(); + + let discriminator = u64::from_le_bytes(C_TOKEN_DISCRIMINATOR_V2); + + AccountV2 { + hash, + address: Some(SerializablePubkey::default()), + data: Some(AccountData { + discriminator: UnsignedInteger(discriminator), + data: Base64String(data_bytes), + data_hash: Hash::default(), + }), + owner: SerializablePubkey::from(LIGHT_TOKEN_PROGRAM_ID), + lamports: UnsignedInteger(lamports), + leaf_index: UnsignedInteger(0), + seq: Some(UnsignedInteger(1)), + slot_created: UnsignedInteger(slot_created), + prove_by_index: false, + merkle_context: MerkleContextV2 { + tree_type: 3, + tree: SerializablePubkey::default(), + queue: SerializablePubkey::default(), + cpi_context: None, + next_tree_context: None, + }, + } + } + + #[test] + fn test_resolve_single_race_prefers_hot_when_newer_or_equal() { + let hot = SolanaAccount { + lamports: 1000, + data: vec![1, 2, 3], + owner: Pubkey::new_unique(), + executable: false, + rent_epoch: 0, + }; + let cold = sample_cold(100, 500); + + let result = resolve_single_race( + Some(&hot), + std::slice::from_ref(&cold), + 200, + SerializablePubkey::default(), + &HashMap::new(), + ) + .expect("expected Some interface"); + + assert_eq!(result.account.lamports.0, 1000); + assert!(result.cold.is_some()); + assert_eq!(result.cold.unwrap().len(), 1); + } + + #[test] + fn test_resolve_single_race_prefers_newer_cold_by_slot() { + let hot = SolanaAccount { + lamports: 1000, + data: vec![1, 2, 3], + owner: Pubkey::new_unique(), + executable: false, + rent_epoch: 0, + }; + let cold = sample_cold(300, 777); + + let result = resolve_single_race( + Some(&hot), + std::slice::from_ref(&cold), + 200, + SerializablePubkey::default(), + &HashMap::new(), + ) + .expect("expected Some interface"); + + assert_eq!(result.account.lamports.0, 777); + assert!(result.cold.is_some()); + } + + #[test] + fn test_resolve_single_race_falls_back_to_cold_when_hot_deleted() { + let hot = SolanaAccount { + lamports: 0, + data: vec![], + owner: Pubkey::new_unique(), + executable: false, + rent_epoch: 0, + }; + let cold = sample_cold(100, 500); + + let result = resolve_single_race( + Some(&hot), + std::slice::from_ref(&cold), + 200, + SerializablePubkey::default(), + &HashMap::new(), + ) + .expect("expected Some interface"); + + assert_eq!(result.account.lamports.0, 500); + assert!(result.cold.is_some()); + } + + #[test] + fn test_resolve_single_race_only_hot() { + let hot = SolanaAccount { + lamports: 1000, + data: vec![1, 2, 3], + owner: Pubkey::new_unique(), + executable: false, + rent_epoch: 0, + }; + + let result = resolve_single_race( + Some(&hot), + &[], + 200, + SerializablePubkey::default(), + &HashMap::new(), + ) + .expect("expected Some interface"); + + assert_eq!(result.account.lamports.0, 1000); + assert!(result.cold.is_none()); + } + + #[test] + fn test_resolve_single_race_only_cold() { + let cold = sample_cold(100, 555); + + let result = resolve_single_race( + None, + std::slice::from_ref(&cold), + 0, + SerializablePubkey::default(), + &HashMap::new(), + ) + .expect("expected Some interface"); + + assert_eq!(result.account.lamports.0, 555); + assert!(result.cold.is_some()); + } + + #[test] + fn test_resolve_single_race_neither() { + let result = + resolve_single_race(None, &[], 0, SerializablePubkey::default(), &HashMap::new()); + assert!(result.is_none()); + } + + // ============ SPL Token Account reconstruction tests ============ + + #[test] + fn test_build_spl_token_account_bytes_basic() { + let mint = Pubkey::new_unique(); + let wallet_owner = [42u8; 32]; + let token_data = TokenData { + mint: SerializablePubkey::from(mint), + owner: SerializablePubkey::from([99u8; 32]), // compressed owner (not wallet) + amount: UnsignedInteger(1_000_000), + delegate: None, + state: AccountState::initialized, + tlv: None, + }; + + let bytes = build_spl_token_account_bytes(&token_data, &wallet_owner); + + assert_eq!(bytes.len(), SplTokenAccount::LEN); + let parsed = SplTokenAccount::unpack(&bytes).expect("valid SPL layout"); + assert_eq!(parsed.mint, mint); + assert_eq!(parsed.owner, Pubkey::from(wallet_owner)); + assert_eq!(parsed.amount, 1_000_000); + assert!(parsed.delegate.is_none()); + assert_eq!( + parsed.state, + spl_token_interface::state::AccountState::Initialized + ); + assert!(parsed.is_native.is_none()); + assert_eq!(parsed.delegated_amount, 0); + assert!(parsed.close_authority.is_none()); + } + + #[test] + fn test_build_spl_token_account_bytes_with_delegate() { + let mint = Pubkey::new_unique(); + let wallet_owner = [10u8; 32]; + let delegate_key = Pubkey::new_unique(); + let token_data = TokenData { + mint: SerializablePubkey::from(mint), + owner: SerializablePubkey::from([99u8; 32]), + amount: UnsignedInteger(500), + delegate: Some(SerializablePubkey::from(delegate_key)), + state: AccountState::frozen, + tlv: None, + }; + + let bytes = build_spl_token_account_bytes(&token_data, &wallet_owner); + + assert_eq!(bytes.len(), SplTokenAccount::LEN); + let parsed = SplTokenAccount::unpack(&bytes).expect("valid SPL layout"); + assert_eq!(parsed.amount, 500); + assert_eq!( + parsed.delegate, + solana_program_option::COption::Some(delegate_key) + ); + assert_eq!( + parsed.state, + spl_token_interface::state::AccountState::Frozen + ); + } + + #[test] + fn test_cold_to_synthetic_token_with_wallet_owner() { + let mint = Pubkey::new_unique(); + let wallet_owner = [55u8; 32]; + let token_data = TokenData { + mint: SerializablePubkey::from(mint), + owner: SerializablePubkey::from([99u8; 32]), + amount: UnsignedInteger(42), + delegate: None, + state: AccountState::initialized, + tlv: None, + }; + + let cold = sample_token_cold(100, 1_000_000, &token_data); + let result = cold_to_synthetic_account_data(&cold, Some(&wallet_owner)); + + // Should produce 165-byte SPL layout + let parsed = SplTokenAccount::unpack(&result.data.0).expect("valid SPL layout"); + assert_eq!(result.space.0, SplTokenAccount::LEN as u64); + // Program owner should be preserved from cold account. + assert_eq!( + result.owner, + SerializablePubkey::from(LIGHT_TOKEN_PROGRAM_ID) + ); + assert_eq!(parsed.owner, Pubkey::from(wallet_owner)); + } + + #[test] + fn test_cold_to_synthetic_token_without_wallet_owner() { + let mint = Pubkey::new_unique(); + let compressed_owner = [99u8; 32]; + let token_data = TokenData { + mint: SerializablePubkey::from(mint), + owner: SerializablePubkey::from(compressed_owner), + amount: UnsignedInteger(42), + delegate: None, + state: AccountState::initialized, + tlv: None, + }; + + let cold = sample_token_cold(100, 1_000_000, &token_data); + // No wallet owner → should fall back to compressed token owner. + let result = cold_to_synthetic_account_data(&cold, None); + + let parsed = SplTokenAccount::unpack(&result.data.0).expect("valid SPL layout"); + assert_eq!(result.space.0, SplTokenAccount::LEN as u64); + assert_eq!( + result.owner, + SerializablePubkey::from(LIGHT_TOKEN_PROGRAM_ID) + ); + assert_eq!(parsed.owner, Pubkey::from(compressed_owner)); + } + + #[test] + fn test_cold_to_synthetic_token_invalid_wallet_owner_uses_compressed_owner() { + let mint = Pubkey::new_unique(); + let compressed_owner = [77u8; 32]; + let token_data = TokenData { + mint: SerializablePubkey::from(mint), + owner: SerializablePubkey::from(compressed_owner), + amount: UnsignedInteger(42), + delegate: None, + state: AccountState::initialized, + tlv: None, + }; + + let cold = sample_token_cold(100, 1_000_000, &token_data); + let invalid_owner = [1u8; 31]; + let result = cold_to_synthetic_account_data(&cold, Some(&invalid_owner)); + + let parsed = SplTokenAccount::unpack(&result.data.0).expect("valid SPL layout"); + assert_eq!(parsed.owner, Pubkey::from(compressed_owner)); + } + + #[test] + fn test_cold_to_synthetic_non_token() { + let cold = sample_cold(100, 500); + let wallet_owner = [55u8; 32]; + + // Non-token account with wallet_owner should be unaffected + let result = cold_to_synthetic_account_data(&cold, Some(&wallet_owner)); + + // Should use fallback (discriminator + data), NOT 165-byte SPL layout + assert_ne!(result.data.0.len(), SplTokenAccount::LEN); + // Owner should remain as the account's owner (default pubkey) + assert_eq!(result.owner, SerializablePubkey::default()); + } + + // ============ Decompressed placeholder filtering tests ============ + + use crate::dao::generated::accounts; + use sea_orm::prelude::Decimal; + + fn make_account_model( + hash_byte: u8, + discriminator: Option, + data: Option>, + data_hash: Option>, + onchain_pubkey: Option>, + ) -> accounts::Model { + accounts::Model { + hash: vec![hash_byte; 32], + address: None, + discriminator, + data, + data_hash, + tree: vec![], + leaf_index: 0, + seq: Some(1), + slot_created: 100, + owner: vec![0u8; 32], + lamports: Decimal::from(0), + spent: false, + prev_spent: Some(false), + tx_hash: None, + onchain_pubkey, + tree_type: Some(3), + nullified_in_tree: false, + nullifier_queue_index: None, + in_output_queue: false, + queue: None, + nullifier: None, + } + } + + #[test] + fn test_find_cold_models_filters_decompressed_placeholders() { + let pda = vec![1u8; 32]; + let data_hash = Sha256BE::hash(&pda).unwrap().to_vec(); + let placeholder = make_account_model( + 1, + Some(Decimal::from(DECOMPRESSED_ACCOUNT_DISCRIMINATOR)), + Some(pda.clone()), + Some(data_hash), + Some(pda), + ); + let normal = make_account_model( + 2, + Some(Decimal::from(0x0807060504030201u64)), + None, + None, + None, + ); + let no_disc = make_account_model(3, None, None, None, None); + + // Simulate the filtering logic from find_cold_models. + let by_hash: HashMap, accounts::Model> = vec![ + (placeholder.hash.clone(), placeholder), + (normal.hash.clone(), normal), + (no_disc.hash.clone(), no_disc), + ] + .into_iter() + .collect(); + + let filtered: Vec<_> = by_hash + .into_values() + .filter(|m| !is_decompressed_placeholder_model(m)) + .collect(); + + // Placeholder should be filtered out, normal and no-disc should remain. + assert_eq!(filtered.len(), 2); + let hashes: Vec<&Vec> = filtered.iter().map(|m| &m.hash).collect(); + assert!(hashes.contains(&&vec![2u8; 32])); + assert!(hashes.contains(&&vec![3u8; 32])); + } + + #[test] + fn test_find_cold_models_filters_sqlite_rounded_decompressed_placeholder() { + // SQLite REAL rounding shifts this discriminator by +1 for large u64 values. + let rounded_disc = Decimal::from(DECOMPRESSED_ACCOUNT_DISCRIMINATOR + 1); + let pda = vec![9u8; 32]; + let data_hash = Sha256BE::hash(&pda).unwrap().to_vec(); + let placeholder = make_account_model( + 9, + Some(rounded_disc), + Some(pda.clone()), + Some(data_hash), + Some(pda.clone()), + ); + + assert!(is_decompressed_placeholder_model(&placeholder)); + } + + #[test] + fn test_find_cold_models_filters_legacy_sqlite_rounded_placeholder_without_disc_bytes() { + let rounded_disc = Decimal::from(DECOMPRESSED_ACCOUNT_DISCRIMINATOR + 1); + let pda = vec![8u8; 32]; + let placeholder = + make_account_model(8, Some(rounded_disc), Some(pda.clone()), None, Some(pda)); + + assert!(is_decompressed_placeholder_model(&placeholder)); + } + + #[test] + fn test_find_cold_models_keeps_non_placeholder_with_onchain_pubkey() { + // Model shape similar to compressed mint linkage: has onchain_pubkey but + // does not store it in the first 32 bytes of data. + let rounded_disc = Decimal::from(DECOMPRESSED_ACCOUNT_DISCRIMINATOR + 1); + let mut mint_data = vec![0u8; 120]; + let onchain = vec![7u8; 32]; + mint_data[84..116].copy_from_slice(&onchain); + + let model = make_account_model(7, Some(rounded_disc), Some(mint_data), None, Some(onchain)); + assert!(!is_decompressed_placeholder_model(&model)); + } + + // ============ Fungible token amount aggregation tests ============ + + #[test] + fn test_resolve_aggregates_fungible_token_amounts() { + let mint = Pubkey::new_unique(); + let wallet_owner = [55u8; 32]; + let hash1 = Hash::try_from(vec![1u8; 32]).unwrap(); + let hash2 = Hash::try_from(vec![2u8; 32]).unwrap(); + let hash3 = Hash::try_from(vec![3u8; 32]).unwrap(); + + let td1 = TokenData { + mint: SerializablePubkey::from(mint), + owner: SerializablePubkey::from([99u8; 32]), + amount: UnsignedInteger(100), + delegate: None, + state: AccountState::initialized, + tlv: None, + }; + let td2 = TokenData { + mint: SerializablePubkey::from(mint), + owner: SerializablePubkey::from([99u8; 32]), + amount: UnsignedInteger(250), + delegate: None, + state: AccountState::initialized, + tlv: None, + }; + let td3 = TokenData { + mint: SerializablePubkey::from(mint), + owner: SerializablePubkey::from([99u8; 32]), + amount: UnsignedInteger(650), + delegate: None, + state: AccountState::initialized, + tlv: None, + }; + + let cold1 = sample_token_cold_with_hash(100, 0, &td1, hash1); + let cold2 = sample_token_cold_with_hash(200, 0, &td2, hash2); + let cold3 = sample_token_cold_with_hash(300, 0, &td3, hash3.clone()); + + let cold_accounts = vec![cold1, cold2, cold3]; + + // Map newest hash → wallet owner + let mut token_wallet_owners = HashMap::new(); + token_wallet_owners.insert(hash3, wallet_owner); + + let result = resolve_single_race( + None, + &cold_accounts, + 0, + SerializablePubkey::default(), + &token_wallet_owners, + ) + .expect("expected Some interface"); + + // Primary view should have aggregated amount = 100 + 250 + 650 = 1000 + let parsed = SplTokenAccount::unpack(&result.account.data.0).expect("valid SPL layout"); + assert_eq!(parsed.amount, 1000); + assert_eq!(parsed.owner, Pubkey::from(wallet_owner)); + + // All cold accounts should be included + assert_eq!(result.cold.as_ref().unwrap().len(), 3); + } + + #[test] + fn test_resolve_mixed_mints_uses_newest() { + let mint_a = Pubkey::new_unique(); + let mint_b = Pubkey::new_unique(); + + let hash1 = Hash::try_from(vec![1u8; 32]).unwrap(); + let hash2 = Hash::try_from(vec![2u8; 32]).unwrap(); + + let td1 = TokenData { + mint: SerializablePubkey::from(mint_a), + owner: SerializablePubkey::from([99u8; 32]), + amount: UnsignedInteger(100), + delegate: None, + state: AccountState::initialized, + tlv: None, + }; + let td2 = TokenData { + mint: SerializablePubkey::from(mint_b), + owner: SerializablePubkey::from([99u8; 32]), + amount: UnsignedInteger(200), + delegate: None, + state: AccountState::initialized, + tlv: None, + }; + + let cold1 = sample_token_cold_with_hash(100, 0, &td1, hash1); + let cold2 = sample_token_cold_with_hash(200, 0, &td2, hash2.clone()); + + let cold_accounts = vec![cold1, cold2]; + + let mut token_wallet_owners = HashMap::new(); + token_wallet_owners.insert(hash2, [55u8; 32]); + + let result = resolve_single_race( + None, + &cold_accounts, + 0, + SerializablePubkey::default(), + &token_wallet_owners, + ) + .expect("expected Some interface"); + + // Mixed mints → fallback to newest by slot (cold2, amount=200) + let parsed = SplTokenAccount::unpack(&result.account.data.0).expect("valid SPL layout"); + assert_eq!(parsed.amount, 200); + } + + #[test] + fn test_resolve_single_token_unchanged() { + let mint = Pubkey::new_unique(); + let wallet_owner = [55u8; 32]; + let hash1 = Hash::try_from(vec![1u8; 32]).unwrap(); + + let td = TokenData { + mint: SerializablePubkey::from(mint), + owner: SerializablePubkey::from([99u8; 32]), + amount: UnsignedInteger(42), + delegate: None, + state: AccountState::initialized, + tlv: None, + }; + + let cold = sample_token_cold_with_hash(100, 1_000_000, &td, hash1.clone()); + let cold_accounts = vec![cold]; + + let mut token_wallet_owners = HashMap::new(); + token_wallet_owners.insert(hash1, wallet_owner); + + let result = resolve_single_race( + None, + &cold_accounts, + 0, + SerializablePubkey::default(), + &token_wallet_owners, + ) + .expect("expected Some interface"); + + // Single token account → amount should be 42, unchanged + let parsed = SplTokenAccount::unpack(&result.account.data.0).expect("valid SPL layout"); + assert_eq!(parsed.amount, 42); + assert_eq!(parsed.owner, Pubkey::from(wallet_owner)); + } + + // ============ Hot + cold aggregation tests ============ + + /// Helper to build a hot SolanaAccount that looks like an SPL token ATA. + fn sample_hot_token_account(mint: &Pubkey, owner: &Pubkey, amount: u64) -> SolanaAccount { + use solana_program_option::COption; + use spl_token_interface::state::AccountState as SplAccountState; + + let spl_account = SplTokenAccount { + mint: *mint, + owner: *owner, + amount, + delegate: COption::None, + state: SplAccountState::Initialized, + is_native: COption::None, + delegated_amount: 0, + close_authority: COption::None, + }; + + let mut data = vec![0u8; SplTokenAccount::LEN]; + SplTokenAccount::pack(spl_account, &mut data).unwrap(); + + // Use the real SPL Token program id. + let spl_token_program = Pubkey::try_from(spl_token_interface::ID.as_ref()).unwrap(); + + SolanaAccount { + lamports: 2_039_280, // typical rent-exempt ATA + data, + owner: spl_token_program, + executable: false, + rent_epoch: 0, + } + } + + #[test] + fn test_resolve_aggregates_hot_and_cold_token_amounts() { + let mint = Pubkey::new_unique(); + let wallet_owner = Pubkey::new_unique(); + let hash1 = Hash::try_from(vec![1u8; 32]).unwrap(); + let hash2 = Hash::try_from(vec![2u8; 32]).unwrap(); + + // Hot ATA with 500 tokens. + let hot = sample_hot_token_account(&mint, &wallet_owner, 500); + + // Two cold compressed token accounts, 100 + 400 tokens. + let td1 = TokenData { + mint: SerializablePubkey::from(mint), + owner: SerializablePubkey::from([99u8; 32]), + amount: UnsignedInteger(100), + delegate: None, + state: AccountState::initialized, + tlv: None, + }; + let td2 = TokenData { + mint: SerializablePubkey::from(mint), + owner: SerializablePubkey::from([99u8; 32]), + amount: UnsignedInteger(400), + delegate: None, + state: AccountState::initialized, + tlv: None, + }; + + let cold1 = sample_token_cold_with_hash(100, 0, &td1, hash1); + let cold2 = sample_token_cold_with_hash(200, 0, &td2, hash2); + let cold_accounts = vec![cold1, cold2]; + + let result = resolve_single_race( + Some(&hot), + &cold_accounts, + 300, // hot_slot + SerializablePubkey::default(), + &HashMap::new(), + ) + .expect("expected Some interface"); + + // Primary view should have aggregated amount = 500 (hot) + 100 + 400 (cold) = 1000 + let parsed = SplTokenAccount::unpack(&result.account.data.0).expect("valid SPL layout"); + assert_eq!(parsed.amount, 1000); + // Wallet owner should come from the hot ATA. + assert_eq!(parsed.owner, wallet_owner); + assert_eq!(parsed.mint, mint); + // Cold accounts still included. + assert_eq!(result.cold.as_ref().unwrap().len(), 2); + } + + #[test] + fn test_resolve_hot_token_different_mint_from_cold_uses_hot() { + let mint_hot = Pubkey::new_unique(); + let mint_cold = Pubkey::new_unique(); + let wallet_owner = Pubkey::new_unique(); + let hash1 = Hash::try_from(vec![1u8; 32]).unwrap(); + + let hot = sample_hot_token_account(&mint_hot, &wallet_owner, 500); + + let td = TokenData { + mint: SerializablePubkey::from(mint_cold), + owner: SerializablePubkey::from([99u8; 32]), + amount: UnsignedInteger(300), + delegate: None, + state: AccountState::initialized, + tlv: None, + }; + let cold = sample_token_cold_with_hash(100, 0, &td, hash1); + + let result = resolve_single_race( + Some(&hot), + std::slice::from_ref(&cold), + 300, + SerializablePubkey::default(), + &HashMap::new(), + ) + .expect("expected Some interface"); + + // Different mints → fallback to hot account (since hot is present). + let parsed = SplTokenAccount::unpack(&result.account.data.0).expect("valid SPL layout"); + assert_eq!(parsed.amount, 500); + assert_eq!(parsed.mint, mint_hot); + } + + #[test] + fn test_resolve_hot_non_token_with_cold_tokens_uses_hot() { + let mint = Pubkey::new_unique(); + let hash1 = Hash::try_from(vec![1u8; 32]).unwrap(); + + // Hot is not a token account (random data, too short for SPL). + let hot = SolanaAccount { + lamports: 1000, + data: vec![1, 2, 3], + owner: Pubkey::new_unique(), + executable: false, + rent_epoch: 0, + }; + + let td = TokenData { + mint: SerializablePubkey::from(mint), + owner: SerializablePubkey::from([99u8; 32]), + amount: UnsignedInteger(300), + delegate: None, + state: AccountState::initialized, + tlv: None, + }; + let cold = sample_token_cold_with_hash(100, 0, &td, hash1); + + let result = resolve_single_race( + Some(&hot), + std::slice::from_ref(&cold), + 300, + SerializablePubkey::default(), + &HashMap::new(), + ) + .expect("expected Some interface"); + + // Hot is not a token → can't aggregate → fallback to hot. + assert_eq!(result.account.lamports.0, 1000); + assert_eq!(result.account.data.0, vec![1, 2, 3]); + } +} diff --git a/src/api/method/interface/types.rs b/src/api/method/interface/types.rs new file mode 100644 index 00000000..42ba4a8d --- /dev/null +++ b/src/api/method/interface/types.rs @@ -0,0 +1,87 @@ +use serde::{Deserialize, Serialize}; +use solana_pubkey::{pubkey, Pubkey}; +use utoipa::ToSchema; + +use crate::common::typedefs::account::AccountV2; +use crate::common::typedefs::bs64_string::Base64String; +use crate::common::typedefs::context::Context; +use crate::common::typedefs::serializable_pubkey::SerializablePubkey; +use crate::common::typedefs::unsigned_integer::UnsignedInteger; + +/// Nested Solana account fields (matches getAccountInfo shape) +#[derive(Debug, Clone, PartialEq, Eq, Serialize, ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct SolanaAccountData { + pub lamports: UnsignedInteger, + pub data: Base64String, + pub owner: SerializablePubkey, + pub executable: bool, + pub rent_epoch: UnsignedInteger, + pub space: UnsignedInteger, +} + +/// Unified account interface — works for both on-chain and compressed accounts +#[derive(Debug, Clone, PartialEq, Eq, Serialize, ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct AccountInterface { + /// The queried Solana pubkey + pub key: SerializablePubkey, + /// Standard Solana account fields (hot view or synthetic cold view) + pub account: SolanaAccountData, + /// Compressed accounts associated with this pubkey + pub cold: Option>, +} + +// ============ Request Types ============ + +/// Request for getAccountInterface +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, ToSchema, Default)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub struct GetAccountInterfaceRequest { + /// The account address to look up + pub address: SerializablePubkey, +} + +/// Request for getMultipleAccountInterfaces +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, ToSchema, Default)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub struct GetMultipleAccountInterfacesRequest { + /// List of account addresses to look up (max 100) + pub addresses: Vec, +} + +// ============ Response Types ============ + +/// Response for getAccountInterface +#[derive(Debug, Clone, PartialEq, Eq, Serialize, ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct GetAccountInterfaceResponse { + /// Current context (slot) + pub context: Context, + /// The account data, or None if not found + pub value: Option, +} + +/// Response for getMultipleAccountInterfaces +#[derive(Debug, Clone, PartialEq, Eq, Serialize, ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct GetMultipleAccountInterfacesResponse { + /// Current context (slot) + pub context: Context, + /// List of account results (Some for found accounts, None for not found) + pub value: Vec>, +} + +// ============ Constants ============ + +/// Maximum number of accounts that can be looked up in a single batch request +pub const MAX_BATCH_SIZE: usize = 100; + +/// RPC timeout in milliseconds for hot lookups +pub const RPC_TIMEOUT_MS: u64 = 5000; + +/// Database timeout in milliseconds for cold lookups +pub const DB_TIMEOUT_MS: u64 = 3000; + +/// SPL Token program ID (on-chain Token program) +pub const SPL_TOKEN_PROGRAM_ID: Pubkey = pubkey!("TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA"); diff --git a/src/api/method/mod.rs b/src/api/method/mod.rs index 50073948..93a4496a 100644 --- a/src/api/method/mod.rs +++ b/src/api/method/mod.rs @@ -24,5 +24,6 @@ pub mod get_queue_elements; pub mod get_queue_info; pub mod get_transaction_with_compression_info; pub mod get_validity_proof; +pub mod interface; pub mod utils; diff --git a/src/api/rpc_server.rs b/src/api/rpc_server.rs index 5cc11dd8..b9649121 100644 --- a/src/api/rpc_server.rs +++ b/src/api/rpc_server.rs @@ -425,5 +425,26 @@ fn build_rpc_module(api_and_indexer: PhotonApi) -> Result, }, )?; + // Interface endpoints - race hot (on-chain) and cold (compressed) lookups + module.register_async_method( + "getAccountInterface", + |rpc_params, rpc_context| async move { + let api = rpc_context.as_ref(); + let payload = rpc_params.parse()?; + api.get_account_interface(payload).await.map_err(Into::into) + }, + )?; + + module.register_async_method( + "getMultipleAccountInterfaces", + |rpc_params, rpc_context| async move { + let api = rpc_context.as_ref(); + let payload = rpc_params.parse()?; + api.get_multiple_account_interfaces(payload) + .await + .map_err(Into::into) + }, + )?; + Ok(module) } diff --git a/src/openapi/mod.rs b/src/openapi/mod.rs index 5bfadf77..214d133e 100644 --- a/src/openapi/mod.rs +++ b/src/openapi/mod.rs @@ -33,6 +33,7 @@ use crate::api::method::get_validity_proof::{ AccountProofInputs, AddressProofInputs, CompressedProof, CompressedProofWithContext, CompressedProofWithContextV2, MerkleContextV2, RootIndex, TreeContextInfo, }; +use crate::api::method::interface::types::{AccountInterface, SolanaAccountData}; use crate::api::method::utils::PaginatedSignatureInfoList; use crate::api::method::utils::SignatureInfo; use crate::api::method::utils::SignatureInfoList; @@ -148,6 +149,9 @@ const JSON_CONTENT_TYPE: &str = "application/json"; TreeContextInfo, GetCompressedAccountProofResponseValue, GetCompressedAccountProofResponseValueV2, + // Interface types + AccountInterface, + SolanaAccountData, )))] struct ApiDoc; diff --git a/tests/data/transactions/indexer_interface/2Q8KnAuf9TuPThbkEFZp6tfFC9bsGBVEpvoDJzLZAAEDKi2eSZuEdjKrqw9ez2yhxJt5U7S8LYdrUdSq1KKZid4X b/tests/data/transactions/indexer_interface/2Q8KnAuf9TuPThbkEFZp6tfFC9bsGBVEpvoDJzLZAAEDKi2eSZuEdjKrqw9ez2yhxJt5U7S8LYdrUdSq1KKZid4X new file mode 100644 index 00000000..6514a3a3 --- /dev/null +++ b/tests/data/transactions/indexer_interface/2Q8KnAuf9TuPThbkEFZp6tfFC9bsGBVEpvoDJzLZAAEDKi2eSZuEdjKrqw9ez2yhxJt5U7S8LYdrUdSq1KKZid4X @@ -0,0 +1,158 @@ +{ + "slot": 200, + "transaction": [ + "AkX10M+vjtXvXVcyhHxy20yOQNg+i6YhaLQWTY9OjscFDTMeotSI3DKM8Scq2juC8isiS40iKcSP+MhwBK7TNARXGFC8jgns1c5ti9DfHEHnEEUFtf6CPX3hhcUuLbmNQuFPO/XOom3OP1WtMwxmUk4Hynw8nTXiWQQ8OkkCPJsJAgEIDjwnoQx7ujHJcfKDkl7hQKUz/Hvi6qrsfBpPmC20LrGTUl3YAEgdiOAbZtUAebWqVueellDUwIToGo3jD8DY3VUIpumYsF/pK5ig2fd52dvosvPu9ngTKHvrpSZJgJFovQv/FayGQswvkPcc7/1XU+ilA3gepE4uMS/o0kSPDPCgDI2bLhfzRRfeR7LAd9xMdGmgOSCB64sMQmncryF2aMP/FEBUY9NQC5zeHFjcA5wRMaQnaOChzXgQrzecV5U53QAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABqdV+CE5BU1EJLFa8MQwzy9Lf5h5OtoSUtSPNmbGy84JFaNXI3lOj7ZdB1trcmmcON0C5ZSLdbDloEGOgJdbRAksNuwi9ReDAP20Sqpq/8/wpG4cvGQcDj7QnaE7nvUIHuvEjPsyZLvDS/XFpag0/GdQAG8phtlvI1vIh1703USIrUFUKyGRFyFIIDtR9PTMC/j/zeXfXPye4yA/xd8IK+bJGLC9fM+RVESKer9qjzl4KaHo/Qin8NCzqrh4Uvwy+7ModRBcroW1tER2B+2/4UUnv7QjkRykBCO/0VJ6JnUN20rUZ+rp7CRfJxPQChsery38xTizp4HM/11v+0giagEIDgcBAAsFBAAMCg0JBgMCswJnAAAAAAAAAAAAAQAAAAAAAAAAAAAAAAEAAAAHAAAAAAABJU17ueh1/feW4mxAqYqnn5xlI4gq8MetfQ2bCq+4U1ehgkf0jk6O8I5ArokNry+qwiWAIUrsmgfHjDnt8IUoCwmMOUiE6moo8MN5F0Ny/UIy52CpO8nCjC2M50yAgC/UJ19v2t3/LsIYbb6ly0PJAcgFlk2L7IebDm5T1PNJivUAAQAAAAAAAAAACQMA/xRAVGPTUAuc3hxY3AOcETGkJ2jgoc14EK83nFeVOd1SXdgASB2I4Btm1QB5tapW556WUNTAhOgajeMPwNjdVfwBPCehDHu6Mclx8oOSXuFApTP8e+Lqqux8Gk+YLbQusZMBPCehDHu6Mclx8oOSXuFApTP8e+Lqqux8Gk+YLbQusZMA", + "base64" + ], + "meta": { + "err": null, + "status": { + "Ok": null + }, + "fee": 10000, + "preBalances": [ + 99994966515, + 0, + 331204641, + 29687444, + 997278640, + 0, + 1, + 1141440, + 1141440, + 1141440, + 1614720, + 3048480, + 0, + 0 + ], + "postBalances": [ + 99994930513, + 0, + 331214641, + 29692446, + 994557280, + 2732360, + 1, + 1141440, + 1141440, + 1141440, + 1614720, + 3048480, + 0, + 0 + ], + "innerInstructions": [ + { + "index": 0, + "instructions": [ + { + "programIdIndex": 6, + "accounts": [ + 4, + 5 + ], + "data": "11113xKfKE6p9J9ueSoGmavScokzpTedhAJrNMacgmywBmT1tm1vFGSKi8kPhX5guiprAo", + "stackHeight": 2 + }, + { + "programIdIndex": 6, + "accounts": [ + 0, + 5 + ], + "data": "3Bxs4iNAP7JpFgdV", + "stackHeight": 2 + }, + { + "programIdIndex": 7, + "accounts": [ + 0, + 12, + 10, + 13, + 9, + 6, + 3, + 2 + ], + "data": "SsreHtLmPZSmph3vsdxZMpf2WYPqC95uk5eE3MNA6WrSEc5Xyqb3DcHgPyM1FFK6D9iPD85Fd2zguWvfFuP372c3adQKWzTYHdNzPGhDSpBQaZu3oyZ8MBWFxGNCLZvCChsAKT3BfuhpA4pP7iSeVMwuMcrtEabo4ziZQfUBpBugTaZiF19Jc316RxiWvFPtMu5t635jj3TPdCyNr48a9fy5soUGmXoSLbvKktWacRpoyv2Tz1XPsaNJT4PKDH4tzJYb9mKNFCMcn3ekjf83JCCBeNufWnQ23w6UaG1WE8VRyVJPdBLzqQrpafqsaPYttf99VWDGfG3gPaNLgtKQnpcyjFefu77GmNHk5FnPpFvZGmW4HGuB8M6WkNjpZRcpGxUAVELPFBJmWNo2weSENcadsubjQgHLCrzNaXPJVoKopZmTsAhzercbZXMymDkwiXiUajT2Q1HUdCTJFqXggVNSrzamtULVnChxFH31iUUDXmQ7jppZJqV7nyJhrzmKJ6BQxCj5hvJQL5qdP2ixafJk5d", + "stackHeight": 2 + }, + { + "programIdIndex": 6, + "accounts": [ + 0, + 2 + ], + "data": "3Bxs43ZMjSRQLs6o", + "stackHeight": 3 + }, + { + "programIdIndex": 6, + "accounts": [ + 0, + 3 + ], + "data": "3Bxs41C4bWBEDcNb", + "stackHeight": 3 + }, + { + "programIdIndex": 6, + "accounts": [ + 0, + 3 + ], + "data": "3Bxs4PckVVt51W8w", + "stackHeight": 3 + }, + { + "programIdIndex": 9, + "accounts": [ + 13, + 10, + 2, + 3 + ], + "data": "HDtpqY3uCYv8VK5FAXvJVKJiyeFUY1KJsHnwwLbivMrxEE5JmPBPHoCLNUJzwdQDk68HHfELvRZtMSVoM6iTvvEjFFWfzhRiXykKqnaMan7tthkyuLTKgq6BwoDffh2QjRvuT7Q3doiRs6xoEKDk7WAjPPRgSVVp8j16x45uJeWjXYDAbkL6EebJwvQSTTiTmdnmdLuYEAd46K8Ama2KFffMuDmqwjSGHqsEKwasYR8Z3RDggqtdZeJ5LJahZsQhTEj6WXuCp9fg2p4vCYYgXXYBX2XT2yTK8rRZ8wZTSCyuuVcCyfhjNCVLspbzhHVWNH1Lres54TWWNgJ57wA6YbGcUnRfDiuUPrtTE2Kt27tGXpf9aznCCPDzys52hKru3Bh2GnkVEa4J7sV", + "stackHeight": 3 + } + ] + } + ], + "logMessages": [ + "Program cTokenmWW8bLPjZEBAUgYy3zKxQZW6VKi7bqNFEVv3m invoke [1]", + "Program log: MintAction", + "Program 11111111111111111111111111111111 invoke [2]", + "Program 11111111111111111111111111111111 success", + "Program 11111111111111111111111111111111 invoke [2]", + "Program 11111111111111111111111111111111 success", + "Program SySTEM1eSU2p4BGQfQpimFEWWSC1XDFeun3Nqzz3rT7 invoke [2]", + "Program log: invoke_cpi_with_read_only", + "Program log: mode V2", + "Program 11111111111111111111111111111111 invoke [3]", + "Program 11111111111111111111111111111111 success", + "Program 11111111111111111111111111111111 invoke [3]", + "Program 11111111111111111111111111111111 success", + "Program 11111111111111111111111111111111 invoke [3]", + "Program 11111111111111111111111111111111 success", + "Program compr6CUsB5m2jS4Y3831ztGSTnDpnKJTKS95d64XVq invoke [3]", + "Program log: Instruction: InsertIntoQueues", + "Program compr6CUsB5m2jS4Y3831ztGSTnDpnKJTKS95d64XVq consumed 7887 of 68064 compute units", + "Program compr6CUsB5m2jS4Y3831ztGSTnDpnKJTKS95d64XVq success", + "Program SySTEM1eSU2p4BGQfQpimFEWWSC1XDFeun3Nqzz3rT7 consumed 121394 of 181534 compute units", + "Program SySTEM1eSU2p4BGQfQpimFEWWSC1XDFeun3Nqzz3rT7 success", + "Program cTokenmWW8bLPjZEBAUgYy3zKxQZW6VKi7bqNFEVv3m consumed 139956 of 200000 compute units", + "Program cTokenmWW8bLPjZEBAUgYy3zKxQZW6VKi7bqNFEVv3m success" + ], + "preTokenBalances": [], + "postTokenBalances": [], + "rewards": [], + "loadedAddresses": { + "writable": [], + "readonly": [] + }, + "computeUnitsConsumed": 139956 + }, + "blockTime": 1769454113 +} \ No newline at end of file diff --git a/tests/data/transactions/indexer_interface/2YTv5hjSmRAgfwoNHdc4DRDFWW7fqQb57f9s8Rxtu9u6jA2hDxNxEWoiybvn4p7ua2nw3scYeNo6htYCSuBYviFd b/tests/data/transactions/indexer_interface/2YTv5hjSmRAgfwoNHdc4DRDFWW7fqQb57f9s8Rxtu9u6jA2hDxNxEWoiybvn4p7ua2nw3scYeNo6htYCSuBYviFd new file mode 100644 index 00000000..66968c8a --- /dev/null +++ b/tests/data/transactions/indexer_interface/2YTv5hjSmRAgfwoNHdc4DRDFWW7fqQb57f9s8Rxtu9u6jA2hDxNxEWoiybvn4p7ua2nw3scYeNo6htYCSuBYviFd @@ -0,0 +1,157 @@ +{ + "slot": 181, + "transaction": [ + "AU0mgPKoIcxqWWNDOxB0mQnROtQjP4uGrnPyvhrrS/Ns/RdY4AnJxBcRWOF1dc+KdHTxFWoYdL1+BMr3IWkfzgwBAAoOPCehDHu6Mclx8oOSXuFApTP8e+Lqqux8Gk+YLbQusZML/xWshkLML5D3HO/9V1PopQN4HqROLjEv6NJEjwzwoA3papgQCCfpwR3bIM+xFmRoYlGnMDhgngGQUuDiXFK0lom6RB9jsvMqIWoCd4Ulg7euwYGDqc2I4P4ScSwQhQgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAMGRm/lIRcy/+ytunLDm+e8jOW7xfcSayxDmzpAAAAABqdV+CE5BU1EJLFa8MQwzy9Lf5h5OtoSUtSPNmbGy84G3fbh12Whk9nL4UbO63msHLSF7V9bN5E6jPWFfv8AqQkVo1cjeU6Ptl0HW2tyaZw43QLllIt1sOWgQY6Al1tECSw27CL1F4MA/bRKqmr/z/Ckbhy8ZBwOPtCdoTue9QgLvA/Au0fKL3TEES6UqxPPo8Y05dwX6ssDzRojzX54fB7rxIz7MmS7w0v1xaWoNPxnUABvKYbZbyNbyIde9N1E5skYsL18z5FURIp6v2qPOXgpoej9CKfw0LOquHhS/DL7syh1EFyuhbW0RHYH7b/hRSe/tCORHKQEI7/RUnomdf427ipsEwFFqy88r4hvgc1jxHYducXzE7h0qGBGX02oAgUABQJAQg8ACA8AAAwDAgcGCwoNCQEIBAhh8SIwuiWze8ACAAAAvXb5WzGk9JaBZcitjB/xcMqTTXU1My2mkuHY7C6jjy7iIFXC69uRm/KOi+tBbbgYh8BI6jB4XeQTMyvupurcpwIAAAAAypo7AAAAAABlzR0AAAAAAA==", + "base64" + ], + "meta": { + "err": null, + "status": { + "Ok": null + }, + "fee": 5000, + "preBalances": [ + 99995027520, + 29677440, + 2039280, + 2923200, + 1, + 1, + 1141440, + 929020800, + 1141440, + 1141440, + 1141440, + 1614720, + 0, + 0 + ], + "postBalances": [ + 99995017518, + 29682442, + 2039280, + 2923200, + 1, + 1, + 1141440, + 929020800, + 1141440, + 1141440, + 1141440, + 1614720, + 0, + 0 + ], + "innerInstructions": [ + { + "index": 1, + "instructions": [ + { + "programIdIndex": 7, + "accounts": [ + 3, + 2, + 0 + ], + "data": "6AmSbVFP5kF9", + "stackHeight": 2 + }, + { + "programIdIndex": 6, + "accounts": [ + 0, + 12, + 11, + 10, + 13, + 9, + 8, + 6, + 6, + 4, + 6, + 1 + ], + "data": "GTTgHALwTd7DpQtTbhDPe6Etuj33yy5U6jgYa2WWYaV5bxbWNAoYdv6GqxzfqaSCM5hfpmMaS2XqiKaBpmhGziUyBwjEmtxfYUJKhK7rf8rTopmkctMexb3fYyQP2GHm41uaogWrVYgBHDCZrm2mQpmet157csi1YDjpkM4QNFks6rXCKZyJsU64atn2qSSsqVFuFXuNRcuiFSbU9h1xW354vfPAaDj8TP7TVosEgyoRemqnZ6a9HTh43uiCJgfaop36ghCh2RDcJnQLy4Tmo2FqaqA3qwq97SqA71Qpa7xnfX3oLLwCyZ5scn6YLd3RXr5vNhqxk2CkWKZZV4arwmDJG3SW7N1nWDjXewrUo2aA9jrWHD72R73bhZi3iZA3PLFk9iQTeMJZm3JP1aT652osgroGsJ1Qddq35gBbCHQRa9M5vv3B8fHAhh34UmfKvA6YNgAjS6Hv7Zzw5W91LgsKZDhUsnWDTM", + "stackHeight": 2 + }, + { + "programIdIndex": 4, + "accounts": [ + 0, + 1 + ], + "data": "3Bxs4Px9qXVhzify", + "stackHeight": 3 + }, + { + "programIdIndex": 9, + "accounts": [ + 13, + 11, + 1 + ], + "data": "9d3AmkaMU85XDt4fU7UFAkrqRj3M5LhLS5fHMv6N4JJL5sZD7yoaCiaCdPo2PyUeapwFnA8Y132N9LAPWc2GV1KTFFwtRvfzgpaJKEbtWX25RDQTrTvRKYYkPDvb7DytLhT5ULmYZXuzf2tYQxnwwJgest8dqv2dJpraxQEdE4Bqx7FaGJTze3Z7fRjQKJJ4s7gNnwAvEkxewwbqTQXNhX32sSnxkZ8Y8jXfF8s8Juwr9osmY249FDqjQbmgvdEGqkd9gbLfGgC3e6EHbMBs2JtcEj4bYYLEx5eWHd", + "stackHeight": 3 + } + ] + } + ], + "logMessages": [ + "Program ComputeBudget111111111111111111111111111111 invoke [1]", + "Program ComputeBudget111111111111111111111111111111 success", + "Program cTokenmWW8bLPjZEBAUgYy3zKxQZW6VKi7bqNFEVv3m invoke [1]", + "Program log: Instruction: MintTo", + "Program TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA invoke [2]", + "Program log: Instruction: MintTo", + "Program TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA consumed 4538 of 986662 compute units", + "Program TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA success", + "Program SySTEM1eSU2p4BGQfQpimFEWWSC1XDFeun3Nqzz3rT7 invoke [2]", + "Program 11111111111111111111111111111111 invoke [3]", + "Program 11111111111111111111111111111111 success", + "Program compr6CUsB5m2jS4Y3831ztGSTnDpnKJTKS95d64XVq invoke [3]", + "Program log: Instruction: InsertIntoQueues", + "Program compr6CUsB5m2jS4Y3831ztGSTnDpnKJTKS95d64XVq consumed 5020 of 956018 compute units", + "Program compr6CUsB5m2jS4Y3831ztGSTnDpnKJTKS95d64XVq success", + "Program SySTEM1eSU2p4BGQfQpimFEWWSC1XDFeun3Nqzz3rT7 consumed 21167 of 972129 compute units", + "Program SySTEM1eSU2p4BGQfQpimFEWWSC1XDFeun3Nqzz3rT7 success", + "Program cTokenmWW8bLPjZEBAUgYy3zKxQZW6VKi7bqNFEVv3m consumed 49171 of 999850 compute units", + "Program cTokenmWW8bLPjZEBAUgYy3zKxQZW6VKi7bqNFEVv3m success" + ], + "preTokenBalances": [ + { + "accountIndex": 2, + "mint": "B8dxn19gmQFB7g1wHsiN2R5jkqEWw14B3CFNQZ9tDwbm", + "uiTokenAmount": { + "uiAmount": null, + "decimals": 2, + "amount": "0", + "uiAmountString": "0" + }, + "owner": "GXtd2izAiMJPwMEjfgTRH3d7k9mjn4Jq3JrWFv9gySYy", + "programId": "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA" + } + ], + "postTokenBalances": [ + { + "accountIndex": 2, + "mint": "B8dxn19gmQFB7g1wHsiN2R5jkqEWw14B3CFNQZ9tDwbm", + "uiTokenAmount": { + "uiAmount": 15000000.0, + "decimals": 2, + "amount": "1500000000", + "uiAmountString": "15000000" + }, + "owner": "GXtd2izAiMJPwMEjfgTRH3d7k9mjn4Jq3JrWFv9gySYy", + "programId": "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA" + } + ], + "rewards": [], + "loadedAddresses": { + "writable": [], + "readonly": [] + }, + "computeUnitsConsumed": 49321 + }, + "blockTime": 1769454104 +} \ No newline at end of file diff --git a/tests/data/transactions/indexer_interface/2afJTiZyNEMvrKJasbDFqPaTaLWMttjAHnBLgm7CizqzqsiqFp6pXAB9fdrFHq66u6zvv3ddY5xLAzpmREASatnB b/tests/data/transactions/indexer_interface/2afJTiZyNEMvrKJasbDFqPaTaLWMttjAHnBLgm7CizqzqsiqFp6pXAB9fdrFHq66u6zvv3ddY5xLAzpmREASatnB new file mode 100644 index 00000000..85666707 --- /dev/null +++ b/tests/data/transactions/indexer_interface/2afJTiZyNEMvrKJasbDFqPaTaLWMttjAHnBLgm7CizqzqsiqFp6pXAB9fdrFHq66u6zvv3ddY5xLAzpmREASatnB @@ -0,0 +1,114 @@ +{ + "slot": 208, + "transaction": [ + "AU8LXvOcRJuGCjHXPKqHFyWVyEl7rT90u4shh2Vy9uNYNig5cL0OePQA16cEVuIOXcewwrnIxH89mrCiV8tWrAABAAgNPCehDHu6Mclx8oOSXuFApTP8e+Lqqux8Gk+YLbQusZMI6H6PkrgpCuD+a6ghBiFJ+7bEwI+4xaykgT6BErceDwv/FayGQswvkPcc7/1XU+ilA3gepE4uMS/o0kSPDPCgDI2bLhfzRRfeR7LAd9xMdGmgOSCB64sMQmncryF2aMP/FEBUY9NQC5zeHFjcA5wRMaQnaOChzXgQrzecV5U53QAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABqdV+CE5BU1EJLFa8MQwzy9Lf5h5OtoSUtSPNmbGy84JFaNXI3lOj7ZdB1trcmmcON0C5ZSLdbDloEGOgJdbRAksNuwi9ReDAP20Sqpq/8/wpG4cvGQcDj7QnaE7nvUIHuvEjPsyZLvDS/XFpag0/GdQAG8phtlvI1vIh1703USIrUFUKyGRFyFIIDtR9PTMC/j/zeXfXPye4yA/xd8IK+bJGLC9fM+RVESKer9qjzl4KaHo/Qin8NCzqrh4Uvwy+7ModRBcroW1tER2B+2/4UUnv7QjkRykBCO/0VJ6JnUiA7TT6TASDX8cJIvdrUQV4V/RQJjaJBFxQv0uKK3+CgEHDgYACgQDAAsJDAgFAgECFGcDAAAAAQAAAAAAAQAAAAgAAAAA", + "base64" + ], + "meta": { + "err": null, + "status": { + "Ok": null + }, + "fee": 5000, + "preBalances": [ + 99994930513, + 331184640, + 29692446, + 994557280, + 2732360, + 1, + 1141440, + 1141440, + 1141440, + 1614720, + 3048480, + 0, + 0 + ], + "postBalances": [ + 99994920512, + 331184640, + 29697447, + 997289640, + 0, + 1, + 1141440, + 1141440, + 1141440, + 1614720, + 3048480, + 0, + 0 + ], + "innerInstructions": [ + { + "index": 0, + "instructions": [ + { + "programIdIndex": 6, + "accounts": [ + 0, + 11, + 9, + 12, + 8, + 5, + 2, + 1, + 2 + ], + "data": "7yPYWt3P1KLPqjnnhJNfj5DorRne6QD8zS9JUUYH4kJpXGovUoiE6HxphCA6gsTLyzN2ch4UUJXkk86r6mthoWQvFC3bYagFdqeRCsJkmezxzSJeWmGRJGNMeJ9sTXq2jDJCx8UP57yWiigktnJszKufAmgveJGD8VxrpnHgTPeQaaB1V6TTNP11FBcDypXyJmuSZkktnDZCo7AN8W3LAZBkiyu47F3z3Xodf7E4HVKD3UXv1Wy5GkS2qnkyWy6V9NQvPaK2xcrVLUGT1DmUSfptTTPa7ndBqDwqrGAc2EFMBW2cAFVxh1eWNrFtN6Hw5AqDEXy9FeV3NqPJdmgX2UB81jkdZPeiD9RoWdkK6UFAbQr17k2X6cysEqycpcmpimxLwKfwtTTVyrgNWiTcuC6PmhZdqD8j8sNrwredsq7pRBndp39RVvmvRNxuVVKw3wmVrmavgq4bVeFukqABddKsL6pjoRESq5WxPqnJiTGdF1uPRXVBgU4wx9cvRPMHHWLo4XTtJM86PPZEq7JC2sUkU36ntPSUUVE36KSCTZa3mGFXBYkqjHgYpJsUERL2BFFg6sZViyPqeS1ncQ7s8YTsmxvHXC2ss27MmnzvJjUYybnHxWgcuLdjwACJ5VU88sPGZscaHhHmWcaQuaPJ7sPFQitGBYLmDYnirNgi2cf58BxZ4PG9vDS7PPJeTzv9yL8h5YP3qGWJwiakjS2LF1rdx9FCM85KbFYLU7LUQk24Ej", + "stackHeight": 2 + }, + { + "programIdIndex": 5, + "accounts": [ + 0, + 2 + ], + "data": "3Bxs4PnTAWgtW7QT", + "stackHeight": 3 + }, + { + "programIdIndex": 8, + "accounts": [ + 12, + 9, + 2, + 2, + 1 + ], + "data": "95YB9JH8MTYtuZfgNwFoqXrHxz6iXNSKY1qkjTcBMMeHjhd7su25YYcxsur46vsYc4kvTLvUGUB1E2EmdLVQzNBiyrMqCRy82cF6QSUzwTvHFrkC9LSD49oYxj89q7W1UAdFFvB6nrfud9pEUsDGM8sRYKYu3HqkPs5FYBpbAA2yvUEFvSqnRXpbYxpxFgPJ8EGrQrcsW5pVHvrixd89yT1P2Mpzaacg8e9SQRjZS3t2qWHmmnwutPE2AEpy9jszxwjbgNvcUkj18drGNNfKox7Dn64QSWNDmb9CKqh7rska5Lp1yghsDRnQUuh6MLaLust5qEKUHDK568Cih2T83NeZasP84hce5UEDcoinBEtKrSjoFUB7vm9oERjjwDn9bBUoATx31CYHiokexpQwpT", + "stackHeight": 3 + } + ] + } + ], + "logMessages": [ + "Program cTokenmWW8bLPjZEBAUgYy3zKxQZW6VKi7bqNFEVv3m invoke [1]", + "Program log: MintAction", + "Program SySTEM1eSU2p4BGQfQpimFEWWSC1XDFeun3Nqzz3rT7 invoke [2]", + "Program log: invoke_cpi_with_read_only", + "Program log: mode V2", + "Program 11111111111111111111111111111111 invoke [3]", + "Program 11111111111111111111111111111111 success", + "Program compr6CUsB5m2jS4Y3831ztGSTnDpnKJTKS95d64XVq invoke [3]", + "Program log: Instruction: InsertIntoQueues", + "Program compr6CUsB5m2jS4Y3831ztGSTnDpnKJTKS95d64XVq consumed 9419 of 171642 compute units", + "Program compr6CUsB5m2jS4Y3831ztGSTnDpnKJTKS95d64XVq success", + "Program SySTEM1eSU2p4BGQfQpimFEWWSC1XDFeun3Nqzz3rT7 consumed 28244 of 190430 compute units", + "Program SySTEM1eSU2p4BGQfQpimFEWWSC1XDFeun3Nqzz3rT7 success", + "Program cTokenmWW8bLPjZEBAUgYy3zKxQZW6VKi7bqNFEVv3m consumed 37908 of 200000 compute units", + "Program cTokenmWW8bLPjZEBAUgYy3zKxQZW6VKi7bqNFEVv3m success" + ], + "preTokenBalances": [], + "postTokenBalances": [], + "rewards": [], + "loadedAddresses": { + "writable": [], + "readonly": [] + }, + "computeUnitsConsumed": 37908 + }, + "blockTime": 1769454116 +} \ No newline at end of file diff --git a/tests/data/transactions/indexer_interface/5bLkzWasPYAivyrVvrt5UN3amycT3MSdNB2evCEMyuW1Ajbufv4nTbqrp7ZQ5fpopHh7pU9RnimvvR31XeckFt9F b/tests/data/transactions/indexer_interface/5bLkzWasPYAivyrVvrt5UN3amycT3MSdNB2evCEMyuW1Ajbufv4nTbqrp7ZQ5fpopHh7pU9RnimvvR31XeckFt9F new file mode 100644 index 00000000..66a67be0 --- /dev/null +++ b/tests/data/transactions/indexer_interface/5bLkzWasPYAivyrVvrt5UN3amycT3MSdNB2evCEMyuW1Ajbufv4nTbqrp7ZQ5fpopHh7pU9RnimvvR31XeckFt9F @@ -0,0 +1,158 @@ +{ + "slot": 193, + "transaction": [ + "AuWunKtcv7kqto1NtjY8HIuzotEs+lg1kijLpU+PBwo0mYt51Ln4bh1F9NOgiGpRmUty4UE6FueiV0W9DTjXYwqAsevIRqtuTRPENNSzGW7y2vIVX3wxPdQTp9JRwz/57LvsV3lUbmBUaKE2HIG2K4KQyyaLMP5npN/xHkt+paMAAgEIDjwnoQx7ujHJcfKDkl7hQKUz/Hvi6qrsfBpPmC20LrGTMtb6DSl+WkiPAtcrxA8NVI+tc289204ixlOq983dKEcIpumYsF/pK5ig2fd52dvosvPu9ngTKHvrpSZJgJFovQv/FayGQswvkPcc7/1XU+ilA3gepE4uMS/o0kSPDPCgDI2bLhfzRRfeR7LAd9xMdGmgOSCB64sMQmncryF2aMOqHn30jJgOMQTkdobcfQiPsJtmK5kNekZQUhC5RGMHSAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABqdV+CE5BU1EJLFa8MQwzy9Lf5h5OtoSUtSPNmbGy84JFaNXI3lOj7ZdB1trcmmcON0C5ZSLdbDloEGOgJdbRAksNuwi9ReDAP20Sqpq/8/wpG4cvGQcDj7QnaE7nvUIHuvEjPsyZLvDS/XFpag0/GdQAG8phtlvI1vIh1703USIrUFUKyGRFyFIIDtR9PTMC/j/zeXfXPye4yA/xd8IK+bJGLC9fM+RVESKer9qjzl4KaHo/Qin8NCzqrh4Uvwy+7ModRBcroW1tER2B+2/4UUnv7QjkRykBCO/0VJ6JnVMF4x44Hz7GtbAJgLPgC0V51Frv1yJL2Zbk7+uCvjBgQEIDgcBAAsFBAAMCg0JBgMCkwJnAAAAAAAAAAAAAQAAAAAAAAAAAAAAAAEAAAAHAAAAAAABAZi6vOM5+jYyfzW0tiOzPz0FS26iRcNK0P98UId9FU6ruLyzZ2rzuMC5iE4jD2hI3y6+KgtNCdbEHDjqzRXQOSdyPKepwSiCmdX4eYgL+4yMbYaifBspG1KAotUoacNrrXqc78QvOFGYjsG7eoaMFRcbW7ulIBRLim0QA0PLp/YAAQAAAAAAAAAABgMAqh599IyYDjEE5HaG3H0Ij7CbZiuZDXpGUFIQuURjB0gy1voNKX5aSI8C1yvEDw1Uj61zbz3bTiLGU6r3zd0oR/8BPCehDHu6Mclx8oOSXuFApTP8e+Lqqux8Gk+YLbQusZMAAA==", + "base64" + ], + "meta": { + "err": null, + "status": { + "Ok": null + }, + "fee": 10000, + "preBalances": [ + 99995002517, + 0, + 331194641, + 29682442, + 1000000000, + 0, + 1, + 1141440, + 1141440, + 1141440, + 1614720, + 3048480, + 0, + 0 + ], + "postBalances": [ + 99994966515, + 0, + 331204641, + 29687444, + 997278640, + 2732360, + 1, + 1141440, + 1141440, + 1141440, + 1614720, + 3048480, + 0, + 0 + ], + "innerInstructions": [ + { + "index": 0, + "instructions": [ + { + "programIdIndex": 6, + "accounts": [ + 4, + 5 + ], + "data": "11113xKfKE6p9J9ueSoGmavScokzpTedhAJrNMacgmywBmT1tm1vFGSKi8kPhX5guiprAo", + "stackHeight": 2 + }, + { + "programIdIndex": 6, + "accounts": [ + 0, + 5 + ], + "data": "3Bxs4iNAP7JpFgdV", + "stackHeight": 2 + }, + { + "programIdIndex": 7, + "accounts": [ + 0, + 12, + 10, + 13, + 9, + 6, + 3, + 2 + ], + "data": "SsreHtLmPZSmph3vsdxZMpf2WYPqC95uk5eE3MNA6WrSEc5Xyqb3DcHgPyM1FFK6D9iPD85Fd2zguWaLho3EC7ujP4ng4EZ3qCbzGSWxnrNRiC3USzXkjCy83WiMTkvQ7Z9SFADiEZpFS9fuQqWVz5eyy1Mtno2i8WyQ3HW1VjNkUHDjkJxHLavNCn8UQEhrYqoRGr2r2ufXusxvKwFzbKFi64EUSEMFzA2iGQ3gBjNPeByjUAJ4dbZUX4Cyhh8VcBqTXUx6B6UmNTZ3SDYFKidJVarjCDfwVKa4RGenMJZ3b8wFAVzu9FyFLtoSp5yPoBg8LkkbkYXQjnXxaNvaCFNy7vJQkDDU6Zi46EVVx7XmJhAgUMP4x65xMn41dyf1T85BsZKK1fmfqCqbfQn3bod5jbQWiUUvzbrDeqUuYx1oVHQ5uLnPVvmBBCeJy6HWLyD4CfFE3oZRgMfK6aarzBRrDhTVyswtnKf4ZDhsPPqXhSxNkKWNjZ3XkLxqjWqmW3ZwUW7HxU1vP9BDysFVutwqAP", + "stackHeight": 2 + }, + { + "programIdIndex": 6, + "accounts": [ + 0, + 2 + ], + "data": "3Bxs43ZMjSRQLs6o", + "stackHeight": 3 + }, + { + "programIdIndex": 6, + "accounts": [ + 0, + 3 + ], + "data": "3Bxs41C4bWBEDcNb", + "stackHeight": 3 + }, + { + "programIdIndex": 6, + "accounts": [ + 0, + 3 + ], + "data": "3Bxs4PckVVt51W8w", + "stackHeight": 3 + }, + { + "programIdIndex": 9, + "accounts": [ + 13, + 10, + 2, + 3 + ], + "data": "HDtpqY3uCYv8VK5FAXvJVKJiyeFUY1KJsHnwwLbivMrxEE5JmPBPHoCLNUJzwdQDk68HHfEMf5Xnt1Fm2otBYmxvscTYkmVupf9G1BrBgdoRqPzYqx9KMYqykNczWyupQCxSuTTDrFqeWjtygFFH8qSZzYBwBTmiiVYbcBe1Bo8GQRsfmZ4uiWQoFtJWtyyk8rhqMSfUHtgAvjoPN2d9hWXW5nafb4sPr9zDxb3NkLdp8ej8Fdogyh8czH81eUUvActkPVZTi75zyaoNPvKqVtW7Bg1pAodLD4kKkHhHndp7QtD9X9vV37ZMgU5cwMXcah9vV7RhqVfgeCXAygbQBu9wcPkBnPfDyqXn2rNKQRcheoWwqxj2J4koibvtUtbH9mJioDyutZ1oF1R", + "stackHeight": 3 + } + ] + } + ], + "logMessages": [ + "Program cTokenmWW8bLPjZEBAUgYy3zKxQZW6VKi7bqNFEVv3m invoke [1]", + "Program log: MintAction", + "Program 11111111111111111111111111111111 invoke [2]", + "Program 11111111111111111111111111111111 success", + "Program 11111111111111111111111111111111 invoke [2]", + "Program 11111111111111111111111111111111 success", + "Program SySTEM1eSU2p4BGQfQpimFEWWSC1XDFeun3Nqzz3rT7 invoke [2]", + "Program log: invoke_cpi_with_read_only", + "Program log: mode V2", + "Program 11111111111111111111111111111111 invoke [3]", + "Program 11111111111111111111111111111111 success", + "Program 11111111111111111111111111111111 invoke [3]", + "Program 11111111111111111111111111111111 success", + "Program 11111111111111111111111111111111 invoke [3]", + "Program 11111111111111111111111111111111 success", + "Program compr6CUsB5m2jS4Y3831ztGSTnDpnKJTKS95d64XVq invoke [3]", + "Program log: Instruction: InsertIntoQueues", + "Program compr6CUsB5m2jS4Y3831ztGSTnDpnKJTKS95d64XVq consumed 7887 of 72596 compute units", + "Program compr6CUsB5m2jS4Y3831ztGSTnDpnKJTKS95d64XVq success", + "Program SySTEM1eSU2p4BGQfQpimFEWWSC1XDFeun3Nqzz3rT7 consumed 121394 of 186066 compute units", + "Program SySTEM1eSU2p4BGQfQpimFEWWSC1XDFeun3Nqzz3rT7 success", + "Program cTokenmWW8bLPjZEBAUgYy3zKxQZW6VKi7bqNFEVv3m consumed 135424 of 200000 compute units", + "Program cTokenmWW8bLPjZEBAUgYy3zKxQZW6VKi7bqNFEVv3m success" + ], + "preTokenBalances": [], + "postTokenBalances": [], + "rewards": [], + "loadedAddresses": { + "writable": [], + "readonly": [] + }, + "computeUnitsConsumed": 135424 + }, + "blockTime": 1769454109 +} \ No newline at end of file diff --git a/tests/data/transactions/indexer_interface/628ZqqrNWuVUfHF2seH7XQEZqBJtWA8NaAjcVeH96XWaubUSeWpegrdfvrPkGA8qxfBFpu5ru9DjTFktRSB6w4s1 b/tests/data/transactions/indexer_interface/628ZqqrNWuVUfHF2seH7XQEZqBJtWA8NaAjcVeH96XWaubUSeWpegrdfvrPkGA8qxfBFpu5ru9DjTFktRSB6w4s1 new file mode 100644 index 00000000..7143dbc5 --- /dev/null +++ b/tests/data/transactions/indexer_interface/628ZqqrNWuVUfHF2seH7XQEZqBJtWA8NaAjcVeH96XWaubUSeWpegrdfvrPkGA8qxfBFpu5ru9DjTFktRSB6w4s1 @@ -0,0 +1,115 @@ +{ + "slot": 192, + "transaction": [ + "AfsPUmjOEBICNrhP7/Bg2o9koAwZ2oY8a/MQzvBpCrlc8YqlThsS2hSNtXJqkoBYwVPiwwdah77JQjbepbbS4gABAAkLPCehDHu6Mclx8oOSXuFApTP8e+Lqqux8Gk+YLbQusZMIpumYsF/pK5ig2fd52dvosvPu9ngTKHvrpSZJgJFovQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAwZGb+UhFzL/7K26csOb57yM5bvF9xJrLEObOkAAAAAGp1X4ITkFTUQksVrwxDDPL0t/mHk62hJS1I82ZsbLzgksNuwi9ReDAP20Sqpq/8/wpG4cvGQcDj7QnaE7nvUIC7wPwLtHyi90xBEulKsTz6PGNOXcF+rLA80aI81+eHwe68SM+zJku8NL9cWlqDT8Z1AAbymG2W8jW8iHXvTdRNWem8sa08HDC9sJmzqsOsj+S+dqH6i3TLNx6mW/Y5xi9f/NHLoyuMDzJdu5uE5yCqPBB+d1u+u44BM7lLKWQ/L7syh1EFyuhbW0RHYH7b/hRSe/tCORHKQEI7/RUnomdQflONqnQEnTSXyD+ptXOtcIdrRa5K5HsdBhsmpzu8yWAgMABQJAQg8ACAoABAUKBwYICQIBgAIx1L+BJ8IrxPMAAABWL6OmFd9cCAD/1Z6byxrTwcML2wmbOqw6yP5L52ofqLdMs3HqZb9jnGIAAAAAAAAAAAAAAQAAAAEmb4gY4t+qkKEhVaTDn5Ilgei4BYNQdfyEHk1ly1JGxA60J22rap8E3REII0w22VHmKvaSG7I+NrP42R6HXdBsLbZ0BS0Gp/MT4S8hwuMMh/E3GVOMexZ7Oq1NgrNu0juDiEZXj/+M6ysKKbZcbac4fWisuPBckRrLrs185dp0wQEAAAAqKioqKioqKioqKioqKioqKioqKioqKioqKioqKioqKgAAAAAAAAAAAAAAAAAAAAAAAAAAAAD/", + "base64" + ], + "meta": { + "err": null, + "status": { + "Ok": null + }, + "fee": 5000, + "preBalances": [ + 99995017518, + 331184640, + 1, + 1, + 1141440, + 1141440, + 1141440, + 1614720, + 1141440, + 0, + 0 + ], + "postBalances": [ + 99995002517, + 331194641, + 1, + 1, + 1141440, + 1141440, + 1141440, + 1614720, + 1141440, + 0, + 0 + ], + "innerInstructions": [ + { + "index": 1, + "instructions": [ + { + "programIdIndex": 4, + "accounts": [ + 0, + 9, + 7, + 6, + 10, + 5, + 8, + 4, + 4, + 2, + 4, + 1 + ], + "data": "BqT7gkM6QBUXLrebMuy9CaECRJVBDqCrrkzhkYSUzDvv98DeYSB9aE7c7gULTn24ZQLCzP11o1hX4SUYQaJC3V6xgkhkK3EZPddj4RKWAZQABjBp5nCzEZuZtWLpyagyQgUk9RvPxzGVZMXkR3gqsunjMy8oDT73cv41srUqyc56kRTSLvkRwzqizowJKdWUFxsWbt6FCWPEGBFw79WPV3FBvmA4kWVNM2jTuKhm5Zr422BJdp2VcxgTjY4efecNV3JjUGv3nZ6SX2jVRTBEFDzC1xhXcN7n3Bb5cTMPbWyChs9WyTcMM4vJijHm6pVAecWQjEkUxmQs", + "stackHeight": 2 + }, + { + "programIdIndex": 2, + "accounts": [ + 0, + 1 + ], + "data": "3Bxs43j4QTEDqUNK", + "stackHeight": 3 + }, + { + "programIdIndex": 5, + "accounts": [ + 10, + 7, + 1 + ], + "data": "gK3e1kzqySfsx4tv3nzLMZFLDuiod2Yo4Ayk9tULRFFquKTNa1tMWGhsXsKvAdGNkc4anw77fExSM5RvUkhM5bjJ8zuEXpJ7WD8bDCXHyUbDFS3dtLq3FfiVPsWj5AoNQfS27FS3mMVcMLuSgTUWSMYndJ6CJG3xi3HVTGCzdP8oiNCmobqQVdqPALH8Gsud8AsEYzBTuCkJXk18QKgXnk7KkZYNMyRxYEMohxpD5ofNTTD", + "stackHeight": 3 + } + ] + } + ], + "logMessages": [ + "Program ComputeBudget111111111111111111111111111111 invoke [1]", + "Program ComputeBudget111111111111111111111111111111 success", + "Program FNt7byTHev1k5x2cXZLBr8TdWiC3zoP5vcnZR4P682Uy invoke [1]", + "Program log: Instruction: InvokeCpi", + "Program consumption: 996329 units remaining", + "Program consumption: 993542 units remaining", + "Program SySTEM1eSU2p4BGQfQpimFEWWSC1XDFeun3Nqzz3rT7 invoke [2]", + "Program log: invoke_cpi_with_read_only", + "Program log: mode Anchor", + "Program 11111111111111111111111111111111 invoke [3]", + "Program 11111111111111111111111111111111 success", + "Program compr6CUsB5m2jS4Y3831ztGSTnDpnKJTKS95d64XVq invoke [3]", + "Program log: Instruction: InsertIntoQueues", + "Program compr6CUsB5m2jS4Y3831ztGSTnDpnKJTKS95d64XVq consumed 4712 of 883793 compute units", + "Program compr6CUsB5m2jS4Y3831ztGSTnDpnKJTKS95d64XVq success", + "Program SySTEM1eSU2p4BGQfQpimFEWWSC1XDFeun3Nqzz3rT7 consumed 110338 of 989382 compute units", + "Program SySTEM1eSU2p4BGQfQpimFEWWSC1XDFeun3Nqzz3rT7 success", + "Program FNt7byTHev1k5x2cXZLBr8TdWiC3zoP5vcnZR4P682Uy consumed 121427 of 999850 compute units", + "Program FNt7byTHev1k5x2cXZLBr8TdWiC3zoP5vcnZR4P682Uy success" + ], + "preTokenBalances": [], + "postTokenBalances": [], + "rewards": [], + "loadedAddresses": { + "writable": [], + "readonly": [] + }, + "computeUnitsConsumed": 121577 + }, + "blockTime": 1769454109 +} \ No newline at end of file diff --git a/tests/integration_tests/interface_tests.rs b/tests/integration_tests/interface_tests.rs new file mode 100644 index 00000000..f753b3e1 --- /dev/null +++ b/tests/integration_tests/interface_tests.rs @@ -0,0 +1,222 @@ +use std::sync::Arc; + +use function_name::named; +use futures::{pin_mut, StreamExt}; +use photon_indexer::api::method::interface::{ + GetAccountInterfaceRequest, GetMultipleAccountInterfacesRequest, +}; +use photon_indexer::common::typedefs::serializable_pubkey::SerializablePubkey; +use photon_indexer::ingester::index_block; +use photon_indexer::ingester::typedefs::block_info::{BlockInfo, BlockMetadata}; +use sea_orm::DatabaseConnection; +use serial_test::serial; +use solana_client::nonblocking::rpc_client::RpcClient; + +use crate::utils::*; + +/// Directory containing interface test transaction data. +/// Generated by forester's test_indexer_interface test. +const INTERFACE_TEST_DATA_DIR: &str = "indexer_interface"; + +/// Helper to set up indexing methodologies for interface tests. +fn all_indexing_methodologies_for_interface( + db_conn: Arc, + rpc_client: Arc, + txns: &[&str], +) -> impl futures::Stream { + let txs = txns.iter().map(|x| x.to_string()).collect::>(); + async_stream::stream! { + reset_tables(db_conn.as_ref()).await.unwrap(); + populate_test_tree_metadata(db_conn.as_ref()).await; + index_block( + db_conn.as_ref(), + &BlockInfo { + metadata: BlockMetadata { + slot: 0, + ..Default::default() + }, + ..Default::default() + }, + ) + .await + .unwrap(); + + for tx in &txs { + index_transaction(INTERFACE_TEST_DATA_DIR, db_conn.clone(), rpc_client.clone(), tx).await; + } + yield (); + } +} + +/// Test getAccountInterface resolves compressed token accounts by token owner pubkey. +#[named] +#[rstest] +#[tokio::test] +#[serial] +async fn test_get_account_interface_token_owner( + #[values(DatabaseBackend::Sqlite, DatabaseBackend::Postgres)] db_backend: DatabaseBackend, +) { + let name = trim_test_name(function_name!()); + let setup = setup_with_options( + name.clone(), + TestSetupOptions { + network: Network::Localnet, + db_backend, + }, + ) + .await; + + // Mint compressed tokens to Bob and Charlie + let mint_tokens_tx = + "2YTv5hjSmRAgfwoNHdc4DRDFWW7fqQb57f9s8Rxtu9u6jA2hDxNxEWoiybvn4p7ua2nw3scYeNo6htYCSuBYviFd"; + + // Bob's pubkey is the token owner. Interface lookup should return + // synthetic account data plus compressed token accounts in `cold`. + let bob_pubkey = + SerializablePubkey::try_from("DkbH1tracp6nxSQLrHQqJwVE7NaDAAb7eGKzfB9TwBdF").unwrap(); + + let txs = [mint_tokens_tx]; + let indexing = + all_indexing_methodologies_for_interface(setup.db_conn.clone(), setup.client.clone(), &txs); + pin_mut!(indexing); + + while let Some(_) = indexing.next().await { + // getAccountInterface must support token-owner lookups. + let result = setup + .api + .get_account_interface(GetAccountInterfaceRequest { + address: bob_pubkey, + }) + .await + .unwrap(); + + let value = result.value.clone().expect("expected interface value"); + let cold = value.cold.expect("expected compressed token accounts"); + assert!( + !cold.is_empty(), + "expected at least one compressed token account" + ); + } +} + +/// Test getMultipleAccountInterfaces with mixed addresses. +/// +/// Tests batch lookup with addresses that don't exist in compressed DB. +#[named] +#[rstest] +#[tokio::test] +#[serial] +async fn test_get_multiple_account_interfaces( + #[values(DatabaseBackend::Sqlite, DatabaseBackend::Postgres)] db_backend: DatabaseBackend, +) { + let name = trim_test_name(function_name!()); + let setup = setup_with_options( + name.clone(), + TestSetupOptions { + network: Network::Localnet, + db_backend, + }, + ) + .await; + + // Mint compressed tokens to Bob and Charlie + let mint_tokens_tx = + "2YTv5hjSmRAgfwoNHdc4DRDFWW7fqQb57f9s8Rxtu9u6jA2hDxNxEWoiybvn4p7ua2nw3scYeNo6htYCSuBYviFd"; + + // Token owner pubkeys. + let bob_pubkey = + SerializablePubkey::try_from("DkbH1tracp6nxSQLrHQqJwVE7NaDAAb7eGKzfB9TwBdF").unwrap(); + let charlie_pubkey = + SerializablePubkey::try_from("GDhjk4DmDQ8bFWqrdcLGaJcYBDnq73ExjGyZYrca6nDc").unwrap(); + // Use a random address that definitely doesn't exist on-chain or compressed + // (default pubkey 11111... is the system program which exists on-chain) + let nonexistent_address = + SerializablePubkey::try_from("DeadDeadDeadDeadDeadDeadDeadDeadDeadDeadDead").unwrap(); + + let txs = [mint_tokens_tx]; + let indexing = + all_indexing_methodologies_for_interface(setup.db_conn.clone(), setup.client.clone(), &txs); + pin_mut!(indexing); + + while let Some(_) = indexing.next().await { + // Test getMultipleAccountInterfaces + let result = setup + .api + .get_multiple_account_interfaces(GetMultipleAccountInterfacesRequest { + addresses: vec![bob_pubkey, charlie_pubkey, nonexistent_address], + }) + .await + .unwrap(); + + // Both owner pubkeys should resolve to token-backed interfaces. + assert_eq!(result.value.len(), 3); + assert!(result.value[0] + .as_ref() + .and_then(|v| v.cold.as_ref()) + .is_some_and(|cold| !cold.is_empty())); + assert!(result.value[1] + .as_ref() + .and_then(|v| v.cold.as_ref()) + .is_some_and(|cold| !cold.is_empty())); + assert!(result.value[2].is_none()); + } +} + +/// Test batch size validation for getMultipleAccountInterfaces. +#[named] +#[rstest] +#[tokio::test] +#[serial] +async fn test_get_multiple_account_interfaces_validation( + #[values(DatabaseBackend::Sqlite, DatabaseBackend::Postgres)] db_backend: DatabaseBackend, +) { + let name = trim_test_name(function_name!()); + let setup = setup_with_options( + name.clone(), + TestSetupOptions { + network: Network::Localnet, + db_backend, + }, + ) + .await; + + // Test empty request - should fail + let empty_result = setup + .api + .get_multiple_account_interfaces(GetMultipleAccountInterfacesRequest { addresses: vec![] }) + .await; + + assert!(empty_result.is_err()); + + // Test over limit - should fail (MAX_BATCH_SIZE is 100) + let over_limit: Vec = + (0..101).map(|_| SerializablePubkey::default()).collect(); + + let over_limit_result = setup + .api + .get_multiple_account_interfaces(GetMultipleAccountInterfacesRequest { + addresses: over_limit, + }) + .await; + + assert!(over_limit_result.is_err()); +} + +/* +## Test Data + +Test data is generated by forester's test_indexer_interface test: + cd light-protocol/forester && cargo test -p forester --test test_indexer_interface -- --nocapture + +Then exported using: + cargo xtask export-photon-test-data --test-name indexer_interface + +The test creates: +1. SPL Mint (on-chain) - standard mint for token operations +2. Compressed token accounts (via mint_to) - these have NO address field +3. Registered v2 address in batched address tree - for address tree verification +4. Compressible token accounts - on-chain accounts that can be compressed + +Transaction files are stored in tests/data/transactions/indexer_interface/ + +*/ diff --git a/tests/integration_tests/main.rs b/tests/integration_tests/main.rs index 78bad68e..b6691a99 100644 --- a/tests/integration_tests/main.rs +++ b/tests/integration_tests/main.rs @@ -6,6 +6,7 @@ mod batch_append_nullified_test; mod batched_address_tree_tests; mod batched_state_tree_tests; mod e2e_tests; +mod interface_tests; mod merkle_tree_deserialization; mod mock_tests; mod monitor_tests; diff --git a/tests/integration_tests/snapshots/integration_tests__interface_tests__get_account_interface_compressed_only-account-interface.snap b/tests/integration_tests/snapshots/integration_tests__interface_tests__get_account_interface_compressed_only-account-interface.snap new file mode 100644 index 00000000..b3875ed4 --- /dev/null +++ b/tests/integration_tests/snapshots/integration_tests__interface_tests__get_account_interface_compressed_only-account-interface.snap @@ -0,0 +1,11 @@ +--- +source: tests/integration_tests/interface_tests.rs +assertion_line: 107 +expression: result +--- +{ + "context": { + "slot": 0 + }, + "value": null +} diff --git a/tests/integration_tests/snapshots/integration_tests__interface_tests__get_account_interface_nonexistent-account-interface.snap b/tests/integration_tests/snapshots/integration_tests__interface_tests__get_account_interface_nonexistent-account-interface.snap new file mode 100644 index 00000000..a9bd9223 --- /dev/null +++ b/tests/integration_tests/snapshots/integration_tests__interface_tests__get_account_interface_nonexistent-account-interface.snap @@ -0,0 +1,11 @@ +--- +source: tests/integration_tests/interface_tests.rs +assertion_line: 106 +expression: result +--- +{ + "context": { + "slot": 0 + }, + "value": null +} diff --git a/tests/integration_tests/snapshots/integration_tests__interface_tests__get_multiple_account_interfaces-multiple-account-interfaces.snap b/tests/integration_tests/snapshots/integration_tests__interface_tests__get_multiple_account_interfaces-multiple-account-interfaces.snap new file mode 100644 index 00000000..3ee0f30e --- /dev/null +++ b/tests/integration_tests/snapshots/integration_tests__interface_tests__get_multiple_account_interfaces-multiple-account-interfaces.snap @@ -0,0 +1,15 @@ +--- +source: tests/integration_tests/interface_tests.rs +assertion_line: 230 +expression: result +--- +{ + "context": { + "slot": 0 + }, + "value": [ + null, + null, + null + ] +} diff --git a/tests/integration_tests/snapshots/integration_tests__interface_tests__get_token_account_interface_by_owner-token-account-interface.snap b/tests/integration_tests/snapshots/integration_tests__interface_tests__get_token_account_interface_by_owner-token-account-interface.snap new file mode 100644 index 00000000..06f65e54 --- /dev/null +++ b/tests/integration_tests/snapshots/integration_tests__interface_tests__get_token_account_interface_by_owner-token-account-interface.snap @@ -0,0 +1,11 @@ +--- +source: tests/integration_tests/interface_tests.rs +assertion_line: 161 +expression: result +--- +{ + "context": { + "slot": 0 + }, + "value": null +} diff --git a/tests/integration_tests/snapshots/integration_tests__interface_tests__get_token_account_interface_compressed_only-token-account-interface.snap b/tests/integration_tests/snapshots/integration_tests__interface_tests__get_token_account_interface_compressed_only-token-account-interface.snap new file mode 100644 index 00000000..a280f25a --- /dev/null +++ b/tests/integration_tests/snapshots/integration_tests__interface_tests__get_token_account_interface_compressed_only-token-account-interface.snap @@ -0,0 +1,11 @@ +--- +source: tests/integration_tests/interface_tests.rs +assertion_line: 157 +expression: result +--- +{ + "context": { + "slot": 0 + }, + "value": null +} From c80ba0e834ad8caee221174d4564c0a56e71b7b7 Mon Sep 17 00:00:00 2001 From: Sergey Timoshin Date: Tue, 3 Mar 2026 15:30:38 +0000 Subject: [PATCH 2/3] fix: remove redundant comment for SPL Token program ID in types.rs --- src/api/method/interface/types.rs | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/src/api/method/interface/types.rs b/src/api/method/interface/types.rs index 42ba4a8d..737979a9 100644 --- a/src/api/method/interface/types.rs +++ b/src/api/method/interface/types.rs @@ -81,7 +81,4 @@ pub const MAX_BATCH_SIZE: usize = 100; pub const RPC_TIMEOUT_MS: u64 = 5000; /// Database timeout in milliseconds for cold lookups -pub const DB_TIMEOUT_MS: u64 = 3000; - -/// SPL Token program ID (on-chain Token program) -pub const SPL_TOKEN_PROGRAM_ID: Pubkey = pubkey!("TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA"); +pub const DB_TIMEOUT_MS: u64 = 3000; \ No newline at end of file From 01cae51ca58935eb46571094ce7abd0ea252c344 Mon Sep 17 00:00:00 2001 From: Sergey Timoshin Date: Tue, 3 Mar 2026 18:47:05 +0000 Subject: [PATCH 3/3] feat: add discriminator_v2 field to account interface and update related test structure --- src/api/method/interface/racing.rs | 1 + src/api/method/interface/types.rs | 3 +-- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/api/method/interface/racing.rs b/src/api/method/interface/racing.rs index 8ed98f2f..fa5efeb5 100644 --- a/src/api/method/interface/racing.rs +++ b/src/api/method/interface/racing.rs @@ -1030,6 +1030,7 @@ mod tests { prev_spent: Some(false), tx_hash: None, onchain_pubkey, + discriminator_v2: None, tree_type: Some(3), nullified_in_tree: false, nullifier_queue_index: None, diff --git a/src/api/method/interface/types.rs b/src/api/method/interface/types.rs index 737979a9..d120bab8 100644 --- a/src/api/method/interface/types.rs +++ b/src/api/method/interface/types.rs @@ -1,5 +1,4 @@ use serde::{Deserialize, Serialize}; -use solana_pubkey::{pubkey, Pubkey}; use utoipa::ToSchema; use crate::common::typedefs::account::AccountV2; @@ -81,4 +80,4 @@ pub const MAX_BATCH_SIZE: usize = 100; pub const RPC_TIMEOUT_MS: u64 = 5000; /// Database timeout in milliseconds for cold lookups -pub const DB_TIMEOUT_MS: u64 = 3000; \ No newline at end of file +pub const DB_TIMEOUT_MS: u64 = 3000;