1619 lines
67 KiB
Rust
1619 lines
67 KiB
Rust
// file: kb-pipeline-demo-scenarios/src/metadata/metaplex_token_metadata/devnet_execution.rs
|
|
// version: 17
|
|
|
|
//! Real Devnet simulation and submission for current Metaplex Token Metadata operations.
|
|
|
|
/// Complete request for one real Devnet Metaplex Token Metadata execution.
|
|
#[derive(Clone, Debug, PartialEq)]
|
|
pub struct DevnetMetaplexTokenMetadataExecutionRequest {
|
|
/// Stable caller-provided execution identifier.
|
|
pub intent_id: std::string::String,
|
|
/// Endpoint role used for reads, balance, blockhash and fee queries.
|
|
pub query_role: std::string::String,
|
|
/// Endpoint role used for simulation, submission and confirmation.
|
|
pub transaction_role: std::string::String,
|
|
/// Exact current Metaplex operation.
|
|
pub operation: kb_lib::ExMetaplexTokenMetadataOperation,
|
|
/// Additional non-profile signers explicitly authorized for this exact operation.
|
|
pub additional_authorized_signers: std::vec::Vec<kb_lib::MdPubkey>,
|
|
/// Bounded state reads required before execution.
|
|
pub preflight_reads: std::vec::Vec<kb_pipeline::MetaplexTokenMetadataStatefulReadRequest>,
|
|
/// Bounded state reads repeated after confirmed submission.
|
|
pub postcondition_reads: std::vec::Vec<kb_pipeline::MetaplexTokenMetadataStatefulReadRequest>,
|
|
/// Explicitly authorizes signing and submission after successful simulation.
|
|
pub submit: bool,
|
|
/// Explicit operator confirmation required for submission.
|
|
pub operator_confirmed: bool,
|
|
/// Materializes canonical account snapshots after confirmed submission.
|
|
pub materialize_after_confirmation: bool,
|
|
/// Number of `getTransaction` retries after the first hydration attempt.
|
|
pub post_validation_max_retries: u32,
|
|
/// Replaces existing core and decode outputs for the submitted signature.
|
|
pub force_post_validation_replay: bool,
|
|
}
|
|
|
|
impl crate::DevnetMetaplexTokenMetadataExecutionRequest {
|
|
/// Creates a conservative simulation-only request.
|
|
pub fn new(
|
|
intent_id: impl std::convert::Into<std::string::String>,
|
|
operation: kb_lib::ExMetaplexTokenMetadataOperation,
|
|
) -> Self {
|
|
return Self {
|
|
intent_id: intent_id.into(),
|
|
query_role: "http_queries".to_string(),
|
|
transaction_role: "http_transactions".to_string(),
|
|
operation,
|
|
additional_authorized_signers: std::vec::Vec::new(),
|
|
preflight_reads: std::vec::Vec::new(),
|
|
postcondition_reads: std::vec::Vec::new(),
|
|
submit: false,
|
|
operator_confirmed: false,
|
|
materialize_after_confirmation: false,
|
|
post_validation_max_retries: 10,
|
|
force_post_validation_replay: false,
|
|
};
|
|
}
|
|
|
|
/// Creates a conservative request from one serialized typed Metaplex operation.
|
|
///
|
|
/// This entry point lets automated Devnet campaigns and the desktop reuse the
|
|
/// complete executor surface without duplicating one Rust constructor per
|
|
/// operation. The JSON must deserialize to `ExMetaplexTokenMetadataOperation`.
|
|
pub fn from_operation_json(
|
|
intent_id: impl std::convert::Into<std::string::String>,
|
|
operation_json: &str,
|
|
) -> kb_core::Result<Self> {
|
|
if operation_json.contains("\"operation\":\"...\"")
|
|
|| operation_json.contains("\"operation\": \"...\"")
|
|
|| operation_json.trim() == "..."
|
|
{
|
|
return std::result::Result::Err(kb_core::Error::config(
|
|
"Metaplex operation JSON still contains the documentation placeholder `...`; use metaplex_token_metadata_current_operation_json_template or one of the named request constructors",
|
|
));
|
|
}
|
|
let operation = match serde_json::from_str::<kb_lib::ExMetaplexTokenMetadataOperation>(
|
|
operation_json,
|
|
) {
|
|
std::result::Result::Ok(value) => value,
|
|
std::result::Result::Err(error) => {
|
|
return std::result::Result::Err(kb_core::Error::config(format!(
|
|
"invalid typed Metaplex operation JSON: {error}"
|
|
)));
|
|
},
|
|
};
|
|
let request = Self::new(intent_id, operation);
|
|
if let std::result::Result::Err(error) = request.validate() {
|
|
return std::result::Result::Err(error);
|
|
}
|
|
return std::result::Result::Ok(request);
|
|
}
|
|
|
|
/// Validates request-local bounds and rejects deprecated operations.
|
|
pub fn validate(&self) -> kb_core::Result<()> {
|
|
if self.intent_id.trim().is_empty() {
|
|
return std::result::Result::Err(kb_core::Error::config(
|
|
"Devnet Metaplex execution intent id must not be empty",
|
|
));
|
|
}
|
|
if self.query_role.trim().is_empty() || self.transaction_role.trim().is_empty() {
|
|
return std::result::Result::Err(kb_core::Error::config(
|
|
"Devnet Metaplex execution endpoint roles must not be empty",
|
|
));
|
|
}
|
|
if self.additional_authorized_signers.len() > 4 {
|
|
return std::result::Result::Err(kb_core::Error::config(
|
|
"Devnet Metaplex execution supports at most four additional authorized signers",
|
|
));
|
|
}
|
|
let mut unique_authorized_signers = std::collections::BTreeSet::new();
|
|
for signer in &self.additional_authorized_signers {
|
|
if let std::result::Result::Err(error) =
|
|
kb_onchain_transport::validate_solana_pubkey_text(
|
|
signer.0.as_str(),
|
|
"Metaplex additional authorized signer",
|
|
)
|
|
{
|
|
return std::result::Result::Err(error);
|
|
}
|
|
if !unique_authorized_signers.insert(signer.0.as_str()) {
|
|
return std::result::Result::Err(kb_core::Error::config(
|
|
"Devnet Metaplex additional authorized signers must be unique",
|
|
));
|
|
}
|
|
}
|
|
if self.preflight_reads.len() > kb_pipeline::MAX_METAPLEX_TOKEN_METADATA_PREFLIGHT_ACCOUNTS
|
|
|| self.postcondition_reads.len()
|
|
> kb_pipeline::MAX_METAPLEX_TOKEN_METADATA_PREFLIGHT_ACCOUNTS
|
|
{
|
|
return std::result::Result::Err(kb_core::Error::config(
|
|
"Devnet Metaplex execution stateful read limit exceeded",
|
|
));
|
|
}
|
|
if self.post_validation_max_retries > 20 {
|
|
return std::result::Result::Err(kb_core::Error::config(
|
|
"post-execution getTransaction retries must not exceed 20",
|
|
));
|
|
}
|
|
if self.submit && self.materialize_after_confirmation && self.postcondition_reads.is_empty()
|
|
{
|
|
return std::result::Result::Err(kb_core::Error::config(
|
|
"Metaplex materialization requires at least one postcondition account read",
|
|
));
|
|
}
|
|
let operation_code = self.operation.operation_code();
|
|
if kb_lib::EX_METAPLEX_TOKEN_METADATA_DEPRECATED_OPERATION_CODES.contains(&operation_code) {
|
|
return std::result::Result::Err(kb_core::Error::new(
|
|
"execution_metaplex_devnet_deprecated_operation_forbidden",
|
|
"automatic Devnet campaigns do not execute deprecated Metaplex operations",
|
|
));
|
|
}
|
|
return std::result::Result::Ok(());
|
|
}
|
|
}
|
|
|
|
/// Returns the current operation names accepted by automatic Devnet campaigns.
|
|
pub fn metaplex_token_metadata_current_operation_names() -> &'static [&'static str] {
|
|
return &[
|
|
"create",
|
|
"update_as_update_authority_v2",
|
|
"verify",
|
|
"unverify",
|
|
"burn",
|
|
"delegate",
|
|
"revoke",
|
|
"lock",
|
|
"unlock",
|
|
"transfer",
|
|
"close_accounts",
|
|
"create_escrow_account",
|
|
"close_escrow_account",
|
|
"transfer_out_of_escrow",
|
|
"mint",
|
|
"migrate",
|
|
"use",
|
|
"collect",
|
|
"print",
|
|
"resize",
|
|
];
|
|
}
|
|
|
|
/// Returns a formatted JSON template for the first named Devnet campaign operations.
|
|
///
|
|
/// Placeholders beginning with `<` must be replaced before parsing or execution.
|
|
pub fn metaplex_token_metadata_current_operation_json_template(
|
|
operation: &str,
|
|
) -> kb_core::Result<std::string::String> {
|
|
let value = match operation {
|
|
"create" => serde_json::json!({
|
|
"operation": "create",
|
|
"metadata": "<METADATA_PDA>",
|
|
"master_edition": "<MASTER_EDITION_PDA_OR_NULL>",
|
|
"mint": "<MINT>",
|
|
"mint_as_signer": false,
|
|
"authority": "<MINT_AUTHORITY>",
|
|
"update_authority": "<UPDATE_AUTHORITY>",
|
|
"update_authority_as_signer": true,
|
|
"spl_token_program": "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA",
|
|
"create_args": {
|
|
"V1": {
|
|
"name": "Khadhroony Devnet Asset",
|
|
"symbol": "KHDEV",
|
|
"uri": "https://example.invalid/khadhroony-devnet.json",
|
|
"seller_fee_basis_points": 0,
|
|
"creators": null,
|
|
"primary_sale_happened": false,
|
|
"is_mutable": true,
|
|
"token_standard": "NonFungible",
|
|
"collection": null,
|
|
"uses": null,
|
|
"collection_details": null,
|
|
"rule_set": null,
|
|
"decimals": null,
|
|
"print_supply": "Zero"
|
|
}
|
|
}
|
|
}),
|
|
"update_as_update_authority_v2" => serde_json::json!({
|
|
"operation": "update_as_update_authority_v2",
|
|
"authority": "<UPDATE_AUTHORITY>",
|
|
"mint": "<MINT>",
|
|
"metadata": "<METADATA_PDA>",
|
|
"edition": null,
|
|
"token": null,
|
|
"authorization_rules_program": null,
|
|
"authorization_rules": null,
|
|
"update_args": {
|
|
"AsUpdateAuthorityV2": {
|
|
"new_update_authority": null,
|
|
"data": null,
|
|
"primary_sale_happened": null,
|
|
"is_mutable": null,
|
|
"collection": null,
|
|
"collection_details": null,
|
|
"uses": null,
|
|
"rule_set": null,
|
|
"authorization_data": null
|
|
}
|
|
}
|
|
}),
|
|
"verify_creator" | "verify" => serde_json::json!({
|
|
"operation": "verify",
|
|
"authority": "<CREATOR_AUTHORITY>",
|
|
"delegate_record": null,
|
|
"metadata": "<METADATA_PDA>",
|
|
"collection_mint": null,
|
|
"collection_metadata": null,
|
|
"collection_master_edition": null,
|
|
"verification_args": "CreatorV1"
|
|
}),
|
|
"unverify_creator" | "unverify" => serde_json::json!({
|
|
"operation": "unverify",
|
|
"authority": "<CREATOR_AUTHORITY>",
|
|
"delegate_record": null,
|
|
"metadata": "<METADATA_PDA>",
|
|
"collection_mint": null,
|
|
"collection_metadata": null,
|
|
"verification_args": "CreatorV1"
|
|
}),
|
|
_ => {
|
|
return std::result::Result::Err(kb_core::Error::config(format!(
|
|
"no named JSON template for `{operation}`; current operations: {}",
|
|
metaplex_token_metadata_current_operation_names().join(", ")
|
|
)));
|
|
},
|
|
};
|
|
return match serde_json::to_string_pretty(&value) {
|
|
std::result::Result::Ok(value) => std::result::Result::Ok(value),
|
|
std::result::Result::Err(error) => std::result::Result::Err(kb_core::Error::json(format!(
|
|
"unable to serialize Metaplex operation template: {error}"
|
|
))),
|
|
};
|
|
}
|
|
|
|
/// Creates one named creator verification request without handwritten operation JSON.
|
|
pub fn devnet_metaplex_creator_verify_request(
|
|
intent_id: impl std::convert::Into<std::string::String>,
|
|
authority: kb_lib::MdPubkey,
|
|
metadata: kb_lib::MdPubkey,
|
|
) -> crate::DevnetMetaplexTokenMetadataExecutionRequest {
|
|
return crate::DevnetMetaplexTokenMetadataExecutionRequest::new(
|
|
intent_id,
|
|
kb_lib::ExMetaplexTokenMetadataOperation::Verify {
|
|
authority,
|
|
delegate_record: std::option::Option::None,
|
|
metadata,
|
|
collection_mint: std::option::Option::None,
|
|
collection_metadata: std::option::Option::None,
|
|
collection_master_edition: std::option::Option::None,
|
|
verification_args: mpl_token_metadata::types::VerificationArgs::CreatorV1,
|
|
},
|
|
);
|
|
}
|
|
|
|
/// Creates one named creator unverification request without handwritten operation JSON.
|
|
pub fn devnet_metaplex_creator_unverify_request(
|
|
intent_id: impl std::convert::Into<std::string::String>,
|
|
authority: kb_lib::MdPubkey,
|
|
metadata: kb_lib::MdPubkey,
|
|
) -> crate::DevnetMetaplexTokenMetadataExecutionRequest {
|
|
return crate::DevnetMetaplexTokenMetadataExecutionRequest::new(
|
|
intent_id,
|
|
kb_lib::ExMetaplexTokenMetadataOperation::Unverify {
|
|
authority,
|
|
delegate_record: std::option::Option::None,
|
|
metadata,
|
|
collection_mint: std::option::Option::None,
|
|
collection_metadata: std::option::Option::None,
|
|
verification_args: mpl_token_metadata::types::VerificationArgs::CreatorV1,
|
|
},
|
|
);
|
|
}
|
|
|
|
/// Complete evidence produced by one real Devnet Metaplex execution.
|
|
#[derive(Clone, Debug, PartialEq)]
|
|
pub struct DevnetMetaplexTokenMetadataExecutionSummary {
|
|
/// Profile used by the orchestration.
|
|
pub profile_name: std::string::String,
|
|
/// Exact classified cluster.
|
|
pub cluster: kb_lib::ExApiExecutionCluster,
|
|
/// Genesis hash returned by the selected endpoint.
|
|
pub genesis_hash: std::string::String,
|
|
/// Non-secret persistent wallet description.
|
|
pub wallet: kb_wallet::WalletSummary,
|
|
/// Wallet balance observed before planning.
|
|
pub balance_lamports: u64,
|
|
/// Stateful snapshots observed before simulation.
|
|
pub before: std::vec::Vec<kb_pipeline::MetaplexTokenMetadataStatefulReadResult>,
|
|
/// Stateful preflight report bound to the exact plan.
|
|
pub stateful_preflight: kb_pipeline::MetaplexTokenMetadataPreflightReport,
|
|
/// Exact prepared Metaplex plan.
|
|
pub plan: kb_lib::ExApiPreparedExecutionPlan,
|
|
/// Recent blockhash used by the exact transaction.
|
|
pub latest_blockhash: kb_onchain_transport::LatestBlockhashResult,
|
|
/// Fee estimate for the exact compiled message.
|
|
pub fee: kb_onchain_transport::FeeForMessageResult,
|
|
/// Exact real RPC simulation result.
|
|
pub simulation: kb_lib::ExApiExecutionSimulationResult,
|
|
/// RPC context slot returned by the exact simulation call.
|
|
pub simulation_context_slot: u64,
|
|
/// Readiness report proving exact-message simulation and signer resolution.
|
|
pub readiness: kb_pipeline::MetaplexTokenMetadataExecutionReadinessReport,
|
|
/// Submission result when explicitly authorized.
|
|
pub send_result: std::option::Option<kb_lib::ExApiExecutionSendResult>,
|
|
/// Confirmation result when submitted.
|
|
pub confirmation: std::option::Option<kb_lib::ExApiExecutionConfirmationResult>,
|
|
/// Stateful snapshots observed after confirmed submission.
|
|
pub after: std::vec::Vec<kb_pipeline::MetaplexTokenMetadataStatefulReadResult>,
|
|
/// Whether canonical account materialization was requested.
|
|
pub materialization_requested: bool,
|
|
/// Canonical projections emitted from confirmed account snapshots.
|
|
pub materialized_snapshots: std::vec::Vec<serde_json::Value>,
|
|
/// Canonical hydration result for the exact submitted signature.
|
|
pub backfill: std::option::Option<kb_pipeline::BackfillSummary>,
|
|
/// Core extraction result for the exact submitted signature.
|
|
pub core_extraction: std::option::Option<kb_pipeline::CoreExtractionSummary>,
|
|
/// First Metaplex decode and materialization replay.
|
|
pub decode_replay: std::option::Option<kb_pipeline::DecodeReplaySummary>,
|
|
/// Second replay proving idempotence for the exact same input.
|
|
pub idempotence_replay: std::option::Option<kb_pipeline::DecodeReplaySummary>,
|
|
/// Exact materialized rows produced for the submitted Metaplex transaction.
|
|
pub materializations: std::vec::Vec<kb_store::MaterializedEventQueryRow>,
|
|
/// Aggregated canonical post-execution diagnostic.
|
|
pub post_execution: std::option::Option<kb_lib::ExApiPostExecutionDiagnostic>,
|
|
}
|
|
|
|
struct PreparedMetaplexExecution {
|
|
wallet: kb_wallet::TemporaryWallet,
|
|
unsigned: kb_lib::ExSolanaUnsignedTransaction,
|
|
evidence: kb_lib::ExSolanaSimulationEvidence,
|
|
summary: crate::DevnetMetaplexTokenMetadataExecutionSummary,
|
|
}
|
|
|
|
/// Simulates one current Metaplex operation against a real Devnet endpoint.
|
|
pub async fn simulate_devnet_metaplex_token_metadata<O>(
|
|
http_pool: &kb_onchain_transport::HttpEndpointPool,
|
|
profile: &kb_config::ProfileConfig,
|
|
workspace_root: &std::path::Path,
|
|
request: &crate::DevnetMetaplexTokenMetadataExecutionRequest,
|
|
observer: &O,
|
|
) -> kb_core::Result<crate::DevnetMetaplexTokenMetadataExecutionSummary>
|
|
where
|
|
O: crate::SolanaExecutionObserver,
|
|
{
|
|
if request.submit {
|
|
return std::result::Result::Err(kb_core::Error::config(
|
|
"simulate_devnet_metaplex_token_metadata requires submit=false",
|
|
));
|
|
}
|
|
let prepared =
|
|
match prepare_execution(http_pool, profile, workspace_root, request, &[], observer).await {
|
|
std::result::Result::Ok(value) => value,
|
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
|
};
|
|
return std::result::Result::Ok(prepared.summary);
|
|
}
|
|
|
|
/// Executes one real Devnet Metaplex simulation or explicitly authorized submission.
|
|
#[allow(clippy::too_many_arguments)]
|
|
pub async fn execute_devnet_metaplex_token_metadata<S, O>(
|
|
http_pool: &kb_onchain_transport::HttpEndpointPool,
|
|
store: &S,
|
|
profile: &kb_config::ProfileConfig,
|
|
workspace_root: &std::path::Path,
|
|
request: &crate::DevnetMetaplexTokenMetadataExecutionRequest,
|
|
decoders: &[std::sync::Arc<dyn kb_lib::DcApiInstructionDecoder>],
|
|
materializers: &[std::sync::Arc<dyn kb_lib::MtApiEventMaterializer>],
|
|
observer: &O,
|
|
) -> kb_core::Result<crate::DevnetMetaplexTokenMetadataExecutionSummary>
|
|
where
|
|
S: kb_store::RawTransactionStore
|
|
+ kb_store::CoreExtractionStore
|
|
+ kb_store::DecodePipelineStore
|
|
+ Sync,
|
|
O: crate::SolanaExecutionObserver,
|
|
{
|
|
return crate::execute_devnet_metaplex_token_metadata_with_signers(
|
|
http_pool,
|
|
store,
|
|
profile,
|
|
workspace_root,
|
|
request,
|
|
decoders,
|
|
materializers,
|
|
&[],
|
|
observer,
|
|
)
|
|
.await;
|
|
}
|
|
|
|
/// Executes one real Devnet Metaplex submission with explicitly authorized extra signers.
|
|
#[allow(clippy::too_many_arguments)]
|
|
pub async fn execute_devnet_metaplex_token_metadata_with_signers<S, O>(
|
|
http_pool: &kb_onchain_transport::HttpEndpointPool,
|
|
store: &S,
|
|
profile: &kb_config::ProfileConfig,
|
|
workspace_root: &std::path::Path,
|
|
request: &crate::DevnetMetaplexTokenMetadataExecutionRequest,
|
|
decoders: &[std::sync::Arc<dyn kb_lib::DcApiInstructionDecoder>],
|
|
materializers: &[std::sync::Arc<dyn kb_lib::MtApiEventMaterializer>],
|
|
additional_signers: &[&(dyn solana_signer::Signer + std::marker::Sync)],
|
|
observer: &O,
|
|
) -> kb_core::Result<crate::DevnetMetaplexTokenMetadataExecutionSummary>
|
|
where
|
|
S: kb_store::RawTransactionStore
|
|
+ kb_store::CoreExtractionStore
|
|
+ kb_store::DecodePipelineStore
|
|
+ Sync,
|
|
O: crate::SolanaExecutionObserver,
|
|
{
|
|
let prepared = match prepare_execution(
|
|
http_pool,
|
|
profile,
|
|
workspace_root,
|
|
request,
|
|
additional_signers,
|
|
observer,
|
|
)
|
|
.await
|
|
{
|
|
std::result::Result::Ok(value) => value,
|
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
|
};
|
|
if !request.submit {
|
|
return std::result::Result::Ok(prepared.summary);
|
|
}
|
|
if !prepared.summary.simulation.success {
|
|
return std::result::Result::Err(kb_core::Error::new(
|
|
"execution_simulation_failed",
|
|
crate::simulation_failure_message(&prepared.summary.simulation),
|
|
));
|
|
}
|
|
let send_evaluation = match kb_lib::ExSafetyChecker
|
|
.evaluate_send(&prepared.summary.plan, &prepared.summary.simulation)
|
|
{
|
|
std::result::Result::Ok(value) => value,
|
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
|
};
|
|
if send_evaluation.decision == kb_lib::ExSafetyDecision::Deny {
|
|
return std::result::Result::Err(kb_core::Error::new(
|
|
"execution_send_denied",
|
|
crate::violation_message(send_evaluation.violations.as_slice()),
|
|
));
|
|
}
|
|
let signed = match sign_prepared_transaction(
|
|
prepared.unsigned,
|
|
&prepared.evidence,
|
|
prepared.wallet.as_sync_signer(),
|
|
additional_signers,
|
|
) {
|
|
std::result::Result::Ok(value) => value,
|
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
|
};
|
|
if let std::result::Result::Err(error) = signed.verify_signatures() {
|
|
return std::result::Result::Err(error);
|
|
}
|
|
let signature = signed.primary_signature().clone();
|
|
let mut summary = prepared.summary;
|
|
let mut diagnostic = kb_lib::ExApiPostExecutionDiagnostic {
|
|
signature: signature.clone(),
|
|
canonical_inserted: false,
|
|
core_extracted: false,
|
|
decode_replayed: false,
|
|
materialized: false,
|
|
diagnostics: std::vec::Vec::new(),
|
|
};
|
|
let send_config = match kb_onchain_transport::SendTransactionConfig::from_execution_config(
|
|
&profile.execution,
|
|
std::option::Option::Some(summary.latest_blockhash.context.slot),
|
|
) {
|
|
std::result::Result::Ok(value) => value,
|
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
|
};
|
|
let sent = match http_pool
|
|
.send_transaction_for_role(
|
|
request.transaction_role.as_str(),
|
|
signed.transaction_base64().as_str(),
|
|
&signature,
|
|
&send_config,
|
|
)
|
|
.await
|
|
{
|
|
std::result::Result::Ok(value) => value,
|
|
std::result::Result::Err(error) => {
|
|
diagnostic.diagnostics.push(format!("Metaplex submission failed: {error}"));
|
|
summary.post_execution = std::option::Option::Some(diagnostic);
|
|
return std::result::Result::Ok(summary);
|
|
},
|
|
};
|
|
summary.send_result =
|
|
std::option::Option::Some(sent.to_execution_result(kb_lib::ExApiExecutionCluster::Devnet));
|
|
let confirmation_config =
|
|
match kb_onchain_transport::ConfirmTransactionConfig::from_execution_config(
|
|
&profile.execution,
|
|
std::option::Option::Some(summary.latest_blockhash.last_valid_block_height),
|
|
std::option::Option::Some(summary.latest_blockhash.context.slot),
|
|
) {
|
|
std::result::Result::Ok(value) => value,
|
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
|
};
|
|
let confirmation = match http_pool
|
|
.confirm_transaction_for_roles(
|
|
request.transaction_role.as_str(),
|
|
request.query_role.as_str(),
|
|
kb_lib::ExApiExecutionCluster::Devnet,
|
|
&signature,
|
|
&confirmation_config,
|
|
)
|
|
.await
|
|
{
|
|
std::result::Result::Ok(value) => value,
|
|
std::result::Result::Err(error) => {
|
|
diagnostic.diagnostics.push(format!("Metaplex confirmation failed: {error}"));
|
|
summary.post_execution = std::option::Option::Some(diagnostic);
|
|
return std::result::Result::Ok(summary);
|
|
},
|
|
};
|
|
let confirmation_status = confirmation.status;
|
|
summary.confirmation = std::option::Option::Some(confirmation);
|
|
if !matches!(
|
|
confirmation_status,
|
|
kb_lib::ExApiExecutionConfirmationStatus::Confirmed
|
|
| kb_lib::ExApiExecutionConfirmationStatus::Finalized
|
|
) {
|
|
diagnostic.diagnostics.push(format!(
|
|
"Metaplex post-validation stopped at confirmation status {confirmation_status:?}"
|
|
));
|
|
summary.post_execution = std::option::Option::Some(diagnostic);
|
|
return std::result::Result::Ok(summary);
|
|
}
|
|
let postcondition_reads = bind_postcondition_min_context_slot(
|
|
request.postcondition_reads.as_slice(),
|
|
summary.confirmation.as_ref().and_then(|value| return value.slot),
|
|
);
|
|
summary.after = match read_snapshots(http_pool, postcondition_reads.as_slice()).await {
|
|
std::result::Result::Ok(value) => value,
|
|
std::result::Result::Err(error) => {
|
|
diagnostic
|
|
.diagnostics
|
|
.push(format!("Metaplex stateful postcondition read failed: {error}"));
|
|
summary.post_execution = std::option::Option::Some(diagnostic);
|
|
return std::result::Result::Ok(summary);
|
|
},
|
|
};
|
|
if request.materialize_after_confirmation {
|
|
summary.materialized_snapshots = materialize_confirmed_snapshots(summary.after.as_slice());
|
|
}
|
|
let backfill = match crate::hydrate_signature(
|
|
http_pool,
|
|
store,
|
|
profile,
|
|
request.query_role.as_str(),
|
|
request.post_validation_max_retries,
|
|
observer,
|
|
&signature,
|
|
)
|
|
.await
|
|
{
|
|
std::result::Result::Ok(value) => value,
|
|
std::result::Result::Err(error) => {
|
|
diagnostic.diagnostics.push(format!("Metaplex hydration failed: {error}"));
|
|
summary.post_execution = std::option::Option::Some(diagnostic);
|
|
return std::result::Result::Ok(summary);
|
|
},
|
|
};
|
|
diagnostic.canonical_inserted = crate::canonical_available(&backfill);
|
|
summary.backfill = std::option::Option::Some(backfill);
|
|
if !diagnostic.canonical_inserted {
|
|
diagnostic.diagnostics.push(
|
|
"confirmed Metaplex transaction was unavailable for canonical hydration".to_string(),
|
|
);
|
|
summary.post_execution = std::option::Option::Some(diagnostic);
|
|
return std::result::Result::Ok(summary);
|
|
}
|
|
let extraction = match kb_pipeline::execute_core_extraction(
|
|
store,
|
|
&kb_pipeline::CoreExtractionRequest {
|
|
source: kb_pipeline::CoreExtractionSource::Signatures(std::vec![signature.0.clone()]),
|
|
limit: 1,
|
|
max_concurrent_extractions: 1,
|
|
force_replay: request.force_post_validation_replay,
|
|
},
|
|
observer,
|
|
)
|
|
.await
|
|
{
|
|
std::result::Result::Ok(value) => value,
|
|
std::result::Result::Err(error) => {
|
|
diagnostic.diagnostics.push(format!("Metaplex core extraction failed: {error}"));
|
|
summary.post_execution = std::option::Option::Some(diagnostic);
|
|
return std::result::Result::Ok(summary);
|
|
},
|
|
};
|
|
diagnostic.core_extracted = extraction.failed == 0
|
|
&& !extraction.cancelled
|
|
&& extraction.selected == 1
|
|
&& extraction.extracted.saturating_add(extraction.skipped) >= 1;
|
|
summary.core_extraction = std::option::Option::Some(extraction);
|
|
if !diagnostic.core_extracted {
|
|
diagnostic
|
|
.diagnostics
|
|
.push("Metaplex core extraction did not complete".to_string());
|
|
summary.post_execution = std::option::Option::Some(diagnostic);
|
|
return std::result::Result::Ok(summary);
|
|
}
|
|
let first_replay = match crate::replay_program(
|
|
store,
|
|
&signature,
|
|
false,
|
|
request.force_post_validation_replay,
|
|
&[kb_program_ids::METADATA_METAPLEX_TOKEN_METADATA_PROGRAM_ID],
|
|
decoders,
|
|
materializers,
|
|
observer,
|
|
)
|
|
.await
|
|
{
|
|
std::result::Result::Ok(value) => value,
|
|
std::result::Result::Err(error) => {
|
|
diagnostic.diagnostics.push(format!("Metaplex decode replay failed: {error}"));
|
|
summary.post_execution = std::option::Option::Some(diagnostic);
|
|
return std::result::Result::Ok(summary);
|
|
},
|
|
};
|
|
diagnostic.decode_replayed = crate::decode_completed(&first_replay);
|
|
if !diagnostic.decode_replayed {
|
|
diagnostic
|
|
.diagnostics
|
|
.push(replay_summary_diagnostic("first Metaplex replay", &first_replay));
|
|
}
|
|
summary.decode_replay = std::option::Option::Some(first_replay);
|
|
let filter = match kb_store::MaterializedEventFilter::new(
|
|
std::option::Option::None,
|
|
std::option::Option::None,
|
|
std::option::Option::Some(signature.0.clone()),
|
|
64,
|
|
) {
|
|
std::result::Result::Ok(value) => value,
|
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
|
};
|
|
let rows = match kb_store::DecodePipelineStore::list_materialized_events(store, &filter).await {
|
|
std::result::Result::Ok(value) => value,
|
|
std::result::Result::Err(error) => {
|
|
diagnostic
|
|
.diagnostics
|
|
.push(format!("Metaplex materialization query failed: {error}"));
|
|
summary.post_execution = std::option::Option::Some(diagnostic);
|
|
return std::result::Result::Ok(summary);
|
|
},
|
|
};
|
|
summary.materializations = metaplex_instruction_materializations(rows);
|
|
let metaplex_materializer_name = kb_lib::MtApiEventMaterializer::identity(
|
|
&kb_lib::MtMetadataMetaplexTokenMetadataMaterializer,
|
|
)
|
|
.name;
|
|
diagnostic.materialized =
|
|
!summary.plan.policy.post_execution_validation.materialization_required
|
|
|| !summary.materializations.is_empty();
|
|
if summary.plan.policy.post_execution_validation.materialization_required
|
|
&& summary.materializations.is_empty()
|
|
{
|
|
diagnostic.diagnostics.push(format!(
|
|
"Metaplex instruction materializer `{metaplex_materializer_name}` produced no queryable row for signature {}",
|
|
signature.0
|
|
));
|
|
}
|
|
let second_replay = match crate::replay_program(
|
|
store,
|
|
&signature,
|
|
true,
|
|
request.force_post_validation_replay,
|
|
&[kb_program_ids::METADATA_METAPLEX_TOKEN_METADATA_PROGRAM_ID],
|
|
decoders,
|
|
materializers,
|
|
observer,
|
|
)
|
|
.await
|
|
{
|
|
std::result::Result::Ok(value) => value,
|
|
std::result::Result::Err(error) => {
|
|
diagnostic
|
|
.diagnostics
|
|
.push(format!("Metaplex idempotence replay failed: {error}"));
|
|
summary.post_execution = std::option::Option::Some(diagnostic);
|
|
return std::result::Result::Ok(summary);
|
|
},
|
|
};
|
|
let idempotent = second_replay.failed_inputs == 0
|
|
&& second_replay.processing_error_inputs == 0
|
|
&& second_replay.processors.iter().all(|processor| {
|
|
return processor.failed == 0
|
|
&& processor.processing_errors == 0
|
|
&& processor.materialized_outputs == 0
|
|
&& processor.materialization_refused == 0;
|
|
});
|
|
summary.idempotence_replay = std::option::Option::Some(second_replay);
|
|
if !idempotent {
|
|
diagnostic
|
|
.diagnostics
|
|
.push("second Metaplex replay did not prove a clean idempotent skip".to_string());
|
|
} else if diagnostic.canonical_inserted
|
|
&& diagnostic.core_extracted
|
|
&& diagnostic.decode_replayed
|
|
&& diagnostic.materialized
|
|
{
|
|
diagnostic.diagnostics.push(
|
|
"Metaplex completed canonical hydration, core extraction, decode, materialization and idempotence validation"
|
|
.to_string(),
|
|
);
|
|
}
|
|
summary.post_execution = std::option::Option::Some(diagnostic);
|
|
return std::result::Result::Ok(summary);
|
|
}
|
|
|
|
fn sign_prepared_transaction(
|
|
unsigned: kb_lib::ExSolanaUnsignedTransaction,
|
|
evidence: &kb_lib::ExSolanaSimulationEvidence,
|
|
wallet: &(dyn solana_signer::Signer + std::marker::Sync),
|
|
additional_signers: &[&(dyn solana_signer::Signer + std::marker::Sync)],
|
|
) -> kb_core::Result<kb_lib::ExSolanaSignedTransaction> {
|
|
let mut signers: std::vec::Vec<&dyn solana_signer::Signer> = std::vec![wallet];
|
|
for signer in additional_signers {
|
|
let signer: &dyn solana_signer::Signer = *signer;
|
|
signers.push(signer);
|
|
}
|
|
return unsigned.sign_after_simulation(evidence, signers.as_slice());
|
|
}
|
|
|
|
fn replay_summary_diagnostic(
|
|
label: &str,
|
|
summary: &kb_pipeline::DecodeReplaySummary,
|
|
) -> std::string::String {
|
|
let processors = summary
|
|
.processors
|
|
.iter()
|
|
.map(|processor| {
|
|
return format!(
|
|
"{}@{} dispatched={} skipped={} decoded={} ignored={} unsupported={} failed={} processing_errors={} materialized_outputs={} materialization_refused={}",
|
|
processor.processor_name,
|
|
processor.processor_version,
|
|
processor.dispatched,
|
|
processor.skipped,
|
|
processor.decoded,
|
|
processor.ignored,
|
|
processor.unsupported,
|
|
processor.failed,
|
|
processor.processing_errors,
|
|
processor.materialized_outputs,
|
|
processor.materialization_refused,
|
|
);
|
|
})
|
|
.collect::<std::vec::Vec<_>>()
|
|
.join("; ");
|
|
return format!(
|
|
"{label}: selected={} started={} completed={} unmatched={} failed_inputs={} processing_error_inputs={} cancelled={} processors=[{}]",
|
|
summary.selected,
|
|
summary.started,
|
|
summary.completed,
|
|
summary.unmatched,
|
|
summary.failed_inputs,
|
|
summary.processing_error_inputs,
|
|
summary.cancelled,
|
|
processors,
|
|
);
|
|
}
|
|
|
|
async fn prepare_execution<O>(
|
|
http_pool: &kb_onchain_transport::HttpEndpointPool,
|
|
profile: &kb_config::ProfileConfig,
|
|
workspace_root: &std::path::Path,
|
|
request: &crate::DevnetMetaplexTokenMetadataExecutionRequest,
|
|
additional_signers: &[&(dyn solana_signer::Signer + std::marker::Sync)],
|
|
observer: &O,
|
|
) -> kb_core::Result<PreparedMetaplexExecution>
|
|
where
|
|
O: crate::SolanaExecutionObserver,
|
|
{
|
|
if let std::result::Result::Err(error) = request.validate() {
|
|
return std::result::Result::Err(error);
|
|
}
|
|
if let std::result::Result::Err(error) = validate_profile(profile, request) {
|
|
return std::result::Result::Err(error);
|
|
}
|
|
if let std::result::Result::Err(error) =
|
|
crate::ensure_not_cancelled(observer, "metaplex_validate")
|
|
{
|
|
return std::result::Result::Err(error);
|
|
}
|
|
let genesis = match http_pool.get_genesis_hash_for_role(request.query_role.as_str()).await {
|
|
std::result::Result::Ok(value) => value,
|
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
|
};
|
|
if genesis.classified_cluster
|
|
!= std::option::Option::Some(kb_lib::ExApiExecutionCluster::Devnet)
|
|
{
|
|
return std::result::Result::Err(kb_core::Error::new(
|
|
"execution_cluster_mismatch",
|
|
format!(
|
|
"expected Devnet genesis hash but endpoint returned {} classified as {:?}",
|
|
genesis.genesis_hash, genesis.classified_cluster
|
|
),
|
|
));
|
|
}
|
|
let wallet = match crate::load_profile_wallet(profile, workspace_root).await {
|
|
std::result::Result::Ok(value) => value,
|
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
|
};
|
|
let wallet_summary = wallet.summary();
|
|
let fee_payer = kb_lib::MdPubkey(wallet_summary.public_key.clone());
|
|
let balance = match http_pool
|
|
.get_balance_for_role(
|
|
request.query_role.as_str(),
|
|
&fee_payer,
|
|
&kb_onchain_transport::GetBalanceConfig::confirmed(),
|
|
)
|
|
.await
|
|
{
|
|
std::result::Result::Ok(value) => value,
|
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
|
};
|
|
if balance.lamports < profile.execution.max_fee_lamports {
|
|
return std::result::Result::Err(kb_core::Error::new(
|
|
"execution_balance_insufficient",
|
|
"Devnet wallet balance is below the configured fee ceiling",
|
|
));
|
|
}
|
|
let plan = match build_plan(profile, request, fee_payer.clone()) {
|
|
std::result::Result::Ok(value) => value,
|
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
|
};
|
|
let before = match read_snapshots(http_pool, &request.preflight_reads).await {
|
|
std::result::Result::Ok(value) => value,
|
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
|
};
|
|
let stateful_preflight = match kb_pipeline::inspect_metaplex_token_metadata_preflight(
|
|
&kb_pipeline::MetaplexTokenMetadataPreflightRequest {
|
|
plan: plan.clone(),
|
|
snapshots: before.clone(),
|
|
allow_deprecated_operation: false,
|
|
},
|
|
) {
|
|
std::result::Result::Ok(value) => value,
|
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
|
};
|
|
let plan_evaluation = match kb_lib::ExSafetyChecker.evaluate_prepared_plan(&plan) {
|
|
std::result::Result::Ok(value) => value,
|
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
|
};
|
|
if plan_evaluation.decision == kb_lib::ExSafetyDecision::Deny {
|
|
return std::result::Result::Err(kb_core::Error::new(
|
|
"execution_plan_denied",
|
|
crate::violation_message(plan_evaluation.violations.as_slice()),
|
|
));
|
|
}
|
|
let latest_blockhash = match http_pool
|
|
.get_latest_blockhash_for_role(
|
|
request.query_role.as_str(),
|
|
&kb_onchain_transport::GetLatestBlockhashConfig::confirmed(),
|
|
)
|
|
.await
|
|
{
|
|
std::result::Result::Ok(value) => value,
|
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
|
};
|
|
let unsigned = match kb_lib::executor_solana_build_legacy_transaction(
|
|
&plan,
|
|
latest_blockhash.blockhash.as_str(),
|
|
) {
|
|
std::result::Result::Ok(value) => value,
|
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
|
};
|
|
let resolved_signers = match validate_available_signers(
|
|
unsigned.required_signer_pubkeys(),
|
|
wallet_summary.public_key.as_str(),
|
|
request.additional_authorized_signers.as_slice(),
|
|
additional_signers,
|
|
) {
|
|
std::result::Result::Ok(value) => value,
|
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
|
};
|
|
let fee = match http_pool
|
|
.get_fee_for_message_for_role(
|
|
request.query_role.as_str(),
|
|
unsigned.message_base64().as_str(),
|
|
&kb_onchain_transport::GetFeeForMessageConfig::new(
|
|
kb_onchain_transport::RpcCommitmentLevel::Confirmed,
|
|
std::option::Option::Some(latest_blockhash.context.slot),
|
|
),
|
|
)
|
|
.await
|
|
{
|
|
std::result::Result::Ok(value) => value,
|
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
|
};
|
|
let unsigned_base64 = match unsigned.transaction_base64() {
|
|
std::result::Result::Ok(value) => value,
|
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
|
};
|
|
let simulation_config = match kb_onchain_transport::SimulateTransactionConfig::new(
|
|
kb_onchain_transport::RpcCommitmentLevel::Confirmed,
|
|
false,
|
|
false,
|
|
std::option::Option::Some(latest_blockhash.context.slot),
|
|
true,
|
|
std::option::Option::None,
|
|
) {
|
|
std::result::Result::Ok(value) => value,
|
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
|
};
|
|
crate::emit(
|
|
observer,
|
|
crate::SolanaExecutionProgressLevel::Info,
|
|
"metaplex_token_metadata_simulation",
|
|
format!("simulating exact Metaplex message {}", unsigned.message_hash()),
|
|
std::option::Option::None,
|
|
);
|
|
let simulation_rpc = match http_pool
|
|
.simulate_transaction_for_role(
|
|
request.transaction_role.as_str(),
|
|
unsigned_base64.as_str(),
|
|
&simulation_config,
|
|
)
|
|
.await
|
|
{
|
|
std::result::Result::Ok(value) => value,
|
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
|
};
|
|
let simulation = simulation_rpc.to_execution_result(
|
|
kb_lib::ExApiExecutionCluster::Devnet,
|
|
kb_lib::ExApiExecutionBlockhashKind::Latest,
|
|
std::option::Option::Some(
|
|
simulation_rpc.context.slot.saturating_sub(latest_blockhash.context.slot),
|
|
),
|
|
std::option::Option::None,
|
|
std::option::Option::None,
|
|
std::option::Option::Some(&fee),
|
|
);
|
|
let readiness = match kb_pipeline::validate_metaplex_token_metadata_execution_readiness(
|
|
&kb_pipeline::MetaplexTokenMetadataExecutionReadinessRequest {
|
|
plan: plan.clone(),
|
|
preflight: stateful_preflight.clone(),
|
|
message_hash: unsigned.message_hash().to_string(),
|
|
simulated_message_hash: unsigned.message_hash().to_string(),
|
|
simulated: true,
|
|
simulation_succeeded: simulation.success,
|
|
resolved_signers,
|
|
submit: request.submit,
|
|
operator_confirmed: request.operator_confirmed,
|
|
},
|
|
) {
|
|
std::result::Result::Ok(value) => value,
|
|
std::result::Result::Err(error) => {
|
|
let simulation_json = match serde_json::to_string_pretty(&simulation) {
|
|
std::result::Result::Ok(value) => value,
|
|
std::result::Result::Err(_) => "<simulation serialization unavailable>".to_string(),
|
|
};
|
|
return std::result::Result::Err(kb_core::Error::new(
|
|
error.code(),
|
|
format!("{}; simulation={simulation_json}", error.message()),
|
|
));
|
|
},
|
|
};
|
|
let evidence = unsigned.bind_simulation(simulation.clone());
|
|
return std::result::Result::Ok(PreparedMetaplexExecution {
|
|
wallet,
|
|
unsigned,
|
|
evidence,
|
|
summary: crate::DevnetMetaplexTokenMetadataExecutionSummary {
|
|
profile_name: profile.name.clone(),
|
|
cluster: kb_lib::ExApiExecutionCluster::Devnet,
|
|
genesis_hash: genesis.genesis_hash,
|
|
wallet: wallet_summary,
|
|
balance_lamports: balance.lamports,
|
|
before,
|
|
stateful_preflight,
|
|
plan,
|
|
latest_blockhash,
|
|
fee,
|
|
simulation,
|
|
simulation_context_slot: simulation_rpc.context.slot,
|
|
readiness,
|
|
send_result: std::option::Option::None,
|
|
confirmation: std::option::Option::None,
|
|
after: std::vec::Vec::new(),
|
|
materialization_requested: request.materialize_after_confirmation,
|
|
materialized_snapshots: std::vec::Vec::new(),
|
|
backfill: std::option::Option::None,
|
|
core_extraction: std::option::Option::None,
|
|
decode_replay: std::option::Option::None,
|
|
idempotence_replay: std::option::Option::None,
|
|
materializations: std::vec::Vec::new(),
|
|
post_execution: std::option::Option::None,
|
|
},
|
|
});
|
|
}
|
|
|
|
fn metaplex_instruction_materializations(
|
|
rows: std::vec::Vec<kb_store::MaterializedEventQueryRow>,
|
|
) -> std::vec::Vec<kb_store::MaterializedEventQueryRow> {
|
|
let materializer_name = kb_lib::MtApiEventMaterializer::identity(
|
|
&kb_lib::MtMetadataMetaplexTokenMetadataMaterializer,
|
|
)
|
|
.name;
|
|
return rows
|
|
.into_iter()
|
|
.filter(|row| return row.processor_name == materializer_name)
|
|
.collect();
|
|
}
|
|
|
|
fn materialize_confirmed_snapshots(
|
|
snapshots: &[kb_pipeline::MetaplexTokenMetadataStatefulReadResult],
|
|
) -> std::vec::Vec<serde_json::Value> {
|
|
return snapshots
|
|
.iter()
|
|
.map(|result| {
|
|
return serde_json::json!({
|
|
"projectionVersion": 1,
|
|
"domain": "metaplex_token_metadata",
|
|
"projectionSemantics": "confirmed_bounded_account_snapshot",
|
|
"account": &result.snapshot.account,
|
|
"accountKind": &result.snapshot.account_kind,
|
|
"mint": &result.snapshot.mint,
|
|
"slot": result.snapshot.slot.to_string(),
|
|
"commitment": &result.commitment,
|
|
"state": &result.snapshot.payload_json,
|
|
"externalUriFetched": false
|
|
});
|
|
})
|
|
.collect();
|
|
}
|
|
|
|
fn bind_postcondition_min_context_slot(
|
|
requests: &[kb_pipeline::MetaplexTokenMetadataStatefulReadRequest],
|
|
confirmation_slot: std::option::Option<u64>,
|
|
) -> std::vec::Vec<kb_pipeline::MetaplexTokenMetadataStatefulReadRequest> {
|
|
return requests
|
|
.iter()
|
|
.cloned()
|
|
.map(|mut request| {
|
|
request.min_context_slot = match (request.min_context_slot, confirmation_slot) {
|
|
(std::option::Option::Some(existing), std::option::Option::Some(confirmed)) => {
|
|
std::option::Option::Some(existing.max(confirmed))
|
|
},
|
|
(std::option::Option::None, std::option::Option::Some(confirmed)) => {
|
|
std::option::Option::Some(confirmed)
|
|
},
|
|
(existing, std::option::Option::None) => existing,
|
|
};
|
|
return request;
|
|
})
|
|
.collect();
|
|
}
|
|
|
|
async fn read_snapshots(
|
|
http_pool: &kb_onchain_transport::HttpEndpointPool,
|
|
requests: &[kb_pipeline::MetaplexTokenMetadataStatefulReadRequest],
|
|
) -> kb_core::Result<std::vec::Vec<kb_pipeline::MetaplexTokenMetadataStatefulReadResult>> {
|
|
let mut results = std::vec::Vec::with_capacity(requests.len());
|
|
for request in requests {
|
|
let value =
|
|
match kb_pipeline::read_metaplex_token_metadata_stateful_snapshot(http_pool, request)
|
|
.await
|
|
{
|
|
std::result::Result::Ok(value) => value,
|
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
|
};
|
|
results.push(value);
|
|
}
|
|
return std::result::Result::Ok(results);
|
|
}
|
|
|
|
fn build_plan(
|
|
profile: &kb_config::ProfileConfig,
|
|
request: &crate::DevnetMetaplexTokenMetadataExecutionRequest,
|
|
fee_payer: kb_lib::MdPubkey,
|
|
) -> kb_core::Result<kb_lib::ExApiPreparedExecutionPlan> {
|
|
let intent = kb_lib::ExMetaplexTokenMetadataExecutionIntent {
|
|
intent_id: request.intent_id.clone(),
|
|
fee_payer: fee_payer.clone(),
|
|
max_rent_lamports: profile.execution.devnet_max_spend_lamports,
|
|
policy: kb_lib::ExApiExecutionPolicy {
|
|
cluster: kb_lib::ExApiExecutionClusterPolicy {
|
|
expected_cluster: kb_lib::ExApiExecutionCluster::Devnet,
|
|
allow_mainnet: false,
|
|
mainnet_confirmation: false,
|
|
},
|
|
simulation: kb_lib::ExApiExecutionSimulationPolicy::Required,
|
|
blockhash: kb_lib::ExApiExecutionBlockhashPolicy {
|
|
kind: kb_lib::ExApiExecutionBlockhashKind::Latest,
|
|
max_age_slots: std::option::Option::Some(
|
|
profile.execution.recent_blockhash_max_age_slots,
|
|
),
|
|
nonce_account: std::option::Option::None,
|
|
nonce_authority: std::option::Option::None,
|
|
},
|
|
cost_limit: kb_lib::ExApiExecutionCostLimit {
|
|
max_spend_lamports: std::option::Option::Some(
|
|
profile.execution.devnet_max_spend_lamports,
|
|
),
|
|
max_fee_lamports: std::option::Option::Some(profile.execution.max_fee_lamports),
|
|
max_compute_unit_price_micro_lamports: std::option::Option::Some(
|
|
profile.execution.max_compute_unit_price_micro_lamports,
|
|
),
|
|
},
|
|
authorized_signers: {
|
|
let mut authorized_signers = vec![fee_payer.clone()];
|
|
for signer in &request.additional_authorized_signers {
|
|
if !authorized_signers.contains(signer) {
|
|
authorized_signers.push(signer.clone());
|
|
}
|
|
}
|
|
authorized_signers
|
|
},
|
|
dry_run: !request.submit,
|
|
post_execution_validation: kb_lib::ExApiPostExecutionValidationPolicy {
|
|
canonical_insert_required: request.submit,
|
|
core_extraction_required: request.submit,
|
|
decode_replay_required: request.submit,
|
|
materialization_required: request.submit && request.materialize_after_confirmation,
|
|
},
|
|
},
|
|
allow_deprecated_operation: false,
|
|
operation: request.operation.clone(),
|
|
};
|
|
return kb_lib::ExApiTypedInstructionExecutor::build_prepared_plan(
|
|
&kb_lib::ExMetadataMetaplexTokenMetadataExecutor,
|
|
&intent,
|
|
);
|
|
}
|
|
|
|
fn validate_profile(
|
|
profile: &kb_config::ProfileConfig,
|
|
request: &crate::DevnetMetaplexTokenMetadataExecutionRequest,
|
|
) -> kb_core::Result<()> {
|
|
if profile.wallet.cluster != "devnet" {
|
|
return std::result::Result::Err(kb_core::Error::config(
|
|
"Metaplex Devnet orchestration requires a Devnet wallet profile",
|
|
));
|
|
}
|
|
if !profile.wallet.temporary_wallet_enabled || !profile.wallet.temporary_wallet_persist {
|
|
return std::result::Result::Err(kb_core::Error::config(
|
|
"Metaplex Devnet orchestration requires an enabled persistent temporary wallet",
|
|
));
|
|
}
|
|
if !profile.execution.require_simulation {
|
|
return std::result::Result::Err(kb_core::Error::config(
|
|
"Metaplex Devnet orchestration requires simulation",
|
|
));
|
|
}
|
|
if request.submit && !profile.wallet.devnet_send_enabled {
|
|
return std::result::Result::Err(kb_core::Error::config(
|
|
"Devnet transaction submission is disabled by the wallet profile",
|
|
));
|
|
}
|
|
if request.submit
|
|
&& profile.execution.require_operator_confirmation
|
|
&& !request.operator_confirmed
|
|
{
|
|
return std::result::Result::Err(kb_core::Error::config(
|
|
"Metaplex Devnet submission requires explicit operator confirmation",
|
|
));
|
|
}
|
|
return std::result::Result::Ok(());
|
|
}
|
|
|
|
fn validate_available_signers(
|
|
required_signers: &[std::string::String],
|
|
wallet_pubkey: &str,
|
|
authorized_additional_signers: &[kb_lib::MdPubkey],
|
|
additional_signers: &[&(dyn solana_signer::Signer + std::marker::Sync)],
|
|
) -> kb_core::Result<std::vec::Vec<kb_lib::MdPubkey>> {
|
|
let authorized = authorized_additional_signers
|
|
.iter()
|
|
.map(|value| return value.0.as_str())
|
|
.collect::<std::collections::BTreeSet<_>>();
|
|
let mut available = std::collections::BTreeSet::from([wallet_pubkey.to_string()]);
|
|
let mut resolved = std::vec![kb_lib::MdPubkey(wallet_pubkey.to_string())];
|
|
for signer in additional_signers {
|
|
let pubkey = signer.pubkey().to_string();
|
|
if !authorized.contains(pubkey.as_str()) {
|
|
return std::result::Result::Err(kb_core::Error::new(
|
|
"execution_metaplex_undeclared_signer",
|
|
format!(
|
|
"Metaplex signer {pubkey} was supplied without explicit request authorization"
|
|
),
|
|
));
|
|
}
|
|
if !available.insert(pubkey.clone()) {
|
|
return std::result::Result::Err(kb_core::Error::new(
|
|
"execution_metaplex_duplicate_signer",
|
|
format!("Metaplex signer {pubkey} was supplied more than once"),
|
|
));
|
|
}
|
|
resolved.push(kb_lib::MdPubkey(pubkey));
|
|
}
|
|
let missing_authorized = authorized_additional_signers
|
|
.iter()
|
|
.filter(|value| return !available.contains(value.0.as_str()))
|
|
.map(|value| return value.0.clone())
|
|
.collect::<std::vec::Vec<_>>();
|
|
if !missing_authorized.is_empty() {
|
|
return std::result::Result::Err(kb_core::Error::new(
|
|
"execution_metaplex_authorized_signer_unavailable",
|
|
format!(
|
|
"explicitly authorized Metaplex signers were not supplied: {}",
|
|
missing_authorized.join(",")
|
|
),
|
|
));
|
|
}
|
|
let missing = required_signers
|
|
.iter()
|
|
.filter(|value| return !available.contains(value.as_str()))
|
|
.cloned()
|
|
.collect::<std::vec::Vec<_>>();
|
|
if !missing.is_empty() {
|
|
return std::result::Result::Err(kb_core::Error::new(
|
|
"execution_metaplex_external_signer_unavailable",
|
|
format!(
|
|
"the Devnet Metaplex orchestrator is missing required signers {}; available signers are {}",
|
|
missing.join(","),
|
|
available.into_iter().collect::<std::vec::Vec<_>>().join(",")
|
|
),
|
|
));
|
|
}
|
|
return std::result::Result::Ok(resolved);
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
fn example_devnet_profile() -> kb_config::ProfileConfig {
|
|
let config = match kb_config::parse_config_json(include_str!(
|
|
"../../../../config/example.config.json"
|
|
)) {
|
|
std::result::Result::Ok(value) => value,
|
|
std::result::Result::Err(error) => panic!("example config parse failed: {error}"),
|
|
};
|
|
return match crate::resolve_demo_devnet_profile(&config, std::option::Option::None) {
|
|
std::result::Result::Ok(profile) => profile,
|
|
std::result::Result::Err(error) => panic!("Devnet profile resolution failed: {error}"),
|
|
};
|
|
}
|
|
|
|
fn optional_stateful_reads_from_env(
|
|
variable: &str,
|
|
) -> std::vec::Vec<kb_pipeline::MetaplexTokenMetadataStatefulReadRequest> {
|
|
let value = match std::env::var(variable) {
|
|
std::result::Result::Ok(value) => value,
|
|
std::result::Result::Err(_) => return std::vec::Vec::new(),
|
|
};
|
|
return match serde_json::from_str::<
|
|
std::vec::Vec<kb_pipeline::MetaplexTokenMetadataStatefulReadRequest>,
|
|
>(value.as_str())
|
|
{
|
|
std::result::Result::Ok(value) => value,
|
|
std::result::Result::Err(error) => panic!("invalid {variable}: {error}"),
|
|
};
|
|
}
|
|
|
|
#[test]
|
|
#[allow(deprecated)]
|
|
fn request_rejects_deprecated_operations_and_empty_ids() {
|
|
let deprecated = crate::DevnetMetaplexTokenMetadataExecutionRequest::new(
|
|
"deprecated",
|
|
kb_lib::ExMetaplexTokenMetadataOperation::PuffMetadata {
|
|
metadata: kb_lib::MdPubkey("11111111111111111111111111111111".to_string()),
|
|
},
|
|
);
|
|
assert!(deprecated.validate().is_err());
|
|
let mut empty = deprecated;
|
|
empty.intent_id.clear();
|
|
assert!(empty.validate().is_err());
|
|
let mut excessive_retries = crate::DevnetMetaplexTokenMetadataExecutionRequest::new(
|
|
"excessive-retries",
|
|
kb_lib::ExMetaplexTokenMetadataOperation::Collect {
|
|
authority: kb_lib::MdPubkey("11111111111111111111111111111111".to_string()),
|
|
recipient: kb_lib::MdPubkey(
|
|
"SysvarC1ock11111111111111111111111111111111".to_string(),
|
|
),
|
|
},
|
|
);
|
|
excessive_retries.post_validation_max_retries = 21;
|
|
assert!(excessive_retries.validate().is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn typed_operation_json_rejects_invalid_and_deprecated_payloads() {
|
|
assert!(
|
|
crate::DevnetMetaplexTokenMetadataExecutionRequest::from_operation_json(
|
|
"invalid-json",
|
|
"{}",
|
|
)
|
|
.is_err()
|
|
);
|
|
let deprecated = serde_json::json!({
|
|
"operation": "puff_metadata",
|
|
"metadata": "11111111111111111111111111111111"
|
|
});
|
|
assert!(
|
|
crate::DevnetMetaplexTokenMetadataExecutionRequest::from_operation_json(
|
|
"deprecated-operation",
|
|
deprecated.to_string().as_str(),
|
|
)
|
|
.is_err()
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn named_templates_are_json_and_placeholder_is_rejected_explicitly() {
|
|
for operation in ["create", "update_as_update_authority_v2", "verify", "unverify"] {
|
|
let template =
|
|
super::metaplex_token_metadata_current_operation_json_template(operation);
|
|
assert!(template.is_ok());
|
|
let template = match template {
|
|
std::result::Result::Ok(value) => value,
|
|
std::result::Result::Err(_) => return,
|
|
};
|
|
let parsed = serde_json::from_str::<serde_json::Value>(template.as_str());
|
|
assert!(parsed.is_ok());
|
|
if operation == "create" {
|
|
let parsed = match parsed {
|
|
std::result::Result::Ok(value) => value,
|
|
std::result::Result::Err(_) => return,
|
|
};
|
|
assert_eq!(
|
|
parsed
|
|
.pointer("/create_args/V1/print_supply")
|
|
.and_then(serde_json::Value::as_str),
|
|
std::option::Option::Some("Zero"),
|
|
);
|
|
assert_eq!(
|
|
parsed.get("spl_token_program").and_then(serde_json::Value::as_str),
|
|
std::option::Option::Some("TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA",),
|
|
);
|
|
}
|
|
}
|
|
let placeholder = crate::DevnetMetaplexTokenMetadataExecutionRequest::from_operation_json(
|
|
"placeholder",
|
|
r#"{"operation":"..."}"#,
|
|
);
|
|
assert_eq!(
|
|
placeholder.as_ref().err().map(kb_core::Error::code),
|
|
std::option::Option::Some("config"),
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn named_creator_requests_are_conservative_and_current() {
|
|
let authority = kb_lib::MdPubkey("11111111111111111111111111111111".to_string());
|
|
let metadata = kb_lib::MdPubkey("SysvarC1ock11111111111111111111111111111111".to_string());
|
|
let verify = super::devnet_metaplex_creator_verify_request(
|
|
"verify-creator",
|
|
authority.clone(),
|
|
metadata.clone(),
|
|
);
|
|
let unverify = super::devnet_metaplex_creator_unverify_request(
|
|
"unverify-creator",
|
|
authority,
|
|
metadata,
|
|
);
|
|
assert!(!verify.submit);
|
|
assert!(!verify.operator_confirmed);
|
|
assert!(verify.validate().is_ok());
|
|
assert!(unverify.validate().is_ok());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn optional_devnet_current_operation_from_env() {
|
|
if std::env::var("KB_DEVNET_METAPLEX_EXECUTION_TEST").ok().as_deref()
|
|
!= std::option::Option::Some("1")
|
|
{
|
|
return;
|
|
}
|
|
let operation_json = match std::env::var("KB_DEVNET_METAPLEX_OPERATION_JSON") {
|
|
std::result::Result::Ok(value) => value,
|
|
std::result::Result::Err(error) => {
|
|
panic!("KB_DEVNET_METAPLEX_OPERATION_JSON is required: {error}");
|
|
},
|
|
};
|
|
let mut profile = example_devnet_profile();
|
|
if let std::result::Result::Ok(directory) = std::env::var("KB_DEVNET_WALLET_DIR") {
|
|
profile.wallet.wallet_dir = directory;
|
|
}
|
|
let pool = match kb_onchain_transport::HttpEndpointPool::from_profile(&profile) {
|
|
std::result::Result::Ok(value) => value,
|
|
std::result::Result::Err(error) => panic!("HTTP pool creation failed: {error}"),
|
|
};
|
|
let database_url = match std::env::var("KB_POSTGRES_TEST_URL") {
|
|
std::result::Result::Ok(value) => value,
|
|
std::result::Result::Err(error) => {
|
|
panic!("KB_POSTGRES_TEST_URL is required for Metaplex execution: {error}")
|
|
},
|
|
};
|
|
let store_options = match kb_store::PostgresStoreOptions::new(database_url, 5, 5_000, false)
|
|
{
|
|
std::result::Result::Ok(value) => value,
|
|
std::result::Result::Err(error) => panic!("Postgres options failed: {error}"),
|
|
};
|
|
let store = match kb_store::PostgresStore::connect(store_options).await {
|
|
std::result::Result::Ok(value) => value,
|
|
std::result::Result::Err(error) => panic!("Postgres connection failed: {error}"),
|
|
};
|
|
if let std::result::Result::Err(error) = store.initialize_store_schema().await {
|
|
panic!("Postgres schema initialization failed: {error}");
|
|
}
|
|
let workspace_root = match std::path::Path::new(env!("CARGO_MANIFEST_DIR")).parent() {
|
|
std::option::Option::Some(value) => value,
|
|
std::option::Option::None => panic!("workspace root cannot be resolved"),
|
|
};
|
|
let mut request =
|
|
match crate::DevnetMetaplexTokenMetadataExecutionRequest::from_operation_json(
|
|
format!("devnet-metaplex-test-{}", uuid::Uuid::new_v4()),
|
|
operation_json.as_str(),
|
|
) {
|
|
std::result::Result::Ok(value) => value,
|
|
std::result::Result::Err(error) => {
|
|
panic!("Metaplex request parsing failed: {error}")
|
|
},
|
|
};
|
|
request.preflight_reads =
|
|
optional_stateful_reads_from_env("KB_DEVNET_METAPLEX_PREFLIGHT_READS_JSON");
|
|
request.postcondition_reads =
|
|
optional_stateful_reads_from_env("KB_DEVNET_METAPLEX_POSTCONDITION_READS_JSON");
|
|
request.submit = std::env::var("KB_DEVNET_METAPLEX_SUBMIT").ok().as_deref()
|
|
== std::option::Option::Some("1");
|
|
request.operator_confirmed = request.submit
|
|
&& std::env::var("KB_DEVNET_METAPLEX_OPERATOR_CONFIRMED").ok().as_deref()
|
|
== std::option::Option::Some("1");
|
|
request.materialize_after_confirmation = request.submit
|
|
&& std::env::var("KB_DEVNET_METAPLEX_MATERIALIZE_AFTER_CONFIRMATION")
|
|
.ok()
|
|
.as_deref()
|
|
== std::option::Option::Some("1");
|
|
request.post_validation_max_retries = 20;
|
|
let decoders: std::vec::Vec<std::sync::Arc<dyn kb_lib::DcApiInstructionDecoder>> =
|
|
std::vec![std::sync::Arc::new(kb_lib::DcMetadataMetaplexTokenMetadataDecoder)];
|
|
let materializers: std::vec::Vec<std::sync::Arc<dyn kb_lib::MtApiEventMaterializer>> = std::vec![
|
|
std::sync::Arc::new(kb_lib::MtAdminMaterializer),
|
|
std::sync::Arc::new(kb_lib::MtComplianceAuditMaterializer),
|
|
std::sync::Arc::new(kb_lib::MtLifecycleMaterializer),
|
|
std::sync::Arc::new(kb_lib::MtMetadataMetaplexTokenMetadataMaterializer),
|
|
std::sync::Arc::new(kb_lib::MtRiskMaterializer),
|
|
];
|
|
let summary = match crate::execute_devnet_metaplex_token_metadata(
|
|
&pool,
|
|
&store,
|
|
&profile,
|
|
workspace_root,
|
|
&request,
|
|
decoders.as_slice(),
|
|
materializers.as_slice(),
|
|
&crate::NoopSolanaExecutionObserver,
|
|
)
|
|
.await
|
|
{
|
|
std::result::Result::Ok(value) => value,
|
|
std::result::Result::Err(error) => panic!("Devnet Metaplex execution failed: {error}"),
|
|
};
|
|
assert!(summary.simulation.success, "{:#?}", summary.simulation);
|
|
if request.submit {
|
|
assert!(summary.send_result.is_some());
|
|
assert!(summary.confirmation.is_some());
|
|
let diagnostic = summary.post_execution.as_ref();
|
|
assert!(diagnostic.is_some());
|
|
assert!(diagnostic.is_some_and(|value| return value.canonical_inserted
|
|
&& value.core_extracted
|
|
&& value.decode_replayed
|
|
&& value.materialized));
|
|
if request.materialize_after_confirmation {
|
|
assert!(!summary.materializations.is_empty());
|
|
}
|
|
assert!(summary.idempotence_replay.is_some());
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn postcondition_reads_are_bound_to_the_confirmation_slot() {
|
|
let request = kb_pipeline::MetaplexTokenMetadataStatefulReadRequest {
|
|
query_role: "rpc".to_string(),
|
|
account: kb_lib::MdPubkey("11111111111111111111111111111111".to_string()),
|
|
kind: kb_pipeline::MetaplexTokenMetadataAccountKind::Metadata,
|
|
min_context_slot: std::option::Option::Some(7),
|
|
max_data_bytes: 128,
|
|
};
|
|
let bound = super::bind_postcondition_min_context_slot(
|
|
std::slice::from_ref(&request),
|
|
std::option::Option::Some(11),
|
|
);
|
|
assert_eq!(bound[0].min_context_slot, std::option::Option::Some(11));
|
|
let preserved = super::bind_postcondition_min_context_slot(
|
|
std::slice::from_ref(&request),
|
|
std::option::Option::Some(5),
|
|
);
|
|
assert_eq!(preserved[0].min_context_slot, std::option::Option::Some(7));
|
|
}
|
|
|
|
#[test]
|
|
fn materialization_evidence_is_owned_by_the_metaplex_materializer() {
|
|
fn row(processor_name: &str) -> kb_store::MaterializedEventQueryRow {
|
|
return kb_store::MaterializedEventQueryRow {
|
|
processor_name: processor_name.to_string(),
|
|
processor_version: "0.4.8-pre.13".to_string(),
|
|
input_key: "input".to_string(),
|
|
output_key: "output".to_string(),
|
|
source_event_key: "event".to_string(),
|
|
source_decoder_name: "metadata.metaplex_token_metadata".to_string(),
|
|
source_decoder_version: "0.4.8-pre.13".to_string(),
|
|
signature: "signature".to_string(),
|
|
slot: 42,
|
|
materialized_family: "metadata".to_string(),
|
|
payload_json: serde_json::json!({}),
|
|
created_at: "created".to_string(),
|
|
updated_at: "updated".to_string(),
|
|
};
|
|
}
|
|
let metaplex_name = kb_lib::MtApiEventMaterializer::identity(
|
|
&kb_lib::MtMetadataMetaplexTokenMetadataMaterializer,
|
|
)
|
|
.name;
|
|
let rows = super::metaplex_instruction_materializations(vec![
|
|
row(metaplex_name.as_str()),
|
|
row("materializer.metadata.other"),
|
|
]);
|
|
assert_eq!(rows.len(), 1);
|
|
assert_eq!(rows[0].processor_name, metaplex_name);
|
|
}
|
|
|
|
#[test]
|
|
fn signer_guard_requires_every_external_signer_explicitly() {
|
|
let external = solana_keypair::Keypair::new();
|
|
let external_pubkey = solana_signer::Signer::pubkey(&external).to_string();
|
|
assert!(
|
|
super::validate_available_signers(&["wallet".to_string()], "wallet", &[], &[]).is_ok()
|
|
);
|
|
assert!(
|
|
super::validate_available_signers(
|
|
std::slice::from_ref(&external_pubkey),
|
|
"wallet",
|
|
&[],
|
|
&[],
|
|
)
|
|
.is_err()
|
|
);
|
|
let authorized = vec![kb_lib::MdPubkey(external_pubkey.clone())];
|
|
let resolved = match super::validate_available_signers(
|
|
&["wallet".to_string(), external_pubkey.clone()],
|
|
"wallet",
|
|
authorized.as_slice(),
|
|
&[&external],
|
|
) {
|
|
std::result::Result::Ok(value) => value,
|
|
std::result::Result::Err(_) => return,
|
|
};
|
|
assert_eq!(resolved.len(), 2);
|
|
assert!(super::validate_available_signers(
|
|
&["wallet".to_string()],
|
|
"wallet",
|
|
&[],
|
|
&[&external],
|
|
)
|
|
.is_err());
|
|
assert!(
|
|
super::validate_available_signers(
|
|
&["wallet".to_string(), external_pubkey.clone()],
|
|
"wallet",
|
|
authorized.as_slice(),
|
|
&[],
|
|
)
|
|
.is_err()
|
|
);
|
|
assert!(
|
|
super::validate_available_signers(
|
|
&["wallet".to_string(), external_pubkey],
|
|
"wallet",
|
|
authorized.as_slice(),
|
|
&[&external, &external],
|
|
)
|
|
.is_err()
|
|
);
|
|
}
|
|
}
|