Files
khadhroony-bot3/kb-pipeline/src/solana_metaplex_token_metadata_stateful.rs
2026-08-03 09:34:16 +02:00

279 lines
12 KiB
Rust

// 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<u64>,
/// 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<kb_lib::MdPubkey>,
/// 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<crate::MetaplexTokenMetadataStatefulReadResult> {
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<crate::MetaplexTokenMetadataStatefulReadResult> {
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<crate::MetaplexTokenMetadataStatefulSnapshot> {
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::<crate::MetaplexTokenMetadataStatefulReadRequest>(
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()
);
}
}