767 lines
33 KiB
Rust
767 lines
33 KiB
Rust
// file: ks-pipeline-demo-scenarios/src/metadata/metaplex_token_metadata/collection_verify_campaign.rs
|
|
// version: 7
|
|
|
|
//! Confirmed Devnet collection parent/member `Verify -> Unverify` campaign.
|
|
|
|
/// Collection relation and sized-collection state observed across verification.
|
|
#[derive(Clone, Debug, Eq, PartialEq)]
|
|
pub struct DevnetMetaplexCollectionVerifyState {
|
|
/// Member collection flag before `Verify`.
|
|
pub verified_before: bool,
|
|
/// Member collection flag after `Verify`.
|
|
pub verified_after_verify: bool,
|
|
/// Member collection flag after `Unverify`.
|
|
pub verified_after_unverify: bool,
|
|
/// Collection size before `Verify`.
|
|
pub collection_size_before: u64,
|
|
/// Collection size after `Verify`.
|
|
pub collection_size_after_verify: u64,
|
|
/// Collection size after `Unverify`.
|
|
pub collection_size_after_unverify: u64,
|
|
}
|
|
|
|
/// Complete evidence produced by one collection parent/member verification campaign.
|
|
#[derive(Clone, Debug, PartialEq)]
|
|
pub struct DevnetMetaplexCollectionVerifyCampaignSummary {
|
|
/// Fully qualified collection parent fixture and `Create -> Mint` evidence.
|
|
pub parent: crate::DevnetMetaplexCreateMintCampaignSummary,
|
|
/// Fully qualified unverified member fixture and `Create -> Mint` evidence.
|
|
pub member: crate::DevnetMetaplexCreateMintCampaignSummary,
|
|
/// Confirmed collection verification evidence.
|
|
pub verify: crate::DevnetMetaplexTokenMetadataExecutionSummary,
|
|
/// Confirmed collection unverification evidence.
|
|
pub unverify: crate::DevnetMetaplexTokenMetadataExecutionSummary,
|
|
/// Exact collection relation and size transitions.
|
|
pub state: crate::DevnetMetaplexCollectionVerifyState,
|
|
}
|
|
|
|
/// Returns the exact current Metaplex operations qualified by this campaign after fixture setup.
|
|
pub fn metaplex_collection_verify_campaign_operation_names() -> &'static [&'static str; 2] {
|
|
return &["verify", "unverify"];
|
|
}
|
|
|
|
/// Executes one fresh collection parent/member `Verify -> Unverify` campaign on Devnet.
|
|
#[allow(clippy::too_many_arguments)]
|
|
pub async fn execute_devnet_metaplex_collection_verify_campaign<S, O>(
|
|
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<crate::DevnetMetaplexCollectionVerifyCampaignSummary>
|
|
where
|
|
S: ks_store::RawTransactionStore
|
|
+ ks_store::CoreExtractionStore
|
|
+ ks_store::DecodePipelineStore
|
|
+ Sync,
|
|
O: crate::SolanaExecutionObserver,
|
|
{
|
|
let temporary = match crate::load_profile_temporary_wallet(profile, workspace_root).await {
|
|
std::result::Result::Ok(value) => value,
|
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
|
};
|
|
let wallet = crate::DevnetExecutionWallet::Temporary(temporary);
|
|
return execute_devnet_metaplex_collection_verify_campaign_with_wallet(
|
|
http_pool, store, profile, &wallet, options, observer,
|
|
)
|
|
.await;
|
|
}
|
|
|
|
/// Executes one collection verification campaign with an explicit operator wallet.
|
|
#[allow(clippy::too_many_arguments)]
|
|
pub async fn execute_devnet_metaplex_collection_verify_campaign_with_wallet<S, O>(
|
|
http_pool: &ks_onchain_transport::HttpEndpointPool,
|
|
store: &S,
|
|
profile: &ks_config::ProfileConfig,
|
|
wallet: &crate::DevnetExecutionWallet,
|
|
options: &crate::MetaplexCreateFixturePreparationOptions,
|
|
observer: &O,
|
|
) -> ks_core::Result<crate::DevnetMetaplexCollectionVerifyCampaignSummary>
|
|
where
|
|
S: ks_store::RawTransactionStore
|
|
+ ks_store::CoreExtractionStore
|
|
+ ks_store::DecodePipelineStore
|
|
+ Sync,
|
|
O: crate::SolanaExecutionObserver,
|
|
{
|
|
if options.collection_mint.is_some() {
|
|
return std::result::Result::Err(ks_core::Error::config(
|
|
"collection verification campaign base options must not preselect a collection mint",
|
|
));
|
|
}
|
|
let mut parent_options = options.clone();
|
|
parent_options.asset_family = crate::MetaplexTokenMetadataAssetFamily::Collection;
|
|
parent_options.collection_mint = std::option::Option::None;
|
|
let parent = match crate::execute_devnet_metaplex_create_mint_campaign_with_wallet(
|
|
http_pool,
|
|
store,
|
|
profile,
|
|
wallet,
|
|
&parent_options,
|
|
observer,
|
|
)
|
|
.await
|
|
{
|
|
std::result::Result::Ok(value) => value,
|
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
|
};
|
|
let mut member_options = options.clone();
|
|
member_options.asset_family = crate::MetaplexTokenMetadataAssetFamily::Nft;
|
|
member_options.collection_mint = std::option::Option::Some(parent.fixture.mint.clone());
|
|
let member = match crate::execute_devnet_metaplex_create_mint_campaign_with_wallet(
|
|
http_pool,
|
|
store,
|
|
profile,
|
|
wallet,
|
|
&member_options,
|
|
observer,
|
|
)
|
|
.await
|
|
{
|
|
std::result::Result::Ok(value) => value,
|
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
|
};
|
|
let verified_before = match collection_member_verified(
|
|
member.mint.after.as_slice(),
|
|
member.fixture.metadata.as_str(),
|
|
parent.fixture.mint.as_str(),
|
|
) {
|
|
std::result::Result::Ok(value) => value,
|
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
|
};
|
|
let collection_size_before =
|
|
match collection_size(parent.mint.after.as_slice(), parent.fixture.metadata.as_str()) {
|
|
std::result::Result::Ok(value) => value,
|
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
|
};
|
|
if verified_before || collection_size_before != 0 {
|
|
return std::result::Result::Err(ks_core::Error::new(
|
|
"metaplex_collection_verify_initial_state_invalid",
|
|
format!(
|
|
"fresh collection member must start unverified with parent size 0; verified={verified_before}, size={collection_size_before}"
|
|
),
|
|
));
|
|
}
|
|
let parent_mint_slot = match confirmed_slot("parent mint", &parent.mint) {
|
|
std::result::Result::Ok(value) => value,
|
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
|
};
|
|
let member_mint_slot = match confirmed_slot("member mint", &member.mint) {
|
|
std::result::Result::Ok(value) => value,
|
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
|
};
|
|
let fixture_slot = std::cmp::max(parent_mint_slot, member_mint_slot);
|
|
let decoders: std::vec::Vec<std::sync::Arc<dyn ks_lib::DcApiInstructionDecoder>> =
|
|
std::vec![std::sync::Arc::new(ks_lib::DcMetadataMetaplexTokenMetadataDecoder)];
|
|
let materializers: std::vec::Vec<std::sync::Arc<dyn ks_lib::MtApiEventMaterializer>> = 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 verify_operation = ks_lib::ExMetaplexTokenMetadataOperation::Verify {
|
|
authority: ks_lib::MdPubkey(parent.fixture.authority.clone()),
|
|
delegate_record: std::option::Option::None,
|
|
metadata: ks_lib::MdPubkey(member.fixture.metadata.clone()),
|
|
collection_mint: std::option::Option::Some(ks_lib::MdPubkey(parent.fixture.mint.clone())),
|
|
collection_metadata: std::option::Option::Some(ks_lib::MdPubkey(
|
|
parent.fixture.metadata.clone(),
|
|
)),
|
|
collection_master_edition: std::option::Option::Some(ks_lib::MdPubkey(
|
|
parent.fixture.master_edition.clone(),
|
|
)),
|
|
verification_args: mpl_token_metadata::types::VerificationArgs::CollectionV1,
|
|
};
|
|
let mut verify_request = crate::DevnetMetaplexTokenMetadataExecutionRequest::new(
|
|
format!("metaplex-collection-verify-{}", uuid::Uuid::new_v4()),
|
|
verify_operation,
|
|
);
|
|
verify_request.query_role = options.query_role.clone();
|
|
verify_request.transaction_role = options.transaction_role.clone();
|
|
verify_request.submit = true;
|
|
verify_request.operator_confirmed = true;
|
|
verify_request.materialize_after_confirmation = true;
|
|
verify_request.post_validation_max_retries = 20;
|
|
verify_request.preflight_reads = collection_reads(
|
|
&parent.fixture,
|
|
&member.fixture,
|
|
options.query_role.as_str(),
|
|
std::option::Option::Some(fixture_slot),
|
|
);
|
|
verify_request.postcondition_reads = collection_reads(
|
|
&parent.fixture,
|
|
&member.fixture,
|
|
options.query_role.as_str(),
|
|
std::option::Option::Some(fixture_slot),
|
|
);
|
|
let verify = match crate::execute_devnet_metaplex_token_metadata_with_wallet(
|
|
http_pool,
|
|
store,
|
|
profile,
|
|
wallet,
|
|
&verify_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 verify_slot = match validate_confirmed_execution("verify", &verify) {
|
|
std::result::Result::Ok(value) => value,
|
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
|
};
|
|
let verified_after_verify = match collection_member_verified(
|
|
verify.after.as_slice(),
|
|
member.fixture.metadata.as_str(),
|
|
parent.fixture.mint.as_str(),
|
|
) {
|
|
std::result::Result::Ok(value) => value,
|
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
|
};
|
|
let collection_size_after_verify =
|
|
match collection_size(verify.after.as_slice(), parent.fixture.metadata.as_str()) {
|
|
std::result::Result::Ok(value) => value,
|
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
|
};
|
|
if !verified_after_verify || collection_size_after_verify != 1 {
|
|
return std::result::Result::Err(ks_core::Error::new(
|
|
"metaplex_collection_verify_postcondition_failed",
|
|
format!(
|
|
"confirmed collection Verify must set member verified=true and parent size=1; verified={verified_after_verify}, size={collection_size_after_verify}"
|
|
),
|
|
));
|
|
}
|
|
let unverify_operation = ks_lib::ExMetaplexTokenMetadataOperation::Unverify {
|
|
authority: ks_lib::MdPubkey(parent.fixture.authority.clone()),
|
|
delegate_record: std::option::Option::None,
|
|
metadata: ks_lib::MdPubkey(member.fixture.metadata.clone()),
|
|
collection_mint: std::option::Option::Some(ks_lib::MdPubkey(parent.fixture.mint.clone())),
|
|
collection_metadata: std::option::Option::Some(ks_lib::MdPubkey(
|
|
parent.fixture.metadata.clone(),
|
|
)),
|
|
verification_args: mpl_token_metadata::types::VerificationArgs::CollectionV1,
|
|
};
|
|
let mut unverify_request = crate::DevnetMetaplexTokenMetadataExecutionRequest::new(
|
|
format!("metaplex-collection-unverify-{}", uuid::Uuid::new_v4()),
|
|
unverify_operation,
|
|
);
|
|
unverify_request.query_role = options.query_role.clone();
|
|
unverify_request.transaction_role = options.transaction_role.clone();
|
|
unverify_request.submit = true;
|
|
unverify_request.operator_confirmed = true;
|
|
unverify_request.materialize_after_confirmation = true;
|
|
unverify_request.post_validation_max_retries = 20;
|
|
unverify_request.preflight_reads = collection_reads(
|
|
&parent.fixture,
|
|
&member.fixture,
|
|
options.query_role.as_str(),
|
|
std::option::Option::Some(verify_slot),
|
|
);
|
|
unverify_request.postcondition_reads = collection_reads(
|
|
&parent.fixture,
|
|
&member.fixture,
|
|
options.query_role.as_str(),
|
|
std::option::Option::Some(verify_slot),
|
|
);
|
|
let unverify = match crate::execute_devnet_metaplex_token_metadata_with_wallet(
|
|
http_pool,
|
|
store,
|
|
profile,
|
|
wallet,
|
|
&unverify_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 unverify_slot = match validate_confirmed_execution("unverify", &unverify) {
|
|
std::result::Result::Ok(value) => value,
|
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
|
};
|
|
let verified_after_unverify = match collection_member_verified(
|
|
unverify.after.as_slice(),
|
|
member.fixture.metadata.as_str(),
|
|
parent.fixture.mint.as_str(),
|
|
) {
|
|
std::result::Result::Ok(value) => value,
|
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
|
};
|
|
let collection_size_after_unverify =
|
|
match collection_size(unverify.after.as_slice(), parent.fixture.metadata.as_str()) {
|
|
std::result::Result::Ok(value) => value,
|
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
|
};
|
|
if verified_after_unverify || collection_size_after_unverify != 0 {
|
|
return std::result::Result::Err(ks_core::Error::new(
|
|
"metaplex_collection_unverify_postcondition_failed",
|
|
format!(
|
|
"confirmed collection Unverify at slot {unverify_slot} must restore verified=false and parent size=0; verified={verified_after_unverify}, size={collection_size_after_unverify}"
|
|
),
|
|
));
|
|
}
|
|
return std::result::Result::Ok(crate::DevnetMetaplexCollectionVerifyCampaignSummary {
|
|
parent,
|
|
member,
|
|
verify,
|
|
unverify,
|
|
state: crate::DevnetMetaplexCollectionVerifyState {
|
|
verified_before,
|
|
verified_after_verify,
|
|
verified_after_unverify,
|
|
collection_size_before,
|
|
collection_size_after_verify,
|
|
collection_size_after_unverify,
|
|
},
|
|
});
|
|
}
|
|
|
|
fn collection_reads(
|
|
parent: &crate::MetaplexCreateFixturePreparationSummary,
|
|
member: &crate::MetaplexCreateFixturePreparationSummary,
|
|
query_role: &str,
|
|
min_context_slot: std::option::Option<u64>,
|
|
) -> std::vec::Vec<ks_pipeline::MetaplexTokenMetadataStatefulReadRequest> {
|
|
return vec![
|
|
ks_pipeline::MetaplexTokenMetadataStatefulReadRequest {
|
|
query_role: query_role.to_string(),
|
|
account: ks_lib::MdPubkey(member.metadata.clone()),
|
|
kind: ks_pipeline::MetaplexTokenMetadataAccountKind::Metadata,
|
|
min_context_slot,
|
|
max_data_bytes: ks_pipeline::MAX_METAPLEX_TOKEN_METADATA_ACCOUNT_BYTES,
|
|
},
|
|
ks_pipeline::MetaplexTokenMetadataStatefulReadRequest {
|
|
query_role: query_role.to_string(),
|
|
account: ks_lib::MdPubkey(parent.metadata.clone()),
|
|
kind: ks_pipeline::MetaplexTokenMetadataAccountKind::Metadata,
|
|
min_context_slot,
|
|
max_data_bytes: ks_pipeline::MAX_METAPLEX_TOKEN_METADATA_ACCOUNT_BYTES,
|
|
},
|
|
ks_pipeline::MetaplexTokenMetadataStatefulReadRequest {
|
|
query_role: query_role.to_string(),
|
|
account: ks_lib::MdPubkey(parent.master_edition.clone()),
|
|
kind: ks_pipeline::MetaplexTokenMetadataAccountKind::Edition {
|
|
mint: ks_lib::MdPubkey(parent.mint.clone()),
|
|
},
|
|
min_context_slot,
|
|
max_data_bytes: ks_pipeline::MAX_METAPLEX_TOKEN_METADATA_ACCOUNT_BYTES,
|
|
},
|
|
];
|
|
}
|
|
|
|
fn confirmed_slot(
|
|
step: &str,
|
|
execution: &crate::DevnetMetaplexTokenMetadataExecutionSummary,
|
|
) -> ks_core::Result<u64> {
|
|
return match execution.confirmation.as_ref().and_then(|value| return value.slot) {
|
|
std::option::Option::Some(value) => std::result::Result::Ok(value),
|
|
std::option::Option::None => std::result::Result::Err(ks_core::Error::new(
|
|
"metaplex_collection_fixture_confirmation_slot_missing",
|
|
format!("Metaplex {step} has no confirmed slot"),
|
|
)),
|
|
};
|
|
}
|
|
|
|
fn validate_confirmed_execution(
|
|
step: &str,
|
|
execution: &crate::DevnetMetaplexTokenMetadataExecutionSummary,
|
|
) -> ks_core::Result<u64> {
|
|
if !execution.simulation.success {
|
|
return std::result::Result::Err(ks_core::Error::new(
|
|
"metaplex_collection_simulation_failed",
|
|
format!("Metaplex collection {step} simulation failed"),
|
|
));
|
|
}
|
|
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_collection_confirmation_incomplete",
|
|
format!("Metaplex collection {step} stopped at {:?}", value.status),
|
|
));
|
|
},
|
|
std::option::Option::None => {
|
|
return std::result::Result::Err(ks_core::Error::new(
|
|
"metaplex_collection_confirmation_missing",
|
|
format!("Metaplex collection {step} 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_collection_post_execution_missing",
|
|
format!("Metaplex collection {step} 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_collection_post_execution_incomplete",
|
|
format!(
|
|
"Metaplex collection {step} 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 second_replay = 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_collection_idempotence_missing",
|
|
format!("Metaplex collection {step} has no idempotence replay evidence"),
|
|
));
|
|
},
|
|
};
|
|
if second_replay.failed_inputs != 0
|
|
|| second_replay.processing_error_inputs != 0
|
|
|| second_replay.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_collection_idempotence_failed",
|
|
format!("Metaplex collection {step} 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_collection_confirmation_slot_missing",
|
|
format!("Metaplex collection {step} confirmation has no slot"),
|
|
)),
|
|
};
|
|
}
|
|
|
|
fn metadata_payload<'a>(
|
|
snapshots: &'a [ks_pipeline::MetaplexTokenMetadataStatefulReadResult],
|
|
metadata: &str,
|
|
) -> ks_core::Result<&'a serde_json::Value> {
|
|
let snapshot = snapshots.iter().find(|value| {
|
|
return value.snapshot.account.0.as_str() == metadata
|
|
&& value.snapshot.account_kind == "metadata";
|
|
});
|
|
return match snapshot {
|
|
std::option::Option::Some(value) => std::result::Result::Ok(&value.snapshot.payload_json),
|
|
std::option::Option::None => std::result::Result::Err(ks_core::Error::new(
|
|
"metaplex_collection_metadata_snapshot_missing",
|
|
format!("Metaplex metadata snapshot {metadata} is missing"),
|
|
)),
|
|
};
|
|
}
|
|
|
|
fn collection_member_verified(
|
|
snapshots: &[ks_pipeline::MetaplexTokenMetadataStatefulReadResult],
|
|
member_metadata: &str,
|
|
expected_collection_mint: &str,
|
|
) -> ks_core::Result<bool> {
|
|
let payload = match metadata_payload(snapshots, member_metadata) {
|
|
std::result::Result::Ok(value) => value,
|
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
|
};
|
|
let collection = match payload.get("collection") {
|
|
std::option::Option::Some(value) if value.is_object() => value,
|
|
_ => {
|
|
return std::result::Result::Err(ks_core::Error::new(
|
|
"metaplex_collection_relation_missing",
|
|
"member metadata does not contain a collection relation",
|
|
));
|
|
},
|
|
};
|
|
let key = collection.get("key").and_then(serde_json::Value::as_str);
|
|
let verified = collection.get("verified").and_then(serde_json::Value::as_bool);
|
|
if key != std::option::Option::Some(expected_collection_mint) || verified.is_none() {
|
|
return std::result::Result::Err(ks_core::Error::new(
|
|
"metaplex_collection_relation_invalid",
|
|
format!(
|
|
"member collection relation mismatch: expected key={expected_collection_mint}, got key={key:?}, verified={verified:?}"
|
|
),
|
|
));
|
|
}
|
|
return match verified {
|
|
std::option::Option::Some(value) => std::result::Result::Ok(value),
|
|
std::option::Option::None => std::result::Result::Err(ks_core::Error::new(
|
|
"metaplex_collection_verified_flag_missing",
|
|
"member collection relation does not expose a verified flag",
|
|
)),
|
|
};
|
|
}
|
|
|
|
fn collection_size(
|
|
snapshots: &[ks_pipeline::MetaplexTokenMetadataStatefulReadResult],
|
|
collection_metadata: &str,
|
|
) -> ks_core::Result<u64> {
|
|
let payload = match metadata_payload(snapshots, collection_metadata) {
|
|
std::result::Result::Ok(value) => value,
|
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
|
};
|
|
let size = payload
|
|
.get("collection_details")
|
|
.and_then(|value| return value.get("V1"))
|
|
.and_then(|value| return value.get("size"))
|
|
.and_then(serde_json::Value::as_u64);
|
|
return match size {
|
|
std::option::Option::Some(value) => std::result::Result::Ok(value),
|
|
std::option::Option::None => std::result::Result::Err(ks_core::Error::new(
|
|
"metaplex_collection_size_missing",
|
|
"collection metadata does not expose CollectionDetails::V1 size",
|
|
)),
|
|
};
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
#[test]
|
|
fn collection_campaign_contract_is_exactly_verify_then_unverify() {
|
|
assert_eq!(
|
|
crate::metaplex_collection_verify_campaign_operation_names(),
|
|
&["verify", "unverify"]
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn collection_payload_contract_tracks_relation_and_size() {
|
|
let collection = solana_pubkey::Pubkey::new_unique().to_string();
|
|
let relation = serde_json::json!({"collection": {"verified": false, "key": collection}});
|
|
let details = serde_json::json!({"collection_details": {"V1": {"size": 0}}});
|
|
assert_eq!(
|
|
relation.pointer("/collection/verified").and_then(serde_json::Value::as_bool),
|
|
std::option::Option::Some(false)
|
|
);
|
|
assert_eq!(
|
|
details
|
|
.pointer("/collection_details/V1/size")
|
|
.and_then(serde_json::Value::as_u64),
|
|
std::option::Option::Some(0)
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn metadata_serde_contract_uses_stateful_collection_keys() {
|
|
let metadata = mpl_token_metadata::accounts::Metadata {
|
|
key: mpl_token_metadata::types::Key::MetadataV1,
|
|
update_authority: mpl_token_metadata::ID,
|
|
mint: mpl_token_metadata::ID,
|
|
name: "Collection".to_string(),
|
|
symbol: "COL".to_string(),
|
|
uri: "https://example.invalid/collection.json".to_string(),
|
|
seller_fee_basis_points: 0,
|
|
creators: std::option::Option::None,
|
|
primary_sale_happened: false,
|
|
is_mutable: true,
|
|
edition_nonce: std::option::Option::None,
|
|
token_standard: std::option::Option::Some(
|
|
mpl_token_metadata::types::TokenStandard::NonFungible,
|
|
),
|
|
collection: std::option::Option::None,
|
|
uses: std::option::Option::None,
|
|
collection_details: std::option::Option::Some(
|
|
mpl_token_metadata::types::CollectionDetails::V1 { size: 7 },
|
|
),
|
|
programmable_config: std::option::Option::None,
|
|
};
|
|
let payload = match serde_json::to_value(metadata) {
|
|
std::result::Result::Ok(value) => value,
|
|
std::result::Result::Err(error) => panic!("metadata serialization failed: {error}"),
|
|
};
|
|
assert_eq!(
|
|
payload
|
|
.pointer("/collection_details/V1/size")
|
|
.and_then(serde_json::Value::as_u64),
|
|
std::option::Option::Some(7)
|
|
);
|
|
assert!(payload.get("collectionDetails").is_none());
|
|
}
|
|
|
|
fn execution_evidence_json(
|
|
execution: &crate::DevnetMetaplexTokenMetadataExecutionSummary,
|
|
) -> serde_json::Value {
|
|
let confirmation = execution.confirmation.as_ref();
|
|
let confirmation_status = confirmation
|
|
.map(|value| return format!("{:?}", value.status))
|
|
.unwrap_or_else(|| return "missing".to_string());
|
|
let confirmation_slot = confirmation.and_then(|value| return value.slot);
|
|
let signature = confirmation
|
|
.map(|value| return value.signature.0.clone())
|
|
.unwrap_or_else(|| return "missing".to_string());
|
|
let diagnostic = 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": signature,
|
|
"confirmationStatus": confirmation_status,
|
|
"confirmationSlot": confirmation_slot,
|
|
"beforeSnapshots": execution.before.len(),
|
|
"afterSnapshots": execution.after.len(),
|
|
"canonicalHydration": diagnostic.is_some_and(|value| return value.canonical_inserted),
|
|
"coreExtraction": diagnostic.is_some_and(|value| return value.core_extracted),
|
|
"decodeReplay": diagnostic.is_some_and(|value| return value.decode_replayed),
|
|
"materialized": diagnostic.is_some_and(|value| return value.materialized),
|
|
"instructionMaterializations": execution.materializations.len(),
|
|
"materializedSnapshots": execution.materialized_snapshots.len(),
|
|
"idempotenceReplayClean": idempotence_clean,
|
|
});
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn optional_devnet_metaplex_collection_verify_campaign_from_env() {
|
|
if std::env::var("KS_DEVNET_METAPLEX_COLLECTION_VERIFY_CAMPAIGN_TEST")
|
|
.ok()
|
|
.as_deref()
|
|
!= std::option::Option::Some("1")
|
|
{
|
|
return;
|
|
}
|
|
if std::env::var("KS_DEVNET_METAPLEX_OPERATOR_CONFIRMED").ok().as_deref()
|
|
!= std::option::Option::Some("1")
|
|
{
|
|
panic!("KS_DEVNET_METAPLEX_OPERATOR_CONFIRMED=1 is required for the spending campaign");
|
|
}
|
|
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 pool = ks_onchain_transport::HttpEndpointPool::from_profile(&profile)
|
|
.unwrap_or_else(|error| panic!("HTTP pool creation 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.temporary_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);
|
|
let summary = crate::execute_devnet_metaplex_collection_verify_campaign(
|
|
&pool,
|
|
&store,
|
|
&profile,
|
|
workspace_root,
|
|
&options,
|
|
&crate::NoopSolanaExecutionObserver,
|
|
)
|
|
.await
|
|
.unwrap_or_else(|error| {
|
|
panic!("Metaplex collection Verify -> Unverify Devnet campaign failed: {error}")
|
|
});
|
|
let verify_confirmation = summary
|
|
.verify
|
|
.confirmation
|
|
.as_ref()
|
|
.unwrap_or_else(|| panic!("Verify confirmation missing"));
|
|
let unverify_confirmation = summary
|
|
.unverify
|
|
.confirmation
|
|
.as_ref()
|
|
.unwrap_or_else(|| panic!("Unverify confirmation missing"));
|
|
println!(
|
|
"METAPLEX_COLLECTION_VERIFY_FIXTURE parent_mint={} parent_metadata={} parent_master_edition={} member_mint={} member_metadata={} member_master_edition={}",
|
|
summary.parent.fixture.mint,
|
|
summary.parent.fixture.metadata,
|
|
summary.parent.fixture.master_edition,
|
|
summary.member.fixture.mint,
|
|
summary.member.fixture.metadata,
|
|
summary.member.fixture.master_edition,
|
|
);
|
|
println!(
|
|
"METAPLEX_COLLECTION_VERIFY_STEP operation={} signature={} slot={:?} materializations={}",
|
|
summary.verify.plan.operation_code,
|
|
verify_confirmation.signature.0,
|
|
verify_confirmation.slot,
|
|
summary.verify.materializations.len(),
|
|
);
|
|
println!(
|
|
"METAPLEX_COLLECTION_VERIFY_STEP operation={} signature={} slot={:?} materializations={}",
|
|
summary.unverify.plan.operation_code,
|
|
unverify_confirmation.signature.0,
|
|
unverify_confirmation.slot,
|
|
summary.unverify.materializations.len(),
|
|
);
|
|
println!(
|
|
"METAPLEX_COLLECTION_VERIFY_STATE verified_before={} verified_after_verify={} verified_after_unverify={} size_before={} size_after_verify={} size_after_unverify={}",
|
|
summary.state.verified_before,
|
|
summary.state.verified_after_verify,
|
|
summary.state.verified_after_unverify,
|
|
summary.state.collection_size_before,
|
|
summary.state.collection_size_after_verify,
|
|
summary.state.collection_size_after_unverify,
|
|
);
|
|
println!(
|
|
"METAPLEX_COLLECTION_VERIFY_EVIDENCE verify={} unverify={}",
|
|
execution_evidence_json(&summary.verify),
|
|
execution_evidence_json(&summary.unverify),
|
|
);
|
|
}
|
|
}
|