// file: ks-pipeline-demo-scenarios/src/metadata/metaplex_token_metadata/use_campaign.rs // version: 6 //! Bounded Devnet availability probe for the current Metaplex `Use` surface. /// Outcome of one bounded Devnet `Use` availability probe. #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub enum DevnetMetaplexUseProbeStatus { /// The current `Use` instruction was confirmed with complete pipeline evidence. Confirmed, /// The exact current `Use` instruction was rejected by Devnet simulation. RuntimeUnavailable, } /// Complete evidence produced by one current Metaplex `Use` availability probe. #[derive(Clone, Debug, PartialEq)] pub struct DevnetMetaplexUseProbeSummary { /// Fresh classic NFT prepared with two bounded `Multiple` uses. pub fixture: crate::DevnetMetaplexCreateMintCampaignSummary, /// Exact simulation evidence for the current `Use` instruction. pub simulation: crate::DevnetMetaplexTokenMetadataExecutionSummary, /// Confirmed execution evidence when the runtime accepts the current instruction. pub execution: std::option::Option, /// Runtime availability classification produced by this probe. pub status: crate::DevnetMetaplexUseProbeStatus, /// Remaining uses observed immediately before the `Use` probe. pub uses_before: u64, /// Remaining uses observed after confirmed execution, when available. pub uses_after: std::option::Option, /// Exact bounded simulation failure diagnostic when runtime-unavailable. pub unavailable_reason: std::option::Option, } /// Returns the exact ordered operations exercised by the bounded `Use` probe. pub fn metaplex_use_probe_operation_names() -> &'static [&'static str; 3] { return &["create", "mint", "use"]; } /// Executes one fresh classic NFT `Use` availability probe on Devnet. #[allow(clippy::too_many_arguments)] pub async fn execute_devnet_metaplex_use_probe( http_pool: &ks_onchain_transport::HttpEndpointPool, store: &S, profile: &ks_config::ProfileConfig, workspace_root: &std::path::Path, options: &crate::MetaplexCreateFixturePreparationOptions, observer: &O, ) -> ks_core::Result where S: ks_store::RawTransactionStore + ks_store::CoreExtractionStore + ks_store::DecodePipelineStore + Sync, O: crate::SolanaExecutionObserver, { if options.asset_family != crate::MetaplexTokenMetadataAssetFamily::Nft { return std::result::Result::Err(ks_core::Error::config( "Metaplex Use probe requires the classic NFT fixture family", )); } let fixture_options = options.clone().with_multiple_uses(2); let fixture = match crate::execute_devnet_metaplex_create_mint_campaign( http_pool, store, profile, workspace_root, &fixture_options, observer, ) .await { std::result::Result::Ok(value) => value, std::result::Result::Err(error) => return std::result::Result::Err(error), }; let uses_before = match metadata_remaining_multiple_uses( fixture.mint.after.as_slice(), fixture.fixture.metadata.as_str(), 2, ) { std::result::Result::Ok(value) => value, std::result::Result::Err(error) => return std::result::Result::Err(error), }; if uses_before != 2 { return std::result::Result::Err(ks_core::Error::new( "metaplex_use_probe_initial_uses_invalid", format!("Metaplex Use probe requires remaining=2 before execution; got {uses_before}"), )); } let operation = use_operation(&fixture.fixture); let mut simulation_request = crate::DevnetMetaplexTokenMetadataExecutionRequest::new( format!("metaplex-use-probe-simulation-{}", uuid::Uuid::new_v4()), operation.clone(), ); simulation_request.query_role = fixture_options.query_role.clone(); simulation_request.transaction_role = fixture_options.transaction_role.clone(); let simulation = match crate::simulate_devnet_metaplex_token_metadata( http_pool, profile, workspace_root, &simulation_request, observer, ) .await { std::result::Result::Ok(value) => value, std::result::Result::Err(error) => return std::result::Result::Err(error), }; if !simulation.simulation.success { return std::result::Result::Ok(crate::DevnetMetaplexUseProbeSummary { fixture, unavailable_reason: std::option::Option::Some(crate::simulation_failure_message( &simulation.simulation, )), simulation, execution: std::option::Option::None, status: crate::DevnetMetaplexUseProbeStatus::RuntimeUnavailable, uses_before, uses_after: std::option::Option::None, }); } let decoders: std::vec::Vec> = std::vec![std::sync::Arc::new(ks_lib::DcMetadataMetaplexTokenMetadataDecoder)]; let materializers: std::vec::Vec> = std::vec![ std::sync::Arc::new(ks_lib::MtAdminMaterializer), std::sync::Arc::new(ks_lib::MtComplianceAuditMaterializer), std::sync::Arc::new(ks_lib::MtLifecycleMaterializer), std::sync::Arc::new(ks_lib::MtMetadataMetaplexTokenMetadataMaterializer), std::sync::Arc::new(ks_lib::MtRiskMaterializer), ]; let mut execution_request = crate::DevnetMetaplexTokenMetadataExecutionRequest::new( format!("metaplex-use-probe-submit-{}", uuid::Uuid::new_v4()), operation, ); execution_request.query_role = fixture_options.query_role.clone(); execution_request.transaction_role = fixture_options.transaction_role.clone(); execution_request.submit = true; execution_request.operator_confirmed = true; execution_request.materialize_after_confirmation = true; execution_request.post_validation_max_retries = 20; execution_request.postcondition_reads = metadata_read(fixture.fixture.metadata.as_str(), fixture_options.query_role.as_str()); let execution = match crate::execute_devnet_metaplex_token_metadata( http_pool, store, profile, workspace_root, &execution_request, decoders.as_slice(), materializers.as_slice(), observer, ) .await { std::result::Result::Ok(value) => value, std::result::Result::Err(error) => return std::result::Result::Err(error), }; let confirmation_slot = match validate_confirmed_use_execution(&execution) { std::result::Result::Ok(value) => value, std::result::Result::Err(error) => return std::result::Result::Err(error), }; let uses_after = match metadata_remaining_multiple_uses( execution.after.as_slice(), fixture.fixture.metadata.as_str(), 2, ) { std::result::Result::Ok(value) => value, std::result::Result::Err(error) => return std::result::Result::Err(error), }; if uses_after != 1 { return std::result::Result::Err(ks_core::Error::new( "metaplex_use_probe_remaining_uses_invalid", format!( "confirmed Metaplex Use must decrement remaining uses from 2 to 1; got {uses_after}" ), )); } let token_after = match crate::read_token_account_at_or_after( http_pool, fixture_options.query_role.as_str(), fixture.fixture.token_account.as_str(), std::option::Option::Some(confirmation_slot), ) .await { std::result::Result::Ok(std::option::Option::Some(value)) => value, std::result::Result::Ok(std::option::Option::None) => { return std::result::Result::Err(ks_core::Error::new( "metaplex_use_probe_token_missing", "classic NFT token account disappeared after confirmed Use", )); }, std::result::Result::Err(error) => return std::result::Result::Err(error), }; if let std::result::Result::Err(error) = crate::validate_classic_fixture_token_account_state( &token_after, fixture.fixture.mint.as_str(), fixture.fixture.authority.as_str(), 1, spl_token_interface::state::AccountState::Initialized, ) { return std::result::Result::Err(ks_core::Error::new( "metaplex_use_probe_token_state_invalid", format!("confirmed Metaplex Use left invalid NFT token state: {error}"), )); } return std::result::Result::Ok(crate::DevnetMetaplexUseProbeSummary { fixture, simulation, execution: std::option::Option::Some(execution), status: crate::DevnetMetaplexUseProbeStatus::Confirmed, uses_before, uses_after: std::option::Option::Some(uses_after), unavailable_reason: std::option::Option::None, }); } fn use_operation( fixture: &crate::MetaplexCreateFixturePreparationSummary, ) -> ks_lib::ExMetaplexTokenMetadataOperation { return ks_lib::ExMetaplexTokenMetadataOperation::Use { authority: ks_lib::MdPubkey(fixture.authority.clone()), delegate_record: std::option::Option::None, token: std::option::Option::Some(ks_lib::MdPubkey(fixture.token_account.clone())), mint: ks_lib::MdPubkey(fixture.mint.clone()), metadata: ks_lib::MdPubkey(fixture.metadata.clone()), edition: std::option::Option::Some(ks_lib::MdPubkey(fixture.master_edition.clone())), payer: ks_lib::MdPubkey(fixture.authority.clone()), system_program: ks_lib::MdPubkey(ks_program_ids::SYSTEM_PROGRAM_ID.to_string()), sysvar_instructions: ks_lib::MdPubkey( ks_program_ids::SYSVAR_INSTRUCTIONS_PROGRAM_ID.to_string(), ), spl_token_program: std::option::Option::Some(ks_lib::MdPubkey( ks_program_ids::SPL_TOKEN_PROGRAM_ID.to_string(), )), authorization_rules_program: std::option::Option::None, authorization_rules: std::option::Option::None, args: mpl_token_metadata::types::UseArgs::V1 { authorization_data: std::option::Option::None, }, }; } fn metadata_read( metadata: &str, query_role: &str, ) -> std::vec::Vec { return std::vec![ks_pipeline::MetaplexTokenMetadataStatefulReadRequest { query_role: query_role.to_string(), account: ks_lib::MdPubkey(metadata.to_string()), kind: ks_pipeline::MetaplexTokenMetadataAccountKind::Metadata, min_context_slot: std::option::Option::None, max_data_bytes: ks_pipeline::MAX_METAPLEX_TOKEN_METADATA_ACCOUNT_BYTES, }]; } fn metadata_remaining_multiple_uses( snapshots: &[ks_pipeline::MetaplexTokenMetadataStatefulReadResult], metadata: &str, expected_total: u64, ) -> ks_core::Result { let snapshot = match snapshots.iter().find(|value| { return value.snapshot.account.0.as_str() == metadata && value.snapshot.account_kind == "metadata"; }) { std::option::Option::Some(value) => value, std::option::Option::None => { return std::result::Result::Err(ks_core::Error::new( "metaplex_use_probe_metadata_snapshot_missing", format!("metadata snapshot {metadata} is missing from the Use probe"), )); }, }; let uses_value = match snapshot.snapshot.payload_json.get("uses") { std::option::Option::Some(value) if !value.is_null() => value.clone(), _ => { return std::result::Result::Err(ks_core::Error::new( "metaplex_use_probe_uses_missing", "metadata snapshot has no Uses state", )); }, }; let uses = match serde_json::from_value::(uses_value) { std::result::Result::Ok(value) => value, std::result::Result::Err(error) => { return std::result::Result::Err(ks_core::Error::new( "metaplex_use_probe_uses_decode_failed", format!("metadata Uses projection is invalid: {error}"), )); }, }; if uses.use_method != mpl_token_metadata::types::UseMethod::Multiple || uses.total != expected_total || uses.remaining > uses.total { return std::result::Result::Err(ks_core::Error::new( "metaplex_use_probe_uses_state_invalid", format!( "metadata Uses state must remain Multiple with total={expected_total}; got method={:?}, remaining={}, total={}", uses.use_method, uses.remaining, uses.total ), )); } return std::result::Result::Ok(uses.remaining); } fn validate_confirmed_use_execution( execution: &crate::DevnetMetaplexTokenMetadataExecutionSummary, ) -> ks_core::Result { let confirmation = match execution.confirmation.as_ref() { std::option::Option::Some(value) if matches!( value.status, ks_lib::ExApiExecutionConfirmationStatus::Confirmed | ks_lib::ExApiExecutionConfirmationStatus::Finalized ) => { value }, std::option::Option::Some(value) => { return std::result::Result::Err(ks_core::Error::new( "metaplex_use_probe_confirmation_incomplete", format!("Metaplex Use stopped at {:?}", value.status), )); }, std::option::Option::None => { return std::result::Result::Err(ks_core::Error::new( "metaplex_use_probe_confirmation_missing", "Metaplex Use has no confirmation evidence", )); }, }; let diagnostic = match execution.post_execution.as_ref() { std::option::Option::Some(value) => value, std::option::Option::None => { return std::result::Result::Err(ks_core::Error::new( "metaplex_use_probe_post_execution_missing", "Metaplex Use has no post-execution diagnostic", )); }, }; if !diagnostic.canonical_inserted || !diagnostic.core_extracted || !diagnostic.decode_replayed || !diagnostic.materialized || execution.materializations.is_empty() || execution.materialized_snapshots.is_empty() { return std::result::Result::Err(ks_core::Error::new( "metaplex_use_probe_post_execution_incomplete", format!( "Metaplex Use post-execution incomplete: signature={}, slot={:?}, canonical_inserted={}, core_extracted={}, decode_replayed={}, materialized={}, materialization_rows={}, materialized_snapshots={}, diagnostics={:?}", confirmation.signature.0, confirmation.slot, diagnostic.canonical_inserted, diagnostic.core_extracted, diagnostic.decode_replayed, diagnostic.materialized, execution.materializations.len(), execution.materialized_snapshots.len(), diagnostic.diagnostics ), )); } let idempotence = match execution.idempotence_replay.as_ref() { std::option::Option::Some(value) => value, std::option::Option::None => { return std::result::Result::Err(ks_core::Error::new( "metaplex_use_probe_idempotence_missing", "Metaplex Use has no idempotence replay evidence", )); }, }; if idempotence.failed_inputs != 0 || idempotence.processing_error_inputs != 0 || idempotence.processors.iter().any(|processor| { return processor.failed != 0 || processor.processing_errors != 0 || processor.materialized_outputs != 0 || processor.materialization_refused != 0; }) { return std::result::Result::Err(ks_core::Error::new( "metaplex_use_probe_idempotence_failed", "Metaplex Use second replay is not idempotent", )); } return match confirmation.slot { std::option::Option::Some(value) => std::result::Result::Ok(value), std::option::Option::None => std::result::Result::Err(ks_core::Error::new( "metaplex_use_probe_confirmation_slot_missing", "Metaplex Use confirmation has no slot", )), }; } #[cfg(test)] mod tests { #[test] fn use_probe_contract_is_exactly_create_mint_then_use() { assert_eq!(crate::metaplex_use_probe_operation_names(), &["create", "mint", "use"]); assert_ne!( crate::DevnetMetaplexUseProbeStatus::Confirmed, crate::DevnetMetaplexUseProbeStatus::RuntimeUnavailable, ); } #[tokio::test] async fn optional_devnet_metaplex_use_probe_from_env() { if std::env::var("KS_DEVNET_METAPLEX_USE_PROBE_TEST").ok().as_deref() != std::option::Option::Some("1") { return; } assert_eq!( std::env::var("KS_DEVNET_METAPLEX_OPERATOR_CONFIRMED").ok().as_deref(), std::option::Option::Some("1"), "set KS_DEVNET_METAPLEX_OPERATOR_CONFIRMED=1 before submitting an accepted Use probe" ); 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 config = crate::load_demo_scenario_config(workspace_root) .unwrap_or_else(|error| panic!("Devnet configuration loading failed: {error}")); let profile_name = std::env::var("KS_DEVNET_PROFILE").ok(); let mut profile = crate::resolve_demo_devnet_profile(&config, profile_name.as_deref()) .unwrap_or_else(|error| panic!("Devnet profile resolution failed: {error}")); let database_url = std::env::var("KS_SECRET_POSTGRES_TEST_URL") .unwrap_or_else(|error| panic!("KS_SECRET_POSTGRES_TEST_URL is required: {error}")); profile.database.backend = "postgres".to_string(); profile.database.postgres.url = database_url; let http_pool = ks_onchain_transport::HttpEndpointPool::from_profile(&profile) .unwrap_or_else(|error| panic!("HTTP pool initialization failed: {error}")); let store_options = ks_store::PostgresStoreOptions::new( profile.database.postgres.url.clone(), profile.database.postgres.max_connections, profile.database.postgres.connect_timeout_ms, false, ) .unwrap_or_else(|error| panic!("PostgreSQL options failed: {error}")); let store = ks_store::PostgresStore::connect(store_options) .await .unwrap_or_else(|error| panic!("PostgreSQL connection failed: {error}")); if let std::result::Result::Err(error) = store.initialize_store_schema().await { panic!("PostgreSQL schema initialization failed: {error}"); } let configured_wallet_dir = std::path::PathBuf::from(profile.wallet.wallet_dir.as_str()); let wallet_dir = if configured_wallet_dir.is_absolute() { configured_wallet_dir } else { workspace_root.join(configured_wallet_dir) }; let options = crate::MetaplexCreateFixturePreparationOptions::new(wallet_dir) .with_asset_family(crate::MetaplexTokenMetadataAssetFamily::Nft); let summary = crate::execute_devnet_metaplex_use_probe( &http_pool, &store, &profile, workspace_root, &options, &crate::NoopSolanaExecutionObserver, ) .await .unwrap_or_else(|error| panic!("Metaplex Use Devnet probe failed: {error}")); println!( "METAPLEX_USE_PROBE_FIXTURE mint={} metadata={} master_edition={} token={} uses_before={}", summary.fixture.fixture.mint, summary.fixture.fixture.metadata, summary.fixture.fixture.master_edition, summary.fixture.fixture.token_account, summary.uses_before, ); println!( "METAPLEX_USE_PROBE_SIMULATION success={} context_slot={} logs={} error={}", summary.simulation.simulation.success, summary.simulation.simulation_context_slot, summary.simulation.simulation.logs.len(), serde_json::to_string(&summary.simulation.simulation.error).unwrap_or_else( |error| panic!("Use simulation error serialization failed: {error}") ), ); match summary.status { crate::DevnetMetaplexUseProbeStatus::RuntimeUnavailable => { println!( "METAPLEX_USE_PROBE_STATUS status=runtime_unavailable uses_before={} uses_after=null reason={}", summary.uses_before, serde_json::to_string(&summary.unavailable_reason).unwrap_or_else( |error| panic!("Use unavailable reason serialization failed: {error}") ), ); println!( "METAPLEX_USE_PROBE_LOGS {}", serde_json::to_string(&summary.simulation.simulation.logs).unwrap_or_else( |error| panic!("Use simulation log serialization failed: {error}") ), ); }, crate::DevnetMetaplexUseProbeStatus::Confirmed => { let execution = summary .execution .as_ref() .unwrap_or_else(|| panic!("confirmed Use probe execution is missing")); let confirmation = execution .confirmation .as_ref() .unwrap_or_else(|| panic!("confirmed Use probe confirmation is missing")); println!( "METAPLEX_USE_PROBE_STATUS status=confirmed uses_before={} uses_after={}", summary.uses_before, summary .uses_after .unwrap_or_else(|| panic!("confirmed Use uses_after is missing")), ); println!( "METAPLEX_USE_PROBE_STEP operation={} signature={} slot={:?} materializations={}", execution.plan.operation_code, confirmation.signature.0, confirmation.slot, execution.materializations.len(), ); println!("METAPLEX_USE_PROBE_EVIDENCE {}", execution_evidence_json(execution),); }, } } fn execution_evidence_json( execution: &crate::DevnetMetaplexTokenMetadataExecutionSummary, ) -> serde_json::Value { let confirmation = execution.confirmation.as_ref(); let post_execution = execution.post_execution.as_ref(); let idempotence_clean = execution.idempotence_replay.as_ref().is_some_and(|replay| { return replay.failed_inputs == 0 && replay.processing_error_inputs == 0 && replay.processors.iter().all(|processor| { return processor.failed == 0 && processor.processing_errors == 0 && processor.materialized_outputs == 0 && processor.materialization_refused == 0; }); }); return serde_json::json!({ "operation": execution.plan.operation_code.as_str(), "cluster": execution.cluster, "genesisHash": execution.genesis_hash.as_str(), "simulationContextSlot": execution.simulation_context_slot, "simulationSuccess": execution.simulation.success, "simulationLogCount": execution.simulation.logs.len(), "messageHash": execution.readiness.message_hash.as_str(), "feeLamports": execution.fee.fee_lamports, "signature": confirmation.map(|value| return value.signature.0.clone()), "confirmationStatus": confirmation.map(|value| return format!("{:?}", value.status)), "confirmationSlot": confirmation.and_then(|value| return value.slot), "beforeSnapshots": execution.before.len(), "afterSnapshots": execution.after.len(), "canonicalHydration": post_execution.is_some_and(|value| return value.canonical_inserted), "coreExtraction": post_execution.is_some_and(|value| return value.core_extracted), "decodeReplay": post_execution.is_some_and(|value| return value.decode_replayed), "materialized": post_execution.is_some_and(|value| return value.materialized), "instructionMaterializations": execution.materializations.len(), "materializedSnapshots": execution.materialized_snapshots.len(), "idempotenceReplayClean": idempotence_clean, }); } }