279 lines
12 KiB
Rust
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()
|
|
);
|
|
}
|
|
}
|