// file: kb-pipeline/src/solana_metaplex_token_metadata_stateful.rs // version: 3 //! Bounded Metaplex Token Metadata account reads and canonical state projections. /// Maximum complete Metaplex account data accepted by one RPC read. pub const MAX_METAPLEX_TOKEN_METADATA_ACCOUNT_BYTES: usize = 1_048_576; /// Supported Metaplex account category for one bounded stateful read. #[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)] #[serde(tag = "kind", rename_all = "snake_case")] pub enum MetaplexTokenMetadataAccountKind { /// Metadata PDA derived from the mint encoded in the account. Metadata, /// Master edition or printed edition PDA derived from the supplied mint. Edition { /// Mint used to derive the edition PDA. mint: kb_lib::MdPubkey, }, /// Programmable token record PDA derived from mint and token account. TokenRecord { /// Mint used to derive the token-record PDA. mint: kb_lib::MdPubkey, /// Token account used to derive the token-record PDA. token: kb_lib::MdPubkey, }, } /// One bounded Metaplex account read request. #[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)] pub struct MetaplexTokenMetadataStatefulReadRequest { /// Endpoint role used for the HTTP RPC request. pub query_role: std::string::String, /// Canonical account address. pub account: kb_lib::MdPubkey, /// Expected account category and derivation inputs. pub kind: crate::MetaplexTokenMetadataAccountKind, /// Optional minimum RPC context slot. pub min_context_slot: std::option::Option, /// Maximum decoded account bytes accepted from the endpoint. pub max_data_bytes: usize, } /// Canonical bounded Metaplex state snapshot. #[derive(Clone, Debug, PartialEq, serde::Deserialize, serde::Serialize)] pub struct MetaplexTokenMetadataStatefulSnapshot { /// Canonical account address. pub account: kb_lib::MdPubkey, /// Stable account category. pub account_kind: std::string::String, /// Mint correlated with this account when available. pub mint: std::option::Option, /// RPC context slot. pub slot: u64, /// Bounded decoder-owned projection. pub payload_json: serde_json::Value, } /// One bounded RPC read and its validated snapshot. #[derive(Clone, Debug, PartialEq, serde::Deserialize, serde::Serialize)] pub struct MetaplexTokenMetadataStatefulReadResult { /// Commitment used by the read. pub commitment: std::string::String, /// Context slot returned by the endpoint. pub context_slot: u64, /// Validated canonical snapshot. pub snapshot: crate::MetaplexTokenMetadataStatefulSnapshot, } /// Reads and validates one bounded Metaplex Token Metadata account. pub async fn read_metaplex_token_metadata_stateful_snapshot( pool: &kb_onchain_transport::HttpEndpointPool, request: &crate::MetaplexTokenMetadataStatefulReadRequest, ) -> kb_core::Result { if request.query_role.trim().is_empty() { return std::result::Result::Err(kb_core::Error::config( "Metaplex stateful read query_role must not be empty", )); } let config = match kb_onchain_transport::GetAccountInfoConfig::new_with_data( kb_onchain_transport::RpcCommitmentLevel::Confirmed, request.min_context_slot, request.max_data_bytes, ) { std::result::Result::Ok(value) => value, std::result::Result::Err(error) => return std::result::Result::Err(error), }; let result = match pool .get_account_info_for_role(request.query_role.as_str(), &request.account, &config) .await { std::result::Result::Ok(value) => value, std::result::Result::Err(error) => return std::result::Result::Err(error), }; return crate::materialize_metaplex_token_metadata_account_info_result(request, &result); } /// Validates one RPC account response and delegates parsing to `kb-lib`. pub fn materialize_metaplex_token_metadata_account_info_result( request: &crate::MetaplexTokenMetadataStatefulReadRequest, result: &kb_onchain_transport::AccountInfoResult, ) -> kb_core::Result { if request.max_data_bytes == 0 || request.max_data_bytes > crate::MAX_METAPLEX_TOKEN_METADATA_ACCOUNT_BYTES { return std::result::Result::Err(kb_core::Error::config(format!( "Metaplex stateful max_data_bytes must be between 1 and {}", crate::MAX_METAPLEX_TOKEN_METADATA_ACCOUNT_BYTES ))); } if let std::option::Option::Some(minimum) = request.min_context_slot { if result.context.slot < minimum { return std::result::Result::Err(kb_core::Error::new( "metaplex_stateful_context_slot_too_old", "Metaplex account context slot is below the requested minimum", )); } } let account = match result.account.as_ref() { std::option::Option::Some(value) => value, std::option::Option::None => { return std::result::Result::Err(kb_core::Error::new( "metaplex_stateful_account_missing", format!("Metaplex account {} does not exist", request.account.0), )); }, }; if account.executable { return std::result::Result::Err(kb_core::Error::new( "metaplex_stateful_account_executable", "Metaplex state accounts must not be executable", )); } if account.owner.0 != kb_program_ids::METADATA_METAPLEX_TOKEN_METADATA_PROGRAM_ID { return std::result::Result::Err(kb_core::Error::new( "metaplex_stateful_owner_mismatch", "Metaplex state account owner does not match Token Metadata", )); } if account.space != account.data.len() as u64 || account.data.len() > request.max_data_bytes { return std::result::Result::Err(kb_core::Error::new( "metaplex_stateful_account_data_invalid", "Metaplex account data is incomplete or above the configured bound", )); } let snapshot = match decode_snapshot(request, result.context.slot, account.data.as_slice()) { std::result::Result::Ok(value) => value, std::result::Result::Err(error) => return std::result::Result::Err(error), }; return std::result::Result::Ok(crate::MetaplexTokenMetadataStatefulReadResult { commitment: "confirmed".to_string(), context_slot: result.context.slot, snapshot, }); } fn decode_snapshot( request: &crate::MetaplexTokenMetadataStatefulReadRequest, slot: u64, data: &[u8], ) -> kb_core::Result { let owner = kb_program_ids::METADATA_METAPLEX_TOKEN_METADATA_PROGRAM_ID; return match &request.kind { crate::MetaplexTokenMetadataAccountKind::Metadata => { match kb_lib::decoder_metadata_metaplex_token_metadata_decode_metadata_account( request.account.0.as_str(), owner, data, ) { std::result::Result::Ok(value) => { std::result::Result::Ok(crate::MetaplexTokenMetadataStatefulSnapshot { account: request.account.clone(), account_kind: "metadata".to_string(), mint: std::option::Option::Some(kb_lib::MdPubkey(value.mint)), slot, payload_json: value.payload_json, }) }, std::result::Result::Err(error) => std::result::Result::Err(kb_core::Error::new( "metaplex_stateful_decode_failed", error.to_string(), )), } }, crate::MetaplexTokenMetadataAccountKind::Edition { mint } => { match kb_lib::decoder_metadata_metaplex_token_metadata_decode_edition_account( request.account.0.as_str(), owner, mint.0.as_str(), data, ) { std::result::Result::Ok(value) => { std::result::Result::Ok(crate::MetaplexTokenMetadataStatefulSnapshot { account: request.account.clone(), account_kind: "edition".to_string(), mint: std::option::Option::Some(mint.clone()), slot, payload_json: value.payload_json, }) }, std::result::Result::Err(error) => std::result::Result::Err(kb_core::Error::new( "metaplex_stateful_decode_failed", error.to_string(), )), } }, crate::MetaplexTokenMetadataAccountKind::TokenRecord { mint, token } => { match kb_lib::decoder_metadata_metaplex_token_metadata_decode_token_record_account( request.account.0.as_str(), owner, mint.0.as_str(), token.0.as_str(), data, ) { std::result::Result::Ok(value) => { std::result::Result::Ok(crate::MetaplexTokenMetadataStatefulSnapshot { account: request.account.clone(), account_kind: "token_record".to_string(), mint: std::option::Option::Some(mint.clone()), slot, payload_json: value.payload_json, }) }, std::result::Result::Err(error) => std::result::Result::Err(kb_core::Error::new( "metaplex_stateful_decode_failed", error.to_string(), )), } }, }; } #[cfg(test)] mod tests { #[test] fn stateful_read_request_round_trips_through_json() { let request = crate::MetaplexTokenMetadataStatefulReadRequest { query_role: "rpc".to_string(), account: kb_lib::MdPubkey("11111111111111111111111111111111".to_string()), kind: crate::MetaplexTokenMetadataAccountKind::Metadata, min_context_slot: std::option::Option::Some(42), max_data_bytes: 1024, }; let json = match serde_json::to_string(&request) { std::result::Result::Ok(json) => json, std::result::Result::Err(error) => panic!("request serialization failed: {error}"), }; let decoded = match serde_json::from_str::( json.as_str(), ) { std::result::Result::Ok(decoded) => decoded, std::result::Result::Err(error) => panic!("request deserialization failed: {error}"), }; assert_eq!(decoded, request); } #[test] fn rejects_zero_and_oversize_bounds_before_account_decoding() { let request = crate::MetaplexTokenMetadataStatefulReadRequest { query_role: "rpc".to_string(), account: kb_lib::MdPubkey("11111111111111111111111111111111".to_string()), kind: crate::MetaplexTokenMetadataAccountKind::Metadata, min_context_slot: std::option::Option::None, max_data_bytes: 0, }; let result = kb_onchain_transport::AccountInfoResult { context: kb_onchain_transport::RpcResponseContext { slot: 1, api_version: std::option::Option::None, }, account: std::option::Option::None, }; assert!( crate::materialize_metaplex_token_metadata_account_info_result(&request, &result) .is_err() ); } }