0.5.1-pre.002
This commit is contained in:
@@ -0,0 +1,152 @@
|
||||
// file: ks-pipeline-demo-scenarios/src/bin/ks_pipeline_demo_scenarios_cli.rs
|
||||
// version: 2
|
||||
|
||||
#![forbid(unsafe_code)]
|
||||
#![deny(unreachable_pub)]
|
||||
#![warn(missing_docs)]
|
||||
|
||||
//! Command-line entry point for reusable Devnet fixture preparation.
|
||||
|
||||
macro_rules! ks_try {
|
||||
($expression:expr) => {
|
||||
match $expression {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() {
|
||||
let result = run().await;
|
||||
if let std::result::Result::Err(error) = result {
|
||||
eprintln!("{}: {}", error.code(), error.message());
|
||||
std::process::exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
async fn run() -> ks_core::Result<()> {
|
||||
let arguments = std::env::args().skip(1).collect::<std::vec::Vec<_>>();
|
||||
let command = match arguments.first() {
|
||||
std::option::Option::Some(value) => value.as_str(),
|
||||
std::option::Option::None => return usage_error("missing command"),
|
||||
};
|
||||
if command != "prepare-token-2022-fixture" {
|
||||
return usage_error(format!("unknown command {command}").as_str());
|
||||
}
|
||||
let options = ks_try!(parse_prepare_options(arguments.as_slice()));
|
||||
let summary = ks_try!(ks_pipeline_demo_scenarios::prepare_token_2022_fixture(&options).await);
|
||||
let output = match serde_json::to_string_pretty(&summary) {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => {
|
||||
return std::result::Result::Err(ks_core::Error::json(format!(
|
||||
"unable to serialize Token-2022 fixture summary: {error}"
|
||||
)));
|
||||
},
|
||||
};
|
||||
println!("{output}");
|
||||
return std::result::Result::Ok(());
|
||||
}
|
||||
|
||||
fn parse_prepare_options(
|
||||
arguments: &[std::string::String],
|
||||
) -> ks_core::Result<ks_pipeline_demo_scenarios::Token2022FixturePreparationOptions> {
|
||||
let mut rpc_url = std::option::Option::None;
|
||||
let mut wallet_path = std::option::Option::None;
|
||||
let mut wallet_dir = std::option::Option::None;
|
||||
let mut decimals = 9_u8;
|
||||
let mut index = 1_usize;
|
||||
while index < arguments.len() {
|
||||
let name = arguments[index].as_str();
|
||||
let value = match arguments.get(index + 1) {
|
||||
std::option::Option::Some(value) => value,
|
||||
std::option::Option::None => {
|
||||
return usage_error(format!("missing value for {name}").as_str());
|
||||
},
|
||||
};
|
||||
match name {
|
||||
"--rpc-url" => rpc_url = std::option::Option::Some(value.clone()),
|
||||
"--wallet" => wallet_path = std::option::Option::Some(std::path::PathBuf::from(value)),
|
||||
"--wallet-dir" => {
|
||||
wallet_dir = std::option::Option::Some(std::path::PathBuf::from(value))
|
||||
},
|
||||
"--decimals" => {
|
||||
decimals = match value.parse::<u8>() {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => {
|
||||
return std::result::Result::Err(ks_core::Error::config(format!(
|
||||
"invalid --decimals value {value}: {error}"
|
||||
)));
|
||||
},
|
||||
}
|
||||
},
|
||||
_ => return usage_error(format!("unknown option {name}").as_str()),
|
||||
}
|
||||
index += 2;
|
||||
}
|
||||
let rpc_url = match rpc_url.or_else(|| return std::env::var("KB_DEVNET_RPC_URL").ok()) {
|
||||
std::option::Option::Some(value) => value,
|
||||
std::option::Option::None => {
|
||||
return std::result::Result::Err(ks_core::Error::config(
|
||||
"missing --rpc-url or KB_DEVNET_RPC_URL",
|
||||
));
|
||||
},
|
||||
};
|
||||
let wallet_path = match wallet_path
|
||||
.or_else(|| return std::env::var("KB_DEVNET_WALLET").ok().map(std::path::PathBuf::from))
|
||||
{
|
||||
std::option::Option::Some(value) => value,
|
||||
std::option::Option::None => {
|
||||
return std::result::Result::Err(ks_core::Error::config(
|
||||
"missing --wallet or KB_DEVNET_WALLET",
|
||||
));
|
||||
},
|
||||
};
|
||||
let wallet_dir = match wallet_dir {
|
||||
std::option::Option::Some(value) => value,
|
||||
std::option::Option::None => match wallet_path.parent() {
|
||||
std::option::Option::Some(value) => std::path::Path::to_path_buf(value),
|
||||
std::option::Option::None => std::path::PathBuf::from("."),
|
||||
},
|
||||
};
|
||||
return std::result::Result::Ok(
|
||||
ks_pipeline_demo_scenarios::Token2022FixturePreparationOptions {
|
||||
rpc_url,
|
||||
wallet_path,
|
||||
wallet_dir,
|
||||
decimals,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
fn usage_error<T>(message: &str) -> ks_core::Result<T> {
|
||||
return std::result::Result::Err(ks_core::Error::config(format!(
|
||||
"{message}. Usage: cargo run -p ks-pipeline-demo-scenarios --bin ks-pipeline-demo-scenarios-cli -- prepare-token-2022-fixture --rpc-url URL --wallet PATH [--wallet-dir PATH] [--decimals 9]"
|
||||
)));
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#[test]
|
||||
fn prepare_options_accept_explicit_values() {
|
||||
let arguments = vec![
|
||||
"prepare-token-2022-fixture".to_string(),
|
||||
"--rpc-url".to_string(),
|
||||
"https://api.devnet.solana.com".to_string(),
|
||||
"--wallet".to_string(),
|
||||
"wallet.json".to_string(),
|
||||
"--wallet-dir".to_string(),
|
||||
"wallet-dir".to_string(),
|
||||
"--decimals".to_string(),
|
||||
"9".to_string(),
|
||||
];
|
||||
let options = super::parse_prepare_options(arguments.as_slice());
|
||||
assert!(options.is_ok());
|
||||
let options = match options {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(_) => return,
|
||||
};
|
||||
assert_eq!(options.wallet_dir, std::path::PathBuf::from("wallet-dir"));
|
||||
assert_eq!(options.decimals, 9);
|
||||
}
|
||||
}
|
||||
10
ks-pipeline-demo-scenarios/src/constants.rs
Normal file
10
ks-pipeline-demo-scenarios/src/constants.rs
Normal file
@@ -0,0 +1,10 @@
|
||||
// file: ks-pipeline-demo-scenarios/src/constants.rs
|
||||
// version: 4
|
||||
|
||||
//! Demo scenario constants.
|
||||
|
||||
/// Canonical tracing target for demo pipeline scenarios.
|
||||
pub(crate) const TRACING_TARGET: &str = "ks-pipeline-demo-scenarios";
|
||||
|
||||
/// Total transactions reserved by one complete Solana Program Metadata campaign.
|
||||
pub(crate) const SOLANA_PROGRAM_METADATA_CAMPAIGN_TRANSACTION_COUNT: u64 = 11;
|
||||
184
ks-pipeline-demo-scenarios/src/environment.rs
Normal file
184
ks-pipeline-demo-scenarios/src/environment.rs
Normal file
@@ -0,0 +1,184 @@
|
||||
// file: ks-pipeline-demo-scenarios/src/environment.rs
|
||||
// version: 3
|
||||
|
||||
//! Environment initialization for opt-in demonstration scenarios.
|
||||
|
||||
/// Loads the workspace `.env` selected by `ks-config`.
|
||||
pub fn initialize_demo_scenario_environment(
|
||||
workspace_root: &std::path::Path,
|
||||
) -> ks_core::Result<ks_config::EnvironmentLoadReport> {
|
||||
return ks_config::load_workspace_environment(workspace_root);
|
||||
}
|
||||
|
||||
/// Resolves one compatible Devnet profile for demo scenarios.
|
||||
///
|
||||
/// An explicit name has priority, followed by `KB_DEVNET_PROFILE`, then the
|
||||
/// first compatible Devnet profile declared by the configuration.
|
||||
pub fn resolve_demo_devnet_profile(
|
||||
config: &ks_config::AppConfig,
|
||||
requested_profile_name: std::option::Option<&str>,
|
||||
) -> ks_core::Result<ks_config::ProfileConfig> {
|
||||
let explicit = requested_profile_name
|
||||
.map(str::trim)
|
||||
.filter(|value| return !value.is_empty())
|
||||
.map(std::string::ToString::to_string);
|
||||
let selected_name = match explicit {
|
||||
std::option::Option::Some(value) => std::option::Option::Some(value),
|
||||
std::option::Option::None => std::env::var("KB_DEVNET_PROFILE")
|
||||
.ok()
|
||||
.map(|value| return value.trim().to_string())
|
||||
.filter(|value| return !value.is_empty()),
|
||||
};
|
||||
for profile in &config.profiles {
|
||||
let compatible = profile.wallet.cluster == "devnet"
|
||||
&& profile.wallet.temporary_wallet_enabled
|
||||
&& profile.wallet.temporary_wallet_persist;
|
||||
if !compatible {
|
||||
continue;
|
||||
}
|
||||
if let std::option::Option::Some(name) = selected_name.as_ref()
|
||||
&& profile.name != *name
|
||||
{
|
||||
continue;
|
||||
}
|
||||
return std::result::Result::Ok(profile.clone());
|
||||
}
|
||||
return match selected_name {
|
||||
std::option::Option::Some(name) => std::result::Result::Err(ks_core::Error::config(
|
||||
format!("compatible Devnet profile '{name}' is unavailable"),
|
||||
)),
|
||||
std::option::Option::None => std::result::Result::Err(ks_core::Error::config(
|
||||
"no compatible Devnet profile is configured",
|
||||
)),
|
||||
};
|
||||
}
|
||||
|
||||
/// Readiness report for one Devnet profile PostgreSQL store.
|
||||
#[derive(Clone, Debug, serde::Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct DevnetProfileStoreReadiness {
|
||||
/// Selected Devnet profile.
|
||||
pub profile_name: std::string::String,
|
||||
/// Whether schema creation was allowed by configuration.
|
||||
pub auto_initialize_schema: bool,
|
||||
/// Number of known tables already present before preparation.
|
||||
pub existing_tables_before: usize,
|
||||
/// Number of known tables created during preparation.
|
||||
pub created_tables: usize,
|
||||
/// Number of known tables present after preparation.
|
||||
pub existing_tables_after: usize,
|
||||
/// Total number of known tables expected by the current store.
|
||||
pub expected_tables: usize,
|
||||
}
|
||||
|
||||
/// Verifies or initializes the PostgreSQL schema selected by one Devnet profile.
|
||||
pub async fn prepare_demo_devnet_profile_store(
|
||||
profile: &ks_config::ProfileConfig,
|
||||
) -> ks_core::Result<DevnetProfileStoreReadiness> {
|
||||
if profile.wallet.cluster != "devnet" {
|
||||
return std::result::Result::Err(ks_core::Error::config(format!(
|
||||
"profile '{}' is not configured for Devnet",
|
||||
profile.name
|
||||
)));
|
||||
}
|
||||
if !profile.database.enabled {
|
||||
return std::result::Result::Err(ks_core::Error::config(format!(
|
||||
"Devnet profile '{}' has database storage disabled",
|
||||
profile.name
|
||||
)));
|
||||
}
|
||||
if profile.database.backend != "postgres" {
|
||||
return std::result::Result::Err(ks_core::Error::config(format!(
|
||||
"Devnet profile '{}' must use the PostgreSQL backend",
|
||||
profile.name
|
||||
)));
|
||||
}
|
||||
let options = match ks_store::PostgresStoreOptions::new(
|
||||
profile.database.postgres.url.clone(),
|
||||
profile.database.postgres.max_connections,
|
||||
profile.database.postgres.connect_timeout_ms,
|
||||
false,
|
||||
) {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let store = match ks_store::PostgresStore::connect(options).await {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let before = match store.known_table_diagnostics().await {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let existing_tables_before = before.iter().filter(|table| return table.exists).count();
|
||||
if profile.database.postgres.auto_initialize_schema
|
||||
&& let std::result::Result::Err(error) = store.initialize_store_schema().await
|
||||
{
|
||||
return std::result::Result::Err(error);
|
||||
}
|
||||
let after = match store.known_table_diagnostics().await {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let existing_tables_after = after.iter().filter(|table| return table.exists).count();
|
||||
if existing_tables_after != after.len() {
|
||||
let missing = after
|
||||
.iter()
|
||||
.filter(|table| return !table.exists)
|
||||
.map(|table| return table.table_name.clone())
|
||||
.collect::<std::vec::Vec<_>>();
|
||||
return std::result::Result::Err(ks_core::Error::config(format!(
|
||||
"Devnet profile '{}' PostgreSQL schema is incomplete and auto initialization is {}: missing {}",
|
||||
profile.name,
|
||||
if profile.database.postgres.auto_initialize_schema {
|
||||
"enabled"
|
||||
} else {
|
||||
"disabled"
|
||||
},
|
||||
missing.join(", ")
|
||||
)));
|
||||
}
|
||||
return std::result::Result::Ok(DevnetProfileStoreReadiness {
|
||||
profile_name: profile.name.clone(),
|
||||
auto_initialize_schema: profile.database.postgres.auto_initialize_schema,
|
||||
existing_tables_before,
|
||||
created_tables: existing_tables_after.saturating_sub(existing_tables_before),
|
||||
existing_tables_after,
|
||||
expected_tables: after.len(),
|
||||
});
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
fn example_config() -> ks_config::AppConfig {
|
||||
return match ks_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}"),
|
||||
};
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolver_finds_a_compatible_devnet_profile_without_a_fixed_name() {
|
||||
let config = example_config();
|
||||
let profile = match super::resolve_demo_devnet_profile(&config, std::option::Option::None) {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => panic!("Devnet profile resolution failed: {error}"),
|
||||
};
|
||||
assert_eq!(profile.wallet.cluster, "devnet");
|
||||
assert!(profile.wallet.temporary_wallet_enabled);
|
||||
assert!(profile.wallet.temporary_wallet_persist);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolver_rejects_an_explicit_non_devnet_profile() {
|
||||
let config = example_config();
|
||||
assert!(
|
||||
super::resolve_demo_devnet_profile(
|
||||
&config,
|
||||
std::option::Option::Some("mainnet_research"),
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
}
|
||||
378
ks-pipeline-demo-scenarios/src/lib.rs
Normal file
378
ks-pipeline-demo-scenarios/src/lib.rs
Normal file
@@ -0,0 +1,378 @@
|
||||
// file: ks-pipeline-demo-scenarios/src/lib.rs
|
||||
// version: 28
|
||||
|
||||
#![forbid(unsafe_code)]
|
||||
#![deny(unreachable_pub)]
|
||||
#![warn(missing_docs)]
|
||||
|
||||
//! Reusable demonstration and Devnet validation scenarios built on `ks-pipeline`.
|
||||
|
||||
mod constants;
|
||||
mod environment;
|
||||
mod metadata;
|
||||
mod solana;
|
||||
mod spl;
|
||||
|
||||
/// Readiness report for one Devnet profile PostgreSQL store.
|
||||
pub use self::environment::DevnetProfileStoreReadiness;
|
||||
/// Loads the workspace environment for opt-in demo and Devnet scenarios.
|
||||
pub use self::environment::initialize_demo_scenario_environment;
|
||||
/// Verifies or initializes the PostgreSQL schema selected by one Devnet profile.
|
||||
pub use self::environment::prepare_demo_devnet_profile_store;
|
||||
/// Resolves one configured Devnet profile for UI, CLI and test scenarios.
|
||||
pub use self::environment::resolve_demo_devnet_profile;
|
||||
/// Complete evidence produced by one collection parent/member verification campaign.
|
||||
pub use self::metadata::metaplex_token_metadata::collection_verify_campaign::DevnetMetaplexCollectionVerifyCampaignSummary;
|
||||
/// Collection relation and sized-collection state observed across verification.
|
||||
pub use self::metadata::metaplex_token_metadata::collection_verify_campaign::DevnetMetaplexCollectionVerifyState;
|
||||
/// Executes one fresh collection parent/member verification campaign on Devnet.
|
||||
pub use self::metadata::metaplex_token_metadata::collection_verify_campaign::execute_devnet_metaplex_collection_verify_campaign;
|
||||
/// Returns the exact current operations qualified by the collection verification campaign.
|
||||
pub use self::metadata::metaplex_token_metadata::collection_verify_campaign::metaplex_collection_verify_campaign_operation_names;
|
||||
/// Complete evidence produced by one family-specific Metaplex `Create -> Mint` campaign.
|
||||
pub use self::metadata::metaplex_token_metadata::create_mint_campaign::DevnetMetaplexCreateMintCampaignSummary;
|
||||
/// Confirmed SPL state observed around the Metaplex `Mint` operation.
|
||||
pub use self::metadata::metaplex_token_metadata::create_mint_campaign::DevnetMetaplexCreateMintTokenState;
|
||||
/// Executes one fresh family-specific Metaplex `Create -> Mint` campaign on Devnet.
|
||||
pub use self::metadata::metaplex_token_metadata::create_mint_campaign::execute_devnet_metaplex_create_mint_campaign;
|
||||
/// Returns the exact ordered operations covered by the `Create -> Mint` campaign.
|
||||
pub use self::metadata::metaplex_token_metadata::create_mint_campaign::metaplex_create_mint_campaign_operation_names;
|
||||
/// Complete request for one real Devnet Metaplex Token Metadata execution.
|
||||
pub use self::metadata::metaplex_token_metadata::devnet_execution::DevnetMetaplexTokenMetadataExecutionRequest;
|
||||
/// Complete evidence produced by one real Devnet Metaplex execution.
|
||||
pub use self::metadata::metaplex_token_metadata::devnet_execution::DevnetMetaplexTokenMetadataExecutionSummary;
|
||||
/// Creates one named creator unverification request without handwritten JSON.
|
||||
pub use self::metadata::metaplex_token_metadata::devnet_execution::devnet_metaplex_creator_unverify_request;
|
||||
/// Creates one named creator verification request without handwritten JSON.
|
||||
pub use self::metadata::metaplex_token_metadata::devnet_execution::devnet_metaplex_creator_verify_request;
|
||||
/// Executes one real Devnet Metaplex simulation or explicitly authorized submission.
|
||||
pub use self::metadata::metaplex_token_metadata::devnet_execution::execute_devnet_metaplex_token_metadata;
|
||||
/// Executes one real Devnet Metaplex submission with explicitly authorized extra signers.
|
||||
pub use self::metadata::metaplex_token_metadata::devnet_execution::execute_devnet_metaplex_token_metadata_with_signers;
|
||||
/// Returns a formatted JSON template for one named current operation.
|
||||
pub use self::metadata::metaplex_token_metadata::devnet_execution::metaplex_token_metadata_current_operation_json_template;
|
||||
/// Returns the current operation names accepted by automatic Devnet campaigns.
|
||||
pub use self::metadata::metaplex_token_metadata::devnet_execution::metaplex_token_metadata_current_operation_names;
|
||||
/// Simulates one current Metaplex operation against a real Devnet endpoint.
|
||||
pub use self::metadata::metaplex_token_metadata::devnet_execution::simulate_devnet_metaplex_token_metadata;
|
||||
/// Exact SPL state transitions exercised around the Token Owned Escrow operations.
|
||||
pub use self::metadata::metaplex_token_metadata::escrow_campaign::DevnetMetaplexEscrowCampaignState;
|
||||
/// Complete evidence retained by one Token Owned Escrow Devnet campaign.
|
||||
pub use self::metadata::metaplex_token_metadata::escrow_campaign::DevnetMetaplexEscrowCampaignSummary;
|
||||
/// Executes a fresh Token Owned Escrow lifecycle on Devnet.
|
||||
pub use self::metadata::metaplex_token_metadata::escrow_campaign::execute_devnet_metaplex_escrow_campaign;
|
||||
/// Returns the exact ordered Metaplex operations qualified by the escrow campaign.
|
||||
pub use self::metadata::metaplex_token_metadata::escrow_campaign::metaplex_escrow_campaign_operation_names;
|
||||
/// Options for one Metaplex Create fixture preparation.
|
||||
pub use self::metadata::metaplex_token_metadata::fixture::MetaplexCreateFixturePreparationOptions;
|
||||
/// Public values generated for one Metaplex Create fixture.
|
||||
pub use self::metadata::metaplex_token_metadata::fixture::MetaplexCreateFixturePreparationSummary;
|
||||
/// Reads the validated classic SPL mint supply for an internal Metaplex campaign.
|
||||
pub(crate) use self::metadata::metaplex_token_metadata::fixture::classic_fixture_mint_supply;
|
||||
/// Reads the validated classic SPL token-account state for an internal Metaplex campaign.
|
||||
pub(crate) use self::metadata::metaplex_token_metadata::fixture::classic_fixture_token_account_state;
|
||||
/// Reads the validated classic SPL token-account amount for an internal Metaplex campaign.
|
||||
pub(crate) use self::metadata::metaplex_token_metadata::fixture::classic_fixture_token_amount;
|
||||
/// Creates or reuses a classic SPL mint and derives its Metaplex PDAs.
|
||||
pub use self::metadata::metaplex_token_metadata::fixture::prepare_metaplex_create_fixture;
|
||||
/// Reads one classic SPL mint account at or after a minimum context slot.
|
||||
pub(crate) use self::metadata::metaplex_token_metadata::fixture::read_mint_account_at_or_after;
|
||||
/// Reads one classic SPL token account at or after a minimum context slot.
|
||||
pub(crate) use self::metadata::metaplex_token_metadata::fixture::read_token_account_at_or_after;
|
||||
/// Validates one classic SPL mint fixture against an exact expected supply.
|
||||
pub(crate) use self::metadata::metaplex_token_metadata::fixture::validate_classic_fixture_mint_state;
|
||||
/// Validates one classic SPL token-account fixture against an exact expected amount.
|
||||
pub(crate) use self::metadata::metaplex_token_metadata::fixture::validate_classic_fixture_token_account_state;
|
||||
/// Complete evidence produced by the final Metaplex maintenance campaign.
|
||||
pub use self::metadata::metaplex_token_metadata::maintenance_campaign::DevnetMetaplexMaintenanceCampaignSummary;
|
||||
/// Executes the final bounded current maintenance campaign on Devnet.
|
||||
pub use self::metadata::metaplex_token_metadata::maintenance_campaign::execute_devnet_metaplex_maintenance_campaign;
|
||||
/// Returns the exact ordered operations exercised by the maintenance campaign.
|
||||
pub use self::metadata::metaplex_token_metadata::maintenance_campaign::metaplex_maintenance_campaign_operation_names;
|
||||
/// Complete evidence produced by one pNFT lifecycle campaign.
|
||||
pub use self::metadata::metaplex_token_metadata::pnft_lifecycle_campaign::DevnetMetaplexPnftLifecycleCampaignSummary;
|
||||
/// Exact programmable-token state observed across the pNFT lifecycle campaign.
|
||||
pub use self::metadata::metaplex_token_metadata::pnft_lifecycle_campaign::DevnetMetaplexPnftLifecycleState;
|
||||
/// Executes one fresh pNFT delegate/lock/unlock/revoke/transfer campaign on Devnet.
|
||||
pub use self::metadata::metaplex_token_metadata::pnft_lifecycle_campaign::execute_devnet_metaplex_pnft_lifecycle_campaign;
|
||||
/// Returns the exact ordered current operations exercised by the pNFT lifecycle campaign.
|
||||
pub use self::metadata::metaplex_token_metadata::pnft_lifecycle_campaign::metaplex_pnft_lifecycle_campaign_operation_names;
|
||||
/// Complete evidence produced by one printable master NFT `Print -> Burn` campaign.
|
||||
pub use self::metadata::metaplex_token_metadata::print_burn_campaign::DevnetMetaplexPrintBurnCampaignSummary;
|
||||
/// Exact printed-edition state transition observed around `Print` and `Burn`.
|
||||
pub use self::metadata::metaplex_token_metadata::print_burn_campaign::DevnetMetaplexPrintBurnState;
|
||||
/// Executes one printable master NFT `Print -> Burn` campaign on Devnet.
|
||||
pub use self::metadata::metaplex_token_metadata::print_burn_campaign::execute_devnet_metaplex_print_burn_campaign;
|
||||
/// Returns the exact ordered current operations qualified by the print/burn campaign.
|
||||
pub use self::metadata::metaplex_token_metadata::print_burn_campaign::metaplex_print_burn_campaign_operation_names;
|
||||
/// Stable asset family covered by one Metaplex scenario.
|
||||
pub use self::metadata::metaplex_token_metadata::scenarios::MetaplexTokenMetadataAssetFamily;
|
||||
/// Stable fixture graph required by one Metaplex scenario journey.
|
||||
pub use self::metadata::metaplex_token_metadata::scenarios::MetaplexTokenMetadataFixtureKind;
|
||||
/// Stable lifecycle state consumed or produced by one Metaplex scenario journey.
|
||||
pub use self::metadata::metaplex_token_metadata::scenarios::MetaplexTokenMetadataFixtureState;
|
||||
/// One stable Metaplex scenario reusable by automated tests and demo adapters.
|
||||
pub use self::metadata::metaplex_token_metadata::scenarios::MetaplexTokenMetadataScenario;
|
||||
/// Execution mode accepted by one Metaplex scenario.
|
||||
pub use self::metadata::metaplex_token_metadata::scenarios::MetaplexTokenMetadataScenarioMode;
|
||||
/// Returns the ordered Metaplex Devnet simulation scenario inventory.
|
||||
pub use self::metadata::metaplex_token_metadata::scenarios::metaplex_token_metadata_devnet_scenarios;
|
||||
/// Returns the complete ordered synthetic Metaplex scenario inventory.
|
||||
pub use self::metadata::metaplex_token_metadata::scenarios::metaplex_token_metadata_synthetic_scenarios;
|
||||
/// Runtime availability classification produced by one bounded current `Use` probe.
|
||||
pub use self::metadata::metaplex_token_metadata::use_campaign::DevnetMetaplexUseProbeStatus;
|
||||
/// Complete evidence produced by one bounded current `Use` probe.
|
||||
pub use self::metadata::metaplex_token_metadata::use_campaign::DevnetMetaplexUseProbeSummary;
|
||||
/// Executes one bounded current `Use` availability probe on Devnet.
|
||||
pub use self::metadata::metaplex_token_metadata::use_campaign::execute_devnet_metaplex_use_probe;
|
||||
/// Returns the exact setup and target operations exercised by the `Use` probe.
|
||||
pub use self::metadata::metaplex_token_metadata::use_campaign::metaplex_use_probe_operation_names;
|
||||
/// Maximum evidence entries accepted by one Metaplex validation scenario.
|
||||
pub use self::metadata::metaplex_token_metadata::validation::MAX_METAPLEX_TOKEN_METADATA_VALIDATION_EVIDENCE;
|
||||
/// One case in the closed Metaplex cross-validation corpus.
|
||||
pub use self::metadata::metaplex_token_metadata::validation::MetaplexTokenMetadataCrossValidationCase;
|
||||
/// Closed Metaplex cross-validation corpus for `0.4.7-pre.010`.
|
||||
pub use self::metadata::metaplex_token_metadata::validation::MetaplexTokenMetadataCrossValidationMatrix;
|
||||
/// Family-specific evidence bundle for one current Metaplex Devnet operation.
|
||||
pub use self::metadata::metaplex_token_metadata::validation::MetaplexTokenMetadataDevnetExecutionFamilyEvidence;
|
||||
/// Closed inventory of every current Metaplex operation requiring Devnet coverage.
|
||||
pub use self::metadata::metaplex_token_metadata::validation::MetaplexTokenMetadataDevnetExecutionMatrix;
|
||||
/// One current Metaplex operation in the closed Devnet execution matrix.
|
||||
pub use self::metadata::metaplex_token_metadata::validation::MetaplexTokenMetadataDevnetExecutionOperation;
|
||||
/// Stable failure category exercised by one negative case.
|
||||
pub use self::metadata::metaplex_token_metadata::validation::MetaplexTokenMetadataFailureClass;
|
||||
/// Expected transaction outcome for one cross-validation case.
|
||||
pub use self::metadata::metaplex_token_metadata::validation::MetaplexTokenMetadataTransactionOutcome;
|
||||
/// Transaction location covered by one cross-validation case.
|
||||
pub use self::metadata::metaplex_token_metadata::validation::MetaplexTokenMetadataTransactionPath;
|
||||
/// One bounded Metaplex validation evidence entry.
|
||||
pub use self::metadata::metaplex_token_metadata::validation::MetaplexTokenMetadataValidationEvidence;
|
||||
/// Canonical Metaplex validation matrix.
|
||||
pub use self::metadata::metaplex_token_metadata::validation::MetaplexTokenMetadataValidationMatrix;
|
||||
/// One scenario declared by the canonical Metaplex validation matrix.
|
||||
pub use self::metadata::metaplex_token_metadata::validation::MetaplexTokenMetadataValidationMatrixScenario;
|
||||
/// Exact validation status for one Metaplex scenario.
|
||||
pub use self::metadata::metaplex_token_metadata::validation::MetaplexTokenMetadataValidationStatus;
|
||||
/// Loads and validates the closed Metaplex cross-validation corpus.
|
||||
pub use self::metadata::metaplex_token_metadata::validation::load_metaplex_token_metadata_cross_validation_matrix;
|
||||
/// Loads and validates the closed Devnet execution matrix.
|
||||
pub use self::metadata::metaplex_token_metadata::validation::load_metaplex_token_metadata_devnet_execution_matrix;
|
||||
/// Loads and validates the canonical Metaplex validation matrix.
|
||||
pub use self::metadata::metaplex_token_metadata::validation::load_metaplex_token_metadata_validation_matrix;
|
||||
/// Validates the closed Metaplex cross-validation corpus and evidence claims.
|
||||
pub use self::metadata::metaplex_token_metadata::validation::validate_metaplex_token_metadata_cross_validation_matrix;
|
||||
/// Validates exact current-operation coverage and conservative network statuses.
|
||||
pub use self::metadata::metaplex_token_metadata::validation::validate_metaplex_token_metadata_devnet_execution_matrix;
|
||||
/// Validates the canonical Metaplex validation matrix.
|
||||
pub use self::metadata::metaplex_token_metadata::validation::validate_metaplex_token_metadata_validation_matrix;
|
||||
/// One confirmed step retained by the complete Devnet campaign.
|
||||
pub use self::metadata::solana_program::campaign::DevnetSolanaProgramMetadataCampaignStepSummary;
|
||||
/// Complete evidence retained by the two-journey Devnet campaign.
|
||||
pub use self::metadata::solana_program::campaign::DevnetSolanaProgramMetadataCampaignSummary;
|
||||
/// Prepares fresh accounts and executes the nine stable operations on Devnet.
|
||||
pub use self::metadata::solana_program::campaign::execute_devnet_solana_program_metadata_campaign;
|
||||
/// Complete request for one Solana Program Metadata Devnet execution.
|
||||
pub use self::metadata::solana_program::devnet_execution::DevnetSolanaProgramMetadataExecutionRequest;
|
||||
/// Complete evidence produced by one Solana Program Metadata Devnet execution.
|
||||
pub use self::metadata::solana_program::devnet_execution::DevnetSolanaProgramMetadataExecutionSummary;
|
||||
/// Executes one Solana Program Metadata simulation or authorized submission.
|
||||
pub use self::metadata::solana_program::devnet_execution::execute_devnet_solana_program_metadata;
|
||||
/// Simulates one stable Solana Program Metadata operation against Devnet.
|
||||
pub use self::metadata::solana_program::devnet_execution::simulate_devnet_solana_program_metadata;
|
||||
/// Bytes reserved for the public Buffer journey before `Trim`.
|
||||
pub use self::metadata::solana_program::fixture::SOLANA_PROGRAM_METADATA_FIXTURE_BUFFER_DATA_BYTES;
|
||||
/// Options used to prepare one complete Solana Program Metadata fixture.
|
||||
pub use self::metadata::solana_program::fixture::SolanaProgramMetadataFixturePreparationOptions;
|
||||
/// Public fixture values for the two Solana Program Metadata journeys.
|
||||
pub use self::metadata::solana_program::fixture::SolanaProgramMetadataFixturePreparationSummary;
|
||||
/// One prepared executable step in a Solana Program Metadata journey.
|
||||
pub use self::metadata::solana_program::fixture::SolanaProgramMetadataPreparedStep;
|
||||
/// Creates two unique pre-funded PDAs and the nine typed journey steps.
|
||||
pub use self::metadata::solana_program::fixture::prepare_solana_program_metadata_fixture;
|
||||
/// Stable lifecycle state consumed or produced by one scenario step.
|
||||
pub use self::metadata::solana_program::scenarios::SolanaProgramMetadataFixtureState;
|
||||
/// One ordered multi-step Solana Program Metadata journey.
|
||||
pub use self::metadata::solana_program::scenarios::SolanaProgramMetadataScenario;
|
||||
/// One stable step in a Solana Program Metadata Devnet journey.
|
||||
pub use self::metadata::solana_program::scenarios::SolanaProgramMetadataScenarioStep;
|
||||
/// Returns the two ordered Devnet journeys covering all nine stable operations.
|
||||
pub use self::metadata::solana_program::scenarios::solana_program_metadata_devnet_scenarios;
|
||||
/// Maximum evidence entries accepted by one operation validation row.
|
||||
pub use self::metadata::solana_program::validation::MAX_SOLANA_PROGRAM_METADATA_VALIDATION_EVIDENCE;
|
||||
/// One bounded evidence record.
|
||||
pub use self::metadata::solana_program::validation::SolanaProgramMetadataValidationEvidence;
|
||||
/// Closed validation matrix for all nine stable operations.
|
||||
pub use self::metadata::solana_program::validation::SolanaProgramMetadataValidationMatrix;
|
||||
/// One operation row in the closed Devnet validation matrix.
|
||||
pub use self::metadata::solana_program::validation::SolanaProgramMetadataValidationOperation;
|
||||
/// Exact network validation status.
|
||||
pub use self::metadata::solana_program::validation::SolanaProgramMetadataValidationStatus;
|
||||
/// Loads and validates the canonical Solana Program Metadata Devnet matrix.
|
||||
pub use self::metadata::solana_program::validation::load_solana_program_metadata_validation_matrix;
|
||||
/// Validates exact inventory, order and conservative evidence claims.
|
||||
pub use self::metadata::solana_program::validation::validate_solana_program_metadata_validation_matrix;
|
||||
/// Complete request for one bounded System Program transfer on Devnet.
|
||||
pub use self::solana::DevnetSystemTransferRequest;
|
||||
/// Summary returned by one bounded System Program transfer execution.
|
||||
pub use self::solana::DevnetSystemTransferSummary;
|
||||
/// No-op observer suitable for CLI tools and opt-in integration tests.
|
||||
pub use self::solana::NoopSolanaExecutionObserver;
|
||||
/// Observer notified during Solana execution orchestration.
|
||||
pub use self::solana::SolanaExecutionObserver;
|
||||
/// Progress event emitted by Solana execution orchestration.
|
||||
pub use self::solana::SolanaExecutionProgressEvent;
|
||||
/// Severity of one Solana execution progress event.
|
||||
pub use self::solana::SolanaExecutionProgressLevel;
|
||||
/// Emits one execution progress event for specialized pipeline orchestrators.
|
||||
pub(crate) use self::solana::emit;
|
||||
/// Rejects one execution stage when the observer reports cancellation.
|
||||
pub(crate) use self::solana::ensure_not_cancelled;
|
||||
/// Executes one bounded System Program transfer on Devnet.
|
||||
pub use self::solana::execute_devnet_system_transfer;
|
||||
/// Loads the persistent wallet configured by one execution profile.
|
||||
pub(crate) use self::solana::load_profile_wallet;
|
||||
/// Formats one failed simulation without discarding runtime diagnostics.
|
||||
pub(crate) use self::solana::simulation_failure_message;
|
||||
/// Formats safety violations for one denied execution plan.
|
||||
pub(crate) use self::solana::violation_message;
|
||||
/// Complete request for one Devnet Associated Token Account execution.
|
||||
pub use self::spl::associated_token_account::DevnetSplAssociatedTokenAccountExecutionRequest;
|
||||
/// Complete result of one Devnet Associated Token Account execution.
|
||||
pub use self::spl::associated_token_account::DevnetSplAssociatedTokenAccountExecutionSummary;
|
||||
/// Executes one Devnet Associated Token Account simulation or authorized submission.
|
||||
pub use self::spl::associated_token_account::execute_devnet_spl_associated_token_account;
|
||||
/// Simulates one Devnet Associated Token Account operation after stateful preflight.
|
||||
pub use self::spl::associated_token_account::simulate_devnet_spl_associated_token_account;
|
||||
/// Complete request for one SPL Memo v4 Devnet execution.
|
||||
pub use self::spl::memo::DevnetMemoExecutionRequest;
|
||||
/// Complete result of one SPL Memo v4 Devnet execution and post-validation.
|
||||
pub use self::spl::memo::DevnetMemoExecutionSummary;
|
||||
/// Executes one SPL Memo v4 Devnet simulation or explicitly authorized submission.
|
||||
pub use self::spl::memo::execute_devnet_memo;
|
||||
/// Complete request for one Devnet classic SPL Token execution.
|
||||
pub use self::spl::token::execution::DevnetSplTokenExecutionRequest;
|
||||
/// Complete result of one Devnet classic SPL Token execution.
|
||||
pub use self::spl::token::execution::DevnetSplTokenExecutionSummary;
|
||||
/// Canonical hydration availability helper shared by Token lifecycle orchestration.
|
||||
pub(crate) use self::spl::token::execution::canonical_available;
|
||||
/// Decode completion helper shared by Token lifecycle orchestration.
|
||||
pub(crate) use self::spl::token::execution::decode_completed;
|
||||
/// Executes one Devnet classic SPL Token simulation or authorized submission.
|
||||
pub use self::spl::token::execution::execute_devnet_spl_token;
|
||||
/// Canonical signature hydration helper shared by Token lifecycle orchestration.
|
||||
pub(crate) use self::spl::token::execution::hydrate_signature;
|
||||
/// Targeted program replay helper shared by Token lifecycle orchestration.
|
||||
pub(crate) use self::spl::token::execution::replay_program;
|
||||
/// Simulates one Devnet classic SPL Token operation after stateful preflight.
|
||||
pub use self::spl::token::execution::simulate_devnet_spl_token;
|
||||
/// Complete request to prepare raw accounts for one Devnet SPL Token lifecycle.
|
||||
pub use self::spl::token::lifecycle::DevnetSplTokenLifecyclePreparationRequest;
|
||||
/// One prepared raw account step.
|
||||
pub use self::spl::token::lifecycle::DevnetSplTokenLifecyclePreparationStep;
|
||||
/// Summary of raw-account preparation for one lifecycle.
|
||||
pub use self::spl::token::lifecycle::DevnetSplTokenLifecyclePreparationSummary;
|
||||
/// Complete request for one controlled Devnet SPL Token lifecycle.
|
||||
pub use self::spl::token::lifecycle::DevnetSplTokenLifecycleRequest;
|
||||
/// Summary of one lifecycle operation step.
|
||||
pub use self::spl::token::lifecycle::DevnetSplTokenLifecycleStepSummary;
|
||||
/// Complete controlled lifecycle summary.
|
||||
pub use self::spl::token::lifecycle::DevnetSplTokenLifecycleSummary;
|
||||
/// Executes one controlled Devnet SPL Token lifecycle.
|
||||
pub use self::spl::token::lifecycle::execute_devnet_spl_token_lifecycle;
|
||||
/// Prepares raw accounts for one controlled Devnet SPL Token lifecycle.
|
||||
pub use self::spl::token::lifecycle::prepare_devnet_spl_token_lifecycle_accounts;
|
||||
/// Complete request for one Devnet Token-2022 execution.
|
||||
pub use self::spl::token_2022::devnet_execution::DevnetSplToken2022ExecutionRequest;
|
||||
/// Complete result of one Devnet Token-2022 execution.
|
||||
pub use self::spl::token_2022::devnet_execution::DevnetSplToken2022ExecutionSummary;
|
||||
/// Executes one Devnet Token-2022 simulation or authorized submission.
|
||||
pub use self::spl::token_2022::devnet_execution::execute_devnet_spl_token_2022;
|
||||
/// Simulates one Devnet Token-2022 operation after stateful preflight.
|
||||
pub use self::spl::token_2022::devnet_execution::simulate_devnet_spl_token_2022;
|
||||
/// Stable category of one independent Devnet validation scenario.
|
||||
pub use self::spl::token_2022::devnet_scenarios::DevnetSplValidationFamily;
|
||||
/// Current implementation status of one Devnet validation scenario.
|
||||
pub use self::spl::token_2022::devnet_scenarios::DevnetSplValidationImplementationStatus;
|
||||
/// One independent Devnet validation scenario exposed to applications.
|
||||
pub use self::spl::token_2022::devnet_scenarios::DevnetSplValidationScenario;
|
||||
/// Returns the complete ordered Devnet scenario inventory.
|
||||
pub use self::spl::token_2022::devnet_scenarios::devnet_spl_validation_scenarios;
|
||||
/// Default raw amount delegated by the public ApproveChecked scenario.
|
||||
pub use self::spl::token_2022::fixture::DEFAULT_TOKEN_2022_APPROVE_AMOUNT_RAW;
|
||||
/// Default raw amount burned by the public BurnChecked scenario.
|
||||
pub use self::spl::token_2022::fixture::DEFAULT_TOKEN_2022_BURN_AMOUNT_RAW;
|
||||
/// Default raw amount minted by the public MintToChecked scenario.
|
||||
pub use self::spl::token_2022::fixture::DEFAULT_TOKEN_2022_MINT_AMOUNT_RAW;
|
||||
/// Default raw amount transferred by the public TransferChecked scenario.
|
||||
pub use self::spl::token_2022::fixture::DEFAULT_TOKEN_2022_TRANSFER_AMOUNT_RAW;
|
||||
/// Command-line options for one Token-2022 fixture preparation.
|
||||
pub use self::spl::token_2022::fixture::Token2022FixturePreparationOptions;
|
||||
/// Public values written to one Token-2022 fixture file.
|
||||
pub use self::spl::token_2022::fixture::Token2022FixturePreparationSummary;
|
||||
/// Creates or reuses one complete Token-2022 public scenario fixture.
|
||||
pub use self::spl::token_2022::fixture::prepare_token_2022_fixture;
|
||||
/// One confirmed step in the five-instruction Token Metadata Devnet campaign.
|
||||
pub use self::spl::token_2022::metadata::campaign::DevnetToken2022MetadataCampaignStepSummary;
|
||||
/// Complete evidence retained by the Token Metadata Devnet campaign.
|
||||
pub use self::spl::token_2022::metadata::campaign::DevnetToken2022MetadataCampaignSummary;
|
||||
/// Executes all five Token Metadata interface instructions on one fresh Devnet mint.
|
||||
pub use self::spl::token_2022::metadata::campaign::execute_devnet_token_2022_metadata_campaign;
|
||||
/// Returns the exact ordered operation names covered by the Token Metadata campaign.
|
||||
pub use self::spl::token_2022::metadata::campaign::token_2022_metadata_campaign_operation_names;
|
||||
/// Additional metadata key created and removed by the campaign.
|
||||
pub use self::spl::token_2022::metadata::fixture::TOKEN_2022_METADATA_CAMPAIGN_KEY;
|
||||
/// Stable metadata name used by the `0.4.8-pre.012` campaign.
|
||||
pub use self::spl::token_2022::metadata::fixture::TOKEN_2022_METADATA_CAMPAIGN_NAME;
|
||||
/// Stable metadata symbol used by the `0.4.8-pre.012` campaign.
|
||||
pub use self::spl::token_2022::metadata::fixture::TOKEN_2022_METADATA_CAMPAIGN_SYMBOL;
|
||||
/// Stable opaque URI stored by the `0.4.8-pre.012` campaign.
|
||||
pub use self::spl::token_2022::metadata::fixture::TOKEN_2022_METADATA_CAMPAIGN_URI;
|
||||
/// Additional metadata value created and removed by the campaign.
|
||||
pub use self::spl::token_2022::metadata::fixture::TOKEN_2022_METADATA_CAMPAIGN_VALUE;
|
||||
/// Options used to prepare one fresh Token-2022 metadata fixture.
|
||||
pub use self::spl::token_2022::metadata::fixture::Token2022MetadataFixturePreparationOptions;
|
||||
/// Fresh mint and authorities prepared for one Token Metadata campaign.
|
||||
pub use self::spl::token_2022::metadata::fixture::Token2022MetadataFixturePreparationSummary;
|
||||
/// Creates one fresh Token-2022 mint with a self-referential Metadata Pointer.
|
||||
pub use self::spl::token_2022::metadata::fixture::prepare_token_2022_metadata_fixture;
|
||||
/// Maximum evidence entries retained by one Token Metadata validation scenario.
|
||||
pub use self::spl::token_2022::metadata::validation::MAX_TOKEN_2022_METADATA_VALIDATION_EVIDENCE;
|
||||
/// One bounded evidence item retained by the Token Metadata validation matrix.
|
||||
pub use self::spl::token_2022::metadata::validation::Token2022MetadataValidationEvidence;
|
||||
/// Canonical Token Metadata Devnet validation matrix.
|
||||
pub use self::spl::token_2022::metadata::validation::Token2022MetadataValidationMatrix;
|
||||
/// One scenario in the Token Metadata Devnet validation matrix.
|
||||
pub use self::spl::token_2022::metadata::validation::Token2022MetadataValidationScenario;
|
||||
/// Exact network-validation status for one Token Metadata scenario.
|
||||
pub use self::spl::token_2022::metadata::validation::Token2022MetadataValidationStatus;
|
||||
/// Loads and validates the Token Metadata Devnet validation matrix.
|
||||
pub use self::spl::token_2022::metadata::validation::load_token_2022_metadata_validation_matrix;
|
||||
/// Validates exact Token Metadata campaign coverage and evidence claims.
|
||||
pub use self::spl::token_2022::metadata::validation::validate_token_2022_metadata_validation_matrix;
|
||||
/// Maximum number of evidence records accepted in one validation report.
|
||||
pub use self::spl::token_2022::validation::MAX_TOKEN_2022_VALIDATION_EVIDENCE;
|
||||
/// Required validation environment for one Token-2022 scenario.
|
||||
pub use self::spl::token_2022::validation::Token2022ValidationEnvironment;
|
||||
/// One bounded proof attached to a Token-2022 validation scenario.
|
||||
pub use self::spl::token_2022::validation::Token2022ValidationEvidence;
|
||||
/// Machine-readable Token-2022 validation matrix.
|
||||
pub use self::spl::token_2022::validation::Token2022ValidationMatrix;
|
||||
/// One scenario declared by the canonical validation matrix.
|
||||
pub use self::spl::token_2022::validation::Token2022ValidationMatrixScenario;
|
||||
/// Complete bounded Token-2022 validation report.
|
||||
pub use self::spl::token_2022::validation::Token2022ValidationReport;
|
||||
/// One declared Token-2022 validation scenario and its observed evidence.
|
||||
pub use self::spl::token_2022::validation::Token2022ValidationScenario;
|
||||
/// Exact status of one Token-2022 validation scenario.
|
||||
pub use self::spl::token_2022::validation::Token2022ValidationStatus;
|
||||
/// Loads and validates the canonical Token-2022 validation matrix.
|
||||
pub use self::spl::token_2022::validation::load_token_2022_validation_matrix;
|
||||
/// Validates one Token-2022 validation matrix.
|
||||
pub use self::spl::token_2022::validation::validate_token_2022_validation_matrix;
|
||||
/// Validates one bounded Token-2022 milestone report.
|
||||
pub use self::spl::token_2022::validation::validate_token_2022_validation_report;
|
||||
|
||||
/// Total transactions reserved by one complete Solana Program Metadata campaign.
|
||||
pub(crate) use self::constants::SOLANA_PROGRAM_METADATA_CAMPAIGN_TRANSACTION_COUNT;
|
||||
/// Canonical tracing target for demo pipeline scenarios.
|
||||
pub(crate) use self::constants::TRACING_TARGET;
|
||||
7
ks-pipeline-demo-scenarios/src/metadata.rs
Normal file
7
ks-pipeline-demo-scenarios/src/metadata.rs
Normal file
@@ -0,0 +1,7 @@
|
||||
// file: ks-pipeline-demo-scenarios/src/metadata.rs
|
||||
// version: 2
|
||||
|
||||
//! Metadata-oriented reusable demo and Devnet validation scenarios.
|
||||
|
||||
pub(crate) mod metaplex_token_metadata;
|
||||
pub(crate) mod solana_program;
|
||||
@@ -0,0 +1,16 @@
|
||||
// file: ks-pipeline-demo-scenarios/src/metadata/metaplex_token_metadata.rs
|
||||
// version: 8
|
||||
|
||||
//! Metaplex Token Metadata demo and Devnet validation scenarios.
|
||||
|
||||
pub(crate) mod collection_verify_campaign;
|
||||
pub(crate) mod create_mint_campaign;
|
||||
pub(crate) mod devnet_execution;
|
||||
pub(crate) mod escrow_campaign;
|
||||
pub(crate) mod fixture;
|
||||
pub(crate) mod maintenance_campaign;
|
||||
pub(crate) mod pnft_lifecycle_campaign;
|
||||
pub(crate) mod print_burn_campaign;
|
||||
pub(crate) mod scenarios;
|
||||
pub(crate) mod use_campaign;
|
||||
pub(crate) mod validation;
|
||||
@@ -0,0 +1,747 @@
|
||||
// file: ks-pipeline-demo-scenarios/src/metadata/metaplex_token_metadata/collection_verify_campaign.rs
|
||||
// version: 2
|
||||
|
||||
//! 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,
|
||||
{
|
||||
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(
|
||||
http_pool,
|
||||
store,
|
||||
profile,
|
||||
workspace_root,
|
||||
&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(
|
||||
http_pool,
|
||||
store,
|
||||
profile,
|
||||
workspace_root,
|
||||
&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(
|
||||
http_pool,
|
||||
store,
|
||||
profile,
|
||||
workspace_root,
|
||||
&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(
|
||||
http_pool,
|
||||
store,
|
||||
profile,
|
||||
workspace_root,
|
||||
&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("KB_DEVNET_METAPLEX_COLLECTION_VERIFY_CAMPAIGN_TEST")
|
||||
.ok()
|
||||
.as_deref()
|
||||
!= std::option::Option::Some("1")
|
||||
{
|
||||
return;
|
||||
}
|
||||
if std::env::var("KB_DEVNET_METAPLEX_OPERATOR_CONFIRMED").ok().as_deref()
|
||||
!= std::option::Option::Some("1")
|
||||
{
|
||||
panic!("KB_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_path = match std::env::var("KB_DEVNET_CONFIG_PATH") {
|
||||
std::result::Result::Ok(value) => {
|
||||
let path = std::path::PathBuf::from(value);
|
||||
if path.is_absolute() { path } else { workspace_root.join(path) }
|
||||
},
|
||||
std::result::Result::Err(_) => workspace_root.join("config/example.config.json"),
|
||||
};
|
||||
let config = ks_config::read_config_json_file_with_environment(
|
||||
config_path.as_path(),
|
||||
workspace_root,
|
||||
)
|
||||
.unwrap_or_else(|error| panic!("Devnet configuration loading failed: {error}"));
|
||||
let profile_name = std::env::var("KB_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("KB_POSTGRES_TEST_URL")
|
||||
.unwrap_or_else(|error| panic!("KB_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.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),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,922 @@
|
||||
// file: ks-pipeline-demo-scenarios/src/metadata/metaplex_token_metadata/create_mint_campaign.rs
|
||||
// version: 10
|
||||
|
||||
//! Confirmed Devnet `Create -> Mint` campaign for one Metaplex asset family.
|
||||
|
||||
/// Confirmed SPL state observed before and after the Metaplex `Mint` operation.
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub struct DevnetMetaplexCreateMintTokenState {
|
||||
/// Raw amount minted by the campaign.
|
||||
pub expected_amount_raw: u64,
|
||||
/// SPL mint supply observed after `Create` and before `Mint`.
|
||||
pub supply_before_mint: u64,
|
||||
/// Operator token-account amount observed after `Create` and before `Mint`.
|
||||
pub token_amount_before_mint: u64,
|
||||
/// Raw classic SPL token-account state observed before `Mint`.
|
||||
pub token_account_state_before_mint: u8,
|
||||
/// SPL mint supply observed at or after the confirmed `Mint` slot.
|
||||
pub supply_after_mint: u64,
|
||||
/// Operator token-account amount observed at or after the confirmed `Mint` slot.
|
||||
pub token_amount_after_mint: u64,
|
||||
/// Raw classic SPL token-account state observed after `Mint`.
|
||||
pub token_account_state_after_mint: u8,
|
||||
}
|
||||
|
||||
/// Complete evidence produced by one family-specific Metaplex `Create -> Mint` campaign.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct DevnetMetaplexCreateMintCampaignSummary {
|
||||
/// Asset family exercised by this campaign.
|
||||
pub asset_family: crate::MetaplexTokenMetadataAssetFamily,
|
||||
/// Fresh classic SPL mint and ATA fixture.
|
||||
pub fixture: crate::MetaplexCreateFixturePreparationSummary,
|
||||
/// Confirmed `Create` execution evidence.
|
||||
pub create: crate::DevnetMetaplexTokenMetadataExecutionSummary,
|
||||
/// Confirmed `Mint` execution evidence.
|
||||
pub mint: crate::DevnetMetaplexTokenMetadataExecutionSummary,
|
||||
/// Exact classic SPL supply and token-account transition caused by `Mint`.
|
||||
pub token_state: crate::DevnetMetaplexCreateMintTokenState,
|
||||
}
|
||||
|
||||
/// Returns the exact ordered operation names exercised by this campaign.
|
||||
pub fn metaplex_create_mint_campaign_operation_names() -> &'static [&'static str; 2] {
|
||||
return &["create", "mint"];
|
||||
}
|
||||
|
||||
/// Executes one fresh family-specific `Create -> Mint` campaign on Devnet.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub async fn execute_devnet_metaplex_create_mint_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::DevnetMetaplexCreateMintCampaignSummary>
|
||||
where
|
||||
S: ks_store::RawTransactionStore
|
||||
+ ks_store::CoreExtractionStore
|
||||
+ ks_store::DecodePipelineStore
|
||||
+ Sync,
|
||||
O: crate::SolanaExecutionObserver,
|
||||
{
|
||||
if !profile.wallet.devnet_send_enabled {
|
||||
return std::result::Result::Err(ks_core::Error::config(
|
||||
"Metaplex Create -> Mint Devnet campaign requires devnet_send_enabled=true",
|
||||
));
|
||||
}
|
||||
let fixture =
|
||||
match crate::prepare_metaplex_create_fixture(http_pool, profile, workspace_root, options)
|
||||
.await
|
||||
{
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
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 mut create_request =
|
||||
match crate::DevnetMetaplexTokenMetadataExecutionRequest::from_operation_json(
|
||||
format!("metaplex-create-mint-create-{}", uuid::Uuid::new_v4()),
|
||||
fixture.operation_json.as_str(),
|
||||
) {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
create_request.query_role = options.query_role.clone();
|
||||
create_request.transaction_role = options.transaction_role.clone();
|
||||
create_request.submit = true;
|
||||
create_request.operator_confirmed = true;
|
||||
create_request.materialize_after_confirmation = true;
|
||||
create_request.post_validation_max_retries = 20;
|
||||
create_request.postcondition_reads = postcondition_reads(
|
||||
&fixture,
|
||||
options.query_role.as_str(),
|
||||
std::option::Option::None,
|
||||
false,
|
||||
);
|
||||
let create = match crate::execute_devnet_metaplex_token_metadata(
|
||||
http_pool,
|
||||
store,
|
||||
profile,
|
||||
workspace_root,
|
||||
&create_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 create_slot = match validate_confirmed_execution("create", &create) {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let create_signature = match create.confirmation.as_ref() {
|
||||
std::option::Option::Some(value) => value.signature.0.as_str(),
|
||||
std::option::Option::None => "<missing-signature>",
|
||||
};
|
||||
if let std::result::Result::Err(error) =
|
||||
validate_expected_stateful_accounts(&fixture, &create.after, false)
|
||||
{
|
||||
return std::result::Result::Err(ks_core::Error::new(
|
||||
"metaplex_create_mint_post_create_stateful_invalid",
|
||||
format!(
|
||||
"confirmed Metaplex Create {create_signature} at slot {create_slot} left invalid expected stateful accounts: {error}"
|
||||
),
|
||||
));
|
||||
}
|
||||
let mint_before = match crate::read_mint_account_at_or_after(
|
||||
http_pool,
|
||||
options.query_role.as_str(),
|
||||
fixture.mint.as_str(),
|
||||
std::option::Option::Some(create_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_create_mint_mint_missing_before_mint",
|
||||
format!(
|
||||
"classic SPL mint is unavailable after confirmed Metaplex Create {create_signature} at slot {create_slot}"
|
||||
),
|
||||
));
|
||||
},
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let expected_mint_authority = expected_mint_authority_after_create(&fixture);
|
||||
if let std::result::Result::Err(error) = crate::validate_classic_fixture_mint_state(
|
||||
&mint_before,
|
||||
expected_mint_authority,
|
||||
fixture.mint_decimals,
|
||||
0,
|
||||
) {
|
||||
return std::result::Result::Err(ks_core::Error::new(
|
||||
"metaplex_create_mint_post_create_spl_state_invalid",
|
||||
format!(
|
||||
"confirmed Metaplex Create {} at slot {} left an invalid classic mint state; expected mint/freeze authority {}; {error}",
|
||||
create_signature, create_slot, expected_mint_authority
|
||||
),
|
||||
));
|
||||
}
|
||||
let token_before = match crate::read_token_account_at_or_after(
|
||||
http_pool,
|
||||
options.query_role.as_str(),
|
||||
fixture.token_account.as_str(),
|
||||
std::option::Option::Some(create_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_create_mint_token_missing_before_mint",
|
||||
format!(
|
||||
"classic SPL token account is unavailable after confirmed Metaplex Create {create_signature} at slot {create_slot}"
|
||||
),
|
||||
));
|
||||
},
|
||||
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_before,
|
||||
fixture.mint.as_str(),
|
||||
fixture.authority.as_str(),
|
||||
0,
|
||||
spl_token_interface::state::AccountState::Initialized,
|
||||
) {
|
||||
return std::result::Result::Err(ks_core::Error::new(
|
||||
"metaplex_create_mint_post_create_token_state_invalid",
|
||||
format!(
|
||||
"confirmed Metaplex Create {create_signature} at slot {create_slot} left an invalid classic token-account state: {error}"
|
||||
),
|
||||
));
|
||||
}
|
||||
let supply_before_mint = match crate::classic_fixture_mint_supply(&mint_before) {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let token_amount_before_mint = match crate::classic_fixture_token_amount(&token_before) {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let token_account_state_before_mint =
|
||||
match crate::classic_fixture_token_account_state(&token_before) {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let mut mint_request =
|
||||
match crate::DevnetMetaplexTokenMetadataExecutionRequest::from_operation_json(
|
||||
format!("metaplex-create-mint-mint-{}", uuid::Uuid::new_v4()),
|
||||
fixture.mint_operation_json.as_str(),
|
||||
) {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
mint_request.query_role = options.query_role.clone();
|
||||
mint_request.transaction_role = options.transaction_role.clone();
|
||||
mint_request.submit = true;
|
||||
mint_request.operator_confirmed = true;
|
||||
mint_request.materialize_after_confirmation = true;
|
||||
mint_request.post_validation_max_retries = 20;
|
||||
mint_request.preflight_reads = postcondition_reads(
|
||||
&fixture,
|
||||
options.query_role.as_str(),
|
||||
std::option::Option::Some(create_slot),
|
||||
false,
|
||||
);
|
||||
mint_request.postcondition_reads = postcondition_reads(
|
||||
&fixture,
|
||||
options.query_role.as_str(),
|
||||
std::option::Option::Some(create_slot),
|
||||
true,
|
||||
);
|
||||
let mint = match crate::execute_devnet_metaplex_token_metadata(
|
||||
http_pool,
|
||||
store,
|
||||
profile,
|
||||
workspace_root,
|
||||
&mint_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 mint_slot = match validate_confirmed_execution("mint", &mint) {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let mint_signature = match mint.confirmation.as_ref() {
|
||||
std::option::Option::Some(value) => value.signature.0.as_str(),
|
||||
std::option::Option::None => "<missing-signature>",
|
||||
};
|
||||
if let std::result::Result::Err(error) =
|
||||
validate_expected_stateful_accounts(&fixture, &mint.after, true)
|
||||
{
|
||||
return std::result::Result::Err(ks_core::Error::new(
|
||||
"metaplex_create_mint_post_mint_stateful_invalid",
|
||||
format!(
|
||||
"confirmed Metaplex Mint {mint_signature} at slot {mint_slot} left invalid expected stateful accounts: {error}"
|
||||
),
|
||||
));
|
||||
}
|
||||
let mint_after = match crate::read_mint_account_at_or_after(
|
||||
http_pool,
|
||||
options.query_role.as_str(),
|
||||
fixture.mint.as_str(),
|
||||
std::option::Option::Some(mint_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_create_mint_mint_missing_after_mint",
|
||||
format!(
|
||||
"classic SPL mint is unavailable after confirmed Metaplex Mint {mint_signature} at slot {mint_slot}"
|
||||
),
|
||||
));
|
||||
},
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
if let std::result::Result::Err(error) = crate::validate_classic_fixture_mint_state(
|
||||
&mint_after,
|
||||
expected_mint_authority,
|
||||
fixture.mint_decimals,
|
||||
fixture.mint_amount_raw,
|
||||
) {
|
||||
return std::result::Result::Err(ks_core::Error::new(
|
||||
"metaplex_create_mint_post_mint_spl_state_invalid",
|
||||
format!(
|
||||
"confirmed Metaplex Mint {} at slot {} left an invalid classic mint state; expected mint/freeze authority {}; {error}",
|
||||
mint_signature, mint_slot, expected_mint_authority
|
||||
),
|
||||
));
|
||||
}
|
||||
let token_after = match crate::read_token_account_at_or_after(
|
||||
http_pool,
|
||||
options.query_role.as_str(),
|
||||
fixture.token_account.as_str(),
|
||||
std::option::Option::Some(mint_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_create_mint_token_missing_after_mint",
|
||||
format!(
|
||||
"classic SPL token account is unavailable after confirmed Metaplex Mint {mint_signature} at slot {mint_slot}"
|
||||
),
|
||||
));
|
||||
},
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let expected_token_account_state_after_mint =
|
||||
expected_token_account_state_after_mint(options.asset_family);
|
||||
if let std::result::Result::Err(error) = crate::validate_classic_fixture_token_account_state(
|
||||
&token_after,
|
||||
fixture.mint.as_str(),
|
||||
fixture.authority.as_str(),
|
||||
fixture.mint_amount_raw,
|
||||
expected_token_account_state_after_mint,
|
||||
) {
|
||||
return std::result::Result::Err(ks_core::Error::new(
|
||||
"metaplex_create_mint_post_mint_token_state_invalid",
|
||||
format!(
|
||||
"confirmed Metaplex Mint {mint_signature} at slot {mint_slot} left an invalid classic token-account state: {error}"
|
||||
),
|
||||
));
|
||||
}
|
||||
let supply_after_mint = match crate::classic_fixture_mint_supply(&mint_after) {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let token_amount_after_mint = match crate::classic_fixture_token_amount(&token_after) {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let token_account_state_after_mint =
|
||||
match crate::classic_fixture_token_account_state(&token_after) {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
return std::result::Result::Ok(crate::DevnetMetaplexCreateMintCampaignSummary {
|
||||
asset_family: options.asset_family,
|
||||
token_state: crate::DevnetMetaplexCreateMintTokenState {
|
||||
expected_amount_raw: fixture.mint_amount_raw,
|
||||
supply_before_mint,
|
||||
token_amount_before_mint,
|
||||
token_account_state_before_mint,
|
||||
supply_after_mint,
|
||||
token_amount_after_mint,
|
||||
token_account_state_after_mint,
|
||||
},
|
||||
fixture,
|
||||
create,
|
||||
mint,
|
||||
});
|
||||
}
|
||||
|
||||
fn expected_mint_authority_after_create(
|
||||
fixture: &crate::MetaplexCreateFixturePreparationSummary,
|
||||
) -> &str {
|
||||
if fixture.master_edition_requested {
|
||||
return fixture.master_edition.as_str();
|
||||
}
|
||||
return fixture.authority.as_str();
|
||||
}
|
||||
|
||||
fn expected_token_account_state_after_mint(
|
||||
asset_family: crate::MetaplexTokenMetadataAssetFamily,
|
||||
) -> spl_token_interface::state::AccountState {
|
||||
if asset_family == crate::MetaplexTokenMetadataAssetFamily::ProgrammableNft {
|
||||
return spl_token_interface::state::AccountState::Frozen;
|
||||
}
|
||||
return spl_token_interface::state::AccountState::Initialized;
|
||||
}
|
||||
|
||||
fn postcondition_reads(
|
||||
fixture: &crate::MetaplexCreateFixturePreparationSummary,
|
||||
query_role: &str,
|
||||
min_context_slot: std::option::Option<u64>,
|
||||
include_token_record: bool,
|
||||
) -> std::vec::Vec<ks_pipeline::MetaplexTokenMetadataStatefulReadRequest> {
|
||||
let mint = ks_lib::MdPubkey(fixture.mint.clone());
|
||||
let mut reads = vec![ks_pipeline::MetaplexTokenMetadataStatefulReadRequest {
|
||||
query_role: query_role.to_string(),
|
||||
account: ks_lib::MdPubkey(fixture.metadata.clone()),
|
||||
kind: ks_pipeline::MetaplexTokenMetadataAccountKind::Metadata,
|
||||
min_context_slot,
|
||||
max_data_bytes: ks_pipeline::MAX_METAPLEX_TOKEN_METADATA_ACCOUNT_BYTES,
|
||||
}];
|
||||
if fixture.master_edition_requested {
|
||||
reads.push(ks_pipeline::MetaplexTokenMetadataStatefulReadRequest {
|
||||
query_role: query_role.to_string(),
|
||||
account: ks_lib::MdPubkey(fixture.master_edition.clone()),
|
||||
kind: ks_pipeline::MetaplexTokenMetadataAccountKind::Edition { mint: mint.clone() },
|
||||
min_context_slot,
|
||||
max_data_bytes: ks_pipeline::MAX_METAPLEX_TOKEN_METADATA_ACCOUNT_BYTES,
|
||||
});
|
||||
}
|
||||
if include_token_record {
|
||||
if let std::option::Option::Some(token_record) = fixture.token_record.as_ref() {
|
||||
reads.push(ks_pipeline::MetaplexTokenMetadataStatefulReadRequest {
|
||||
query_role: query_role.to_string(),
|
||||
account: ks_lib::MdPubkey(token_record.clone()),
|
||||
kind: ks_pipeline::MetaplexTokenMetadataAccountKind::TokenRecord {
|
||||
mint,
|
||||
token: ks_lib::MdPubkey(fixture.token_account.clone()),
|
||||
},
|
||||
min_context_slot,
|
||||
max_data_bytes: ks_pipeline::MAX_METAPLEX_TOKEN_METADATA_ACCOUNT_BYTES,
|
||||
});
|
||||
}
|
||||
}
|
||||
return reads;
|
||||
}
|
||||
|
||||
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_create_mint_simulation_failed",
|
||||
format!("Metaplex {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_create_mint_confirmation_incomplete",
|
||||
format!("Metaplex {step} stopped at {:?}", value.status),
|
||||
));
|
||||
},
|
||||
std::option::Option::None => {
|
||||
return std::result::Result::Err(ks_core::Error::new(
|
||||
"metaplex_create_mint_confirmation_missing",
|
||||
format!("Metaplex {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_create_mint_post_execution_missing",
|
||||
format!("Metaplex {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_create_mint_post_execution_incomplete",
|
||||
format!(
|
||||
"Metaplex {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_create_mint_idempotence_missing",
|
||||
format!("Metaplex {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_create_mint_idempotence_failed",
|
||||
format!("Metaplex {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_create_mint_confirmation_slot_missing",
|
||||
format!("Metaplex {step} confirmation has no slot"),
|
||||
)),
|
||||
};
|
||||
}
|
||||
|
||||
fn validate_expected_stateful_accounts(
|
||||
fixture: &crate::MetaplexCreateFixturePreparationSummary,
|
||||
snapshots: &[ks_pipeline::MetaplexTokenMetadataStatefulReadResult],
|
||||
include_token_record: bool,
|
||||
) -> ks_core::Result<()> {
|
||||
let metadata = snapshots.iter().any(|value| {
|
||||
return value.snapshot.account.0.as_str() == fixture.metadata.as_str()
|
||||
&& value.snapshot.account_kind == "metadata"
|
||||
&& value
|
||||
.snapshot
|
||||
.mint
|
||||
.as_ref()
|
||||
.is_some_and(|mint| return mint.0.as_str() == fixture.mint.as_str());
|
||||
});
|
||||
let edition = snapshots.iter().any(|value| {
|
||||
return value.snapshot.account.0.as_str() == fixture.master_edition.as_str()
|
||||
&& value.snapshot.account_kind == "edition"
|
||||
&& value
|
||||
.snapshot
|
||||
.mint
|
||||
.as_ref()
|
||||
.is_some_and(|mint| return mint.0.as_str() == fixture.mint.as_str());
|
||||
});
|
||||
let token_record = fixture.token_record.as_ref().is_some_and(|expected| {
|
||||
return snapshots.iter().any(|value| {
|
||||
return value.snapshot.account.0.as_str() == expected.as_str()
|
||||
&& value.snapshot.account_kind == "token_record"
|
||||
&& value
|
||||
.snapshot
|
||||
.mint
|
||||
.as_ref()
|
||||
.is_some_and(|mint| return mint.0.as_str() == fixture.mint.as_str());
|
||||
});
|
||||
});
|
||||
if !metadata
|
||||
|| fixture.master_edition_requested != edition
|
||||
|| (include_token_record && fixture.token_record.is_some() && !token_record)
|
||||
|| (!include_token_record && token_record)
|
||||
{
|
||||
return std::result::Result::Err(ks_core::Error::new(
|
||||
"metaplex_create_mint_stateful_postcondition_failed",
|
||||
format!(
|
||||
"Metaplex stateful accounts mismatch: metadata={metadata}, edition={edition}, token_record={token_record}, master_edition_requested={}, token_record_expected={}",
|
||||
fixture.master_edition_requested,
|
||||
include_token_record && fixture.token_record.is_some()
|
||||
),
|
||||
));
|
||||
}
|
||||
return std::result::Result::Ok(());
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
fn fixture(
|
||||
family: crate::MetaplexTokenMetadataAssetFamily,
|
||||
master_edition_requested: bool,
|
||||
token_record: std::option::Option<&str>,
|
||||
) -> crate::MetaplexCreateFixturePreparationSummary {
|
||||
return crate::MetaplexCreateFixturePreparationSummary {
|
||||
mint_keypair_path: std::path::PathBuf::from("mint.json"),
|
||||
asset_family: family,
|
||||
mint: "11111111111111111111111111111111".to_string(),
|
||||
mint_decimals: if family == crate::MetaplexTokenMetadataAssetFamily::Fungible {
|
||||
9
|
||||
} else {
|
||||
0
|
||||
},
|
||||
metadata: "SysvarC1ock11111111111111111111111111111111".to_string(),
|
||||
master_edition: "SysvarRent111111111111111111111111111111111".to_string(),
|
||||
master_edition_requested,
|
||||
token_account: "SysvarS1otHashes111111111111111111111111111".to_string(),
|
||||
token_record: token_record.map(|value| return value.to_string()),
|
||||
collection_mint: std::option::Option::None,
|
||||
authority: "11111111111111111111111111111111".to_string(),
|
||||
mint_amount_raw: if family == crate::MetaplexTokenMetadataAssetFamily::Fungible {
|
||||
1_000_000_000
|
||||
} else {
|
||||
1
|
||||
},
|
||||
operation_json: "{}".to_string(),
|
||||
mint_operation_json: "{}".to_string(),
|
||||
mint_created: true,
|
||||
preparation_signature: std::option::Option::Some("signature".to_string()),
|
||||
};
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn campaign_contract_is_exactly_create_then_mint() {
|
||||
assert_eq!(crate::metaplex_create_mint_campaign_operation_names(), &["create", "mint"],);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stateful_read_inventory_tracks_master_edition_and_pnft_token_record() {
|
||||
let nft =
|
||||
fixture(crate::MetaplexTokenMetadataAssetFamily::Nft, true, std::option::Option::None);
|
||||
let nft_create =
|
||||
super::postcondition_reads(&nft, "rpc", std::option::Option::Some(7), false);
|
||||
assert_eq!(nft_create.len(), 2);
|
||||
assert!(
|
||||
nft_create
|
||||
.iter()
|
||||
.all(|value| return value.min_context_slot == std::option::Option::Some(7))
|
||||
);
|
||||
assert!(nft_create.iter().all(|value| {
|
||||
return value.max_data_bytes == ks_onchain_transport::MAX_COMPLETE_ACCOUNT_DATA_BYTES;
|
||||
}));
|
||||
let pnft = fixture(
|
||||
crate::MetaplexTokenMetadataAssetFamily::ProgrammableNft,
|
||||
true,
|
||||
std::option::Option::Some("Stake11111111111111111111111111111111111111"),
|
||||
);
|
||||
let pnft_mint =
|
||||
super::postcondition_reads(&pnft, "rpc", std::option::Option::Some(9), true);
|
||||
assert_eq!(pnft_mint.len(), 3);
|
||||
assert!(matches!(
|
||||
&pnft_mint[2].kind,
|
||||
ks_pipeline::MetaplexTokenMetadataAccountKind::TokenRecord { .. }
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn campaign_supports_all_five_fixture_families() {
|
||||
for family in [
|
||||
crate::MetaplexTokenMetadataAssetFamily::Nft,
|
||||
crate::MetaplexTokenMetadataAssetFamily::Sft,
|
||||
crate::MetaplexTokenMetadataAssetFamily::Fungible,
|
||||
crate::MetaplexTokenMetadataAssetFamily::Collection,
|
||||
crate::MetaplexTokenMetadataAssetFamily::ProgrammableNft,
|
||||
] {
|
||||
let options = crate::MetaplexCreateFixturePreparationOptions::new(
|
||||
std::path::PathBuf::from("wallets"),
|
||||
)
|
||||
.with_asset_family(family);
|
||||
assert_eq!(options.asset_family, family);
|
||||
}
|
||||
}
|
||||
|
||||
fn family_from_env(value: &str) -> crate::MetaplexTokenMetadataAssetFamily {
|
||||
return match value {
|
||||
"nft" => crate::MetaplexTokenMetadataAssetFamily::Nft,
|
||||
"sft" => crate::MetaplexTokenMetadataAssetFamily::Sft,
|
||||
"fungible" => crate::MetaplexTokenMetadataAssetFamily::Fungible,
|
||||
"collection" => crate::MetaplexTokenMetadataAssetFamily::Collection,
|
||||
"programmable_nft" | "pnft" => crate::MetaplexTokenMetadataAssetFamily::ProgrammableNft,
|
||||
_ => panic!("unsupported KB_DEVNET_METAPLEX_CREATE_MINT_FAMILY `{value}`"),
|
||||
};
|
||||
}
|
||||
|
||||
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,
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn post_create_mint_authority_tracks_master_edition_only_for_nft_families() {
|
||||
let authority = solana_pubkey::Pubkey::new_unique().to_string();
|
||||
let master_edition = solana_pubkey::Pubkey::new_unique().to_string();
|
||||
let families = [
|
||||
(crate::MetaplexTokenMetadataAssetFamily::Nft, true),
|
||||
(crate::MetaplexTokenMetadataAssetFamily::Sft, false),
|
||||
(crate::MetaplexTokenMetadataAssetFamily::Fungible, false),
|
||||
(crate::MetaplexTokenMetadataAssetFamily::Collection, true),
|
||||
(crate::MetaplexTokenMetadataAssetFamily::ProgrammableNft, true),
|
||||
];
|
||||
for (asset_family, master_edition_requested) in families {
|
||||
let fixture = crate::MetaplexCreateFixturePreparationSummary {
|
||||
mint_keypair_path: std::path::PathBuf::from("fixture.json"),
|
||||
asset_family,
|
||||
mint: solana_pubkey::Pubkey::new_unique().to_string(),
|
||||
mint_decimals: 0,
|
||||
metadata: solana_pubkey::Pubkey::new_unique().to_string(),
|
||||
master_edition: master_edition.clone(),
|
||||
master_edition_requested,
|
||||
token_account: solana_pubkey::Pubkey::new_unique().to_string(),
|
||||
token_record: std::option::Option::None,
|
||||
collection_mint: std::option::Option::None,
|
||||
authority: authority.clone(),
|
||||
mint_amount_raw: 1,
|
||||
operation_json: "{}".to_string(),
|
||||
mint_operation_json: "{}".to_string(),
|
||||
mint_created: true,
|
||||
preparation_signature: std::option::Option::None,
|
||||
};
|
||||
assert_eq!(
|
||||
super::expected_mint_authority_after_create(&fixture),
|
||||
if master_edition_requested {
|
||||
master_edition.as_str()
|
||||
} else {
|
||||
authority.as_str()
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn post_mint_token_account_state_is_frozen_only_for_programmable_nft() {
|
||||
let families = [
|
||||
(
|
||||
crate::MetaplexTokenMetadataAssetFamily::Nft,
|
||||
spl_token_interface::state::AccountState::Initialized,
|
||||
),
|
||||
(
|
||||
crate::MetaplexTokenMetadataAssetFamily::Sft,
|
||||
spl_token_interface::state::AccountState::Initialized,
|
||||
),
|
||||
(
|
||||
crate::MetaplexTokenMetadataAssetFamily::Fungible,
|
||||
spl_token_interface::state::AccountState::Initialized,
|
||||
),
|
||||
(
|
||||
crate::MetaplexTokenMetadataAssetFamily::Collection,
|
||||
spl_token_interface::state::AccountState::Initialized,
|
||||
),
|
||||
(
|
||||
crate::MetaplexTokenMetadataAssetFamily::ProgrammableNft,
|
||||
spl_token_interface::state::AccountState::Frozen,
|
||||
),
|
||||
];
|
||||
for (asset_family, expected_state) in families {
|
||||
assert_eq!(
|
||||
super::expected_token_account_state_after_mint(asset_family),
|
||||
expected_state
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn optional_devnet_metaplex_create_mint_campaign_from_env() {
|
||||
if std::env::var("KB_DEVNET_METAPLEX_CREATE_MINT_CAMPAIGN_TEST").ok().as_deref()
|
||||
!= std::option::Option::Some("1")
|
||||
{
|
||||
return;
|
||||
}
|
||||
if std::env::var("KB_DEVNET_METAPLEX_OPERATOR_CONFIRMED").ok().as_deref()
|
||||
!= std::option::Option::Some("1")
|
||||
{
|
||||
panic!("KB_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_path = match std::env::var("KB_DEVNET_CONFIG_PATH") {
|
||||
std::result::Result::Ok(value) => {
|
||||
let path = std::path::PathBuf::from(value);
|
||||
if path.is_absolute() { path } else { workspace_root.join(path) }
|
||||
},
|
||||
std::result::Result::Err(_) => workspace_root.join("config/example.config.json"),
|
||||
};
|
||||
let config = ks_config::read_config_json_file_with_environment(
|
||||
config_path.as_path(),
|
||||
workspace_root,
|
||||
)
|
||||
.unwrap_or_else(|error| panic!("Devnet configuration loading failed: {error}"));
|
||||
let profile_name = std::env::var("KB_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("KB_POSTGRES_TEST_URL")
|
||||
.unwrap_or_else(|error| panic!("KB_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.wallet_dir.as_str());
|
||||
let wallet_dir = if configured_wallet_dir.is_absolute() {
|
||||
configured_wallet_dir
|
||||
} else {
|
||||
workspace_root.join(configured_wallet_dir)
|
||||
};
|
||||
let family = std::env::var("KB_DEVNET_METAPLEX_CREATE_MINT_FAMILY")
|
||||
.map(|value| return family_from_env(value.as_str()))
|
||||
.unwrap_or(crate::MetaplexTokenMetadataAssetFamily::Nft);
|
||||
let options = crate::MetaplexCreateFixturePreparationOptions::new(wallet_dir)
|
||||
.with_asset_family(family);
|
||||
let summary = crate::execute_devnet_metaplex_create_mint_campaign(
|
||||
&pool,
|
||||
&store,
|
||||
&profile,
|
||||
workspace_root,
|
||||
&options,
|
||||
&crate::NoopSolanaExecutionObserver,
|
||||
)
|
||||
.await
|
||||
.unwrap_or_else(|error| panic!("Metaplex Create -> Mint Devnet campaign failed: {error}"));
|
||||
let create_confirmation = summary
|
||||
.create
|
||||
.confirmation
|
||||
.as_ref()
|
||||
.unwrap_or_else(|| panic!("Create confirmation missing"));
|
||||
let mint_confirmation = summary
|
||||
.mint
|
||||
.confirmation
|
||||
.as_ref()
|
||||
.unwrap_or_else(|| panic!("Mint confirmation missing"));
|
||||
println!(
|
||||
"METAPLEX_CREATE_MINT_FIXTURE family={:?} mint={} metadata={} master_edition={} token_account={} token_record={} preparation_signature={}",
|
||||
summary.asset_family,
|
||||
summary.fixture.mint,
|
||||
summary.fixture.metadata,
|
||||
summary.fixture.master_edition,
|
||||
summary.fixture.token_account,
|
||||
summary.fixture.token_record.as_deref().unwrap_or("none"),
|
||||
summary.fixture.preparation_signature.as_deref().unwrap_or("missing"),
|
||||
);
|
||||
println!(
|
||||
"METAPLEX_CREATE_MINT_STEP operation={} signature={} slot={:?} materializations={}",
|
||||
summary.create.plan.operation_code,
|
||||
create_confirmation.signature.0,
|
||||
create_confirmation.slot,
|
||||
summary.create.materializations.len(),
|
||||
);
|
||||
println!(
|
||||
"METAPLEX_CREATE_MINT_STEP operation={} signature={} slot={:?} materializations={} supply_before={} supply_after={} token_before={} token_after={} token_state_before={} token_state_after={}",
|
||||
summary.mint.plan.operation_code,
|
||||
mint_confirmation.signature.0,
|
||||
mint_confirmation.slot,
|
||||
summary.mint.materializations.len(),
|
||||
summary.token_state.supply_before_mint,
|
||||
summary.token_state.supply_after_mint,
|
||||
summary.token_state.token_amount_before_mint,
|
||||
summary.token_state.token_amount_after_mint,
|
||||
summary.token_state.token_account_state_before_mint,
|
||||
summary.token_state.token_account_state_after_mint,
|
||||
);
|
||||
println!(
|
||||
"METAPLEX_CREATE_MINT_EVIDENCE family={:?} create={} mint={} token_state={}",
|
||||
summary.asset_family,
|
||||
execution_evidence_json(&summary.create),
|
||||
execution_evidence_json(&summary.mint),
|
||||
serde_json::json!({
|
||||
"expectedAmountRaw": summary.token_state.expected_amount_raw,
|
||||
"supplyBeforeMint": summary.token_state.supply_before_mint,
|
||||
"supplyAfterMint": summary.token_state.supply_after_mint,
|
||||
"tokenAmountBeforeMint": summary.token_state.token_amount_before_mint,
|
||||
"tokenAmountAfterMint": summary.token_state.token_amount_after_mint,
|
||||
"tokenAccountStateBeforeMint": summary.token_state.token_account_state_before_mint,
|
||||
"tokenAccountStateAfterMint": summary.token_state.token_account_state_after_mint,
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,730 @@
|
||||
// file: ks-pipeline-demo-scenarios/src/metadata/metaplex_token_metadata/maintenance_campaign.rs
|
||||
// version: 4
|
||||
|
||||
//! Final bounded Devnet qualification campaign for current Metaplex maintenance operations.
|
||||
|
||||
const METAPLEX_FEE_DESTINATION: &str = "2fb1TjRrJQLy9BkYfBjcYgibV7LUsr9cf6QxvyRZyuXn";
|
||||
const METAPLEX_OWNERLESS_CLOSE_DESTINATION: &str = "GxCXYtrnaU6JXeAza8Ugn4EE6QiFinpfn8t3Lo4UkBDX";
|
||||
|
||||
/// Complete evidence produced by the final bounded Metaplex maintenance campaign.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct DevnetMetaplexMaintenanceCampaignSummary {
|
||||
/// Fresh classic NFT used by the campaign.
|
||||
pub fixture: crate::DevnetMetaplexCreateMintCampaignSummary,
|
||||
/// Confirmed `Update` execution evidence.
|
||||
pub update: crate::DevnetMetaplexTokenMetadataExecutionSummary,
|
||||
/// Simulation-only `Resize` evidence for a fresh already-resized NFT.
|
||||
pub resize_probe: crate::DevnetMetaplexTokenMetadataExecutionSummary,
|
||||
/// Simulation-only `Migrate` evidence.
|
||||
pub migrate_probe: crate::DevnetMetaplexTokenMetadataExecutionSummary,
|
||||
/// Simulation-only `Collect` evidence.
|
||||
pub collect_probe: crate::DevnetMetaplexTokenMetadataExecutionSummary,
|
||||
/// Simulation-only `CloseAccounts` evidence.
|
||||
pub close_accounts_probe: crate::DevnetMetaplexTokenMetadataExecutionSummary,
|
||||
/// Primary-sale flag before the current `Update` execution.
|
||||
pub primary_sale_before_update: bool,
|
||||
/// Primary-sale flag after the current `Update` execution.
|
||||
pub primary_sale_after_update: bool,
|
||||
}
|
||||
|
||||
/// Returns the exact ordered operations exercised by the maintenance campaign.
|
||||
pub fn metaplex_maintenance_campaign_operation_names() -> &'static [&'static str; 7] {
|
||||
return &["create", "mint", "update", "resize", "migrate", "collect", "close_accounts"];
|
||||
}
|
||||
|
||||
/// Executes the final bounded Metaplex maintenance qualification campaign on Devnet.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub async fn execute_devnet_metaplex_maintenance_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::DevnetMetaplexMaintenanceCampaignSummary>
|
||||
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 maintenance campaign requires the classic NFT fixture family",
|
||||
));
|
||||
}
|
||||
let fixture = match crate::execute_devnet_metaplex_create_mint_campaign(
|
||||
http_pool,
|
||||
store,
|
||||
profile,
|
||||
workspace_root,
|
||||
options,
|
||||
observer,
|
||||
)
|
||||
.await
|
||||
{
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let primary_sale_before_update = match metadata_primary_sale(
|
||||
fixture.mint.after.as_slice(),
|
||||
fixture.fixture.metadata.as_str(),
|
||||
) {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
if primary_sale_before_update {
|
||||
return std::result::Result::Err(ks_core::Error::new(
|
||||
"metaplex_maintenance_initial_primary_sale_invalid",
|
||||
"fresh maintenance NFT already has primary_sale_happened=true",
|
||||
));
|
||||
}
|
||||
let update_operation = match update_operation(&fixture.fixture) {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let update = match execute_confirmed_operation(
|
||||
"update",
|
||||
update_operation,
|
||||
metadata_reads(&fixture.fixture, options.query_role.as_str()),
|
||||
http_pool,
|
||||
store,
|
||||
profile,
|
||||
workspace_root,
|
||||
options,
|
||||
observer,
|
||||
)
|
||||
.await
|
||||
{
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let _update_slot = match validate_confirmed_execution("update", &update) {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let primary_sale_after_update =
|
||||
match metadata_primary_sale(update.after.as_slice(), fixture.fixture.metadata.as_str()) {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
if !primary_sale_after_update {
|
||||
return std::result::Result::Err(ks_core::Error::new(
|
||||
"metaplex_maintenance_update_state_invalid",
|
||||
"confirmed Update did not flip primary_sale_happened to true",
|
||||
));
|
||||
}
|
||||
let resize_operation = ks_lib::ExMetaplexTokenMetadataOperation::Resize {
|
||||
metadata: ks_lib::MdPubkey(fixture.fixture.metadata.clone()),
|
||||
edition: ks_lib::MdPubkey(fixture.fixture.master_edition.clone()),
|
||||
mint: ks_lib::MdPubkey(fixture.fixture.mint.clone()),
|
||||
payer: ks_lib::MdPubkey(fixture.fixture.authority.clone()),
|
||||
authority: std::option::Option::None,
|
||||
token: std::option::Option::Some(ks_lib::MdPubkey(fixture.fixture.token_account.clone())),
|
||||
system_program: ks_lib::MdPubkey(ks_program_ids::SYSTEM_PROGRAM_ID.to_string()),
|
||||
};
|
||||
let resize_probe = match simulate_expected_unavailable(
|
||||
"resize",
|
||||
resize_operation,
|
||||
201,
|
||||
http_pool,
|
||||
profile,
|
||||
workspace_root,
|
||||
options,
|
||||
observer,
|
||||
)
|
||||
.await
|
||||
{
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let migrate_probe = match simulate_expected_unavailable(
|
||||
"migrate",
|
||||
migrate_operation(&fixture.fixture),
|
||||
75,
|
||||
http_pool,
|
||||
profile,
|
||||
workspace_root,
|
||||
options,
|
||||
observer,
|
||||
)
|
||||
.await
|
||||
{
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let collect_probe = match simulate_expected_unavailable(
|
||||
"collect",
|
||||
ks_lib::ExMetaplexTokenMetadataOperation::Collect {
|
||||
authority: ks_lib::MdPubkey(fixture.fixture.authority.clone()),
|
||||
recipient: ks_lib::MdPubkey(METAPLEX_FEE_DESTINATION.to_string()),
|
||||
},
|
||||
7,
|
||||
http_pool,
|
||||
profile,
|
||||
workspace_root,
|
||||
options,
|
||||
observer,
|
||||
)
|
||||
.await
|
||||
{
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let close_accounts_probe = match simulate_expected_unavailable(
|
||||
"close_accounts",
|
||||
ks_lib::ExMetaplexTokenMetadataOperation::CloseAccounts {
|
||||
metadata: ks_lib::MdPubkey(fixture.fixture.metadata.clone()),
|
||||
edition: ks_lib::MdPubkey(fixture.fixture.master_edition.clone()),
|
||||
mint: ks_lib::MdPubkey(fixture.fixture.mint.clone()),
|
||||
authority: ks_lib::MdPubkey(fixture.fixture.authority.clone()),
|
||||
destination: ks_lib::MdPubkey(METAPLEX_OWNERLESS_CLOSE_DESTINATION.to_string()),
|
||||
},
|
||||
188,
|
||||
http_pool,
|
||||
profile,
|
||||
workspace_root,
|
||||
options,
|
||||
observer,
|
||||
)
|
||||
.await
|
||||
{
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
return std::result::Result::Ok(crate::DevnetMetaplexMaintenanceCampaignSummary {
|
||||
fixture,
|
||||
update,
|
||||
resize_probe,
|
||||
migrate_probe,
|
||||
collect_probe,
|
||||
close_accounts_probe,
|
||||
primary_sale_before_update,
|
||||
primary_sale_after_update,
|
||||
});
|
||||
}
|
||||
|
||||
fn update_operation(
|
||||
fixture: &crate::MetaplexCreateFixturePreparationSummary,
|
||||
) -> ks_core::Result<ks_lib::ExMetaplexTokenMetadataOperation> {
|
||||
let value = serde_json::json!({
|
||||
"operation": "update_as_update_authority_v2",
|
||||
"authority": fixture.authority.as_str(),
|
||||
"mint": fixture.mint.as_str(),
|
||||
"metadata": fixture.metadata.as_str(),
|
||||
"edition": fixture.master_edition.as_str(),
|
||||
"token": fixture.token_account.as_str(),
|
||||
"authorization_rules_program": null,
|
||||
"authorization_rules": null,
|
||||
"update_args": {
|
||||
"AsUpdateAuthorityV2": {
|
||||
"new_update_authority": null,
|
||||
"data": null,
|
||||
"primary_sale_happened": true,
|
||||
"is_mutable": null,
|
||||
"collection": "None",
|
||||
"collection_details": "None",
|
||||
"uses": "None",
|
||||
"rule_set": "None",
|
||||
"token_standard": null,
|
||||
"authorization_data": null
|
||||
}
|
||||
}
|
||||
});
|
||||
return match serde_json::from_value::<ks_lib::ExMetaplexTokenMetadataOperation>(value) {
|
||||
std::result::Result::Ok(value) => std::result::Result::Ok(value),
|
||||
std::result::Result::Err(error) => std::result::Result::Err(ks_core::Error::config(
|
||||
format!("Metaplex maintenance Update operation is invalid: {error}"),
|
||||
)),
|
||||
};
|
||||
}
|
||||
|
||||
fn migrate_operation(
|
||||
fixture: &crate::MetaplexCreateFixturePreparationSummary,
|
||||
) -> ks_lib::ExMetaplexTokenMetadataOperation {
|
||||
return ks_lib::ExMetaplexTokenMetadataOperation::Migrate {
|
||||
metadata: ks_lib::MdPubkey(fixture.metadata.clone()),
|
||||
edition: ks_lib::MdPubkey(fixture.master_edition.clone()),
|
||||
token: ks_lib::MdPubkey(fixture.token_account.clone()),
|
||||
token_owner: ks_lib::MdPubkey(fixture.authority.clone()),
|
||||
mint: ks_lib::MdPubkey(fixture.mint.clone()),
|
||||
payer: ks_lib::MdPubkey(fixture.authority.clone()),
|
||||
authority: ks_lib::MdPubkey(fixture.authority.clone()),
|
||||
collection_metadata: ks_lib::MdPubkey(fixture.metadata.clone()),
|
||||
delegate_record: ks_lib::MdPubkey(fixture.metadata.clone()),
|
||||
token_record: ks_lib::MdPubkey(fixture.metadata.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: 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,
|
||||
};
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
async fn execute_confirmed_operation<S, O>(
|
||||
label: &str,
|
||||
operation: ks_lib::ExMetaplexTokenMetadataOperation,
|
||||
reads: std::vec::Vec<ks_pipeline::MetaplexTokenMetadataStatefulReadRequest>,
|
||||
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::DevnetMetaplexTokenMetadataExecutionSummary>
|
||||
where
|
||||
S: ks_store::RawTransactionStore
|
||||
+ ks_store::CoreExtractionStore
|
||||
+ ks_store::DecodePipelineStore
|
||||
+ Sync,
|
||||
O: crate::SolanaExecutionObserver,
|
||||
{
|
||||
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 mut request = crate::DevnetMetaplexTokenMetadataExecutionRequest::new(
|
||||
format!("metaplex-maintenance-{label}-{}", uuid::Uuid::new_v4()),
|
||||
operation,
|
||||
);
|
||||
request.query_role = options.query_role.clone();
|
||||
request.transaction_role = options.transaction_role.clone();
|
||||
request.preflight_reads = reads.clone();
|
||||
request.postcondition_reads = reads;
|
||||
request.submit = true;
|
||||
request.operator_confirmed = true;
|
||||
request.materialize_after_confirmation = true;
|
||||
request.post_validation_max_retries = 20;
|
||||
return crate::execute_devnet_metaplex_token_metadata(
|
||||
http_pool,
|
||||
store,
|
||||
profile,
|
||||
workspace_root,
|
||||
&request,
|
||||
decoders.as_slice(),
|
||||
materializers.as_slice(),
|
||||
observer,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
async fn simulate_expected_unavailable<O>(
|
||||
label: &str,
|
||||
operation: ks_lib::ExMetaplexTokenMetadataOperation,
|
||||
expected_custom_error: u64,
|
||||
http_pool: &ks_onchain_transport::HttpEndpointPool,
|
||||
profile: &ks_config::ProfileConfig,
|
||||
workspace_root: &std::path::Path,
|
||||
options: &crate::MetaplexCreateFixturePreparationOptions,
|
||||
observer: &O,
|
||||
) -> ks_core::Result<crate::DevnetMetaplexTokenMetadataExecutionSummary>
|
||||
where
|
||||
O: crate::SolanaExecutionObserver,
|
||||
{
|
||||
let mut request = crate::DevnetMetaplexTokenMetadataExecutionRequest::new(
|
||||
format!("metaplex-maintenance-{label}-probe-{}", uuid::Uuid::new_v4()),
|
||||
operation,
|
||||
);
|
||||
request.query_role = options.query_role.clone();
|
||||
request.transaction_role = options.transaction_role.clone();
|
||||
let summary = match crate::simulate_devnet_metaplex_token_metadata(
|
||||
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),
|
||||
};
|
||||
if summary.simulation.success || summary.send_result.is_some() || summary.confirmation.is_some()
|
||||
{
|
||||
return std::result::Result::Err(ks_core::Error::new(
|
||||
"metaplex_maintenance_unavailable_probe_unexpected_success",
|
||||
format!("Metaplex {label} probe unexpectedly became submit-capable or confirmed"),
|
||||
));
|
||||
}
|
||||
let observed_custom_error = simulation_custom_error(summary.simulation.error.as_deref());
|
||||
if observed_custom_error != std::option::Option::Some(expected_custom_error) {
|
||||
return std::result::Result::Err(ks_core::Error::new(
|
||||
"metaplex_maintenance_unavailable_probe_error_mismatch",
|
||||
format!(
|
||||
"Metaplex {label} probe expected custom error {expected_custom_error}; got custom_error={observed_custom_error:?}, error={:?}, logs={:?}",
|
||||
summary.simulation.error, summary.simulation.logs
|
||||
),
|
||||
));
|
||||
}
|
||||
return std::result::Result::Ok(summary);
|
||||
}
|
||||
|
||||
fn simulation_custom_error(error: std::option::Option<&str>) -> std::option::Option<u64> {
|
||||
let error = match error {
|
||||
std::option::Option::Some(value) => value,
|
||||
std::option::Option::None => return std::option::Option::None,
|
||||
};
|
||||
let value = match serde_json::from_str::<serde_json::Value>(error) {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(_) => return std::option::Option::None,
|
||||
};
|
||||
let instruction_error =
|
||||
match value.get("InstructionError").and_then(serde_json::Value::as_array) {
|
||||
std::option::Option::Some(value) if value.len() == 2 => value,
|
||||
_ => return std::option::Option::None,
|
||||
};
|
||||
let detail = match instruction_error.get(1) {
|
||||
std::option::Option::Some(value) => value,
|
||||
std::option::Option::None => return std::option::Option::None,
|
||||
};
|
||||
return detail.get("Custom").and_then(serde_json::Value::as_u64);
|
||||
}
|
||||
|
||||
fn metadata_reads(
|
||||
fixture: &crate::MetaplexCreateFixturePreparationSummary,
|
||||
query_role: &str,
|
||||
) -> std::vec::Vec<ks_pipeline::MetaplexTokenMetadataStatefulReadRequest> {
|
||||
return std::vec![ks_pipeline::MetaplexTokenMetadataStatefulReadRequest {
|
||||
query_role: query_role.to_string(),
|
||||
account: ks_lib::MdPubkey(fixture.metadata.clone()),
|
||||
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_primary_sale(
|
||||
snapshots: &[ks_pipeline::MetaplexTokenMetadataStatefulReadResult],
|
||||
metadata: &str,
|
||||
) -> ks_core::Result<bool> {
|
||||
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_maintenance_metadata_snapshot_missing",
|
||||
format!("metadata snapshot {metadata} is missing"),
|
||||
));
|
||||
},
|
||||
};
|
||||
let value = snapshot
|
||||
.snapshot
|
||||
.payload_json
|
||||
.get("primary_sale_happened")
|
||||
.or_else(|| return snapshot.snapshot.payload_json.get("primarySaleHappened"));
|
||||
return match value.and_then(serde_json::Value::as_bool) {
|
||||
std::option::Option::Some(value) => std::result::Result::Ok(value),
|
||||
std::option::Option::None => std::result::Result::Err(ks_core::Error::new(
|
||||
"metaplex_maintenance_primary_sale_missing",
|
||||
"metadata snapshot has no boolean primary-sale field",
|
||||
)),
|
||||
};
|
||||
}
|
||||
|
||||
fn validate_confirmed_execution(
|
||||
label: &str,
|
||||
execution: &crate::DevnetMetaplexTokenMetadataExecutionSummary,
|
||||
) -> ks_core::Result<u64> {
|
||||
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_maintenance_confirmation_incomplete",
|
||||
format!("Metaplex {label} stopped at {:?}", value.status),
|
||||
));
|
||||
},
|
||||
std::option::Option::None => {
|
||||
return std::result::Result::Err(ks_core::Error::new(
|
||||
"metaplex_maintenance_confirmation_missing",
|
||||
format!("Metaplex {label} 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_maintenance_post_execution_missing",
|
||||
format!("Metaplex {label} 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_maintenance_post_execution_incomplete",
|
||||
format!(
|
||||
"Metaplex {label} 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_maintenance_idempotence_missing",
|
||||
format!("Metaplex {label} 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_maintenance_idempotence_failed",
|
||||
format!("Metaplex {label} 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_maintenance_confirmation_slot_missing",
|
||||
format!("Metaplex {label} confirmation has no slot"),
|
||||
)),
|
||||
};
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#[test]
|
||||
fn maintenance_campaign_contract_is_exact() {
|
||||
assert_eq!(
|
||||
crate::metaplex_maintenance_campaign_operation_names(),
|
||||
&["create", "mint", "update", "resize", "migrate", "collect", "close_accounts"],
|
||||
);
|
||||
for custom_error in [201_u64, 75_u64, 7_u64, 188_u64] {
|
||||
let error = format!(r#"{{"InstructionError":[0,{{"Custom":{custom_error}}}]}}"#);
|
||||
assert_eq!(
|
||||
super::simulation_custom_error(std::option::Option::Some(error.as_str())),
|
||||
std::option::Option::Some(custom_error),
|
||||
);
|
||||
}
|
||||
assert_eq!(
|
||||
super::simulation_custom_error(std::option::Option::Some(
|
||||
r#"{"InstructionError":[0,"InvalidInstructionData"]}"#,
|
||||
)),
|
||||
std::option::Option::None,
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn maintenance_reserved_destinations_are_exact() {
|
||||
assert_eq!(super::METAPLEX_FEE_DESTINATION, "2fb1TjRrJQLy9BkYfBjcYgibV7LUsr9cf6QxvyRZyuXn");
|
||||
assert_eq!(
|
||||
super::METAPLEX_OWNERLESS_CLOSE_DESTINATION,
|
||||
"GxCXYtrnaU6JXeAza8Ugn4EE6QiFinpfn8t3Lo4UkBDX",
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn optional_devnet_metaplex_maintenance_campaign_from_env() {
|
||||
if std::env::var("KB_DEVNET_METAPLEX_MAINTENANCE_CAMPAIGN_TEST").ok().as_deref()
|
||||
!= std::option::Option::Some("1")
|
||||
{
|
||||
return;
|
||||
}
|
||||
assert_eq!(
|
||||
std::env::var("KB_DEVNET_METAPLEX_OPERATOR_CONFIRMED").ok().as_deref(),
|
||||
std::option::Option::Some("1"),
|
||||
"set KB_DEVNET_METAPLEX_OPERATOR_CONFIRMED=1 before the maintenance 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_path = match std::env::var("KB_DEVNET_CONFIG_PATH") {
|
||||
std::result::Result::Ok(value) => {
|
||||
let path = std::path::PathBuf::from(value);
|
||||
if path.is_absolute() { path } else { workspace_root.join(path) }
|
||||
},
|
||||
std::result::Result::Err(_) => workspace_root.join("config/example.config.json"),
|
||||
};
|
||||
let config = ks_config::read_config_json_file_with_environment(
|
||||
config_path.as_path(),
|
||||
workspace_root,
|
||||
)
|
||||
.unwrap_or_else(|error| panic!("Devnet configuration loading failed: {error}"));
|
||||
let profile_name = std::env::var("KB_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("KB_POSTGRES_TEST_URL")
|
||||
.unwrap_or_else(|error| panic!("KB_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_maintenance_campaign(
|
||||
&http_pool,
|
||||
&store,
|
||||
&profile,
|
||||
workspace_root,
|
||||
&options,
|
||||
&crate::NoopSolanaExecutionObserver,
|
||||
)
|
||||
.await
|
||||
.unwrap_or_else(|error| panic!("Metaplex maintenance Devnet campaign failed: {error}"));
|
||||
println!(
|
||||
"METAPLEX_MAINTENANCE_FIXTURE mint={} metadata={} master_edition={} token={}",
|
||||
summary.fixture.fixture.mint,
|
||||
summary.fixture.fixture.metadata,
|
||||
summary.fixture.fixture.master_edition,
|
||||
summary.fixture.fixture.token_account,
|
||||
);
|
||||
print_confirmed_step("update", &summary.update);
|
||||
println!(
|
||||
"METAPLEX_MAINTENANCE_STATE primary_sale_before={} primary_sale_after={}",
|
||||
summary.primary_sale_before_update, summary.primary_sale_after_update,
|
||||
);
|
||||
print_probe("resize", 201, &summary.resize_probe);
|
||||
print_probe("migrate", 75, &summary.migrate_probe);
|
||||
print_probe("collect", 7, &summary.collect_probe);
|
||||
print_probe("close_accounts", 188, &summary.close_accounts_probe);
|
||||
println!(
|
||||
"METAPLEX_MAINTENANCE_EVIDENCE update={}",
|
||||
execution_evidence_json(&summary.update),
|
||||
);
|
||||
}
|
||||
|
||||
fn print_confirmed_step(
|
||||
label: &str,
|
||||
execution: &crate::DevnetMetaplexTokenMetadataExecutionSummary,
|
||||
) {
|
||||
let confirmation = execution
|
||||
.confirmation
|
||||
.as_ref()
|
||||
.unwrap_or_else(|| panic!("{label} confirmation is missing"));
|
||||
println!(
|
||||
"METAPLEX_MAINTENANCE_STEP label={} operation={} signature={} slot={:?} materializations={}",
|
||||
label,
|
||||
execution.plan.operation_code,
|
||||
confirmation.signature.0,
|
||||
confirmation.slot,
|
||||
execution.materializations.len(),
|
||||
);
|
||||
}
|
||||
|
||||
fn print_probe(
|
||||
label: &str,
|
||||
expected_custom_error: u64,
|
||||
execution: &crate::DevnetMetaplexTokenMetadataExecutionSummary,
|
||||
) {
|
||||
println!(
|
||||
"METAPLEX_MAINTENANCE_PROBE label={} operation={} success={} context_slot={} expected_custom_error={} error={} logs={}",
|
||||
label,
|
||||
execution.plan.operation_code,
|
||||
execution.simulation.success,
|
||||
execution.simulation_context_slot,
|
||||
expected_custom_error,
|
||||
serde_json::to_string(&execution.simulation.error).unwrap_or_else(|error| panic!(
|
||||
"{label} probe error serialization failed: {error}"
|
||||
)),
|
||||
execution.simulation.logs.len(),
|
||||
);
|
||||
println!(
|
||||
"METAPLEX_MAINTENANCE_PROBE_LOGS label={} {}",
|
||||
label,
|
||||
serde_json::to_string(&execution.simulation.logs)
|
||||
.unwrap_or_else(|error| panic!("{label} probe log serialization failed: {error}")),
|
||||
);
|
||||
}
|
||||
|
||||
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,
|
||||
});
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,404 @@
|
||||
// file: ks-pipeline-demo-scenarios/src/metadata/metaplex_token_metadata/scenarios.rs
|
||||
// version: 9
|
||||
|
||||
//! Coherent Metaplex Token Metadata scenario journeys for tests and Devnet campaigns.
|
||||
|
||||
/// Stable asset family covered by one Metaplex scenario.
|
||||
#[derive(
|
||||
Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd, serde::Deserialize, serde::Serialize,
|
||||
)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum MetaplexTokenMetadataAssetFamily {
|
||||
/// Non-fungible token.
|
||||
Nft,
|
||||
/// Semi-fungible token.
|
||||
Sft,
|
||||
/// Fungible token with Metaplex metadata.
|
||||
Fungible,
|
||||
/// Collection parent and member relationship.
|
||||
Collection,
|
||||
/// Programmable non-fungible token.
|
||||
ProgrammableNft,
|
||||
}
|
||||
|
||||
/// Stable fixture graph required by one scenario journey.
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum MetaplexTokenMetadataFixtureKind {
|
||||
/// Fresh zero-decimal classic SPL mint with profile-owned authorities.
|
||||
ClassicNftMint,
|
||||
/// Fresh SFT mint and token account.
|
||||
SemiFungibleMint,
|
||||
/// Fresh fungible mint and token account.
|
||||
FungibleMint,
|
||||
/// Collection parent plus one unverified member.
|
||||
CollectionParentAndMember,
|
||||
/// Programmable NFT with token record and optional rule set.
|
||||
ProgrammableNftTokenRecord,
|
||||
}
|
||||
|
||||
/// Stable lifecycle state consumed or produced by one scenario journey.
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum MetaplexTokenMetadataFixtureState {
|
||||
/// Mint exists but metadata does not.
|
||||
MintPrepared,
|
||||
/// Metadata exists and remains mutable.
|
||||
MetadataCreated,
|
||||
/// Master edition exists.
|
||||
MasterEditionCreated,
|
||||
/// Metadata is immutable after a confirmed update-authority transition.
|
||||
MetadataImmutable,
|
||||
/// Collection member exists but is not verified.
|
||||
CollectionMemberUnverified,
|
||||
/// Collection member is verified.
|
||||
CollectionMemberVerified,
|
||||
/// Programmable token record exists.
|
||||
ProgrammableTokenRecordCreated,
|
||||
/// Programmable asset is delegated.
|
||||
Delegated,
|
||||
/// Programmable asset is locked.
|
||||
Locked,
|
||||
/// Scenario has reached its terminal validated state.
|
||||
Validated,
|
||||
}
|
||||
|
||||
/// Execution mode accepted by one scenario.
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum MetaplexTokenMetadataScenarioMode {
|
||||
/// Purely deterministic scenario using synthetic accounts and plans.
|
||||
Synthetic,
|
||||
/// Devnet or Testnet simulation without submission.
|
||||
NetworkSimulation,
|
||||
/// Explicitly authorized Devnet or Testnet submission.
|
||||
NetworkSubmission,
|
||||
}
|
||||
|
||||
/// One stable Metaplex journey reusable by automated tests and demo adapters.
|
||||
#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
|
||||
pub struct MetaplexTokenMetadataScenario {
|
||||
/// Stable scenario identifier.
|
||||
pub id: std::string::String,
|
||||
/// Operator-visible scenario label.
|
||||
pub label: std::string::String,
|
||||
/// Asset family exercised by the scenario.
|
||||
pub asset_family: crate::MetaplexTokenMetadataAssetFamily,
|
||||
/// Fixture graph required by the scenario.
|
||||
pub fixture_kind: crate::MetaplexTokenMetadataFixtureKind,
|
||||
/// Required initial lifecycle state.
|
||||
pub initial_state: crate::MetaplexTokenMetadataFixtureState,
|
||||
/// Expected terminal lifecycle state.
|
||||
pub resulting_state: crate::MetaplexTokenMetadataFixtureState,
|
||||
/// Execution mode.
|
||||
pub mode: crate::MetaplexTokenMetadataScenarioMode,
|
||||
/// Stable operation codes executed in order.
|
||||
pub operation_codes: std::vec::Vec<std::string::String>,
|
||||
/// Whether the scenario requires a collection account.
|
||||
pub requires_collection: bool,
|
||||
/// Whether the scenario requires programmable authorization rules.
|
||||
pub requires_programmable_rules: bool,
|
||||
/// Whether a confirmed postcondition is mandatory.
|
||||
pub requires_postcondition: bool,
|
||||
/// Canonical account projections expected when materialization is enabled.
|
||||
pub materialization_targets: std::vec::Vec<std::string::String>,
|
||||
}
|
||||
|
||||
fn scenario(
|
||||
id: &str,
|
||||
label: &str,
|
||||
asset_family: crate::MetaplexTokenMetadataAssetFamily,
|
||||
fixture_kind: crate::MetaplexTokenMetadataFixtureKind,
|
||||
initial_state: crate::MetaplexTokenMetadataFixtureState,
|
||||
resulting_state: crate::MetaplexTokenMetadataFixtureState,
|
||||
operation_codes: std::vec::Vec<std::string::String>,
|
||||
requires_collection: bool,
|
||||
requires_programmable_rules: bool,
|
||||
materialization_targets: &[&str],
|
||||
) -> crate::MetaplexTokenMetadataScenario {
|
||||
return crate::MetaplexTokenMetadataScenario {
|
||||
id: id.to_string(),
|
||||
label: label.to_string(),
|
||||
asset_family,
|
||||
fixture_kind,
|
||||
initial_state,
|
||||
resulting_state,
|
||||
mode: crate::MetaplexTokenMetadataScenarioMode::Synthetic,
|
||||
operation_codes,
|
||||
requires_collection,
|
||||
requires_programmable_rules,
|
||||
requires_postcondition: true,
|
||||
materialization_targets: materialization_targets
|
||||
.iter()
|
||||
.map(|value| return (*value).to_string())
|
||||
.collect(),
|
||||
};
|
||||
}
|
||||
|
||||
/// Returns the complete ordered synthetic Metaplex journey inventory.
|
||||
pub fn metaplex_token_metadata_synthetic_scenarios()
|
||||
-> std::vec::Vec<crate::MetaplexTokenMetadataScenario> {
|
||||
return vec![
|
||||
scenario(
|
||||
"metaplex_nft_create_update",
|
||||
"NFT classique : création, mise à jour et édition maître",
|
||||
crate::MetaplexTokenMetadataAssetFamily::Nft,
|
||||
crate::MetaplexTokenMetadataFixtureKind::ClassicNftMint,
|
||||
crate::MetaplexTokenMetadataFixtureState::MintPrepared,
|
||||
crate::MetaplexTokenMetadataFixtureState::MasterEditionCreated,
|
||||
vec![
|
||||
ks_lib::EX_METAPLEX_TOKEN_METADATA_CREATE_OPERATION.to_string(),
|
||||
ks_lib::EX_METAPLEX_TOKEN_METADATA_UPDATE_OPERATION.to_string(),
|
||||
ks_lib::EX_METAPLEX_TOKEN_METADATA_SET_TOKEN_STANDARD_OPERATION.to_string(),
|
||||
],
|
||||
false,
|
||||
false,
|
||||
&["metadata_state", "edition_state"],
|
||||
),
|
||||
scenario(
|
||||
"metaplex_sft_create_mint",
|
||||
"SFT : création des metadata puis émission de supply",
|
||||
crate::MetaplexTokenMetadataAssetFamily::Sft,
|
||||
crate::MetaplexTokenMetadataFixtureKind::SemiFungibleMint,
|
||||
crate::MetaplexTokenMetadataFixtureState::MintPrepared,
|
||||
crate::MetaplexTokenMetadataFixtureState::Validated,
|
||||
vec![
|
||||
ks_lib::EX_METAPLEX_TOKEN_METADATA_CREATE_OPERATION.to_string(),
|
||||
ks_lib::EX_METAPLEX_TOKEN_METADATA_MINT_OPERATION.to_string(),
|
||||
ks_lib::EX_METAPLEX_TOKEN_METADATA_UPDATE_OPERATION.to_string(),
|
||||
],
|
||||
false,
|
||||
false,
|
||||
&["metadata_state"],
|
||||
),
|
||||
scenario(
|
||||
"metaplex_fungible_create_update",
|
||||
"Fungible : création et transitions d’autorité",
|
||||
crate::MetaplexTokenMetadataAssetFamily::Fungible,
|
||||
crate::MetaplexTokenMetadataFixtureKind::FungibleMint,
|
||||
crate::MetaplexTokenMetadataFixtureState::MintPrepared,
|
||||
crate::MetaplexTokenMetadataFixtureState::Validated,
|
||||
vec![
|
||||
ks_lib::EX_METAPLEX_TOKEN_METADATA_CREATE_OPERATION.to_string(),
|
||||
ks_lib::EX_METAPLEX_TOKEN_METADATA_UPDATE_OPERATION.to_string(),
|
||||
],
|
||||
false,
|
||||
false,
|
||||
&["metadata_state"],
|
||||
),
|
||||
scenario(
|
||||
"metaplex_collection_verify_unverify",
|
||||
"Collection : membre non vérifié, vérification puis révocation",
|
||||
crate::MetaplexTokenMetadataAssetFamily::Collection,
|
||||
crate::MetaplexTokenMetadataFixtureKind::CollectionParentAndMember,
|
||||
crate::MetaplexTokenMetadataFixtureState::CollectionMemberUnverified,
|
||||
crate::MetaplexTokenMetadataFixtureState::CollectionMemberUnverified,
|
||||
vec![
|
||||
ks_lib::EX_METAPLEX_TOKEN_METADATA_VERIFY_OPERATION.to_string(),
|
||||
ks_lib::EX_METAPLEX_TOKEN_METADATA_UNVERIFY_OPERATION.to_string(),
|
||||
],
|
||||
true,
|
||||
false,
|
||||
&["metadata_state", "edition_state"],
|
||||
),
|
||||
scenario(
|
||||
"metaplex_pnft_delegate_transfer_revoke",
|
||||
"pNFT : token record, délégation, verrouillage, transfert et révocation",
|
||||
crate::MetaplexTokenMetadataAssetFamily::ProgrammableNft,
|
||||
crate::MetaplexTokenMetadataFixtureKind::ProgrammableNftTokenRecord,
|
||||
crate::MetaplexTokenMetadataFixtureState::ProgrammableTokenRecordCreated,
|
||||
crate::MetaplexTokenMetadataFixtureState::Validated,
|
||||
vec![
|
||||
ks_lib::EX_METAPLEX_TOKEN_METADATA_DELEGATE_OPERATION.to_string(),
|
||||
ks_lib::EX_METAPLEX_TOKEN_METADATA_LOCK_OPERATION.to_string(),
|
||||
ks_lib::EX_METAPLEX_TOKEN_METADATA_UNLOCK_OPERATION.to_string(),
|
||||
ks_lib::EX_METAPLEX_TOKEN_METADATA_TRANSFER_OPERATION.to_string(),
|
||||
ks_lib::EX_METAPLEX_TOKEN_METADATA_REVOKE_OPERATION.to_string(),
|
||||
],
|
||||
false,
|
||||
true,
|
||||
&["metadata_state", "programmable_token_state", "delegate_admin_state"],
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
/// Returns the ordered Metaplex journeys intended for opt-in Devnet simulation.
|
||||
pub fn metaplex_token_metadata_devnet_scenarios()
|
||||
-> std::vec::Vec<crate::MetaplexTokenMetadataScenario> {
|
||||
let definitions = [
|
||||
(
|
||||
"metaplex_nft_lifecycle_devnet",
|
||||
"NFT classique : création, mise à jour puis immutabilité",
|
||||
crate::MetaplexTokenMetadataAssetFamily::Nft,
|
||||
crate::MetaplexTokenMetadataFixtureKind::ClassicNftMint,
|
||||
false,
|
||||
false,
|
||||
&["metadata_state", "edition_state"][..],
|
||||
),
|
||||
(
|
||||
"metaplex_sft_create_update_devnet",
|
||||
"SFT : création confirmée puis mise à jour",
|
||||
crate::MetaplexTokenMetadataAssetFamily::Sft,
|
||||
crate::MetaplexTokenMetadataFixtureKind::SemiFungibleMint,
|
||||
false,
|
||||
false,
|
||||
&["metadata_state"][..],
|
||||
),
|
||||
(
|
||||
"metaplex_fungible_create_update_devnet",
|
||||
"Fungible : création confirmée puis mise à jour",
|
||||
crate::MetaplexTokenMetadataAssetFamily::Fungible,
|
||||
crate::MetaplexTokenMetadataFixtureKind::FungibleMint,
|
||||
false,
|
||||
false,
|
||||
&["metadata_state"][..],
|
||||
),
|
||||
(
|
||||
"metaplex_collection_create_update_devnet",
|
||||
"Collection parent : création confirmée puis mise à jour",
|
||||
crate::MetaplexTokenMetadataAssetFamily::Collection,
|
||||
crate::MetaplexTokenMetadataFixtureKind::CollectionParentAndMember,
|
||||
true,
|
||||
false,
|
||||
&["metadata_state", "edition_state"][..],
|
||||
),
|
||||
(
|
||||
"metaplex_pnft_create_update_devnet",
|
||||
"pNFT sans rule set : création confirmée puis mise à jour",
|
||||
crate::MetaplexTokenMetadataAssetFamily::ProgrammableNft,
|
||||
crate::MetaplexTokenMetadataFixtureKind::ProgrammableNftTokenRecord,
|
||||
false,
|
||||
false,
|
||||
&["metadata_state", "edition_state"][..],
|
||||
),
|
||||
];
|
||||
return definitions
|
||||
.into_iter()
|
||||
.map(
|
||||
|(
|
||||
id,
|
||||
label,
|
||||
asset_family,
|
||||
fixture_kind,
|
||||
requires_collection,
|
||||
requires_programmable_rules,
|
||||
materialization_targets,
|
||||
)| {
|
||||
let mut value = scenario(
|
||||
id,
|
||||
label,
|
||||
asset_family,
|
||||
fixture_kind,
|
||||
crate::MetaplexTokenMetadataFixtureState::MintPrepared,
|
||||
if asset_family == crate::MetaplexTokenMetadataAssetFamily::Nft {
|
||||
crate::MetaplexTokenMetadataFixtureState::MetadataImmutable
|
||||
} else {
|
||||
crate::MetaplexTokenMetadataFixtureState::MetadataCreated
|
||||
},
|
||||
if asset_family == crate::MetaplexTokenMetadataAssetFamily::Nft {
|
||||
vec![
|
||||
ks_lib::EX_METAPLEX_TOKEN_METADATA_CREATE_OPERATION.to_string(),
|
||||
ks_lib::EX_METAPLEX_TOKEN_METADATA_UPDATE_OPERATION.to_string(),
|
||||
ks_lib::EX_METAPLEX_TOKEN_METADATA_UPDATE_OPERATION.to_string(),
|
||||
]
|
||||
} else {
|
||||
vec![
|
||||
ks_lib::EX_METAPLEX_TOKEN_METADATA_CREATE_OPERATION.to_string(),
|
||||
ks_lib::EX_METAPLEX_TOKEN_METADATA_UPDATE_OPERATION.to_string(),
|
||||
]
|
||||
},
|
||||
requires_collection,
|
||||
requires_programmable_rules,
|
||||
materialization_targets,
|
||||
);
|
||||
value.mode = crate::MetaplexTokenMetadataScenarioMode::NetworkSimulation;
|
||||
return value;
|
||||
},
|
||||
)
|
||||
.collect();
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#[test]
|
||||
fn inventory_is_exact_unique_and_uses_coherent_fixture_graphs() {
|
||||
let scenarios = crate::metaplex_token_metadata_synthetic_scenarios();
|
||||
assert_eq!(scenarios.len(), 5);
|
||||
let ids = scenarios
|
||||
.iter()
|
||||
.map(|scenario| return scenario.id.as_str())
|
||||
.collect::<std::collections::BTreeSet<&str>>();
|
||||
assert_eq!(ids.len(), scenarios.len());
|
||||
for scenario in scenarios {
|
||||
assert!(!scenario.label.trim().is_empty());
|
||||
assert!(!scenario.operation_codes.is_empty());
|
||||
assert!(!scenario.materialization_targets.is_empty());
|
||||
assert!(scenario.requires_postcondition);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn collection_journey_uses_only_current_verify_and_unverify_operations() {
|
||||
let scenarios = crate::metaplex_token_metadata_synthetic_scenarios();
|
||||
let collection = scenarios.iter().find(|scenario| {
|
||||
return scenario.asset_family == crate::MetaplexTokenMetadataAssetFamily::Collection;
|
||||
});
|
||||
assert!(collection.is_some());
|
||||
if let std::option::Option::Some(scenario) = collection {
|
||||
assert_eq!(
|
||||
scenario.operation_codes,
|
||||
vec![
|
||||
ks_lib::EX_METAPLEX_TOKEN_METADATA_VERIFY_OPERATION.to_string(),
|
||||
ks_lib::EX_METAPLEX_TOKEN_METADATA_UNVERIFY_OPERATION.to_string(),
|
||||
]
|
||||
);
|
||||
assert_eq!(
|
||||
scenario.resulting_state,
|
||||
crate::MetaplexTokenMetadataFixtureState::CollectionMemberUnverified
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn collection_and_programmable_journeys_declare_special_requirements() {
|
||||
let scenarios = crate::metaplex_token_metadata_synthetic_scenarios();
|
||||
let collection = scenarios.iter().find(|scenario| {
|
||||
return scenario.asset_family == crate::MetaplexTokenMetadataAssetFamily::Collection;
|
||||
});
|
||||
assert!(collection.is_some_and(|scenario| return scenario.requires_collection));
|
||||
let programmable = scenarios.iter().find(|scenario| {
|
||||
return scenario.asset_family
|
||||
== crate::MetaplexTokenMetadataAssetFamily::ProgrammableNft;
|
||||
});
|
||||
assert!(programmable.is_some_and(|scenario| return scenario.requires_programmable_rules));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn devnet_inventory_exposes_only_fully_preparable_journeys() {
|
||||
let devnet = crate::metaplex_token_metadata_devnet_scenarios();
|
||||
assert_eq!(devnet.len(), 5);
|
||||
let families = devnet
|
||||
.iter()
|
||||
.map(|scenario| return scenario.asset_family)
|
||||
.collect::<std::collections::BTreeSet<_>>();
|
||||
assert_eq!(families.len(), 5);
|
||||
for scenario in devnet {
|
||||
let expected_len =
|
||||
if scenario.asset_family == crate::MetaplexTokenMetadataAssetFamily::Nft {
|
||||
3
|
||||
} else {
|
||||
2
|
||||
};
|
||||
assert_eq!(scenario.operation_codes.len(), expected_len);
|
||||
assert_eq!(
|
||||
scenario.operation_codes[0],
|
||||
ks_lib::EX_METAPLEX_TOKEN_METADATA_CREATE_OPERATION
|
||||
);
|
||||
assert!(
|
||||
scenario.operation_codes[1..].iter().all(|operation| return operation
|
||||
== ks_lib::EX_METAPLEX_TOKEN_METADATA_UPDATE_OPERATION)
|
||||
);
|
||||
assert_eq!(scenario.mode, crate::MetaplexTokenMetadataScenarioMode::NetworkSimulation);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,574 @@
|
||||
// file: ks-pipeline-demo-scenarios/src/metadata/metaplex_token_metadata/use_campaign.rs
|
||||
// version: 2
|
||||
|
||||
//! 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<crate::DevnetMetaplexTokenMetadataExecutionSummary>,
|
||||
/// 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<u64>,
|
||||
/// Exact bounded simulation failure diagnostic when runtime-unavailable.
|
||||
pub unavailable_reason: std::option::Option<std::string::String>,
|
||||
}
|
||||
|
||||
/// 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<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::DevnetMetaplexUseProbeSummary>
|
||||
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::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 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<ks_pipeline::MetaplexTokenMetadataStatefulReadRequest> {
|
||||
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<u64> {
|
||||
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::<mpl_token_metadata::types::Uses>(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<u64> {
|
||||
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("KB_DEVNET_METAPLEX_USE_PROBE_TEST").ok().as_deref()
|
||||
!= std::option::Option::Some("1")
|
||||
{
|
||||
return;
|
||||
}
|
||||
assert_eq!(
|
||||
std::env::var("KB_DEVNET_METAPLEX_OPERATOR_CONFIRMED").ok().as_deref(),
|
||||
std::option::Option::Some("1"),
|
||||
"set KB_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_path = match std::env::var("KB_DEVNET_CONFIG_PATH") {
|
||||
std::result::Result::Ok(value) => {
|
||||
let path = std::path::PathBuf::from(value);
|
||||
if path.is_absolute() { path } else { workspace_root.join(path) }
|
||||
},
|
||||
std::result::Result::Err(_) => workspace_root.join("config/example.config.json"),
|
||||
};
|
||||
let config = ks_config::read_config_json_file_with_environment(
|
||||
config_path.as_path(),
|
||||
workspace_root,
|
||||
)
|
||||
.unwrap_or_else(|error| panic!("Devnet configuration loading failed: {error}"));
|
||||
let profile_name = std::env::var("KB_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("KB_POSTGRES_TEST_URL")
|
||||
.unwrap_or_else(|error| panic!("KB_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,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,918 @@
|
||||
// file: ks-pipeline-demo-scenarios/src/metadata/metaplex_token_metadata/validation.rs
|
||||
// version: 18
|
||||
|
||||
//! Machine-readable Metaplex Token Metadata validation contract.
|
||||
|
||||
/// Maximum evidence entries accepted by one Metaplex validation scenario.
|
||||
pub const MAX_METAPLEX_TOKEN_METADATA_VALIDATION_EVIDENCE: usize = 64;
|
||||
|
||||
/// Exact validation status for one Metaplex scenario.
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum MetaplexTokenMetadataValidationStatus {
|
||||
/// Scenario has not been executed.
|
||||
NotRun,
|
||||
/// Scenario passed deterministic synthetic validation.
|
||||
SyntheticValidated,
|
||||
/// Scenario was simulated on Devnet or Testnet.
|
||||
Simulated,
|
||||
/// Scenario was submitted but confirmation evidence is incomplete.
|
||||
Submitted,
|
||||
/// Scenario was confirmed and all postconditions passed.
|
||||
Confirmed,
|
||||
/// Scenario is unavailable in the selected environment.
|
||||
Unavailable,
|
||||
/// Scenario ran and failed.
|
||||
Failed,
|
||||
}
|
||||
|
||||
/// One bounded validation evidence entry.
|
||||
#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
|
||||
pub struct MetaplexTokenMetadataValidationEvidence {
|
||||
/// Stable evidence kind.
|
||||
pub kind: std::string::String,
|
||||
/// Bounded evidence value.
|
||||
pub value: std::string::String,
|
||||
}
|
||||
|
||||
/// One scenario declared by the canonical Metaplex validation matrix.
|
||||
#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct MetaplexTokenMetadataValidationMatrixScenario {
|
||||
/// Stable scenario identifier.
|
||||
pub id: std::string::String,
|
||||
/// Required scenario mode.
|
||||
pub mode: crate::MetaplexTokenMetadataScenarioMode,
|
||||
/// Exact validation status.
|
||||
pub status: crate::MetaplexTokenMetadataValidationStatus,
|
||||
/// Evidence kinds required before confirmation.
|
||||
pub required_evidence: std::vec::Vec<std::string::String>,
|
||||
/// Observed evidence.
|
||||
#[serde(default)]
|
||||
pub evidence: std::vec::Vec<crate::MetaplexTokenMetadataValidationEvidence>,
|
||||
}
|
||||
|
||||
/// Canonical Metaplex validation matrix.
|
||||
#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct MetaplexTokenMetadataValidationMatrix {
|
||||
/// Matrix schema version.
|
||||
pub matrix_version: u32,
|
||||
/// Owning milestone.
|
||||
pub milestone: std::string::String,
|
||||
/// Ordered validation scenarios.
|
||||
pub scenarios: std::vec::Vec<crate::MetaplexTokenMetadataValidationMatrixScenario>,
|
||||
}
|
||||
|
||||
/// Transaction location covered by one cross-validation case.
|
||||
#[derive(
|
||||
Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd, serde::Deserialize, serde::Serialize,
|
||||
)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum MetaplexTokenMetadataTransactionPath {
|
||||
/// Top-level instruction.
|
||||
Outer,
|
||||
/// Cross-program invocation.
|
||||
Cpi,
|
||||
}
|
||||
|
||||
/// Expected transaction outcome for one cross-validation case.
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum MetaplexTokenMetadataTransactionOutcome {
|
||||
/// Successful transaction path.
|
||||
Success,
|
||||
/// Failed or rejected transaction path.
|
||||
Failed,
|
||||
}
|
||||
|
||||
/// Stable failure category exercised by one negative case.
|
||||
#[derive(
|
||||
Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd, serde::Deserialize, serde::Serialize,
|
||||
)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum MetaplexTokenMetadataFailureClass {
|
||||
/// Transaction failed before committed observations could be emitted.
|
||||
TransactionFailed,
|
||||
/// Program ID mismatch.
|
||||
ProgramId,
|
||||
/// Account owner mismatch.
|
||||
Owner,
|
||||
/// Program-derived address mismatch.
|
||||
Pda,
|
||||
/// Positional account or flag mismatch.
|
||||
Accounts,
|
||||
/// Truncated instruction payload.
|
||||
PayloadTruncated,
|
||||
/// Forbidden trailing instruction bytes.
|
||||
PayloadSuffix,
|
||||
/// Unknown instruction discriminant.
|
||||
UnknownDiscriminant,
|
||||
/// Conflicting Metaplex and Token-2022 metadata sources.
|
||||
MetadataSourceConflict,
|
||||
}
|
||||
|
||||
/// One case in the closed Metaplex cross-validation corpus.
|
||||
#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct MetaplexTokenMetadataCrossValidationCase {
|
||||
/// Stable case identifier.
|
||||
pub id: std::string::String,
|
||||
/// Outer or CPI transaction path.
|
||||
pub path: crate::MetaplexTokenMetadataTransactionPath,
|
||||
/// Expected transaction outcome.
|
||||
pub outcome: crate::MetaplexTokenMetadataTransactionOutcome,
|
||||
/// Optional asset family for successful corpus cases.
|
||||
pub asset_family: std::option::Option<crate::MetaplexTokenMetadataAssetFamily>,
|
||||
/// Optional stable negative-case category.
|
||||
pub failure_class: std::option::Option<crate::MetaplexTokenMetadataFailureClass>,
|
||||
/// Whether status changes require actual RPC evidence.
|
||||
pub requires_network_evidence: bool,
|
||||
/// Exact observed validation status.
|
||||
pub status: crate::MetaplexTokenMetadataValidationStatus,
|
||||
/// Evidence kinds required before the declared status is accepted.
|
||||
pub required_evidence: std::vec::Vec<std::string::String>,
|
||||
/// Bounded observed evidence.
|
||||
#[serde(default)]
|
||||
pub evidence: std::vec::Vec<crate::MetaplexTokenMetadataValidationEvidence>,
|
||||
}
|
||||
|
||||
/// Closed Metaplex cross-validation corpus for `0.4.7-pre.010`.
|
||||
#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct MetaplexTokenMetadataCrossValidationMatrix {
|
||||
/// Matrix schema version.
|
||||
pub matrix_version: u32,
|
||||
/// Exact owning prerelease.
|
||||
pub milestone: std::string::String,
|
||||
/// Ordered validation cases.
|
||||
pub cases: std::vec::Vec<crate::MetaplexTokenMetadataCrossValidationCase>,
|
||||
}
|
||||
|
||||
/// Loads and validates the closed Metaplex cross-validation corpus.
|
||||
pub fn load_metaplex_token_metadata_cross_validation_matrix()
|
||||
-> ks_core::Result<crate::MetaplexTokenMetadataCrossValidationMatrix> {
|
||||
let parsed = match serde_json::from_str::<crate::MetaplexTokenMetadataCrossValidationMatrix>(
|
||||
include_str!(
|
||||
"../../../../test-fixtures/contract-matrices/METAPLEX_TOKEN_METADATA_CROSS_VALIDATION_MATRIX.json"
|
||||
),
|
||||
) {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => {
|
||||
return std::result::Result::Err(ks_core::Error::new(
|
||||
"metaplex_token_metadata_cross_validation_matrix_invalid_json",
|
||||
format!("Metaplex cross-validation matrix JSON is invalid: {error}"),
|
||||
));
|
||||
},
|
||||
};
|
||||
if let std::result::Result::Err(error) =
|
||||
validate_metaplex_token_metadata_cross_validation_matrix(&parsed)
|
||||
{
|
||||
return std::result::Result::Err(error);
|
||||
}
|
||||
return std::result::Result::Ok(parsed);
|
||||
}
|
||||
|
||||
/// Validates the closed Metaplex cross-validation corpus and evidence claims.
|
||||
pub fn validate_metaplex_token_metadata_cross_validation_matrix(
|
||||
matrix: &crate::MetaplexTokenMetadataCrossValidationMatrix,
|
||||
) -> ks_core::Result<()> {
|
||||
if matrix.matrix_version != 1 || matrix.milestone != "0.4.7-pre.010" {
|
||||
return std::result::Result::Err(ks_core::Error::new(
|
||||
"metaplex_token_metadata_cross_validation_matrix_contract_mismatch",
|
||||
"Metaplex cross-validation matrix must use version 1 for 0.4.7-pre.010",
|
||||
));
|
||||
}
|
||||
let mut ids = std::collections::BTreeSet::<&str>::new();
|
||||
for case in &matrix.cases {
|
||||
if !ids.insert(case.id.as_str()) || case.required_evidence.is_empty() {
|
||||
return std::result::Result::Err(ks_core::Error::new(
|
||||
"metaplex_token_metadata_cross_validation_case_invalid",
|
||||
"Metaplex cross-validation cases must be unique and declare evidence",
|
||||
));
|
||||
}
|
||||
if case.evidence.len() > crate::MAX_METAPLEX_TOKEN_METADATA_VALIDATION_EVIDENCE {
|
||||
return std::result::Result::Err(ks_core::Error::new(
|
||||
"metaplex_token_metadata_cross_validation_evidence_limit_exceeded",
|
||||
"Metaplex cross-validation evidence exceeds the compiled bound",
|
||||
));
|
||||
}
|
||||
if case.outcome == crate::MetaplexTokenMetadataTransactionOutcome::Success
|
||||
&& case.asset_family.is_none()
|
||||
{
|
||||
return std::result::Result::Err(ks_core::Error::new(
|
||||
"metaplex_token_metadata_cross_validation_success_without_asset_family",
|
||||
"Successful Metaplex corpus cases must identify an asset family",
|
||||
));
|
||||
}
|
||||
if case.outcome == crate::MetaplexTokenMetadataTransactionOutcome::Failed
|
||||
&& case.failure_class.is_none()
|
||||
{
|
||||
return std::result::Result::Err(ks_core::Error::new(
|
||||
"metaplex_token_metadata_cross_validation_failure_without_class",
|
||||
"Failed Metaplex corpus cases must identify a failure class",
|
||||
));
|
||||
}
|
||||
if matches!(
|
||||
case.status,
|
||||
crate::MetaplexTokenMetadataValidationStatus::NotRun
|
||||
| crate::MetaplexTokenMetadataValidationStatus::Unavailable
|
||||
) && !case.evidence.is_empty()
|
||||
{
|
||||
return std::result::Result::Err(ks_core::Error::new(
|
||||
"metaplex_token_metadata_cross_validation_unobserved_with_evidence",
|
||||
"Not-run or unavailable cross-validation cases must not contain evidence",
|
||||
));
|
||||
}
|
||||
if case.requires_network_evidence
|
||||
&& matches!(
|
||||
case.status,
|
||||
crate::MetaplexTokenMetadataValidationStatus::SyntheticValidated
|
||||
)
|
||||
{
|
||||
return std::result::Result::Err(ks_core::Error::new(
|
||||
"metaplex_token_metadata_cross_validation_network_overdeclared",
|
||||
"Network cases cannot be declared validated from synthetic evidence",
|
||||
));
|
||||
}
|
||||
if matches!(
|
||||
case.status,
|
||||
crate::MetaplexTokenMetadataValidationStatus::Simulated
|
||||
| crate::MetaplexTokenMetadataValidationStatus::Submitted
|
||||
| crate::MetaplexTokenMetadataValidationStatus::Confirmed
|
||||
) {
|
||||
let observed = case
|
||||
.evidence
|
||||
.iter()
|
||||
.map(|evidence| return evidence.kind.as_str())
|
||||
.collect::<std::collections::BTreeSet<&str>>();
|
||||
if !case
|
||||
.required_evidence
|
||||
.iter()
|
||||
.all(|kind| return observed.contains(kind.as_str()))
|
||||
{
|
||||
return std::result::Result::Err(ks_core::Error::new(
|
||||
"metaplex_token_metadata_cross_validation_status_without_required_evidence",
|
||||
"Observed Metaplex network status lacks required evidence",
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
return std::result::Result::Ok(());
|
||||
}
|
||||
|
||||
/// Loads and validates the canonical Metaplex validation matrix.
|
||||
pub fn load_metaplex_token_metadata_validation_matrix()
|
||||
-> ks_core::Result<crate::MetaplexTokenMetadataValidationMatrix> {
|
||||
let parsed = match serde_json::from_str::<crate::MetaplexTokenMetadataValidationMatrix>(
|
||||
include_str!(
|
||||
"../../../../test-fixtures/contract-matrices/METAPLEX_TOKEN_METADATA_VALIDATION_MATRIX.json"
|
||||
),
|
||||
) {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => {
|
||||
return std::result::Result::Err(ks_core::Error::new(
|
||||
"metaplex_token_metadata_validation_matrix_invalid_json",
|
||||
format!("Metaplex validation matrix JSON is invalid: {error}"),
|
||||
));
|
||||
},
|
||||
};
|
||||
if let std::result::Result::Err(error) =
|
||||
validate_metaplex_token_metadata_validation_matrix(&parsed)
|
||||
{
|
||||
return std::result::Result::Err(error);
|
||||
}
|
||||
return std::result::Result::Ok(parsed);
|
||||
}
|
||||
|
||||
/// Validates the canonical Metaplex validation matrix.
|
||||
pub fn validate_metaplex_token_metadata_validation_matrix(
|
||||
matrix: &crate::MetaplexTokenMetadataValidationMatrix,
|
||||
) -> ks_core::Result<()> {
|
||||
if matrix.matrix_version != 1 || matrix.milestone != "0.4.7" {
|
||||
return std::result::Result::Err(ks_core::Error::new(
|
||||
"metaplex_token_metadata_validation_matrix_contract_mismatch",
|
||||
"Metaplex validation matrix must use version 1 for milestone 0.4.7",
|
||||
));
|
||||
}
|
||||
let expected = crate::metaplex_token_metadata_synthetic_scenarios()
|
||||
.iter()
|
||||
.map(|scenario| return scenario.id.clone())
|
||||
.collect::<std::vec::Vec<std::string::String>>();
|
||||
let actual = matrix
|
||||
.scenarios
|
||||
.iter()
|
||||
.map(|scenario| return scenario.id.clone())
|
||||
.collect::<std::vec::Vec<std::string::String>>();
|
||||
if actual != expected {
|
||||
return std::result::Result::Err(ks_core::Error::new(
|
||||
"metaplex_token_metadata_validation_matrix_inventory_mismatch",
|
||||
"Metaplex validation matrix inventory differs from the compiled scenario inventory",
|
||||
));
|
||||
}
|
||||
let mut ids = std::collections::BTreeSet::<&str>::new();
|
||||
for scenario in &matrix.scenarios {
|
||||
if !ids.insert(scenario.id.as_str()) || scenario.required_evidence.is_empty() {
|
||||
return std::result::Result::Err(ks_core::Error::new(
|
||||
"metaplex_token_metadata_validation_matrix_scenario_invalid",
|
||||
"Metaplex validation scenarios must be unique and declare required evidence",
|
||||
));
|
||||
}
|
||||
if scenario.evidence.len() > crate::MAX_METAPLEX_TOKEN_METADATA_VALIDATION_EVIDENCE {
|
||||
return std::result::Result::Err(ks_core::Error::new(
|
||||
"metaplex_token_metadata_validation_evidence_limit_exceeded",
|
||||
"Metaplex validation evidence exceeds the compiled bound",
|
||||
));
|
||||
}
|
||||
if matches!(
|
||||
scenario.status,
|
||||
crate::MetaplexTokenMetadataValidationStatus::NotRun
|
||||
| crate::MetaplexTokenMetadataValidationStatus::Unavailable
|
||||
) && !scenario.evidence.is_empty()
|
||||
{
|
||||
return std::result::Result::Err(ks_core::Error::new(
|
||||
"metaplex_token_metadata_validation_unobserved_with_evidence",
|
||||
"Not-run or unavailable Metaplex scenarios must not contain evidence",
|
||||
));
|
||||
}
|
||||
if scenario.status == crate::MetaplexTokenMetadataValidationStatus::Confirmed {
|
||||
let observed = scenario
|
||||
.evidence
|
||||
.iter()
|
||||
.map(|evidence| return evidence.kind.as_str())
|
||||
.collect::<std::collections::BTreeSet<&str>>();
|
||||
if !scenario
|
||||
.required_evidence
|
||||
.iter()
|
||||
.all(|kind| return observed.contains(kind.as_str()))
|
||||
{
|
||||
return std::result::Result::Err(ks_core::Error::new(
|
||||
"metaplex_token_metadata_validation_confirmed_without_required_evidence",
|
||||
"Confirmed Metaplex scenario lacks required evidence",
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
return std::result::Result::Ok(());
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#[test]
|
||||
fn canonical_matrix_is_machine_readable_and_matches_synthetic_inventory() {
|
||||
let result = crate::load_metaplex_token_metadata_validation_matrix();
|
||||
assert!(result.is_ok());
|
||||
if let std::result::Result::Ok(matrix) = result {
|
||||
assert_eq!(matrix.scenarios.len(), 5);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn confirmed_scenario_requires_every_declared_evidence_kind() {
|
||||
let result = crate::load_metaplex_token_metadata_validation_matrix();
|
||||
assert!(result.is_ok());
|
||||
if let std::result::Result::Ok(mut matrix) = result {
|
||||
matrix.scenarios[0].status = crate::MetaplexTokenMetadataValidationStatus::Confirmed;
|
||||
assert!(crate::validate_metaplex_token_metadata_validation_matrix(&matrix).is_err());
|
||||
matrix.scenarios[0].evidence = matrix.scenarios[0]
|
||||
.required_evidence
|
||||
.iter()
|
||||
.map(|kind| {
|
||||
return crate::MetaplexTokenMetadataValidationEvidence {
|
||||
kind: kind.clone(),
|
||||
value: "synthetic-proof".to_string(),
|
||||
};
|
||||
})
|
||||
.collect();
|
||||
assert!(crate::validate_metaplex_token_metadata_validation_matrix(&matrix).is_ok());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cross_validation_corpus_is_closed_and_covers_every_required_dimension() {
|
||||
let result = crate::load_metaplex_token_metadata_cross_validation_matrix();
|
||||
assert!(result.is_ok());
|
||||
if let std::result::Result::Ok(matrix) = result {
|
||||
assert_eq!(matrix.cases.len(), 19);
|
||||
let paths = matrix
|
||||
.cases
|
||||
.iter()
|
||||
.map(|case| return case.path)
|
||||
.collect::<std::collections::BTreeSet<crate::MetaplexTokenMetadataTransactionPath>>(
|
||||
);
|
||||
assert_eq!(paths.len(), 2);
|
||||
let families = matrix
|
||||
.cases
|
||||
.iter()
|
||||
.filter_map(|case| return case.asset_family)
|
||||
.collect::<std::collections::BTreeSet<crate::MetaplexTokenMetadataAssetFamily>>(
|
||||
);
|
||||
assert_eq!(families.len(), 5);
|
||||
let failures = matrix
|
||||
.cases
|
||||
.iter()
|
||||
.filter_map(|case| return case.failure_class)
|
||||
.collect::<std::collections::BTreeSet<crate::MetaplexTokenMetadataFailureClass>>(
|
||||
);
|
||||
assert_eq!(failures.len(), 9);
|
||||
assert_eq!(
|
||||
matrix.cases.iter().filter(|case| return case.requires_network_evidence).count(),
|
||||
3
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn network_cases_cannot_be_promoted_from_synthetic_or_incomplete_evidence() {
|
||||
let result = crate::load_metaplex_token_metadata_cross_validation_matrix();
|
||||
assert!(result.is_ok());
|
||||
if let std::result::Result::Ok(mut matrix) = result {
|
||||
let network =
|
||||
matrix.cases.iter_mut().find(|case| return case.requires_network_evidence);
|
||||
assert!(network.is_some());
|
||||
if let std::option::Option::Some(case) = network {
|
||||
case.status = crate::MetaplexTokenMetadataValidationStatus::SyntheticValidated;
|
||||
}
|
||||
assert!(
|
||||
crate::validate_metaplex_token_metadata_cross_validation_matrix(&matrix).is_err()
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// One family-specific evidence bundle in the closed Devnet execution matrix.
|
||||
#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct MetaplexTokenMetadataDevnetExecutionFamilyEvidence {
|
||||
/// Asset family exercised by this evidence bundle.
|
||||
pub family: crate::MetaplexTokenMetadataAssetFamily,
|
||||
/// Complete evidence observed for this family.
|
||||
pub evidence: std::vec::Vec<crate::MetaplexTokenMetadataValidationEvidence>,
|
||||
}
|
||||
|
||||
/// One current Metaplex operation in the closed Devnet execution matrix.
|
||||
#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct MetaplexTokenMetadataDevnetExecutionOperation {
|
||||
/// Stable instruction discriminator.
|
||||
pub discriminator: u8,
|
||||
/// Official instruction name.
|
||||
pub name: std::string::String,
|
||||
/// Stable executor operation code.
|
||||
pub operation_code: std::string::String,
|
||||
/// Whether the operation is deprecated.
|
||||
pub deprecated: bool,
|
||||
/// Prerelease responsible for the campaign.
|
||||
pub campaign_prerelease: std::string::String,
|
||||
/// Implementation status of the reusable runner.
|
||||
pub runner_status: std::string::String,
|
||||
/// Exact observed network status.
|
||||
pub network_status: std::string::String,
|
||||
/// Asset families on which this operation has completed its declared network status.
|
||||
#[serde(default)]
|
||||
pub observed_families: std::vec::Vec<crate::MetaplexTokenMetadataAssetFamily>,
|
||||
/// Evidence required after simulation.
|
||||
pub required_evidence: std::vec::Vec<std::string::String>,
|
||||
/// Additional evidence required after submission.
|
||||
pub submission_evidence: std::vec::Vec<std::string::String>,
|
||||
/// Operation-level evidence reserved for non-family states such as unavailability.
|
||||
#[serde(default)]
|
||||
pub evidence: std::vec::Vec<crate::MetaplexTokenMetadataValidationEvidence>,
|
||||
/// Family-specific evidence bundles for simulated or confirmed operation paths.
|
||||
#[serde(default)]
|
||||
pub family_evidence: std::vec::Vec<crate::MetaplexTokenMetadataDevnetExecutionFamilyEvidence>,
|
||||
}
|
||||
|
||||
/// Closed inventory of every current Metaplex operation requiring Devnet coverage.
|
||||
#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct MetaplexTokenMetadataDevnetExecutionMatrix {
|
||||
/// Matrix schema version.
|
||||
pub matrix_version: u32,
|
||||
/// Owning prerelease.
|
||||
pub milestone: std::string::String,
|
||||
/// Canonical Token Metadata program ID.
|
||||
pub program_id: std::string::String,
|
||||
/// Ordered current operations.
|
||||
pub operations: std::vec::Vec<crate::MetaplexTokenMetadataDevnetExecutionOperation>,
|
||||
}
|
||||
|
||||
/// Loads and validates the closed Devnet execution matrix.
|
||||
pub fn load_metaplex_token_metadata_devnet_execution_matrix()
|
||||
-> ks_core::Result<crate::MetaplexTokenMetadataDevnetExecutionMatrix> {
|
||||
let matrix = match serde_json::from_str::<crate::MetaplexTokenMetadataDevnetExecutionMatrix>(
|
||||
include_str!(
|
||||
"../../../../test-fixtures/contract-matrices/METAPLEX_TOKEN_METADATA_DEVNET_EXECUTION_MATRIX.json"
|
||||
),
|
||||
) {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => {
|
||||
return std::result::Result::Err(ks_core::Error::new(
|
||||
"metaplex_devnet_execution_matrix_invalid_json",
|
||||
format!("Metaplex Devnet execution matrix JSON is invalid: {error}"),
|
||||
));
|
||||
},
|
||||
};
|
||||
if let std::result::Result::Err(error) =
|
||||
validate_metaplex_token_metadata_devnet_execution_matrix(&matrix)
|
||||
{
|
||||
return std::result::Result::Err(error);
|
||||
}
|
||||
return std::result::Result::Ok(matrix);
|
||||
}
|
||||
|
||||
/// Validates exact current-operation coverage and conservative network statuses.
|
||||
pub fn validate_metaplex_token_metadata_devnet_execution_matrix(
|
||||
matrix: &crate::MetaplexTokenMetadataDevnetExecutionMatrix,
|
||||
) -> ks_core::Result<()> {
|
||||
if matrix.matrix_version != 3
|
||||
|| matrix.milestone != "0.4.8-pre.013"
|
||||
|| matrix.program_id != ks_program_ids::METADATA_METAPLEX_TOKEN_METADATA_PROGRAM_ID
|
||||
|| matrix.operations.len() != 20
|
||||
{
|
||||
return std::result::Result::Err(ks_core::Error::new(
|
||||
"metaplex_devnet_execution_matrix_contract_mismatch",
|
||||
"Metaplex Devnet matrix must use version 3 and declare exactly 20 current operations for 0.4.8-pre.013",
|
||||
));
|
||||
}
|
||||
let expected = [
|
||||
"metadata.metaplex_token_metadata.create_escrow_account",
|
||||
"metadata.metaplex_token_metadata.close_escrow_account",
|
||||
"metadata.metaplex_token_metadata.transfer_out_of_escrow",
|
||||
"metadata.metaplex_token_metadata.burn",
|
||||
"metadata.metaplex_token_metadata.create",
|
||||
"metadata.metaplex_token_metadata.mint",
|
||||
"metadata.metaplex_token_metadata.delegate",
|
||||
"metadata.metaplex_token_metadata.revoke",
|
||||
"metadata.metaplex_token_metadata.lock",
|
||||
"metadata.metaplex_token_metadata.unlock",
|
||||
"metadata.metaplex_token_metadata.migrate",
|
||||
"metadata.metaplex_token_metadata.transfer",
|
||||
"metadata.metaplex_token_metadata.update",
|
||||
"metadata.metaplex_token_metadata.use",
|
||||
"metadata.metaplex_token_metadata.verify",
|
||||
"metadata.metaplex_token_metadata.unverify",
|
||||
"metadata.metaplex_token_metadata.collect",
|
||||
"metadata.metaplex_token_metadata.print",
|
||||
"metadata.metaplex_token_metadata.resize",
|
||||
"metadata.metaplex_token_metadata.close_accounts",
|
||||
]
|
||||
.into_iter()
|
||||
.collect::<std::collections::BTreeSet<&str>>();
|
||||
let observed = matrix
|
||||
.operations
|
||||
.iter()
|
||||
.map(|operation| return operation.operation_code.as_str())
|
||||
.collect::<std::collections::BTreeSet<&str>>();
|
||||
if observed != expected {
|
||||
return std::result::Result::Err(ks_core::Error::new(
|
||||
"metaplex_devnet_execution_matrix_operation_mismatch",
|
||||
"Metaplex Devnet matrix must cover the exact 20 current operation codes",
|
||||
));
|
||||
}
|
||||
let required_evidence = [
|
||||
"cluster",
|
||||
"genesis_hash",
|
||||
"simulation_slot",
|
||||
"simulation_logs",
|
||||
"message_hash",
|
||||
"fee_lamports",
|
||||
]
|
||||
.into_iter()
|
||||
.collect::<std::collections::BTreeSet<&str>>();
|
||||
let submission_evidence = [
|
||||
"signature",
|
||||
"confirmation_status",
|
||||
"confirmation_slot",
|
||||
"before_state",
|
||||
"after_state",
|
||||
"canonical_hydration",
|
||||
"core_extraction",
|
||||
"decode_replay",
|
||||
"materialization",
|
||||
"idempotence_replay",
|
||||
]
|
||||
.into_iter()
|
||||
.collect::<std::collections::BTreeSet<&str>>();
|
||||
for operation in &matrix.operations {
|
||||
let observed_required = operation
|
||||
.required_evidence
|
||||
.iter()
|
||||
.map(std::string::String::as_str)
|
||||
.collect::<std::collections::BTreeSet<&str>>();
|
||||
let observed_submission = operation
|
||||
.submission_evidence
|
||||
.iter()
|
||||
.map(std::string::String::as_str)
|
||||
.collect::<std::collections::BTreeSet<&str>>();
|
||||
if operation.deprecated
|
||||
|| operation.runner_status != "implemented"
|
||||
|| operation.campaign_prerelease != "0.4.8-pre.013"
|
||||
|| observed_required != required_evidence
|
||||
|| observed_submission != submission_evidence
|
||||
|| !matches!(
|
||||
operation.network_status.as_str(),
|
||||
"not_run" | "simulated" | "confirmed" | "unavailable"
|
||||
)
|
||||
|| operation.evidence.len() > crate::MAX_METAPLEX_TOKEN_METADATA_VALIDATION_EVIDENCE
|
||||
{
|
||||
return std::result::Result::Err(ks_core::Error::new(
|
||||
"metaplex_devnet_execution_matrix_entry_invalid",
|
||||
"Metaplex Devnet entries must be current, implemented, evidenced and conservatively classified",
|
||||
));
|
||||
}
|
||||
let observed_families = operation
|
||||
.observed_families
|
||||
.iter()
|
||||
.copied()
|
||||
.collect::<std::collections::BTreeSet<_>>();
|
||||
if observed_families.len() != operation.observed_families.len() {
|
||||
return std::result::Result::Err(ks_core::Error::new(
|
||||
"metaplex_devnet_execution_matrix_duplicate_family",
|
||||
"Metaplex Devnet observed families must be unique",
|
||||
));
|
||||
}
|
||||
let mut observed_evidence = std::collections::BTreeSet::<&str>::new();
|
||||
for evidence in &operation.evidence {
|
||||
if evidence.kind.trim().is_empty()
|
||||
|| evidence.value.trim().is_empty()
|
||||
|| !observed_evidence.insert(evidence.kind.as_str())
|
||||
{
|
||||
return std::result::Result::Err(ks_core::Error::new(
|
||||
"metaplex_devnet_execution_matrix_evidence_invalid",
|
||||
"Metaplex Devnet operation-level evidence kinds and values must be non-empty and unique",
|
||||
));
|
||||
}
|
||||
}
|
||||
let mut observed_family_evidence =
|
||||
std::collections::BTreeSet::<crate::MetaplexTokenMetadataAssetFamily>::new();
|
||||
for family_evidence in &operation.family_evidence {
|
||||
if !observed_family_evidence.insert(family_evidence.family)
|
||||
|| family_evidence.evidence.len()
|
||||
> crate::MAX_METAPLEX_TOKEN_METADATA_VALIDATION_EVIDENCE
|
||||
{
|
||||
return std::result::Result::Err(ks_core::Error::new(
|
||||
"metaplex_devnet_execution_matrix_family_evidence_invalid",
|
||||
"Metaplex Devnet family evidence bundles must be unique and bounded",
|
||||
));
|
||||
}
|
||||
let mut family_kinds = std::collections::BTreeSet::<&str>::new();
|
||||
for evidence in &family_evidence.evidence {
|
||||
if evidence.kind.trim().is_empty()
|
||||
|| evidence.value.trim().is_empty()
|
||||
|| !family_kinds.insert(evidence.kind.as_str())
|
||||
{
|
||||
return std::result::Result::Err(ks_core::Error::new(
|
||||
"metaplex_devnet_execution_matrix_family_evidence_entry_invalid",
|
||||
"Metaplex Devnet family evidence kinds and values must be non-empty and unique",
|
||||
));
|
||||
}
|
||||
}
|
||||
if operation.network_status == "simulated"
|
||||
&& operation
|
||||
.required_evidence
|
||||
.iter()
|
||||
.any(|kind| return !family_kinds.contains(kind.as_str()))
|
||||
{
|
||||
return std::result::Result::Err(ks_core::Error::new(
|
||||
"metaplex_devnet_execution_matrix_family_simulation_evidence_incomplete",
|
||||
"Simulated Metaplex Devnet family evidence must contain every required simulation evidence kind",
|
||||
));
|
||||
}
|
||||
if operation.network_status == "confirmed"
|
||||
&& operation
|
||||
.required_evidence
|
||||
.iter()
|
||||
.chain(operation.submission_evidence.iter())
|
||||
.any(|kind| return !family_kinds.contains(kind.as_str()))
|
||||
{
|
||||
return std::result::Result::Err(ks_core::Error::new(
|
||||
"metaplex_devnet_execution_matrix_family_confirmation_evidence_incomplete",
|
||||
"Confirmed Metaplex Devnet family evidence must contain every simulation and submission evidence kind",
|
||||
));
|
||||
}
|
||||
}
|
||||
match operation.network_status.as_str() {
|
||||
"not_run" => {
|
||||
if !operation.observed_families.is_empty()
|
||||
|| !operation.evidence.is_empty()
|
||||
|| !operation.family_evidence.is_empty()
|
||||
{
|
||||
return std::result::Result::Err(ks_core::Error::new(
|
||||
"metaplex_devnet_execution_matrix_not_run_with_evidence",
|
||||
"Not-run Metaplex Devnet operations cannot claim families or evidence",
|
||||
));
|
||||
}
|
||||
},
|
||||
"simulated" | "confirmed" => {
|
||||
if operation.observed_families.is_empty()
|
||||
|| !operation.evidence.is_empty()
|
||||
|| observed_family_evidence != observed_families
|
||||
{
|
||||
return std::result::Result::Err(ks_core::Error::new(
|
||||
"metaplex_devnet_execution_matrix_family_evidence_mismatch",
|
||||
"Simulated or confirmed Metaplex Devnet operations require exactly one complete evidence bundle per observed family and no aggregate operation evidence",
|
||||
));
|
||||
}
|
||||
},
|
||||
"unavailable" => {
|
||||
if !operation.observed_families.is_empty()
|
||||
|| !operation.family_evidence.is_empty()
|
||||
|| !observed_evidence.contains("unavailable_reason")
|
||||
{
|
||||
return std::result::Result::Err(ks_core::Error::new(
|
||||
"metaplex_devnet_execution_matrix_unavailable_without_reason",
|
||||
"Unavailable Metaplex Devnet operations require an unavailable_reason and no observed family evidence",
|
||||
));
|
||||
}
|
||||
},
|
||||
_ => {},
|
||||
}
|
||||
}
|
||||
return std::result::Result::Ok(());
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod devnet_execution_matrix_tests {
|
||||
#[test]
|
||||
fn devnet_execution_matrix_is_reopened_for_pre_013_and_conservative() {
|
||||
let result = crate::load_metaplex_token_metadata_devnet_execution_matrix();
|
||||
assert!(result.is_ok());
|
||||
let matrix = if let std::result::Result::Ok(value) = result {
|
||||
value
|
||||
} else {
|
||||
return;
|
||||
};
|
||||
assert_eq!(matrix.matrix_version, 3);
|
||||
assert_eq!(matrix.operations.len(), 20);
|
||||
assert!(
|
||||
matrix
|
||||
.operations
|
||||
.iter()
|
||||
.all(|operation| return operation.campaign_prerelease == "0.4.8-pre.013")
|
||||
);
|
||||
assert!(matrix.operations.iter().all(|operation| return !operation.deprecated));
|
||||
assert!(
|
||||
matrix
|
||||
.operations
|
||||
.iter()
|
||||
.all(|operation| return operation.runner_status == "implemented")
|
||||
);
|
||||
let confirmed = matrix
|
||||
.operations
|
||||
.iter()
|
||||
.filter(|operation| return operation.network_status == "confirmed")
|
||||
.collect::<std::vec::Vec<_>>();
|
||||
assert_eq!(confirmed.len(), 15);
|
||||
assert_eq!(
|
||||
confirmed
|
||||
.iter()
|
||||
.map(|operation| return operation.operation_code.as_str())
|
||||
.collect::<std::collections::BTreeSet<_>>(),
|
||||
[
|
||||
"metadata.metaplex_token_metadata.burn",
|
||||
"metadata.metaplex_token_metadata.close_escrow_account",
|
||||
"metadata.metaplex_token_metadata.create",
|
||||
"metadata.metaplex_token_metadata.create_escrow_account",
|
||||
"metadata.metaplex_token_metadata.delegate",
|
||||
"metadata.metaplex_token_metadata.lock",
|
||||
"metadata.metaplex_token_metadata.mint",
|
||||
"metadata.metaplex_token_metadata.print",
|
||||
"metadata.metaplex_token_metadata.revoke",
|
||||
"metadata.metaplex_token_metadata.transfer",
|
||||
"metadata.metaplex_token_metadata.transfer_out_of_escrow",
|
||||
"metadata.metaplex_token_metadata.unlock",
|
||||
"metadata.metaplex_token_metadata.update",
|
||||
"metadata.metaplex_token_metadata.verify",
|
||||
"metadata.metaplex_token_metadata.unverify",
|
||||
]
|
||||
.into_iter()
|
||||
.collect::<std::collections::BTreeSet<_>>()
|
||||
);
|
||||
let create_mint_families = vec![
|
||||
crate::MetaplexTokenMetadataAssetFamily::Nft,
|
||||
crate::MetaplexTokenMetadataAssetFamily::Sft,
|
||||
crate::MetaplexTokenMetadataAssetFamily::Fungible,
|
||||
crate::MetaplexTokenMetadataAssetFamily::Collection,
|
||||
crate::MetaplexTokenMetadataAssetFamily::ProgrammableNft,
|
||||
];
|
||||
for operation in &confirmed {
|
||||
let expected_families = if matches!(
|
||||
operation.operation_code.as_str(),
|
||||
"metadata.metaplex_token_metadata.create" | "metadata.metaplex_token_metadata.mint"
|
||||
) {
|
||||
create_mint_families.as_slice()
|
||||
} else if matches!(
|
||||
operation.operation_code.as_str(),
|
||||
"metadata.metaplex_token_metadata.print"
|
||||
| "metadata.metaplex_token_metadata.burn"
|
||||
| "metadata.metaplex_token_metadata.create_escrow_account"
|
||||
| "metadata.metaplex_token_metadata.close_escrow_account"
|
||||
| "metadata.metaplex_token_metadata.transfer_out_of_escrow"
|
||||
| "metadata.metaplex_token_metadata.update"
|
||||
) {
|
||||
&[crate::MetaplexTokenMetadataAssetFamily::Nft]
|
||||
} else if matches!(
|
||||
operation.operation_code.as_str(),
|
||||
"metadata.metaplex_token_metadata.delegate"
|
||||
| "metadata.metaplex_token_metadata.revoke"
|
||||
| "metadata.metaplex_token_metadata.lock"
|
||||
| "metadata.metaplex_token_metadata.unlock"
|
||||
| "metadata.metaplex_token_metadata.transfer"
|
||||
) {
|
||||
&[crate::MetaplexTokenMetadataAssetFamily::ProgrammableNft]
|
||||
} else {
|
||||
&[crate::MetaplexTokenMetadataAssetFamily::Collection]
|
||||
};
|
||||
assert_eq!(operation.observed_families.as_slice(), expected_families);
|
||||
assert_eq!(
|
||||
operation
|
||||
.family_evidence
|
||||
.iter()
|
||||
.map(|family_evidence| return family_evidence.family)
|
||||
.collect::<std::collections::BTreeSet<_>>(),
|
||||
operation
|
||||
.observed_families
|
||||
.iter()
|
||||
.copied()
|
||||
.collect::<std::collections::BTreeSet<_>>()
|
||||
);
|
||||
}
|
||||
assert_eq!(
|
||||
matrix
|
||||
.operations
|
||||
.iter()
|
||||
.filter(|operation| return operation.network_status == "not_run")
|
||||
.count(),
|
||||
0
|
||||
);
|
||||
let unavailable = matrix
|
||||
.operations
|
||||
.iter()
|
||||
.filter(|operation| return operation.network_status == "unavailable")
|
||||
.collect::<std::vec::Vec<_>>();
|
||||
assert_eq!(unavailable.len(), 5);
|
||||
let use_operation = unavailable.iter().find(|operation| {
|
||||
return operation.operation_code == "metadata.metaplex_token_metadata.use";
|
||||
});
|
||||
assert!(use_operation.is_some());
|
||||
if let std::option::Option::Some(operation) = use_operation {
|
||||
assert!(operation.evidence.iter().any(|evidence| {
|
||||
return evidence.kind == "unavailable_reason"
|
||||
&& evidence.value.contains("InvalidInstructionData");
|
||||
}));
|
||||
}
|
||||
let resize_operation = unavailable.iter().find(|operation| {
|
||||
return operation.operation_code == "metadata.metaplex_token_metadata.resize";
|
||||
});
|
||||
assert!(resize_operation.is_some());
|
||||
if let std::option::Option::Some(operation) = resize_operation {
|
||||
assert!(operation.evidence.iter().any(|evidence| {
|
||||
return evidence.kind == "unavailable_reason"
|
||||
&& evidence.value.contains("AlreadyResized")
|
||||
&& evidence.value.contains("201");
|
||||
}));
|
||||
}
|
||||
for (operation_code, expected_fragment) in [
|
||||
("metadata.metaplex_token_metadata.migrate", "Removed"),
|
||||
("metadata.metaplex_token_metadata.collect", "fee authority"),
|
||||
("metadata.metaplex_token_metadata.close_accounts", "close authority"),
|
||||
] {
|
||||
let operation = unavailable
|
||||
.iter()
|
||||
.find(|operation| return operation.operation_code == operation_code);
|
||||
assert!(operation.is_some());
|
||||
if let std::option::Option::Some(operation) = operation {
|
||||
assert!(operation.evidence.iter().any(|evidence| {
|
||||
return evidence.kind == "unavailable_reason"
|
||||
&& evidence.value.contains(expected_fragment);
|
||||
}));
|
||||
}
|
||||
}
|
||||
let update_operation = confirmed.iter().find(|operation| {
|
||||
return operation.operation_code == "metadata.metaplex_token_metadata.update";
|
||||
});
|
||||
assert!(update_operation.is_some());
|
||||
if let std::option::Option::Some(operation) = update_operation {
|
||||
assert_eq!(
|
||||
operation.observed_families.as_slice(),
|
||||
&[crate::MetaplexTokenMetadataAssetFamily::Nft]
|
||||
);
|
||||
}
|
||||
let mut incomplete = matrix.clone();
|
||||
let create = incomplete.operations.iter_mut().find(|operation| {
|
||||
return operation.operation_code == "metadata.metaplex_token_metadata.create";
|
||||
});
|
||||
assert!(create.is_some());
|
||||
if let std::option::Option::Some(operation) = create {
|
||||
if let std::option::Option::Some(family_evidence) =
|
||||
operation.family_evidence.first_mut()
|
||||
{
|
||||
family_evidence.evidence.clear();
|
||||
}
|
||||
}
|
||||
assert!(
|
||||
crate::validate_metaplex_token_metadata_devnet_execution_matrix(&incomplete).is_err()
|
||||
);
|
||||
}
|
||||
}
|
||||
10
ks-pipeline-demo-scenarios/src/metadata/solana_program.rs
Normal file
10
ks-pipeline-demo-scenarios/src/metadata/solana_program.rs
Normal file
@@ -0,0 +1,10 @@
|
||||
// file: ks-pipeline-demo-scenarios/src/metadata/solana_program.rs
|
||||
// version: 2
|
||||
|
||||
//! Solana Program Metadata demo and Devnet validation scenarios.
|
||||
|
||||
pub(crate) mod campaign;
|
||||
pub(crate) mod devnet_execution;
|
||||
pub(crate) mod fixture;
|
||||
pub(crate) mod scenarios;
|
||||
pub(crate) mod validation;
|
||||
@@ -0,0 +1,144 @@
|
||||
// file: ks-pipeline-demo-scenarios/src/metadata/solana_program/campaign.rs
|
||||
// version: 4
|
||||
|
||||
//! Ordered confirmed Devnet campaign for all Solana Program Metadata operations.
|
||||
|
||||
/// One confirmed step retained by the complete Devnet campaign.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct DevnetSolanaProgramMetadataCampaignStepSummary {
|
||||
/// Stable scenario identifier.
|
||||
pub scenario_id: std::string::String,
|
||||
/// Stable scenario-step identifier.
|
||||
pub step_id: std::string::String,
|
||||
/// Zero-based position inside the scenario.
|
||||
pub step_index: usize,
|
||||
/// Complete simulation, submission and stateful evidence.
|
||||
pub execution: crate::DevnetSolanaProgramMetadataExecutionSummary,
|
||||
}
|
||||
|
||||
/// Complete evidence retained by the two-journey Devnet campaign.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct DevnetSolanaProgramMetadataCampaignSummary {
|
||||
/// Fresh pre-funded fixture used by the campaign.
|
||||
pub fixture: crate::SolanaProgramMetadataFixturePreparationSummary,
|
||||
/// Nine confirmed steps in deterministic journey order.
|
||||
pub steps: std::vec::Vec<crate::DevnetSolanaProgramMetadataCampaignStepSummary>,
|
||||
}
|
||||
|
||||
/// Prepares fresh accounts and executes the nine stable operations on Devnet.
|
||||
///
|
||||
/// The campaign always submits transactions. The caller must explicitly confirm
|
||||
/// both fixture prefunding and all subsequent state mutations.
|
||||
pub async fn execute_devnet_solana_program_metadata_campaign<O>(
|
||||
http_pool: &ks_onchain_transport::HttpEndpointPool,
|
||||
profile: &ks_config::ProfileConfig,
|
||||
workspace_root: &std::path::Path,
|
||||
options: &crate::SolanaProgramMetadataFixturePreparationOptions,
|
||||
observer: &O,
|
||||
) -> ks_core::Result<crate::DevnetSolanaProgramMetadataCampaignSummary>
|
||||
where
|
||||
O: crate::SolanaExecutionObserver,
|
||||
{
|
||||
if !options.operator_confirmed {
|
||||
return std::result::Result::Err(ks_core::Error::config(
|
||||
"Solana Program Metadata Devnet campaign requires explicit operator confirmation",
|
||||
));
|
||||
}
|
||||
let fixture = match crate::prepare_solana_program_metadata_fixture(
|
||||
http_pool,
|
||||
profile,
|
||||
workspace_root,
|
||||
options,
|
||||
)
|
||||
.await
|
||||
{
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let mut steps = std::vec::Vec::with_capacity(fixture.prepared_steps.len());
|
||||
for prepared in &fixture.prepared_steps {
|
||||
let mut request = prepared
|
||||
.execution_request(format!("spm-devnet-{}-{}", prepared.scenario_id, prepared.step_id));
|
||||
request.query_role = options.query_role.clone();
|
||||
request.transaction_role = options.transaction_role.clone();
|
||||
request.submit = true;
|
||||
request.operator_confirmed = true;
|
||||
let execution = match crate::execute_devnet_solana_program_metadata(
|
||||
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),
|
||||
};
|
||||
let confirmed = match execution.confirmation.as_ref() {
|
||||
std::option::Option::Some(value) => matches!(
|
||||
value.status,
|
||||
ks_lib::ExApiExecutionConfirmationStatus::Confirmed
|
||||
| ks_lib::ExApiExecutionConfirmationStatus::Finalized
|
||||
),
|
||||
std::option::Option::None => false,
|
||||
};
|
||||
if !confirmed || execution.post_execution.is_none() {
|
||||
return std::result::Result::Err(ks_core::Error::new(
|
||||
"solana_program_metadata_campaign_step_not_confirmed",
|
||||
format!(
|
||||
"campaign step {} did not reach confirmed stateful completion",
|
||||
prepared.step_id
|
||||
),
|
||||
));
|
||||
}
|
||||
if prepared.operation.operation_code() != ks_lib::EX_METADATA_SPM_CLOSE_OPERATION
|
||||
&& execution.materialized_snapshots.is_empty()
|
||||
{
|
||||
return std::result::Result::Err(ks_core::Error::new(
|
||||
"solana_program_metadata_campaign_snapshot_missing",
|
||||
format!(
|
||||
"campaign step {} did not produce its required materialized snapshot",
|
||||
prepared.step_id
|
||||
),
|
||||
));
|
||||
}
|
||||
steps.push(crate::DevnetSolanaProgramMetadataCampaignStepSummary {
|
||||
scenario_id: prepared.scenario_id.clone(),
|
||||
step_id: prepared.step_id.clone(),
|
||||
step_index: prepared.step_index,
|
||||
execution,
|
||||
});
|
||||
}
|
||||
return std::result::Result::Ok(crate::DevnetSolanaProgramMetadataCampaignSummary {
|
||||
fixture,
|
||||
steps,
|
||||
});
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#[test]
|
||||
fn campaign_contract_requires_exactly_nine_ordered_steps() {
|
||||
let scenarios = crate::solana_program_metadata_devnet_scenarios();
|
||||
let step_ids = scenarios
|
||||
.iter()
|
||||
.flat_map(|scenario| return scenario.steps.iter())
|
||||
.map(|step| return step.id.as_str())
|
||||
.collect::<std::vec::Vec<_>>();
|
||||
assert_eq!(
|
||||
step_ids,
|
||||
vec![
|
||||
"spm_buffer_allocate",
|
||||
"spm_buffer_extend",
|
||||
"spm_buffer_write",
|
||||
"spm_buffer_set_authority",
|
||||
"spm_buffer_trim",
|
||||
"spm_buffer_close",
|
||||
"spm_metadata_initialize",
|
||||
"spm_metadata_set_data",
|
||||
"spm_metadata_set_immutable",
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,715 @@
|
||||
// file: ks-pipeline-demo-scenarios/src/metadata/solana_program/devnet_execution.rs
|
||||
// version: 3
|
||||
|
||||
//! Real Devnet simulation and controlled submission for Solana Program Metadata.
|
||||
|
||||
/// Complete request for one Solana Program Metadata Devnet execution.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct DevnetSolanaProgramMetadataExecutionRequest {
|
||||
/// 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 stable Solana Program Metadata operation.
|
||||
pub operation: ks_lib::ExMetadataSpmOperation,
|
||||
/// Bounded state reads required before execution.
|
||||
pub preflight_reads: std::vec::Vec<ks_pipeline::SolanaProgramMetadataStatefulReadRequest>,
|
||||
/// Explicit rent observations required by allocation and growth operations.
|
||||
pub rent_observations: std::vec::Vec<ks_pipeline::SolanaProgramMetadataRentObservation>,
|
||||
/// Bounded state reads repeated after confirmed submission.
|
||||
pub postcondition_reads: std::vec::Vec<ks_pipeline::SolanaProgramMetadataStatefulReadRequest>,
|
||||
/// Explicit approval for overwrite, destructive or irreversible operations.
|
||||
pub allow_destructive_operation: bool,
|
||||
/// Explicitly authorizes signing and submission after successful simulation.
|
||||
pub submit: bool,
|
||||
/// Explicit operator confirmation required for submission.
|
||||
pub operator_confirmed: bool,
|
||||
/// Materializes confirmed account snapshots after submission.
|
||||
pub materialize_after_confirmation: bool,
|
||||
}
|
||||
|
||||
impl crate::DevnetSolanaProgramMetadataExecutionRequest {
|
||||
/// Creates a conservative simulation-only request.
|
||||
pub fn new(
|
||||
intent_id: impl std::convert::Into<std::string::String>,
|
||||
operation: ks_lib::ExMetadataSpmOperation,
|
||||
) -> Self {
|
||||
return Self {
|
||||
intent_id: intent_id.into(),
|
||||
query_role: "http_queries".to_string(),
|
||||
transaction_role: "http_transactions".to_string(),
|
||||
operation,
|
||||
preflight_reads: std::vec::Vec::new(),
|
||||
rent_observations: std::vec::Vec::new(),
|
||||
postcondition_reads: std::vec::Vec::new(),
|
||||
allow_destructive_operation: false,
|
||||
submit: false,
|
||||
operator_confirmed: false,
|
||||
materialize_after_confirmation: false,
|
||||
};
|
||||
}
|
||||
|
||||
/// Creates one conservative request from serialized typed operation JSON.
|
||||
pub fn from_operation_json(
|
||||
intent_id: impl std::convert::Into<std::string::String>,
|
||||
operation_json: &str,
|
||||
) -> ks_core::Result<Self> {
|
||||
if operation_json.contains('<') || operation_json.trim() == "..." {
|
||||
return std::result::Result::Err(ks_core::Error::config(
|
||||
"Solana Program Metadata operation JSON still contains a placeholder",
|
||||
));
|
||||
}
|
||||
let operation = match serde_json::from_str::<ks_lib::ExMetadataSpmOperation>(operation_json)
|
||||
{
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => {
|
||||
return std::result::Result::Err(ks_core::Error::config(format!(
|
||||
"invalid typed Solana Program Metadata operation JSON: {error}"
|
||||
)));
|
||||
},
|
||||
};
|
||||
return std::result::Result::Ok(Self::new(intent_id, operation));
|
||||
}
|
||||
|
||||
/// Validates request-local bounds independently from one profile.
|
||||
pub fn validate(&self) -> ks_core::Result<()> {
|
||||
if self.intent_id.trim().is_empty() {
|
||||
return std::result::Result::Err(ks_core::Error::config(
|
||||
"Solana Program Metadata Devnet intent id must not be empty",
|
||||
));
|
||||
}
|
||||
if self.query_role.trim().is_empty() || self.transaction_role.trim().is_empty() {
|
||||
return std::result::Result::Err(ks_core::Error::config(
|
||||
"Solana Program Metadata endpoint roles must not be empty",
|
||||
));
|
||||
}
|
||||
if self.preflight_reads.is_empty()
|
||||
|| self.preflight_reads.len()
|
||||
> ks_pipeline::MAX_SOLANA_PROGRAM_METADATA_PREFLIGHT_ACCOUNTS
|
||||
|| self.postcondition_reads.len()
|
||||
> ks_pipeline::MAX_SOLANA_PROGRAM_METADATA_PREFLIGHT_ACCOUNTS
|
||||
{
|
||||
return std::result::Result::Err(ks_core::Error::config(
|
||||
"Solana Program Metadata stateful read inventory is empty or above the compiled bound",
|
||||
));
|
||||
}
|
||||
if self.operation.requires_explicit_approval() && !self.allow_destructive_operation {
|
||||
return std::result::Result::Err(ks_core::Error::new(
|
||||
"execution_metadata_spm_destructive_approval_required",
|
||||
"destructive Solana Program Metadata execution requires explicit approval",
|
||||
));
|
||||
}
|
||||
if self.submit && self.postcondition_reads.is_empty() {
|
||||
return std::result::Result::Err(ks_core::Error::config(
|
||||
"submitted Solana Program Metadata execution requires postcondition reads",
|
||||
));
|
||||
}
|
||||
if self.materialize_after_confirmation && self.postcondition_reads.is_empty() {
|
||||
return std::result::Result::Err(ks_core::Error::config(
|
||||
"Solana Program Metadata materialization requires postcondition reads",
|
||||
));
|
||||
}
|
||||
return std::result::Result::Ok(());
|
||||
}
|
||||
}
|
||||
|
||||
/// Complete evidence produced by one Solana Program Metadata Devnet execution.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct DevnetSolanaProgramMetadataExecutionSummary {
|
||||
/// Profile used by the orchestration.
|
||||
pub profile_name: std::string::String,
|
||||
/// Exact classified cluster.
|
||||
pub cluster: ks_lib::ExApiExecutionCluster,
|
||||
/// Genesis hash returned by the selected endpoint.
|
||||
pub genesis_hash: std::string::String,
|
||||
/// Non-secret persistent wallet description.
|
||||
pub wallet: ks_wallet::WalletSummary,
|
||||
/// Wallet balance observed before planning.
|
||||
pub balance_lamports: u64,
|
||||
/// Stateful snapshots observed before simulation.
|
||||
pub before: std::vec::Vec<ks_pipeline::SolanaProgramMetadataStatefulReadResult>,
|
||||
/// Stateful preflight report bound to the exact plan.
|
||||
pub stateful_preflight: ks_pipeline::SolanaProgramMetadataPreflightReport,
|
||||
/// Exact prepared plan.
|
||||
pub plan: ks_lib::ExApiPreparedExecutionPlan,
|
||||
/// Recent blockhash used by the exact transaction.
|
||||
pub latest_blockhash: ks_onchain_transport::LatestBlockhashResult,
|
||||
/// Fee estimate for the exact compiled message.
|
||||
pub fee: ks_onchain_transport::FeeForMessageResult,
|
||||
/// Exact real RPC simulation result.
|
||||
pub simulation: ks_lib::ExApiExecutionSimulationResult,
|
||||
/// Readiness report proving exact-message simulation and signer resolution.
|
||||
pub readiness: ks_pipeline::SolanaProgramMetadataExecutionReadinessReport,
|
||||
/// Submission result when explicitly authorized.
|
||||
pub send_result: std::option::Option<ks_lib::ExApiExecutionSendResult>,
|
||||
/// Confirmation result when submitted.
|
||||
pub confirmation: std::option::Option<ks_lib::ExApiExecutionConfirmationResult>,
|
||||
/// Stateful snapshots observed after confirmed submission.
|
||||
pub after: std::vec::Vec<ks_pipeline::SolanaProgramMetadataStatefulReadResult>,
|
||||
/// Stateful postcondition report after confirmed submission.
|
||||
pub post_execution: std::option::Option<ks_pipeline::SolanaProgramMetadataPostExecutionReport>,
|
||||
/// Canonical projections emitted from confirmed account snapshots.
|
||||
pub materialized_snapshots: std::vec::Vec<ks_lib::MtApiMaterializedOutput>,
|
||||
}
|
||||
|
||||
struct PreparedSolanaProgramMetadataExecution {
|
||||
wallet: ks_wallet::TemporaryWallet,
|
||||
unsigned: ks_lib::ExSolanaUnsignedTransaction,
|
||||
evidence: ks_lib::ExSolanaSimulationEvidence,
|
||||
summary: crate::DevnetSolanaProgramMetadataExecutionSummary,
|
||||
}
|
||||
|
||||
/// Simulates one stable Solana Program Metadata operation against Devnet.
|
||||
pub async fn simulate_devnet_solana_program_metadata<O>(
|
||||
http_pool: &ks_onchain_transport::HttpEndpointPool,
|
||||
profile: &ks_config::ProfileConfig,
|
||||
workspace_root: &std::path::Path,
|
||||
request: &crate::DevnetSolanaProgramMetadataExecutionRequest,
|
||||
observer: &O,
|
||||
) -> ks_core::Result<crate::DevnetSolanaProgramMetadataExecutionSummary>
|
||||
where
|
||||
O: crate::SolanaExecutionObserver,
|
||||
{
|
||||
if request.submit {
|
||||
return std::result::Result::Err(ks_core::Error::config(
|
||||
"simulate_devnet_solana_program_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 Solana Program Metadata simulation or authorized submission.
|
||||
pub async fn execute_devnet_solana_program_metadata<O>(
|
||||
http_pool: &ks_onchain_transport::HttpEndpointPool,
|
||||
profile: &ks_config::ProfileConfig,
|
||||
workspace_root: &std::path::Path,
|
||||
request: &crate::DevnetSolanaProgramMetadataExecutionRequest,
|
||||
observer: &O,
|
||||
) -> ks_core::Result<crate::DevnetSolanaProgramMetadataExecutionSummary>
|
||||
where
|
||||
O: crate::SolanaExecutionObserver,
|
||||
{
|
||||
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),
|
||||
};
|
||||
if !request.submit {
|
||||
return std::result::Result::Ok(prepared.summary);
|
||||
}
|
||||
if !prepared.summary.simulation.success {
|
||||
return std::result::Result::Err(ks_core::Error::new(
|
||||
"execution_simulation_failed",
|
||||
crate::simulation_failure_message(&prepared.summary.simulation),
|
||||
));
|
||||
}
|
||||
if let std::result::Result::Err(error) = validate_profile_wallet_signers(
|
||||
prepared.unsigned.required_signer_pubkeys(),
|
||||
prepared.summary.wallet.public_key.as_str(),
|
||||
) {
|
||||
return std::result::Result::Err(error);
|
||||
}
|
||||
let send_evaluation = match ks_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 == ks_lib::ExSafetyDecision::Deny {
|
||||
return std::result::Result::Err(ks_core::Error::new(
|
||||
"execution_send_denied",
|
||||
crate::violation_message(send_evaluation.violations.as_slice()),
|
||||
));
|
||||
}
|
||||
let signed = match prepared
|
||||
.unsigned
|
||||
.sign_after_simulation(&prepared.evidence, &[prepared.wallet.as_signer()])
|
||||
{
|
||||
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 send_config = match ks_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) => return std::result::Result::Err(error),
|
||||
};
|
||||
summary.send_result =
|
||||
std::option::Option::Some(sent.to_execution_result(ks_lib::ExApiExecutionCluster::Devnet));
|
||||
let confirmation_config =
|
||||
match ks_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(),
|
||||
ks_lib::ExApiExecutionCluster::Devnet,
|
||||
&signature,
|
||||
&confirmation_config,
|
||||
)
|
||||
.await
|
||||
{
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let confirmed = matches!(
|
||||
confirmation.status,
|
||||
ks_lib::ExApiExecutionConfirmationStatus::Confirmed
|
||||
| ks_lib::ExApiExecutionConfirmationStatus::Finalized
|
||||
);
|
||||
summary.confirmation = std::option::Option::Some(confirmation);
|
||||
if confirmed {
|
||||
summary.after = match read_snapshots(http_pool, &request.postcondition_reads).await {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let report = match ks_pipeline::inspect_solana_program_metadata_post_execution(
|
||||
&ks_pipeline::SolanaProgramMetadataPostExecutionRequest {
|
||||
operation: request.operation.clone(),
|
||||
before: summary.before.clone(),
|
||||
after: summary.after.clone(),
|
||||
},
|
||||
) {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
if report.status == ks_pipeline::SolanaProgramMetadataPostconditionStatus::Contradicted {
|
||||
return std::result::Result::Err(ks_core::Error::new(
|
||||
"execution_metadata_spm_postcondition_contradicted",
|
||||
"confirmed Solana Program Metadata transaction contradicted its stateful postcondition",
|
||||
));
|
||||
}
|
||||
summary.post_execution = std::option::Option::Some(report);
|
||||
if request.materialize_after_confirmation {
|
||||
summary.materialized_snapshots =
|
||||
materialize_confirmed_snapshots(summary.after.as_slice());
|
||||
}
|
||||
}
|
||||
return std::result::Result::Ok(summary);
|
||||
}
|
||||
|
||||
async fn prepare_execution<O>(
|
||||
http_pool: &ks_onchain_transport::HttpEndpointPool,
|
||||
profile: &ks_config::ProfileConfig,
|
||||
workspace_root: &std::path::Path,
|
||||
request: &crate::DevnetSolanaProgramMetadataExecutionRequest,
|
||||
observer: &O,
|
||||
) -> ks_core::Result<PreparedSolanaProgramMetadataExecution>
|
||||
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, "solana_program_metadata_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(ks_lib::ExApiExecutionCluster::Devnet)
|
||||
{
|
||||
return std::result::Result::Err(ks_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 = ks_lib::MdPubkey(wallet_summary.public_key.clone());
|
||||
let balance = match http_pool
|
||||
.get_balance_for_role(
|
||||
request.query_role.as_str(),
|
||||
&fee_payer,
|
||||
&ks_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(ks_core::Error::new(
|
||||
"execution_balance_insufficient",
|
||||
"Devnet wallet balance is below the configured fee ceiling",
|
||||
));
|
||||
}
|
||||
let intent = build_intent(profile, request, fee_payer.clone());
|
||||
let plan = match ks_lib::ExApiTypedInstructionExecutor::build_prepared_plan(
|
||||
&ks_lib::ExMetadataSolanaProgramMetadataExecutor,
|
||||
&intent,
|
||||
) {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let plan_evaluation = match ks_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 == ks_lib::ExSafetyDecision::Deny {
|
||||
return std::result::Result::Err(ks_core::Error::new(
|
||||
"execution_metadata_spm_plan_denied",
|
||||
crate::violation_message(plan_evaluation.violations.as_slice()),
|
||||
));
|
||||
}
|
||||
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 ks_pipeline::inspect_solana_program_metadata_preflight(
|
||||
&ks_pipeline::SolanaProgramMetadataPreflightRequest {
|
||||
intent: intent.clone(),
|
||||
plan: plan.clone(),
|
||||
before: before.clone(),
|
||||
rent_observations: request.rent_observations.clone(),
|
||||
},
|
||||
) {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let latest_blockhash = match http_pool
|
||||
.get_latest_blockhash_for_role(
|
||||
request.query_role.as_str(),
|
||||
&ks_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 ks_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),
|
||||
};
|
||||
if let std::result::Result::Err(error) = validate_profile_wallet_signers(
|
||||
unsigned.required_signer_pubkeys(),
|
||||
wallet_summary.public_key.as_str(),
|
||||
) {
|
||||
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(),
|
||||
&ks_onchain_transport::GetFeeForMessageConfig::new(
|
||||
ks_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 ks_onchain_transport::SimulateTransactionConfig::new(
|
||||
ks_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,
|
||||
"solana_program_metadata_simulation",
|
||||
format!("simulating exact Solana Program Metadata 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(
|
||||
ks_lib::ExApiExecutionCluster::Devnet,
|
||||
ks_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 ks_pipeline::validate_solana_program_metadata_execution_readiness(
|
||||
&ks_pipeline::SolanaProgramMetadataExecutionReadinessRequest {
|
||||
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: vec![fee_payer],
|
||||
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(ks_core::Error::new(
|
||||
error.code(),
|
||||
format!("{}; simulation={simulation_json}", error.message()),
|
||||
));
|
||||
},
|
||||
};
|
||||
let evidence = unsigned.bind_simulation(simulation.clone());
|
||||
return std::result::Result::Ok(PreparedSolanaProgramMetadataExecution {
|
||||
wallet,
|
||||
unsigned,
|
||||
evidence,
|
||||
summary: crate::DevnetSolanaProgramMetadataExecutionSummary {
|
||||
profile_name: profile.name.clone(),
|
||||
cluster: ks_lib::ExApiExecutionCluster::Devnet,
|
||||
genesis_hash: genesis.genesis_hash,
|
||||
wallet: wallet_summary,
|
||||
balance_lamports: balance.lamports,
|
||||
before,
|
||||
stateful_preflight,
|
||||
plan,
|
||||
latest_blockhash,
|
||||
fee,
|
||||
simulation,
|
||||
readiness,
|
||||
send_result: std::option::Option::None,
|
||||
confirmation: std::option::Option::None,
|
||||
after: std::vec::Vec::new(),
|
||||
post_execution: std::option::Option::None,
|
||||
materialized_snapshots: std::vec::Vec::new(),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
fn build_intent(
|
||||
profile: &ks_config::ProfileConfig,
|
||||
request: &crate::DevnetSolanaProgramMetadataExecutionRequest,
|
||||
fee_payer: ks_lib::MdPubkey,
|
||||
) -> ks_lib::ExMetadataSpmExecutionIntent {
|
||||
return ks_lib::ExMetadataSpmExecutionIntent {
|
||||
intent_id: request.intent_id.clone(),
|
||||
fee_payer: fee_payer.clone(),
|
||||
policy: ks_lib::ExApiExecutionPolicy {
|
||||
cluster: ks_lib::ExApiExecutionClusterPolicy {
|
||||
expected_cluster: ks_lib::ExApiExecutionCluster::Devnet,
|
||||
allow_mainnet: false,
|
||||
mainnet_confirmation: false,
|
||||
},
|
||||
simulation: ks_lib::ExApiExecutionSimulationPolicy::Required,
|
||||
blockhash: ks_lib::ExApiExecutionBlockhashPolicy {
|
||||
kind: ks_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: ks_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: vec![fee_payer],
|
||||
dry_run: !request.submit,
|
||||
post_execution_validation: ks_lib::ExApiPostExecutionValidationPolicy {
|
||||
canonical_insert_required: true,
|
||||
core_extraction_required: true,
|
||||
decode_replay_required: true,
|
||||
materialization_required: true,
|
||||
},
|
||||
},
|
||||
allow_destructive_operation: request.allow_destructive_operation,
|
||||
operation: request.operation.clone(),
|
||||
};
|
||||
}
|
||||
|
||||
async fn read_snapshots(
|
||||
http_pool: &ks_onchain_transport::HttpEndpointPool,
|
||||
requests: &[ks_pipeline::SolanaProgramMetadataStatefulReadRequest],
|
||||
) -> ks_core::Result<std::vec::Vec<ks_pipeline::SolanaProgramMetadataStatefulReadResult>> {
|
||||
let mut results = std::vec::Vec::with_capacity(requests.len());
|
||||
for request in requests {
|
||||
let value =
|
||||
match ks_pipeline::read_solana_program_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 materialize_confirmed_snapshots(
|
||||
snapshots: &[ks_pipeline::SolanaProgramMetadataStatefulReadResult],
|
||||
) -> std::vec::Vec<ks_lib::MtApiMaterializedOutput> {
|
||||
return snapshots
|
||||
.iter()
|
||||
.filter_map(|result| return result.materialized_output.clone())
|
||||
.collect();
|
||||
}
|
||||
|
||||
fn validate_profile(
|
||||
profile: &ks_config::ProfileConfig,
|
||||
request: &crate::DevnetSolanaProgramMetadataExecutionRequest,
|
||||
) -> ks_core::Result<()> {
|
||||
if profile.wallet.cluster != "devnet" {
|
||||
return std::result::Result::Err(ks_core::Error::config(
|
||||
"Solana Program Metadata orchestration requires a Devnet wallet profile",
|
||||
));
|
||||
}
|
||||
if !profile.wallet.temporary_wallet_enabled || !profile.wallet.temporary_wallet_persist {
|
||||
return std::result::Result::Err(ks_core::Error::config(
|
||||
"Solana Program Metadata orchestration requires an enabled persistent temporary wallet",
|
||||
));
|
||||
}
|
||||
if !profile.execution.require_simulation {
|
||||
return std::result::Result::Err(ks_core::Error::config(
|
||||
"Solana Program Metadata orchestration requires simulation",
|
||||
));
|
||||
}
|
||||
if request.submit && !profile.wallet.devnet_send_enabled {
|
||||
return std::result::Result::Err(ks_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(ks_core::Error::config(
|
||||
"Solana Program Metadata submission requires explicit operator confirmation",
|
||||
));
|
||||
}
|
||||
return std::result::Result::Ok(());
|
||||
}
|
||||
|
||||
fn validate_profile_wallet_signers(
|
||||
required_signers: &[std::string::String],
|
||||
wallet_pubkey: &str,
|
||||
) -> ks_core::Result<()> {
|
||||
if required_signers.iter().any(|value| return value.as_str() != wallet_pubkey) {
|
||||
return std::result::Result::Err(ks_core::Error::new(
|
||||
"execution_metadata_spm_external_signer_unavailable",
|
||||
format!(
|
||||
"the Devnet Solana Program Metadata orchestrator can sign only with profile wallet {}; required signers are {}",
|
||||
wallet_pubkey,
|
||||
required_signers.join(",")
|
||||
),
|
||||
));
|
||||
}
|
||||
return std::result::Result::Ok(());
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
fn wallet() -> ks_lib::MdPubkey {
|
||||
return ks_lib::MdPubkey(solana_pubkey::Pubkey::new_from_array([7_u8; 32]).to_string());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn request_rejects_destructive_operations_without_approval() {
|
||||
let wallet = wallet();
|
||||
let mut request = crate::DevnetSolanaProgramMetadataExecutionRequest::new(
|
||||
"write",
|
||||
ks_lib::ExMetadataSpmOperation::Write {
|
||||
buffer: wallet.clone(),
|
||||
authority: wallet,
|
||||
offset: 0,
|
||||
source: ks_lib::ExMetadataSpmWriteSource::Inline { data: vec![1] },
|
||||
},
|
||||
);
|
||||
request
|
||||
.preflight_reads
|
||||
.push(ks_pipeline::SolanaProgramMetadataStatefulReadRequest {
|
||||
query_role: "http_queries".to_string(),
|
||||
account: request.operation_target_for_test(),
|
||||
expected_state: ks_pipeline::SolanaProgramMetadataExpectedAccountState::Buffer,
|
||||
min_context_slot: std::option::Option::None,
|
||||
max_data_bytes: ks_pipeline::MAX_SOLANA_PROGRAM_METADATA_STATEFUL_ACCOUNT_BYTES,
|
||||
});
|
||||
assert!(request.validate().is_err());
|
||||
request.allow_destructive_operation = true;
|
||||
assert!(request.validate().is_ok());
|
||||
}
|
||||
|
||||
trait OperationTargetForTest {
|
||||
fn operation_target_for_test(&self) -> ks_lib::MdPubkey;
|
||||
}
|
||||
|
||||
impl OperationTargetForTest for crate::DevnetSolanaProgramMetadataExecutionRequest {
|
||||
fn operation_target_for_test(&self) -> ks_lib::MdPubkey {
|
||||
return match &self.operation {
|
||||
ks_lib::ExMetadataSpmOperation::Write { buffer, .. } => buffer.clone(),
|
||||
_ => ks_lib::MdPubkey(ks_program_ids::SYSTEM_PROGRAM_ID.to_string()),
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,962 @@
|
||||
// file: ks-pipeline-demo-scenarios/src/metadata/solana_program/fixture.rs
|
||||
// version: 7
|
||||
|
||||
//! Native Solana Program Metadata fixture preparation for Devnet journeys.
|
||||
|
||||
/// Bytes reserved for the public Buffer journey before `Trim`.
|
||||
pub const SOLANA_PROGRAM_METADATA_FIXTURE_BUFFER_DATA_BYTES: u16 = 128;
|
||||
|
||||
/// Options used to prepare one complete Solana Program Metadata fixture.
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub struct SolanaProgramMetadataFixturePreparationOptions {
|
||||
/// Endpoint role used for account, rent, balance, blockhash and fee calls.
|
||||
pub query_role: std::string::String,
|
||||
/// Endpoint role used for simulation, submission and confirmation.
|
||||
pub transaction_role: std::string::String,
|
||||
/// Explicit operator confirmation for the two prefunding transfers.
|
||||
pub operator_confirmed: bool,
|
||||
}
|
||||
|
||||
impl crate::SolanaProgramMetadataFixturePreparationOptions {
|
||||
/// Creates conservative defaults matching the Devnet execution profiles.
|
||||
pub fn new() -> Self {
|
||||
return Self {
|
||||
query_role: "http_queries".to_string(),
|
||||
transaction_role: "http_transactions".to_string(),
|
||||
operator_confirmed: false,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
impl std::default::Default for crate::SolanaProgramMetadataFixturePreparationOptions {
|
||||
fn default() -> Self {
|
||||
return Self::new();
|
||||
}
|
||||
}
|
||||
|
||||
/// One prepared executable step in a Solana Program Metadata journey.
|
||||
#[derive(Clone, Debug, PartialEq, serde::Deserialize, serde::Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct SolanaProgramMetadataPreparedStep {
|
||||
/// Stable scenario identifier.
|
||||
pub scenario_id: std::string::String,
|
||||
/// Stable scenario-step identifier.
|
||||
pub step_id: std::string::String,
|
||||
/// Zero-based step position inside the scenario.
|
||||
pub step_index: usize,
|
||||
/// Exact typed operation.
|
||||
pub operation: ks_lib::ExMetadataSpmOperation,
|
||||
/// Bounded state reads required before execution.
|
||||
pub preflight_reads: std::vec::Vec<ks_pipeline::SolanaProgramMetadataStatefulReadRequest>,
|
||||
/// Explicit rent observations required by the operation.
|
||||
pub rent_observations: std::vec::Vec<ks_pipeline::SolanaProgramMetadataRentObservation>,
|
||||
/// Bounded state reads required after confirmation.
|
||||
pub postcondition_reads: std::vec::Vec<ks_pipeline::SolanaProgramMetadataStatefulReadRequest>,
|
||||
/// Whether explicit destructive approval is required.
|
||||
pub allow_destructive_operation: bool,
|
||||
}
|
||||
|
||||
impl crate::SolanaProgramMetadataPreparedStep {
|
||||
/// Builds one simulation-only execution request from the prepared fixture step.
|
||||
pub fn execution_request(
|
||||
&self,
|
||||
intent_id: impl std::convert::Into<std::string::String>,
|
||||
) -> crate::DevnetSolanaProgramMetadataExecutionRequest {
|
||||
let mut request = crate::DevnetSolanaProgramMetadataExecutionRequest::new(
|
||||
intent_id,
|
||||
self.operation.clone(),
|
||||
);
|
||||
request.preflight_reads = self.preflight_reads.clone();
|
||||
request.rent_observations = self.rent_observations.clone();
|
||||
request.postcondition_reads = self.postcondition_reads.clone();
|
||||
request.allow_destructive_operation = self.allow_destructive_operation;
|
||||
request.materialize_after_confirmation = true;
|
||||
return request;
|
||||
}
|
||||
}
|
||||
|
||||
/// Public fixture values for the two Solana Program Metadata journeys.
|
||||
#[derive(Clone, Debug, PartialEq, serde::Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct SolanaProgramMetadataFixturePreparationSummary {
|
||||
/// Profile used by the fixture preparation.
|
||||
pub profile_name: std::string::String,
|
||||
/// Profile-wallet authority.
|
||||
pub authority: std::string::String,
|
||||
/// Executable program described by the non-canonical metadata PDA.
|
||||
pub described_program: std::string::String,
|
||||
/// Exact 16-byte Buffer seed as UTF-8 text.
|
||||
pub buffer_seed: std::string::String,
|
||||
/// Non-canonical Buffer PDA.
|
||||
pub buffer: std::string::String,
|
||||
/// Lamports transferred to pre-fund the Buffer PDA.
|
||||
pub buffer_prefund_lamports: u64,
|
||||
/// Confirmed Buffer prefunding signature.
|
||||
pub buffer_prefund_signature: std::string::String,
|
||||
/// Exact 16-byte Metadata seed as UTF-8 text.
|
||||
pub metadata_seed: std::string::String,
|
||||
/// Non-canonical Metadata PDA.
|
||||
pub metadata: std::string::String,
|
||||
/// Lamports transferred to pre-fund the Metadata PDA.
|
||||
pub metadata_prefund_lamports: u64,
|
||||
/// Confirmed Metadata prefunding signature.
|
||||
pub metadata_prefund_signature: std::string::String,
|
||||
/// Ordered executable steps for both journeys.
|
||||
pub prepared_steps: std::vec::Vec<crate::SolanaProgramMetadataPreparedStep>,
|
||||
}
|
||||
|
||||
/// Creates two unique pre-funded PDAs and the nine typed journey steps.
|
||||
pub async fn prepare_solana_program_metadata_fixture(
|
||||
http_pool: &ks_onchain_transport::HttpEndpointPool,
|
||||
profile: &ks_config::ProfileConfig,
|
||||
workspace_root: &std::path::Path,
|
||||
options: &crate::SolanaProgramMetadataFixturePreparationOptions,
|
||||
) -> ks_core::Result<crate::SolanaProgramMetadataFixturePreparationSummary> {
|
||||
if options.query_role.trim().is_empty() || options.transaction_role.trim().is_empty() {
|
||||
return std::result::Result::Err(ks_core::Error::config(
|
||||
"Solana Program Metadata fixture endpoint roles must not be empty",
|
||||
));
|
||||
}
|
||||
if profile.wallet.cluster != "devnet"
|
||||
|| !profile.wallet.devnet_send_enabled
|
||||
|| !profile.wallet.temporary_wallet_enabled
|
||||
|| !profile.wallet.temporary_wallet_persist
|
||||
{
|
||||
return std::result::Result::Err(ks_core::Error::config(
|
||||
"Solana Program Metadata fixture requires one persistent send-enabled Devnet profile",
|
||||
));
|
||||
}
|
||||
if !options.operator_confirmed {
|
||||
return std::result::Result::Err(ks_core::Error::config(
|
||||
"Solana Program Metadata fixture prefunding requires explicit operator confirmation",
|
||||
));
|
||||
}
|
||||
let genesis = match http_pool.get_genesis_hash_for_role(options.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(ks_lib::ExApiExecutionCluster::Devnet)
|
||||
{
|
||||
return std::result::Result::Err(ks_core::Error::new(
|
||||
"execution_cluster_mismatch",
|
||||
"Solana Program Metadata fixture preparation requires a Devnet endpoint",
|
||||
));
|
||||
}
|
||||
let operator = 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 authority = ks_lib::MdPubkey(operator.public_key());
|
||||
let described_program = ks_lib::MdPubkey(ks_program_ids::SYSTEM_PROGRAM_ID.to_string());
|
||||
let unique = uuid::Uuid::new_v4().simple().to_string();
|
||||
let buffer_seed = format!("buf{}", &unique[0..13]);
|
||||
let metadata_seed = format!("met{}", &unique[13..26]);
|
||||
let buffer = match ks_lib::executor_metadata_solana_program_metadata_derive_non_canonical_pda(
|
||||
&described_program,
|
||||
&authority,
|
||||
buffer_seed.as_bytes(),
|
||||
) {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let metadata = match ks_lib::executor_metadata_solana_program_metadata_derive_non_canonical_pda(
|
||||
&described_program,
|
||||
&authority,
|
||||
metadata_seed.as_bytes(),
|
||||
) {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
if let std::result::Result::Err(error) = ensure_fixture_addresses_are_missing(
|
||||
http_pool,
|
||||
options,
|
||||
&[buffer.clone(), metadata.clone()],
|
||||
)
|
||||
.await
|
||||
{
|
||||
return std::result::Result::Err(error);
|
||||
}
|
||||
let buffer_header = match u64::try_from(ks_lib::DC_METADATA_SPM_BUFFER_HEADER_BYTES) {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => {
|
||||
return std::result::Result::Err(ks_core::Error::config(error.to_string()));
|
||||
},
|
||||
};
|
||||
let metadata_header = match u64::try_from(ks_lib::DC_METADATA_SPM_METADATA_HEADER_BYTES) {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => {
|
||||
return std::result::Result::Err(ks_core::Error::config(error.to_string()));
|
||||
},
|
||||
};
|
||||
let buffer_target_space = buffer_header
|
||||
.saturating_add(u64::from(crate::SOLANA_PROGRAM_METADATA_FIXTURE_BUFFER_DATA_BYTES));
|
||||
let initial_metadata_bytes = br#"{"name":"Khadhroony Program Metadata","version":1}"#.to_vec();
|
||||
let updated_metadata_bytes =
|
||||
br#"{"name":"Khadhroony Program Metadata","version":2,"validated":true}"#.to_vec();
|
||||
let initial_metadata_data_len = match u64::try_from(initial_metadata_bytes.len()) {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => {
|
||||
return std::result::Result::Err(ks_core::Error::config(error.to_string()));
|
||||
},
|
||||
};
|
||||
let updated_metadata_data_len = match u64::try_from(updated_metadata_bytes.len()) {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => {
|
||||
return std::result::Result::Err(ks_core::Error::config(error.to_string()));
|
||||
},
|
||||
};
|
||||
let initial_metadata_space = metadata_header.saturating_add(initial_metadata_data_len);
|
||||
let updated_metadata_space = metadata_header.saturating_add(updated_metadata_data_len);
|
||||
let buffer_header_rent =
|
||||
match rent_for_space(http_pool, options.query_role.as_str(), buffer_header).await {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let buffer_target_rent =
|
||||
match rent_for_space(http_pool, options.query_role.as_str(), buffer_target_space).await {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let initial_metadata_rent = match rent_for_space(
|
||||
http_pool,
|
||||
options.query_role.as_str(),
|
||||
initial_metadata_space,
|
||||
)
|
||||
.await
|
||||
{
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let updated_metadata_rent = match rent_for_space(
|
||||
http_pool,
|
||||
options.query_role.as_str(),
|
||||
updated_metadata_space,
|
||||
)
|
||||
.await
|
||||
{
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let buffer_prefund_lamports = buffer_target_rent.max(buffer_header_rent);
|
||||
let metadata_prefund_lamports = updated_metadata_rent.max(initial_metadata_rent);
|
||||
let total =
|
||||
match buffer_prefund_lamports
|
||||
.checked_add(metadata_prefund_lamports)
|
||||
.and_then(|value| {
|
||||
return value.checked_add(
|
||||
profile
|
||||
.execution
|
||||
.max_fee_lamports
|
||||
.saturating_mul(crate::SOLANA_PROGRAM_METADATA_CAMPAIGN_TRANSACTION_COUNT),
|
||||
);
|
||||
}) {
|
||||
std::option::Option::Some(value) => value,
|
||||
std::option::Option::None => {
|
||||
return std::result::Result::Err(ks_core::Error::config(
|
||||
"Solana Program Metadata fixture funding requirement overflowed u64",
|
||||
));
|
||||
},
|
||||
};
|
||||
let balance = match http_pool
|
||||
.get_balance_for_role(
|
||||
options.query_role.as_str(),
|
||||
&authority,
|
||||
&ks_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 < total {
|
||||
return std::result::Result::Err(ks_core::Error::new(
|
||||
"solana_program_metadata_fixture_balance_insufficient",
|
||||
format!(
|
||||
"Devnet fixture wallet has {} lamports but requires at least {}; fund or airdrop the profile wallet first",
|
||||
balance.lamports, total
|
||||
),
|
||||
));
|
||||
}
|
||||
let buffer_prefund_signature = match submit_prefund_transfer(
|
||||
http_pool,
|
||||
profile,
|
||||
options,
|
||||
&operator,
|
||||
buffer.clone(),
|
||||
buffer_prefund_lamports,
|
||||
"buffer",
|
||||
)
|
||||
.await
|
||||
{
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let metadata_prefund_signature = match submit_prefund_transfer(
|
||||
http_pool,
|
||||
profile,
|
||||
options,
|
||||
&operator,
|
||||
metadata.clone(),
|
||||
metadata_prefund_lamports,
|
||||
"metadata",
|
||||
)
|
||||
.await
|
||||
{
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let prepared_steps = prepared_steps(
|
||||
options.query_role.as_str(),
|
||||
authority.clone(),
|
||||
described_program.clone(),
|
||||
buffer.clone(),
|
||||
buffer_seed.as_bytes(),
|
||||
metadata.clone(),
|
||||
metadata_seed.as_bytes(),
|
||||
initial_metadata_bytes,
|
||||
updated_metadata_bytes,
|
||||
buffer_prefund_lamports,
|
||||
buffer_header,
|
||||
buffer_header_rent,
|
||||
buffer_target_space,
|
||||
buffer_target_rent,
|
||||
metadata_prefund_lamports,
|
||||
initial_metadata_space,
|
||||
initial_metadata_rent,
|
||||
updated_metadata_space,
|
||||
updated_metadata_rent,
|
||||
);
|
||||
tracing::info!(
|
||||
target: crate::TRACING_TARGET,
|
||||
action = "prepare_solana_program_metadata_fixture",
|
||||
profile = profile.name.as_str(),
|
||||
authority = authority.0.as_str(),
|
||||
buffer = buffer.0.as_str(),
|
||||
metadata = metadata.0.as_str(),
|
||||
"prepared pre-funded Solana Program Metadata Devnet fixture"
|
||||
);
|
||||
return std::result::Result::Ok(crate::SolanaProgramMetadataFixturePreparationSummary {
|
||||
profile_name: profile.name.clone(),
|
||||
authority: authority.0,
|
||||
described_program: described_program.0,
|
||||
buffer_seed,
|
||||
buffer: buffer.0,
|
||||
buffer_prefund_lamports,
|
||||
buffer_prefund_signature,
|
||||
metadata_seed,
|
||||
metadata: metadata.0,
|
||||
metadata_prefund_lamports,
|
||||
metadata_prefund_signature,
|
||||
prepared_steps,
|
||||
});
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn prepared_steps(
|
||||
query_role: &str,
|
||||
authority: ks_lib::MdPubkey,
|
||||
described_program: ks_lib::MdPubkey,
|
||||
buffer: ks_lib::MdPubkey,
|
||||
buffer_seed: &[u8],
|
||||
metadata: ks_lib::MdPubkey,
|
||||
metadata_seed: &[u8],
|
||||
initial_metadata_bytes: std::vec::Vec<u8>,
|
||||
updated_metadata_bytes: std::vec::Vec<u8>,
|
||||
buffer_observed_lamports: u64,
|
||||
buffer_header_space: u64,
|
||||
buffer_header_rent: u64,
|
||||
buffer_target_space: u64,
|
||||
buffer_target_rent: u64,
|
||||
metadata_observed_lamports: u64,
|
||||
initial_metadata_space: u64,
|
||||
initial_metadata_rent: u64,
|
||||
updated_metadata_space: u64,
|
||||
updated_metadata_rent: u64,
|
||||
) -> std::vec::Vec<crate::SolanaProgramMetadataPreparedStep> {
|
||||
let context = ks_lib::ExMetadataSpmProgramContext {
|
||||
program: described_program.clone(),
|
||||
program_data: std::option::Option::None,
|
||||
};
|
||||
let buffer_vacant = read(
|
||||
query_role,
|
||||
buffer.clone(),
|
||||
ks_pipeline::SolanaProgramMetadataExpectedAccountState::Vacant,
|
||||
);
|
||||
let buffer_state = read(
|
||||
query_role,
|
||||
buffer.clone(),
|
||||
ks_pipeline::SolanaProgramMetadataExpectedAccountState::Buffer,
|
||||
);
|
||||
let buffer_missing = read(
|
||||
query_role,
|
||||
buffer.clone(),
|
||||
ks_pipeline::SolanaProgramMetadataExpectedAccountState::Missing,
|
||||
);
|
||||
let metadata_vacant = read(
|
||||
query_role,
|
||||
metadata.clone(),
|
||||
ks_pipeline::SolanaProgramMetadataExpectedAccountState::Vacant,
|
||||
);
|
||||
let metadata_state = read(
|
||||
query_role,
|
||||
metadata.clone(),
|
||||
ks_pipeline::SolanaProgramMetadataExpectedAccountState::Metadata,
|
||||
);
|
||||
return vec![
|
||||
prepared_step(
|
||||
"spm_buffer_lifecycle",
|
||||
"spm_buffer_allocate",
|
||||
0,
|
||||
ks_lib::ExMetadataSpmOperation::Allocate {
|
||||
buffer: buffer.clone(),
|
||||
authority: authority.clone(),
|
||||
seed: std::option::Option::Some(buffer_seed.to_vec()),
|
||||
program_context: std::option::Option::Some(context.clone()),
|
||||
canonical: false,
|
||||
allocate_account: true,
|
||||
},
|
||||
vec![buffer_vacant],
|
||||
vec![rent(
|
||||
buffer.clone(),
|
||||
buffer_header_space,
|
||||
buffer_observed_lamports,
|
||||
buffer_header_rent,
|
||||
)],
|
||||
vec![buffer_state.clone()],
|
||||
false,
|
||||
),
|
||||
prepared_step(
|
||||
"spm_buffer_lifecycle",
|
||||
"spm_buffer_extend",
|
||||
1,
|
||||
ks_lib::ExMetadataSpmOperation::Extend {
|
||||
account: buffer.clone(),
|
||||
authority: authority.clone(),
|
||||
length: crate::SOLANA_PROGRAM_METADATA_FIXTURE_BUFFER_DATA_BYTES,
|
||||
program_context: std::option::Option::None,
|
||||
},
|
||||
vec![buffer_state.clone()],
|
||||
vec![rent(
|
||||
buffer.clone(),
|
||||
buffer_target_space,
|
||||
buffer_observed_lamports,
|
||||
buffer_target_rent,
|
||||
)],
|
||||
vec![buffer_state.clone()],
|
||||
false,
|
||||
),
|
||||
prepared_step(
|
||||
"spm_buffer_lifecycle",
|
||||
"spm_buffer_write",
|
||||
2,
|
||||
ks_lib::ExMetadataSpmOperation::Write {
|
||||
buffer: buffer.clone(),
|
||||
authority: authority.clone(),
|
||||
offset: 0,
|
||||
source: ks_lib::ExMetadataSpmWriteSource::Inline {
|
||||
data: b"Khadhroony Solana Program Metadata Buffer fixture".to_vec(),
|
||||
},
|
||||
},
|
||||
vec![buffer_state.clone()],
|
||||
std::vec::Vec::new(),
|
||||
vec![buffer_state.clone()],
|
||||
true,
|
||||
),
|
||||
prepared_step(
|
||||
"spm_buffer_lifecycle",
|
||||
"spm_buffer_set_authority",
|
||||
3,
|
||||
ks_lib::ExMetadataSpmOperation::SetAuthority {
|
||||
account: buffer.clone(),
|
||||
authority: authority.clone(),
|
||||
new_authority: std::option::Option::Some(authority.clone()),
|
||||
program_context: std::option::Option::None,
|
||||
},
|
||||
vec![buffer_state.clone()],
|
||||
std::vec::Vec::new(),
|
||||
vec![buffer_state.clone()],
|
||||
true,
|
||||
),
|
||||
prepared_step(
|
||||
"spm_buffer_lifecycle",
|
||||
"spm_buffer_trim",
|
||||
4,
|
||||
ks_lib::ExMetadataSpmOperation::Trim {
|
||||
account: buffer.clone(),
|
||||
authority: authority.clone(),
|
||||
destination: authority.clone(),
|
||||
program_context: std::option::Option::None,
|
||||
},
|
||||
vec![buffer_state.clone()],
|
||||
std::vec::Vec::new(),
|
||||
vec![buffer_state.clone()],
|
||||
true,
|
||||
),
|
||||
prepared_step(
|
||||
"spm_buffer_lifecycle",
|
||||
"spm_buffer_close",
|
||||
5,
|
||||
ks_lib::ExMetadataSpmOperation::Close {
|
||||
account: buffer.clone(),
|
||||
authority: authority.clone(),
|
||||
destination: authority.clone(),
|
||||
program_context: std::option::Option::None,
|
||||
},
|
||||
vec![buffer_state.clone()],
|
||||
std::vec::Vec::new(),
|
||||
vec![buffer_missing],
|
||||
true,
|
||||
),
|
||||
prepared_step(
|
||||
"spm_metadata_lifecycle",
|
||||
"spm_metadata_initialize",
|
||||
0,
|
||||
ks_lib::ExMetadataSpmOperation::Initialize {
|
||||
metadata: metadata.clone(),
|
||||
authority: authority.clone(),
|
||||
program: described_program,
|
||||
program_data: std::option::Option::None,
|
||||
canonical: false,
|
||||
seed: metadata_seed.to_vec(),
|
||||
encoding: ks_lib::ExMetadataSpmEncoding::Utf8,
|
||||
compression: ks_lib::ExMetadataSpmCompression::None,
|
||||
format: ks_lib::ExMetadataSpmFormat::Json,
|
||||
data_source: ks_lib::ExMetadataSpmDataSource::Direct,
|
||||
data: std::option::Option::Some(ks_lib::ExMetadataSpmDataInput::Direct {
|
||||
bytes: initial_metadata_bytes,
|
||||
}),
|
||||
allocate_account: true,
|
||||
},
|
||||
vec![metadata_vacant],
|
||||
vec![rent(
|
||||
metadata.clone(),
|
||||
initial_metadata_space,
|
||||
metadata_observed_lamports,
|
||||
initial_metadata_rent,
|
||||
)],
|
||||
vec![metadata_state.clone()],
|
||||
false,
|
||||
),
|
||||
prepared_step(
|
||||
"spm_metadata_lifecycle",
|
||||
"spm_metadata_set_data",
|
||||
1,
|
||||
ks_lib::ExMetadataSpmOperation::SetData {
|
||||
metadata: metadata.clone(),
|
||||
authority: authority.clone(),
|
||||
encoding: ks_lib::ExMetadataSpmEncoding::Utf8,
|
||||
compression: ks_lib::ExMetadataSpmCompression::None,
|
||||
format: ks_lib::ExMetadataSpmFormat::Json,
|
||||
source: ks_lib::ExMetadataSpmSetDataSource::Inline {
|
||||
data: ks_lib::ExMetadataSpmDataInput::Direct { bytes: updated_metadata_bytes },
|
||||
},
|
||||
program_context: std::option::Option::None,
|
||||
},
|
||||
vec![metadata_state.clone()],
|
||||
vec![rent(
|
||||
metadata.clone(),
|
||||
updated_metadata_space,
|
||||
metadata_observed_lamports,
|
||||
updated_metadata_rent,
|
||||
)],
|
||||
vec![metadata_state.clone()],
|
||||
true,
|
||||
),
|
||||
prepared_step(
|
||||
"spm_metadata_lifecycle",
|
||||
"spm_metadata_set_immutable",
|
||||
2,
|
||||
ks_lib::ExMetadataSpmOperation::SetImmutable {
|
||||
metadata: metadata.clone(),
|
||||
authority,
|
||||
program_context: std::option::Option::None,
|
||||
},
|
||||
vec![metadata_state.clone()],
|
||||
std::vec::Vec::new(),
|
||||
vec![metadata_state],
|
||||
true,
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
fn prepared_step(
|
||||
scenario_id: &str,
|
||||
step_id: &str,
|
||||
step_index: usize,
|
||||
operation: ks_lib::ExMetadataSpmOperation,
|
||||
preflight_reads: std::vec::Vec<ks_pipeline::SolanaProgramMetadataStatefulReadRequest>,
|
||||
rent_observations: std::vec::Vec<ks_pipeline::SolanaProgramMetadataRentObservation>,
|
||||
postcondition_reads: std::vec::Vec<ks_pipeline::SolanaProgramMetadataStatefulReadRequest>,
|
||||
allow_destructive_operation: bool,
|
||||
) -> crate::SolanaProgramMetadataPreparedStep {
|
||||
return crate::SolanaProgramMetadataPreparedStep {
|
||||
scenario_id: scenario_id.to_string(),
|
||||
step_id: step_id.to_string(),
|
||||
step_index,
|
||||
operation,
|
||||
preflight_reads,
|
||||
rent_observations,
|
||||
postcondition_reads,
|
||||
allow_destructive_operation,
|
||||
};
|
||||
}
|
||||
|
||||
fn read(
|
||||
query_role: &str,
|
||||
account: ks_lib::MdPubkey,
|
||||
expected_state: ks_pipeline::SolanaProgramMetadataExpectedAccountState,
|
||||
) -> ks_pipeline::SolanaProgramMetadataStatefulReadRequest {
|
||||
return ks_pipeline::SolanaProgramMetadataStatefulReadRequest {
|
||||
query_role: query_role.to_string(),
|
||||
account,
|
||||
expected_state,
|
||||
min_context_slot: std::option::Option::None,
|
||||
max_data_bytes: ks_pipeline::MAX_SOLANA_PROGRAM_METADATA_STATEFUL_ACCOUNT_BYTES,
|
||||
};
|
||||
}
|
||||
|
||||
fn rent(
|
||||
account: ks_lib::MdPubkey,
|
||||
target_space: u64,
|
||||
observed_lamports: u64,
|
||||
required_lamports: u64,
|
||||
) -> ks_pipeline::SolanaProgramMetadataRentObservation {
|
||||
return ks_pipeline::SolanaProgramMetadataRentObservation {
|
||||
account,
|
||||
target_space,
|
||||
observed_lamports,
|
||||
required_lamports,
|
||||
};
|
||||
}
|
||||
|
||||
async fn ensure_fixture_addresses_are_missing(
|
||||
http_pool: &ks_onchain_transport::HttpEndpointPool,
|
||||
options: &crate::SolanaProgramMetadataFixturePreparationOptions,
|
||||
accounts: &[ks_lib::MdPubkey],
|
||||
) -> ks_core::Result<()> {
|
||||
for account in accounts {
|
||||
let value = match http_pool
|
||||
.get_account_info_for_role(
|
||||
options.query_role.as_str(),
|
||||
account,
|
||||
&ks_onchain_transport::GetAccountInfoConfig::confirmed(),
|
||||
)
|
||||
.await
|
||||
{
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
if value.account.is_some() {
|
||||
return std::result::Result::Err(ks_core::Error::new(
|
||||
"solana_program_metadata_fixture_address_collision",
|
||||
format!("generated fixture address {} already exists", account.0),
|
||||
));
|
||||
}
|
||||
}
|
||||
return std::result::Result::Ok(());
|
||||
}
|
||||
|
||||
async fn rent_for_space(
|
||||
http_pool: &ks_onchain_transport::HttpEndpointPool,
|
||||
query_role: &str,
|
||||
space: u64,
|
||||
) -> ks_core::Result<u64> {
|
||||
let result = match http_pool
|
||||
.get_minimum_balance_for_rent_exemption_for_role(
|
||||
query_role,
|
||||
space,
|
||||
&ks_onchain_transport::GetMinimumBalanceForRentExemptionConfig::confirmed(),
|
||||
)
|
||||
.await
|
||||
{
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
return std::result::Result::Ok(result.minimum_balance_lamports);
|
||||
}
|
||||
|
||||
async fn submit_prefund_transfer(
|
||||
http_pool: &ks_onchain_transport::HttpEndpointPool,
|
||||
profile: &ks_config::ProfileConfig,
|
||||
options: &crate::SolanaProgramMetadataFixturePreparationOptions,
|
||||
operator: &ks_wallet::TemporaryWallet,
|
||||
recipient: ks_lib::MdPubkey,
|
||||
lamports: u64,
|
||||
label: &str,
|
||||
) -> ks_core::Result<std::string::String> {
|
||||
let payer = ks_lib::MdPubkey(operator.public_key());
|
||||
let policy = ks_lib::ExApiExecutionPolicy {
|
||||
cluster: ks_lib::ExApiExecutionClusterPolicy {
|
||||
expected_cluster: ks_lib::ExApiExecutionCluster::Devnet,
|
||||
allow_mainnet: false,
|
||||
mainnet_confirmation: false,
|
||||
},
|
||||
simulation: ks_lib::ExApiExecutionSimulationPolicy::Required,
|
||||
blockhash: ks_lib::ExApiExecutionBlockhashPolicy {
|
||||
kind: ks_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: ks_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: vec![payer.clone()],
|
||||
dry_run: false,
|
||||
post_execution_validation: ks_lib::ExApiPostExecutionValidationPolicy {
|
||||
canonical_insert_required: false,
|
||||
core_extraction_required: false,
|
||||
decode_replay_required: false,
|
||||
materialization_required: false,
|
||||
},
|
||||
};
|
||||
let intent = ks_lib::ExSolanaCoreExecutionIntent {
|
||||
intent_id: format!("spm-fixture-prefund-{label}-{}", recipient.0),
|
||||
fee_payer: payer.clone(),
|
||||
policy,
|
||||
operation: ks_lib::ExSolanaCoreOperation::SystemTransfer {
|
||||
from: payer,
|
||||
to: recipient,
|
||||
lamports,
|
||||
},
|
||||
};
|
||||
let plan = match ks_lib::ExApiTypedInstructionExecutor::build_prepared_plan(
|
||||
&ks_lib::ExSolanaCoreExecutor,
|
||||
&intent,
|
||||
) {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let plan_evaluation = match ks_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 == ks_lib::ExSafetyDecision::Deny {
|
||||
return std::result::Result::Err(ks_core::Error::new(
|
||||
"solana_program_metadata_fixture_prefund_plan_denied",
|
||||
crate::violation_message(plan_evaluation.violations.as_slice()),
|
||||
));
|
||||
}
|
||||
let latest_blockhash = match http_pool
|
||||
.get_latest_blockhash_for_role(
|
||||
options.query_role.as_str(),
|
||||
&ks_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 ks_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 fee = match http_pool
|
||||
.get_fee_for_message_for_role(
|
||||
options.query_role.as_str(),
|
||||
unsigned.message_base64().as_str(),
|
||||
&ks_onchain_transport::GetFeeForMessageConfig::new(
|
||||
ks_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 ks_onchain_transport::SimulateTransactionConfig::new(
|
||||
ks_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),
|
||||
};
|
||||
let simulation_rpc = match http_pool
|
||||
.simulate_transaction_for_role(
|
||||
options.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(
|
||||
ks_lib::ExApiExecutionCluster::Devnet,
|
||||
ks_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),
|
||||
);
|
||||
if !simulation.success {
|
||||
return std::result::Result::Err(ks_core::Error::new(
|
||||
"solana_program_metadata_fixture_prefund_simulation_failed",
|
||||
crate::simulation_failure_message(&simulation),
|
||||
));
|
||||
}
|
||||
let send_evaluation = match ks_lib::ExSafetyChecker.evaluate_send(&plan, &simulation) {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
if send_evaluation.decision == ks_lib::ExSafetyDecision::Deny {
|
||||
return std::result::Result::Err(ks_core::Error::new(
|
||||
"solana_program_metadata_fixture_prefund_send_denied",
|
||||
crate::violation_message(send_evaluation.violations.as_slice()),
|
||||
));
|
||||
}
|
||||
let evidence = unsigned.bind_simulation(simulation.clone());
|
||||
let signed = match unsigned.sign_after_simulation(&evidence, &[operator.as_signer()]) {
|
||||
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 send_config = match ks_onchain_transport::SendTransactionConfig::from_execution_config(
|
||||
&profile.execution,
|
||||
std::option::Option::Some(latest_blockhash.context.slot),
|
||||
) {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
if let std::result::Result::Err(error) = http_pool
|
||||
.send_transaction_for_role(
|
||||
options.transaction_role.as_str(),
|
||||
signed.transaction_base64().as_str(),
|
||||
&signature,
|
||||
&send_config,
|
||||
)
|
||||
.await
|
||||
{
|
||||
return std::result::Result::Err(error);
|
||||
}
|
||||
let confirmation_config =
|
||||
match ks_onchain_transport::ConfirmTransactionConfig::from_execution_config(
|
||||
&profile.execution,
|
||||
std::option::Option::Some(latest_blockhash.last_valid_block_height),
|
||||
std::option::Option::Some(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(
|
||||
options.transaction_role.as_str(),
|
||||
options.query_role.as_str(),
|
||||
ks_lib::ExApiExecutionCluster::Devnet,
|
||||
&signature,
|
||||
&confirmation_config,
|
||||
)
|
||||
.await
|
||||
{
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
if !matches!(
|
||||
confirmation.status,
|
||||
ks_lib::ExApiExecutionConfirmationStatus::Confirmed
|
||||
| ks_lib::ExApiExecutionConfirmationStatus::Finalized
|
||||
) {
|
||||
return std::result::Result::Err(ks_core::Error::new(
|
||||
"solana_program_metadata_fixture_prefund_confirmation_failed",
|
||||
format!("prefunding transaction stopped at {:?}", confirmation.status),
|
||||
));
|
||||
}
|
||||
return std::result::Result::Ok(signature.0);
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#[test]
|
||||
fn fixture_seed_shapes_and_journey_inventory_are_exact() {
|
||||
let unique = uuid::Uuid::nil().simple().to_string();
|
||||
let buffer_seed = format!("buf{}", &unique[0..13]);
|
||||
let metadata_seed = format!("met{}", &unique[13..26]);
|
||||
assert_eq!(buffer_seed.len(), ks_lib::DC_METADATA_SPM_SEED_BYTES);
|
||||
assert_eq!(metadata_seed.len(), ks_lib::DC_METADATA_SPM_SEED_BYTES);
|
||||
let authority =
|
||||
ks_lib::MdPubkey(solana_pubkey::Pubkey::new_from_array([1_u8; 32]).to_string());
|
||||
let buffer =
|
||||
ks_lib::MdPubkey(solana_pubkey::Pubkey::new_from_array([2_u8; 32]).to_string());
|
||||
let metadata =
|
||||
ks_lib::MdPubkey(solana_pubkey::Pubkey::new_from_array([3_u8; 32]).to_string());
|
||||
let steps = super::prepared_steps(
|
||||
"custom_queries",
|
||||
authority,
|
||||
ks_lib::MdPubkey(ks_program_ids::SYSTEM_PROGRAM_ID.to_string()),
|
||||
buffer,
|
||||
buffer_seed.as_bytes(),
|
||||
metadata,
|
||||
metadata_seed.as_bytes(),
|
||||
br#"{"version":1}"#.to_vec(),
|
||||
br#"{"version":2}"#.to_vec(),
|
||||
1_000_000,
|
||||
96,
|
||||
1_000_000,
|
||||
224,
|
||||
1_000_000,
|
||||
1_000_000,
|
||||
109,
|
||||
1_000_000,
|
||||
109,
|
||||
1_000_000,
|
||||
);
|
||||
let actual = steps
|
||||
.iter()
|
||||
.map(|step| return step.operation.operation_code())
|
||||
.collect::<std::vec::Vec<_>>();
|
||||
let scenarios = crate::solana_program_metadata_devnet_scenarios();
|
||||
let expected = scenarios
|
||||
.iter()
|
||||
.flat_map(|scenario| return scenario.steps.iter())
|
||||
.map(|step| return step.operation_code.as_str())
|
||||
.collect::<std::vec::Vec<_>>();
|
||||
assert_eq!(actual, expected);
|
||||
let expected_buffer =
|
||||
ks_lib::MdPubkey(solana_pubkey::Pubkey::new_from_array([2_u8; 32]).to_string());
|
||||
let set_authority = steps.iter().find(|step| {
|
||||
return step.operation.operation_code()
|
||||
== ks_lib::EX_METADATA_SPM_SET_AUTHORITY_OPERATION;
|
||||
});
|
||||
assert!(matches!(
|
||||
set_authority.map(|step| return &step.operation),
|
||||
std::option::Option::Some(ks_lib::ExMetadataSpmOperation::SetAuthority {
|
||||
account,
|
||||
..
|
||||
}) if account == &expected_buffer
|
||||
));
|
||||
assert!(steps.iter().all(|step| {
|
||||
return step
|
||||
.preflight_reads
|
||||
.iter()
|
||||
.chain(step.postcondition_reads.iter())
|
||||
.all(|read| return read.query_role == "custom_queries");
|
||||
}));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,237 @@
|
||||
// file: ks-pipeline-demo-scenarios/src/metadata/solana_program/scenarios.rs
|
||||
// version: 5
|
||||
|
||||
//! Coherent Solana Program Metadata journeys for Devnet validation.
|
||||
|
||||
/// Stable lifecycle state consumed or produced by one scenario step.
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum SolanaProgramMetadataFixtureState {
|
||||
/// Pre-funded but uninitialized PDA.
|
||||
Prefunded,
|
||||
/// Initialized Buffer account.
|
||||
Buffer,
|
||||
/// Initialized mutable Metadata account.
|
||||
MutableMetadata,
|
||||
/// Initialized immutable Metadata account.
|
||||
ImmutableMetadata,
|
||||
/// Closed account.
|
||||
Closed,
|
||||
}
|
||||
|
||||
/// One stable step in a Solana Program Metadata Devnet journey.
|
||||
#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct SolanaProgramMetadataScenarioStep {
|
||||
/// Stable scenario-step identifier.
|
||||
pub id: std::string::String,
|
||||
/// Operator-visible label.
|
||||
pub label: std::string::String,
|
||||
/// Stable executor operation code.
|
||||
pub operation_code: std::string::String,
|
||||
/// Expected state before the operation.
|
||||
pub initial_state: crate::SolanaProgramMetadataFixtureState,
|
||||
/// Expected state after confirmed execution.
|
||||
pub resulting_state: crate::SolanaProgramMetadataFixtureState,
|
||||
/// Whether the operation requires explicit destructive approval.
|
||||
pub requires_explicit_approval: bool,
|
||||
/// Required evidence kinds.
|
||||
pub required_evidence: std::vec::Vec<std::string::String>,
|
||||
}
|
||||
|
||||
/// One ordered multi-step Solana Program Metadata journey.
|
||||
#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct SolanaProgramMetadataScenario {
|
||||
/// Stable scenario identifier.
|
||||
pub id: std::string::String,
|
||||
/// Operator-visible label.
|
||||
pub label: std::string::String,
|
||||
/// Fixture account selected by the journey.
|
||||
pub fixture_account: std::string::String,
|
||||
/// Ordered scenario steps.
|
||||
pub steps: std::vec::Vec<crate::SolanaProgramMetadataScenarioStep>,
|
||||
}
|
||||
|
||||
/// Returns the two ordered Devnet journeys covering all nine stable operations.
|
||||
pub fn solana_program_metadata_devnet_scenarios()
|
||||
-> std::vec::Vec<crate::SolanaProgramMetadataScenario> {
|
||||
return vec![
|
||||
crate::SolanaProgramMetadataScenario {
|
||||
id: "spm_buffer_lifecycle".to_string(),
|
||||
label: "Buffer : allocation, extension, écriture, autorité, trim et fermeture"
|
||||
.to_string(),
|
||||
fixture_account: "buffer".to_string(),
|
||||
steps: vec![
|
||||
step(
|
||||
"spm_buffer_allocate",
|
||||
"Allouer le Buffer préfinancé",
|
||||
ks_lib::EX_METADATA_SPM_ALLOCATE_OPERATION,
|
||||
crate::SolanaProgramMetadataFixtureState::Prefunded,
|
||||
crate::SolanaProgramMetadataFixtureState::Buffer,
|
||||
false,
|
||||
),
|
||||
step(
|
||||
"spm_buffer_extend",
|
||||
"Étendre la capacité du Buffer",
|
||||
ks_lib::EX_METADATA_SPM_EXTEND_OPERATION,
|
||||
crate::SolanaProgramMetadataFixtureState::Buffer,
|
||||
crate::SolanaProgramMetadataFixtureState::Buffer,
|
||||
false,
|
||||
),
|
||||
step(
|
||||
"spm_buffer_write",
|
||||
"Écrire les données inline",
|
||||
ks_lib::EX_METADATA_SPM_WRITE_OPERATION,
|
||||
crate::SolanaProgramMetadataFixtureState::Buffer,
|
||||
crate::SolanaProgramMetadataFixtureState::Buffer,
|
||||
true,
|
||||
),
|
||||
step(
|
||||
"spm_buffer_set_authority",
|
||||
"Réaffirmer l’autorité du Buffer non canonique",
|
||||
ks_lib::EX_METADATA_SPM_SET_AUTHORITY_OPERATION,
|
||||
crate::SolanaProgramMetadataFixtureState::Buffer,
|
||||
crate::SolanaProgramMetadataFixtureState::Buffer,
|
||||
true,
|
||||
),
|
||||
step(
|
||||
"spm_buffer_trim",
|
||||
"Réduire le Buffer et restituer l’excédent",
|
||||
ks_lib::EX_METADATA_SPM_TRIM_OPERATION,
|
||||
crate::SolanaProgramMetadataFixtureState::Buffer,
|
||||
crate::SolanaProgramMetadataFixtureState::Buffer,
|
||||
true,
|
||||
),
|
||||
step(
|
||||
"spm_buffer_close",
|
||||
"Fermer le Buffer",
|
||||
ks_lib::EX_METADATA_SPM_CLOSE_OPERATION,
|
||||
crate::SolanaProgramMetadataFixtureState::Buffer,
|
||||
crate::SolanaProgramMetadataFixtureState::Closed,
|
||||
true,
|
||||
),
|
||||
],
|
||||
},
|
||||
crate::SolanaProgramMetadataScenario {
|
||||
id: "spm_metadata_lifecycle".to_string(),
|
||||
label: "Metadata : initialisation, données et immutabilité".to_string(),
|
||||
fixture_account: "metadata".to_string(),
|
||||
steps: vec![
|
||||
step(
|
||||
"spm_metadata_initialize",
|
||||
"Initialiser les metadata non-canoniques",
|
||||
ks_lib::EX_METADATA_SPM_INITIALIZE_OPERATION,
|
||||
crate::SolanaProgramMetadataFixtureState::Prefunded,
|
||||
crate::SolanaProgramMetadataFixtureState::MutableMetadata,
|
||||
false,
|
||||
),
|
||||
step(
|
||||
"spm_metadata_set_data",
|
||||
"Mettre à jour les données JSON",
|
||||
ks_lib::EX_METADATA_SPM_SET_DATA_OPERATION,
|
||||
crate::SolanaProgramMetadataFixtureState::MutableMetadata,
|
||||
crate::SolanaProgramMetadataFixtureState::MutableMetadata,
|
||||
true,
|
||||
),
|
||||
step(
|
||||
"spm_metadata_set_immutable",
|
||||
"Rendre les metadata immuables",
|
||||
ks_lib::EX_METADATA_SPM_SET_IMMUTABLE_OPERATION,
|
||||
crate::SolanaProgramMetadataFixtureState::MutableMetadata,
|
||||
crate::SolanaProgramMetadataFixtureState::ImmutableMetadata,
|
||||
true,
|
||||
),
|
||||
],
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
fn step(
|
||||
id: &str,
|
||||
label: &str,
|
||||
operation_code: &str,
|
||||
initial_state: crate::SolanaProgramMetadataFixtureState,
|
||||
resulting_state: crate::SolanaProgramMetadataFixtureState,
|
||||
requires_explicit_approval: bool,
|
||||
) -> crate::SolanaProgramMetadataScenarioStep {
|
||||
return crate::SolanaProgramMetadataScenarioStep {
|
||||
id: id.to_string(),
|
||||
label: label.to_string(),
|
||||
operation_code: operation_code.to_string(),
|
||||
initial_state,
|
||||
resulting_state,
|
||||
requires_explicit_approval,
|
||||
required_evidence: required_evidence(resulting_state),
|
||||
};
|
||||
}
|
||||
|
||||
fn required_evidence(
|
||||
resulting_state: crate::SolanaProgramMetadataFixtureState,
|
||||
) -> std::vec::Vec<std::string::String> {
|
||||
let terminal_evidence = match resulting_state {
|
||||
crate::SolanaProgramMetadataFixtureState::Closed => "account_absence",
|
||||
_ => "materialized_snapshot",
|
||||
};
|
||||
return vec![
|
||||
"stateful_preflight".to_string(),
|
||||
"rpc_simulation".to_string(),
|
||||
"confirmed_signature".to_string(),
|
||||
"stateful_postcondition".to_string(),
|
||||
terminal_evidence.to_string(),
|
||||
];
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#[test]
|
||||
fn journeys_cover_each_stable_operation_exactly_once() {
|
||||
let scenarios = crate::solana_program_metadata_devnet_scenarios();
|
||||
assert_eq!(scenarios.len(), 2);
|
||||
let operations = scenarios
|
||||
.iter()
|
||||
.flat_map(|scenario| return scenario.steps.iter())
|
||||
.map(|step| return step.operation_code.as_str())
|
||||
.collect::<std::vec::Vec<_>>();
|
||||
assert_eq!(operations.len(), ks_lib::EX_METADATA_SPM_SUPPORTED_OPERATION_CODES.len());
|
||||
for operation in ks_lib::EX_METADATA_SPM_SUPPORTED_OPERATION_CODES {
|
||||
assert_eq!(
|
||||
operations.iter().filter(|candidate| return **candidate == *operation).count(),
|
||||
1
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn set_authority_targets_the_non_canonical_buffer_not_metadata() {
|
||||
let scenarios = crate::solana_program_metadata_devnet_scenarios();
|
||||
let set_authority_steps = scenarios
|
||||
.iter()
|
||||
.flat_map(|scenario| {
|
||||
return scenario
|
||||
.steps
|
||||
.iter()
|
||||
.map(move |step| return (scenario.fixture_account.as_str(), step));
|
||||
})
|
||||
.filter(|(_, step)| {
|
||||
return step.operation_code == ks_lib::EX_METADATA_SPM_SET_AUTHORITY_OPERATION;
|
||||
})
|
||||
.collect::<std::vec::Vec<_>>();
|
||||
assert_eq!(set_authority_steps.len(), 1);
|
||||
assert_eq!(set_authority_steps[0].0, "buffer");
|
||||
assert_eq!(set_authority_steps[0].1.id, "spm_buffer_set_authority");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn destructive_flags_match_the_executor_contract() {
|
||||
for scenario in crate::solana_program_metadata_devnet_scenarios() {
|
||||
for step in scenario.steps {
|
||||
assert_eq!(
|
||||
step.requires_explicit_approval,
|
||||
ks_lib::EX_METADATA_SPM_DESTRUCTIVE_OPERATION_CODES
|
||||
.contains(&step.operation_code.as_str())
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
// file: ks-pipeline-demo-scenarios/src/metadata/solana_program/validation.rs
|
||||
// version: 5
|
||||
|
||||
//! Conservative Solana Program Metadata validation status and evidence contracts.
|
||||
|
||||
/// Maximum evidence entries accepted by one operation validation row.
|
||||
pub const MAX_SOLANA_PROGRAM_METADATA_VALIDATION_EVIDENCE: usize = 16;
|
||||
|
||||
/// Exact network validation status.
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum SolanaProgramMetadataValidationStatus {
|
||||
/// No network execution was attempted.
|
||||
NotRun,
|
||||
/// Exact transaction simulated successfully.
|
||||
Simulated,
|
||||
/// Transaction submitted but not yet confirmed.
|
||||
Submitted,
|
||||
/// Transaction confirmed and postconditions validated.
|
||||
Confirmed,
|
||||
/// Required network capability was unavailable.
|
||||
Unavailable,
|
||||
/// Validation failed with retained diagnostics.
|
||||
Failed,
|
||||
}
|
||||
|
||||
/// One bounded evidence record.
|
||||
#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct SolanaProgramMetadataValidationEvidence {
|
||||
/// Stable evidence kind.
|
||||
pub kind: std::string::String,
|
||||
/// Non-secret evidence value.
|
||||
pub value: std::string::String,
|
||||
}
|
||||
|
||||
/// One operation row in the closed Devnet validation matrix.
|
||||
#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct SolanaProgramMetadataValidationOperation {
|
||||
/// Stable operation code.
|
||||
pub operation_code: std::string::String,
|
||||
/// Scenario step that exercises the operation.
|
||||
pub scenario_step_id: std::string::String,
|
||||
/// Exact current validation status.
|
||||
pub status: crate::SolanaProgramMetadataValidationStatus,
|
||||
/// Whether a destructive approval is required.
|
||||
pub requires_explicit_approval: bool,
|
||||
/// Evidence required for confirmed status.
|
||||
pub required_evidence: std::vec::Vec<std::string::String>,
|
||||
/// Bounded observed evidence.
|
||||
#[serde(default)]
|
||||
pub evidence: std::vec::Vec<crate::SolanaProgramMetadataValidationEvidence>,
|
||||
}
|
||||
|
||||
/// Closed validation matrix for all nine stable operations.
|
||||
#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct SolanaProgramMetadataValidationMatrix {
|
||||
/// Matrix schema version.
|
||||
pub matrix_version: u32,
|
||||
/// Exact owning prerelease.
|
||||
pub milestone: std::string::String,
|
||||
/// Ordered stable operations.
|
||||
pub operations: std::vec::Vec<crate::SolanaProgramMetadataValidationOperation>,
|
||||
}
|
||||
|
||||
/// Loads and validates the canonical Solana Program Metadata Devnet matrix.
|
||||
pub fn load_solana_program_metadata_validation_matrix()
|
||||
-> ks_core::Result<crate::SolanaProgramMetadataValidationMatrix> {
|
||||
let parsed = match serde_json::from_str::<crate::SolanaProgramMetadataValidationMatrix>(
|
||||
include_str!(
|
||||
"../../../../test-fixtures/contract-matrices/SOLANA_PROGRAM_METADATA_DEVNET_VALIDATION_MATRIX.json"
|
||||
),
|
||||
) {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => {
|
||||
return std::result::Result::Err(ks_core::Error::new(
|
||||
"solana_program_metadata_validation_matrix_invalid_json",
|
||||
format!("Solana Program Metadata validation matrix is invalid: {error}"),
|
||||
));
|
||||
},
|
||||
};
|
||||
if let std::result::Result::Err(error) =
|
||||
validate_solana_program_metadata_validation_matrix(&parsed)
|
||||
{
|
||||
return std::result::Result::Err(error);
|
||||
}
|
||||
return std::result::Result::Ok(parsed);
|
||||
}
|
||||
|
||||
/// Validates exact inventory, order and conservative evidence claims.
|
||||
pub fn validate_solana_program_metadata_validation_matrix(
|
||||
matrix: &crate::SolanaProgramMetadataValidationMatrix,
|
||||
) -> ks_core::Result<()> {
|
||||
if matrix.matrix_version != 1 || matrix.milestone != "0.4.8-pre.009" {
|
||||
return std::result::Result::Err(ks_core::Error::new(
|
||||
"solana_program_metadata_validation_matrix_contract_mismatch",
|
||||
"Solana Program Metadata validation matrix must use version 1 for 0.4.8-pre.009",
|
||||
));
|
||||
}
|
||||
let compiled_steps = crate::solana_program_metadata_devnet_scenarios()
|
||||
.into_iter()
|
||||
.flat_map(|scenario| return scenario.steps)
|
||||
.collect::<std::vec::Vec<_>>();
|
||||
let expected = compiled_steps
|
||||
.iter()
|
||||
.map(|step| return step.operation_code.as_str())
|
||||
.collect::<std::vec::Vec<_>>();
|
||||
let actual = matrix
|
||||
.operations
|
||||
.iter()
|
||||
.map(|operation| return operation.operation_code.as_str())
|
||||
.collect::<std::vec::Vec<_>>();
|
||||
if actual != expected {
|
||||
return std::result::Result::Err(ks_core::Error::new(
|
||||
"solana_program_metadata_validation_matrix_inventory_mismatch",
|
||||
"Solana Program Metadata validation matrix differs from the compiled journey order",
|
||||
));
|
||||
}
|
||||
for (operation, step) in matrix.operations.iter().zip(compiled_steps.iter()) {
|
||||
if operation.scenario_step_id != step.id
|
||||
|| operation.requires_explicit_approval != step.requires_explicit_approval
|
||||
|| operation.required_evidence != step.required_evidence
|
||||
|| operation.evidence.len() > crate::MAX_SOLANA_PROGRAM_METADATA_VALIDATION_EVIDENCE
|
||||
{
|
||||
return std::result::Result::Err(ks_core::Error::new(
|
||||
"solana_program_metadata_validation_matrix_row_invalid",
|
||||
"Solana Program Metadata validation row differs from the compiled scenario contract",
|
||||
));
|
||||
}
|
||||
if matches!(
|
||||
operation.status,
|
||||
crate::SolanaProgramMetadataValidationStatus::NotRun
|
||||
| crate::SolanaProgramMetadataValidationStatus::Unavailable
|
||||
) && !operation.evidence.is_empty()
|
||||
{
|
||||
return std::result::Result::Err(ks_core::Error::new(
|
||||
"solana_program_metadata_validation_unobserved_with_evidence",
|
||||
"not-run or unavailable validation rows must not contain observed evidence",
|
||||
));
|
||||
}
|
||||
if operation.status == crate::SolanaProgramMetadataValidationStatus::Confirmed {
|
||||
let observed = operation
|
||||
.evidence
|
||||
.iter()
|
||||
.map(|evidence| return evidence.kind.as_str())
|
||||
.collect::<std::collections::BTreeSet<_>>();
|
||||
if !operation
|
||||
.required_evidence
|
||||
.iter()
|
||||
.all(|kind| return observed.contains(kind.as_str()))
|
||||
{
|
||||
return std::result::Result::Err(ks_core::Error::new(
|
||||
"solana_program_metadata_validation_confirmed_without_evidence",
|
||||
"confirmed validation row lacks required evidence",
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
return std::result::Result::Ok(());
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#[test]
|
||||
fn canonical_matrix_matches_the_compiled_two_journey_inventory() {
|
||||
let matrix = match crate::load_solana_program_metadata_validation_matrix() {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => panic!("matrix load failed: {error}"),
|
||||
};
|
||||
assert_eq!(matrix.operations.len(), 9);
|
||||
assert!(matrix.operations.iter().all(|operation| {
|
||||
return operation.status == crate::SolanaProgramMetadataValidationStatus::Confirmed
|
||||
&& operation.evidence.len() == 10;
|
||||
}));
|
||||
}
|
||||
}
|
||||
1433
ks-pipeline-demo-scenarios/src/solana.rs
Normal file
1433
ks-pipeline-demo-scenarios/src/solana.rs
Normal file
File diff suppressed because it is too large
Load Diff
9
ks-pipeline-demo-scenarios/src/spl.rs
Normal file
9
ks-pipeline-demo-scenarios/src/spl.rs
Normal file
@@ -0,0 +1,9 @@
|
||||
// file: ks-pipeline-demo-scenarios/src/spl.rs
|
||||
// version: 2
|
||||
|
||||
//! SPL-oriented reusable demo and Devnet validation scenarios.
|
||||
|
||||
pub(crate) mod associated_token_account;
|
||||
pub(crate) mod memo;
|
||||
pub(crate) mod token;
|
||||
pub(crate) mod token_2022;
|
||||
1208
ks-pipeline-demo-scenarios/src/spl/associated_token_account.rs
Normal file
1208
ks-pipeline-demo-scenarios/src/spl/associated_token_account.rs
Normal file
File diff suppressed because it is too large
Load Diff
908
ks-pipeline-demo-scenarios/src/spl/memo.rs
Normal file
908
ks-pipeline-demo-scenarios/src/spl/memo.rs
Normal file
@@ -0,0 +1,908 @@
|
||||
// file: ks-pipeline-demo-scenarios/src/spl/memo.rs
|
||||
// version: 7
|
||||
|
||||
//! Devnet SPL Memo v4 execution with canonical post-validation.
|
||||
|
||||
/// Complete request for one SPL Memo v4 Devnet execution.
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub struct DevnetMemoExecutionRequest {
|
||||
/// Stable caller-provided execution identifier.
|
||||
pub intent_id: std::string::String,
|
||||
/// Endpoint role used for cluster, balance, blockhash, fee and hydration calls.
|
||||
pub query_role: std::string::String,
|
||||
/// Endpoint role used for simulation, submission and confirmation polling.
|
||||
pub transaction_role: std::string::String,
|
||||
/// Exact UTF-8 Memo payload.
|
||||
pub message: std::string::String,
|
||||
/// Supplies the persistent Devnet fee payer as a Memo signer account.
|
||||
pub include_wallet_as_memo_signer: bool,
|
||||
/// Explicitly authorizes signing and submission after successful simulation.
|
||||
pub submit: bool,
|
||||
/// Explicit operator confirmation required by the active profile.
|
||||
pub operator_confirmed: 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 DevnetMemoExecutionRequest {
|
||||
/// Creates a conservative simulation-only Memo v4 request.
|
||||
pub fn new(
|
||||
intent_id: impl std::convert::Into<std::string::String>,
|
||||
message: impl std::convert::Into<std::string::String>,
|
||||
) -> Self {
|
||||
return Self {
|
||||
intent_id: intent_id.into(),
|
||||
query_role: "http_queries".to_string(),
|
||||
transaction_role: "http_transactions".to_string(),
|
||||
message: message.into(),
|
||||
include_wallet_as_memo_signer: true,
|
||||
submit: false,
|
||||
operator_confirmed: false,
|
||||
post_validation_max_retries: 10,
|
||||
force_post_validation_replay: false,
|
||||
};
|
||||
}
|
||||
|
||||
/// Validates request-local bounds independently from one profile.
|
||||
pub fn validate(&self) -> ks_core::Result<()> {
|
||||
if self.intent_id.trim().is_empty() {
|
||||
return std::result::Result::Err(ks_core::Error::config(
|
||||
"Devnet Memo 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(ks_core::Error::config(
|
||||
"Devnet Memo execution endpoint roles must not be empty",
|
||||
));
|
||||
}
|
||||
if self.message.len() > ks_lib::EX_SPL_MEMO_MAX_MESSAGE_BYTES {
|
||||
return std::result::Result::Err(ks_core::Error::config(format!(
|
||||
"Devnet Memo payload length {} exceeds the executor limit {}",
|
||||
self.message.len(),
|
||||
ks_lib::EX_SPL_MEMO_MAX_MESSAGE_BYTES
|
||||
)));
|
||||
}
|
||||
if self.post_validation_max_retries > 20 {
|
||||
return std::result::Result::Err(ks_core::Error::config(
|
||||
"post-execution getTransaction retries must not exceed 20",
|
||||
));
|
||||
}
|
||||
return std::result::Result::Ok(());
|
||||
}
|
||||
}
|
||||
|
||||
/// Complete result of one SPL Memo v4 Devnet execution.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct DevnetMemoExecutionSummary {
|
||||
/// Profile used by the orchestration.
|
||||
pub profile_name: std::string::String,
|
||||
/// Exact classified cluster.
|
||||
pub cluster: ks_lib::ExApiExecutionCluster,
|
||||
/// Genesis hash returned by the selected endpoint.
|
||||
pub genesis_hash: std::string::String,
|
||||
/// Non-secret persistent wallet description.
|
||||
pub wallet: ks_wallet::WalletSummary,
|
||||
/// Wallet balance observed before planning.
|
||||
pub balance_lamports: u64,
|
||||
/// Exact prepared Memo plan.
|
||||
pub plan: ks_lib::ExApiPreparedExecutionPlan,
|
||||
/// Recent blockhash used by the exact transaction.
|
||||
pub latest_blockhash: ks_onchain_transport::LatestBlockhashResult,
|
||||
/// Fee estimate for the exact compiled message.
|
||||
pub fee: ks_onchain_transport::FeeForMessageResult,
|
||||
/// Exact simulation result bound to the compiled message.
|
||||
pub simulation: ks_lib::ExApiExecutionSimulationResult,
|
||||
/// Submission result when explicitly authorized.
|
||||
pub send_result: std::option::Option<ks_lib::ExApiExecutionSendResult>,
|
||||
/// Confirmation result when submitted.
|
||||
pub confirmation: std::option::Option<ks_lib::ExApiExecutionConfirmationResult>,
|
||||
/// Canonical hydration result for the exact signature.
|
||||
pub backfill: std::option::Option<ks_pipeline::BackfillSummary>,
|
||||
/// Core extraction result for the exact signature.
|
||||
pub core_extraction: std::option::Option<ks_pipeline::CoreExtractionSummary>,
|
||||
/// First Memo decode and materialization replay.
|
||||
pub decode_replay: std::option::Option<ks_pipeline::DecodeReplaySummary>,
|
||||
/// Second replay proving that the same decoder version and input are idempotent.
|
||||
pub idempotence_replay: std::option::Option<ks_pipeline::DecodeReplaySummary>,
|
||||
/// Exact persisted transaction annotation rows for the submitted signature.
|
||||
pub annotations: std::vec::Vec<ks_store::MaterializedEventQueryRow>,
|
||||
/// Aggregated post-execution validation diagnostic.
|
||||
pub post_execution: std::option::Option<ks_lib::ExApiPostExecutionDiagnostic>,
|
||||
}
|
||||
|
||||
/// Executes one Memo v4 Devnet simulation or explicitly authorized submission.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub async fn execute_devnet_memo<S, O>(
|
||||
http_pool: &ks_onchain_transport::HttpEndpointPool,
|
||||
store: &S,
|
||||
profile: &ks_config::ProfileConfig,
|
||||
workspace_root: &std::path::Path,
|
||||
request: &crate::DevnetMemoExecutionRequest,
|
||||
decoders: &[std::sync::Arc<dyn ks_lib::DcApiInstructionDecoder>],
|
||||
materializers: &[std::sync::Arc<dyn ks_lib::MtApiEventMaterializer>],
|
||||
observer: &O,
|
||||
) -> ks_core::Result<crate::DevnetMemoExecutionSummary>
|
||||
where
|
||||
S: ks_store::RawTransactionStore
|
||||
+ ks_store::CoreExtractionStore
|
||||
+ ks_store::DecodePipelineStore
|
||||
+ Sync,
|
||||
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, "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(ks_lib::ExApiExecutionCluster::Devnet)
|
||||
{
|
||||
return std::result::Result::Err(ks_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 = ks_lib::MdPubkey(wallet_summary.public_key.clone());
|
||||
let balance = match http_pool
|
||||
.get_balance_for_role(
|
||||
request.query_role.as_str(),
|
||||
&fee_payer,
|
||||
&ks_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(ks_core::Error::new(
|
||||
"execution_balance_insufficient",
|
||||
format!(
|
||||
"Devnet wallet balance {} is below the configured fee ceiling {}",
|
||||
balance.lamports, profile.execution.max_fee_lamports
|
||||
),
|
||||
));
|
||||
}
|
||||
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 plan_evaluation = match ks_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 == ks_lib::ExSafetyDecision::Deny {
|
||||
return std::result::Result::Err(ks_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(),
|
||||
&ks_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 ks_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 message_base64 = unsigned.message_base64();
|
||||
let fee = match http_pool
|
||||
.get_fee_for_message_for_role(
|
||||
request.query_role.as_str(),
|
||||
message_base64.as_str(),
|
||||
&ks_onchain_transport::GetFeeForMessageConfig::new(
|
||||
ks_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),
|
||||
};
|
||||
if fee.fee_lamports.is_none() {
|
||||
return std::result::Result::Err(ks_core::Error::new(
|
||||
"execution_fee_unavailable",
|
||||
"getFeeForMessage returned null for the selected recent blockhash",
|
||||
));
|
||||
}
|
||||
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 ks_onchain_transport::SimulateTransactionConfig::new(
|
||||
ks_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,
|
||||
"memo_simulation",
|
||||
format!("simulating exact Memo 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(
|
||||
ks_lib::ExApiExecutionCluster::Devnet,
|
||||
ks_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 evidence = unsigned.bind_simulation(simulation.clone());
|
||||
let mut summary = crate::DevnetMemoExecutionSummary {
|
||||
profile_name: profile.name.clone(),
|
||||
cluster: ks_lib::ExApiExecutionCluster::Devnet,
|
||||
genesis_hash: genesis.genesis_hash,
|
||||
wallet: wallet_summary,
|
||||
balance_lamports: balance.lamports,
|
||||
plan,
|
||||
latest_blockhash,
|
||||
fee,
|
||||
simulation,
|
||||
send_result: std::option::Option::None,
|
||||
confirmation: std::option::Option::None,
|
||||
backfill: std::option::Option::None,
|
||||
core_extraction: std::option::Option::None,
|
||||
decode_replay: std::option::Option::None,
|
||||
idempotence_replay: std::option::Option::None,
|
||||
annotations: std::vec::Vec::new(),
|
||||
post_execution: std::option::Option::None,
|
||||
};
|
||||
if !request.submit {
|
||||
return std::result::Result::Ok(summary);
|
||||
}
|
||||
if !summary.simulation.success {
|
||||
return std::result::Result::Err(ks_core::Error::new(
|
||||
"execution_simulation_failed",
|
||||
crate::simulation_failure_message(&summary.simulation),
|
||||
));
|
||||
}
|
||||
let send_evaluation =
|
||||
match ks_lib::ExSafetyChecker.evaluate_send(&summary.plan, &summary.simulation) {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
if send_evaluation.decision == ks_lib::ExSafetyDecision::Deny {
|
||||
return std::result::Result::Err(ks_core::Error::new(
|
||||
"execution_send_denied",
|
||||
crate::violation_message(send_evaluation.violations.as_slice()),
|
||||
));
|
||||
}
|
||||
let signed = match unsigned.sign_after_simulation(&evidence, &[wallet.as_signer()]) {
|
||||
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 diagnostic = ks_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 ks_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 signed_base64 = signed.transaction_base64();
|
||||
let sent = match http_pool
|
||||
.send_transaction_for_role(
|
||||
request.transaction_role.as_str(),
|
||||
signed_base64.as_str(),
|
||||
&signature,
|
||||
&send_config,
|
||||
)
|
||||
.await
|
||||
{
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => {
|
||||
diagnostic.diagnostics.push(format!("Memo 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(ks_lib::ExApiExecutionCluster::Devnet));
|
||||
let confirmation_config =
|
||||
match ks_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(),
|
||||
ks_lib::ExApiExecutionCluster::Devnet,
|
||||
&signature,
|
||||
&confirmation_config,
|
||||
)
|
||||
.await
|
||||
{
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => {
|
||||
diagnostic.diagnostics.push(format!("Memo 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,
|
||||
ks_lib::ExApiExecutionConfirmationStatus::Confirmed
|
||||
| ks_lib::ExApiExecutionConfirmationStatus::Finalized
|
||||
) {
|
||||
diagnostic.diagnostics.push(format!(
|
||||
"Memo post-validation stopped at confirmation status {confirmation_status:?}"
|
||||
));
|
||||
summary.post_execution = std::option::Option::Some(diagnostic);
|
||||
return std::result::Result::Ok(summary);
|
||||
}
|
||||
let backfill =
|
||||
match hydrate_signature(http_pool, store, profile, request, observer, &signature).await {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => {
|
||||
diagnostic.diagnostics.push(format!("Memo hydration failed: {error}"));
|
||||
summary.post_execution = std::option::Option::Some(diagnostic);
|
||||
return std::result::Result::Ok(summary);
|
||||
},
|
||||
};
|
||||
diagnostic.canonical_inserted = canonical_available(&backfill);
|
||||
summary.backfill = std::option::Option::Some(backfill);
|
||||
if !diagnostic.canonical_inserted {
|
||||
diagnostic
|
||||
.diagnostics
|
||||
.push("confirmed Memo 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 ks_pipeline::execute_core_extraction(
|
||||
store,
|
||||
&ks_pipeline::CoreExtractionRequest {
|
||||
source: ks_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!("Memo 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("Memo 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 replay_memo(store, request, &signature, false, decoders, materializers, observer)
|
||||
.await
|
||||
{
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => {
|
||||
diagnostic.diagnostics.push(format!("Memo decode replay failed: {error}"));
|
||||
summary.post_execution = std::option::Option::Some(diagnostic);
|
||||
return std::result::Result::Ok(summary);
|
||||
},
|
||||
};
|
||||
diagnostic.decode_replayed = decode_completed(&first_replay);
|
||||
summary.decode_replay = std::option::Option::Some(first_replay);
|
||||
let filter = match ks_store::MaterializedEventFilter::new(
|
||||
std::option::Option::Some("materializer.transaction.annotations".to_string()),
|
||||
std::option::Option::Some("transaction_annotation".to_string()),
|
||||
std::option::Option::Some(signature.0.clone()),
|
||||
8,
|
||||
) {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
summary.annotations =
|
||||
match ks_store::DecodePipelineStore::list_materialized_events(store, &filter).await {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => {
|
||||
diagnostic.diagnostics.push(format!("Memo annotation query failed: {error}"));
|
||||
summary.post_execution = std::option::Option::Some(diagnostic);
|
||||
return std::result::Result::Ok(summary);
|
||||
},
|
||||
};
|
||||
diagnostic.materialized = diagnostic.decode_replayed
|
||||
&& summary
|
||||
.annotations
|
||||
.iter()
|
||||
.any(|row| return row.signature.as_str() == signature.0.as_str());
|
||||
let second_replay = match replay_memo(
|
||||
store,
|
||||
request,
|
||||
&signature,
|
||||
true,
|
||||
decoders,
|
||||
materializers,
|
||||
observer,
|
||||
)
|
||||
.await
|
||||
{
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => {
|
||||
diagnostic.diagnostics.push(format!("Memo 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 Memo 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(
|
||||
"Memo completed canonical hydration, core extraction, decode, annotation projection and idempotence validation"
|
||||
.to_string(),
|
||||
);
|
||||
}
|
||||
summary.post_execution = std::option::Option::Some(diagnostic);
|
||||
return std::result::Result::Ok(summary);
|
||||
}
|
||||
|
||||
fn validate_profile(
|
||||
profile: &ks_config::ProfileConfig,
|
||||
request: &crate::DevnetMemoExecutionRequest,
|
||||
) -> ks_core::Result<()> {
|
||||
if profile.wallet.cluster != "devnet" {
|
||||
return std::result::Result::Err(ks_core::Error::config(
|
||||
"Memo Devnet orchestration requires a Devnet wallet profile",
|
||||
));
|
||||
}
|
||||
if !profile.wallet.temporary_wallet_enabled || !profile.wallet.temporary_wallet_persist {
|
||||
return std::result::Result::Err(ks_core::Error::config(
|
||||
"Memo Devnet orchestration requires an enabled persistent temporary wallet",
|
||||
));
|
||||
}
|
||||
if !profile.execution.require_simulation {
|
||||
return std::result::Result::Err(ks_core::Error::config(
|
||||
"Memo Devnet orchestration requires simulation",
|
||||
));
|
||||
}
|
||||
if request.submit && !profile.wallet.devnet_send_enabled {
|
||||
return std::result::Result::Err(ks_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(ks_core::Error::config(
|
||||
"Memo Devnet submission requires explicit operator confirmation",
|
||||
));
|
||||
}
|
||||
return std::result::Result::Ok(());
|
||||
}
|
||||
|
||||
fn build_plan(
|
||||
profile: &ks_config::ProfileConfig,
|
||||
request: &crate::DevnetMemoExecutionRequest,
|
||||
fee_payer: ks_lib::MdPubkey,
|
||||
) -> ks_core::Result<ks_lib::ExApiPreparedExecutionPlan> {
|
||||
let signers = if request.include_wallet_as_memo_signer {
|
||||
std::vec![ks_lib::ExSplMemoSigner { pubkey: fee_payer.clone() }]
|
||||
} else {
|
||||
std::vec::Vec::new()
|
||||
};
|
||||
let intent = ks_lib::ExSplMemoExecutionIntent {
|
||||
intent_id: request.intent_id.clone(),
|
||||
fee_payer: fee_payer.clone(),
|
||||
policy: ks_lib::ExApiExecutionPolicy {
|
||||
cluster: ks_lib::ExApiExecutionClusterPolicy {
|
||||
expected_cluster: ks_lib::ExApiExecutionCluster::Devnet,
|
||||
allow_mainnet: false,
|
||||
mainnet_confirmation: false,
|
||||
},
|
||||
simulation: ks_lib::ExApiExecutionSimulationPolicy::Required,
|
||||
blockhash: ks_lib::ExApiExecutionBlockhashPolicy {
|
||||
kind: ks_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: ks_lib::ExApiExecutionCostLimit {
|
||||
max_spend_lamports: std::option::Option::Some(0),
|
||||
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: std::vec![fee_payer.clone()],
|
||||
dry_run: !request.submit,
|
||||
post_execution_validation: ks_lib::ExApiPostExecutionValidationPolicy {
|
||||
canonical_insert_required: true,
|
||||
core_extraction_required: true,
|
||||
decode_replay_required: true,
|
||||
materialization_required: true,
|
||||
},
|
||||
},
|
||||
operation: ks_lib::ExSplMemoOperation::AddMemo {
|
||||
generation: ks_lib::ExSplMemoGeneration::V4,
|
||||
message: request.message.clone(),
|
||||
signers,
|
||||
},
|
||||
};
|
||||
return ks_lib::ExApiTypedInstructionExecutor::build_prepared_plan(
|
||||
&ks_lib::ExSplMemoExecutor,
|
||||
&intent,
|
||||
);
|
||||
}
|
||||
|
||||
async fn hydrate_signature<S, O>(
|
||||
http_pool: &ks_onchain_transport::HttpEndpointPool,
|
||||
store: &S,
|
||||
profile: &ks_config::ProfileConfig,
|
||||
request: &crate::DevnetMemoExecutionRequest,
|
||||
observer: &O,
|
||||
signature: &ks_lib::MdSignature,
|
||||
) -> ks_core::Result<ks_pipeline::BackfillSummary>
|
||||
where
|
||||
S: ks_store::RawTransactionStore + Sync,
|
||||
O: crate::SolanaExecutionObserver,
|
||||
{
|
||||
let mut retry = 0_u32;
|
||||
loop {
|
||||
let result = match ks_pipeline::execute_http_backfill(
|
||||
http_pool,
|
||||
store,
|
||||
&ks_pipeline::BackfillRequest {
|
||||
role: request.query_role.clone(),
|
||||
commitment: "confirmed".to_string(),
|
||||
source: ks_pipeline::BackfillSource::ExplicitSignatures(std::vec![
|
||||
signature.0.clone()
|
||||
]),
|
||||
page_size: 1,
|
||||
max_pages: 1,
|
||||
max_concurrent_requests: 1,
|
||||
max_retries: 0,
|
||||
},
|
||||
observer,
|
||||
)
|
||||
.await
|
||||
{
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
if canonical_available(&result)
|
||||
|| retry >= request.post_validation_max_retries
|
||||
|| observer.is_execution_cancelled()
|
||||
{
|
||||
return std::result::Result::Ok(result);
|
||||
}
|
||||
retry = retry.saturating_add(1);
|
||||
tokio::time::sleep(std::time::Duration::from_millis(std::cmp::max(
|
||||
profile.execution.confirmation_poll_interval_ms,
|
||||
500,
|
||||
)))
|
||||
.await;
|
||||
}
|
||||
}
|
||||
|
||||
fn canonical_available(summary: &ks_pipeline::BackfillSummary) -> bool {
|
||||
return summary.failed == 0
|
||||
&& summary.missing == 0
|
||||
&& summary.candidates_completed == 1
|
||||
&& summary.candidates_cancelled == 0
|
||||
&& summary.candidates_not_started == 0
|
||||
&& summary
|
||||
.canonical_inserted
|
||||
.saturating_add(summary.canonical_skipped)
|
||||
.saturating_add(summary.existing_skipped)
|
||||
>= 1;
|
||||
}
|
||||
|
||||
async fn replay_memo<S, O>(
|
||||
store: &S,
|
||||
request: &crate::DevnetMemoExecutionRequest,
|
||||
signature: &ks_lib::MdSignature,
|
||||
include_materialized_state: bool,
|
||||
decoders: &[std::sync::Arc<dyn ks_lib::DcApiInstructionDecoder>],
|
||||
materializers: &[std::sync::Arc<dyn ks_lib::MtApiEventMaterializer>],
|
||||
observer: &O,
|
||||
) -> ks_core::Result<ks_pipeline::DecodeReplaySummary>
|
||||
where
|
||||
S: ks_store::DecodePipelineStore + Sync,
|
||||
O: crate::SolanaExecutionObserver,
|
||||
{
|
||||
let mut states = std::vec![
|
||||
ks_store::CoreInstructionProcessingState::Pending,
|
||||
ks_store::CoreInstructionProcessingState::Failed,
|
||||
ks_store::CoreInstructionProcessingState::ReplayRequested,
|
||||
];
|
||||
if include_materialized_state {
|
||||
states.push(ks_store::CoreInstructionProcessingState::Materialized);
|
||||
}
|
||||
let selection = match ks_store::DecodeSelectionFilter::new(
|
||||
std::vec![signature.0.clone()],
|
||||
states,
|
||||
std::option::Option::None,
|
||||
std::option::Option::None,
|
||||
std::vec![ks_program_ids::SPL_MEMO_V4_PROGRAM_ID.to_string()],
|
||||
std::vec::Vec::new(),
|
||||
false,
|
||||
8,
|
||||
) {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
return ks_pipeline::execute_decode_replay(
|
||||
store,
|
||||
&ks_pipeline::DecodeReplayRequest {
|
||||
campaign_id: ks_pipeline::new_decode_campaign_id(),
|
||||
selection,
|
||||
decoder_names: std::vec::Vec::new(),
|
||||
dispatch_policy: ks_pipeline::DecodeDispatchPolicy::HighestPriority,
|
||||
max_concurrent_inputs: 1,
|
||||
force_replay: if include_materialized_state {
|
||||
false
|
||||
} else {
|
||||
request.force_post_validation_replay
|
||||
},
|
||||
force_replay_all_matching: false,
|
||||
materialize_after_decode: true,
|
||||
},
|
||||
decoders,
|
||||
materializers,
|
||||
observer,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
fn decode_completed(summary: &ks_pipeline::DecodeReplaySummary) -> bool {
|
||||
return summary.failed_inputs == 0
|
||||
&& summary.processing_error_inputs == 0
|
||||
&& summary.unmatched == 0
|
||||
&& !summary.cancelled
|
||||
&& summary.completed >= 1
|
||||
&& summary.processors.iter().all(|processor| {
|
||||
return processor.failed == 0
|
||||
&& processor.processing_errors == 0
|
||||
&& processor.unsupported == 0
|
||||
&& processor.materialization_refused == 0;
|
||||
})
|
||||
&& summary.processors.iter().map(|processor| return processor.decoded).sum::<u64>() >= 1;
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
fn example_devnet_profile() -> ks_config::ProfileConfig {
|
||||
let config =
|
||||
match ks_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}"),
|
||||
};
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn request_is_simulation_only_and_bounded_by_default() {
|
||||
let request = crate::DevnetMemoExecutionRequest::new("memo-1", "audit annotation");
|
||||
assert!(!request.submit);
|
||||
assert!(request.include_wallet_as_memo_signer);
|
||||
assert!(request.validate().is_ok());
|
||||
let oversized = "x".repeat(ks_lib::EX_SPL_MEMO_MAX_MESSAGE_BYTES + 1);
|
||||
assert!(crate::DevnetMemoExecutionRequest::new("memo-2", oversized).validate().is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exact_v4_plan_has_zero_spend_and_wallet_signer() {
|
||||
let profile = example_devnet_profile();
|
||||
let fee_payer = ks_lib::MdPubkey(ks_program_ids::SYSTEM_PROGRAM_ID.to_string());
|
||||
let mut request = crate::DevnetMemoExecutionRequest::new("memo-3", "hello");
|
||||
request.submit = true;
|
||||
request.operator_confirmed = true;
|
||||
let plan = match super::build_plan(&profile, &request, fee_payer.clone()) {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => panic!("Memo plan failed: {error}"),
|
||||
};
|
||||
assert_eq!(plan.requested_spend_lamports, 0);
|
||||
assert_eq!(plan.fee_payer, fee_payer);
|
||||
assert_eq!(plan.instructions.len(), 1);
|
||||
assert_eq!(plan.instructions[0].program_id.0, ks_program_ids::SPL_MEMO_V4_PROGRAM_ID);
|
||||
assert_eq!(plan.instructions[0].accounts.len(), 1);
|
||||
assert!(plan.instructions[0].accounts[0].is_signer);
|
||||
assert!(!plan.instructions[0].accounts[0].is_writable);
|
||||
assert!(ks_lib::ExSafetyChecker.evaluate_prepared_plan(&plan).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn submission_requires_profile_enablement_and_confirmation() {
|
||||
let profile = example_devnet_profile();
|
||||
let mut request = crate::DevnetMemoExecutionRequest::new("memo-4", "hello");
|
||||
assert!(super::validate_profile(&profile, &request).is_ok());
|
||||
request.submit = true;
|
||||
assert!(super::validate_profile(&profile, &request).is_err());
|
||||
request.operator_confirmed = true;
|
||||
assert!(super::validate_profile(&profile, &request).is_ok());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn optional_devnet_memo_execution_from_env() {
|
||||
if std::env::var("KB_DEVNET_MEMO_EXECUTION_TEST").ok().as_deref()
|
||||
!= std::option::Option::Some("1")
|
||||
{
|
||||
return;
|
||||
}
|
||||
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: {error}");
|
||||
},
|
||||
};
|
||||
let mut profile = example_devnet_profile();
|
||||
profile.database.backend = "postgres".to_string();
|
||||
profile.database.postgres.url = database_url;
|
||||
if let std::result::Result::Ok(directory) = std::env::var("KB_DEVNET_WALLET_DIR") {
|
||||
profile.wallet.wallet_dir = directory;
|
||||
}
|
||||
let pool = match ks_onchain_transport::HttpEndpointPool::from_profile(&profile) {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => panic!("HTTP pool creation failed: {error}"),
|
||||
};
|
||||
let store_options = match ks_store::PostgresStoreOptions::new(
|
||||
profile.database.postgres.url.clone(),
|
||||
profile.database.postgres.max_connections,
|
||||
profile.database.postgres.connect_timeout_ms,
|
||||
false,
|
||||
) {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => panic!("PostgreSQL options failed: {error}"),
|
||||
};
|
||||
let store = match ks_store::PostgresStore::connect(store_options).await {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(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 mut request = crate::DevnetMemoExecutionRequest::new(
|
||||
format!("devnet-memo-test-{}", uuid::Uuid::new_v4()),
|
||||
format!("khadhroony-bot3 memo validation {}", uuid::Uuid::new_v4()),
|
||||
);
|
||||
request.post_validation_max_retries = 20;
|
||||
if std::env::var("KB_DEVNET_MEMO_SUBMIT").ok().as_deref() == std::option::Option::Some("1")
|
||||
{
|
||||
request.submit = true;
|
||||
request.operator_confirmed = true;
|
||||
}
|
||||
let decoders: std::vec::Vec<std::sync::Arc<dyn ks_lib::DcApiInstructionDecoder>> =
|
||||
std::vec![std::sync::Arc::new(ks_lib::DcSplMemoDecoder)];
|
||||
let materializers: std::vec::Vec<std::sync::Arc<dyn ks_lib::MtApiEventMaterializer>> =
|
||||
std::vec![std::sync::Arc::new(ks_lib::MtTransactionAnnotationMaterializer,)];
|
||||
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 summary = match crate::execute_devnet_memo(
|
||||
&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 Memo execution failed: {error}"),
|
||||
};
|
||||
assert!(summary.simulation.success);
|
||||
if request.submit {
|
||||
let post_execution = match summary.post_execution {
|
||||
std::option::Option::Some(value) => value,
|
||||
std::option::Option::None => panic!("Memo post-execution diagnostic missing"),
|
||||
};
|
||||
assert!(post_execution.canonical_inserted);
|
||||
assert!(post_execution.core_extracted);
|
||||
assert!(post_execution.decode_replayed);
|
||||
assert!(post_execution.materialized);
|
||||
assert!(!summary.annotations.is_empty());
|
||||
let idempotence = match summary.idempotence_replay {
|
||||
std::option::Option::Some(value) => value,
|
||||
std::option::Option::None => panic!("Memo idempotence replay missing"),
|
||||
};
|
||||
assert_eq!(
|
||||
idempotence
|
||||
.processors
|
||||
.iter()
|
||||
.map(|processor| return processor.materialized_outputs)
|
||||
.sum::<u64>(),
|
||||
0
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
7
ks-pipeline-demo-scenarios/src/spl/token.rs
Normal file
7
ks-pipeline-demo-scenarios/src/spl/token.rs
Normal file
@@ -0,0 +1,7 @@
|
||||
// file: ks-pipeline-demo-scenarios/src/spl/token.rs
|
||||
// version: 2
|
||||
|
||||
//! Classic SPL Token demo and Devnet validation scenarios.
|
||||
|
||||
pub(crate) mod execution;
|
||||
pub(crate) mod lifecycle;
|
||||
1354
ks-pipeline-demo-scenarios/src/spl/token/execution.rs
Normal file
1354
ks-pipeline-demo-scenarios/src/spl/token/execution.rs
Normal file
File diff suppressed because it is too large
Load Diff
1742
ks-pipeline-demo-scenarios/src/spl/token/lifecycle.rs
Normal file
1742
ks-pipeline-demo-scenarios/src/spl/token/lifecycle.rs
Normal file
File diff suppressed because it is too large
Load Diff
10
ks-pipeline-demo-scenarios/src/spl/token_2022.rs
Normal file
10
ks-pipeline-demo-scenarios/src/spl/token_2022.rs
Normal file
@@ -0,0 +1,10 @@
|
||||
// file: ks-pipeline-demo-scenarios/src/spl/token_2022.rs
|
||||
// version: 2
|
||||
|
||||
//! SPL Token-2022 demo and Devnet validation scenarios.
|
||||
|
||||
pub(crate) mod devnet_execution;
|
||||
pub(crate) mod devnet_scenarios;
|
||||
pub(crate) mod fixture;
|
||||
pub(crate) mod metadata;
|
||||
pub(crate) mod validation;
|
||||
1045
ks-pipeline-demo-scenarios/src/spl/token_2022/devnet_execution.rs
Normal file
1045
ks-pipeline-demo-scenarios/src/spl/token_2022/devnet_execution.rs
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,180 @@
|
||||
// file: ks-pipeline-demo-scenarios/src/spl/token_2022/devnet_scenarios.rs
|
||||
// version: 9
|
||||
|
||||
//! Stable Devnet validation scenarios required to close milestone 0.4.6.
|
||||
|
||||
/// Stable category of one independent Devnet validation scenario.
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum DevnetSplValidationFamily {
|
||||
/// Public Token-2022 state mutations without cryptographic proofs.
|
||||
Token2022Public,
|
||||
/// ElGamal Registry lifecycle kept technically separate from Token-2022.
|
||||
ElGamalRegistry,
|
||||
/// Token-2022 confidential operations requiring proof material.
|
||||
Token2022Confidential,
|
||||
}
|
||||
|
||||
/// Stable status of one scenario in the application validation workflow.
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum DevnetSplValidationImplementationStatus {
|
||||
/// The scenario can be simulated and submitted through the application.
|
||||
Executable,
|
||||
/// The typed backend exists but the application execution path remains to be connected.
|
||||
BackendReady,
|
||||
/// The scenario requires externally prepared proof material before execution.
|
||||
ProofFixtureRequired,
|
||||
}
|
||||
|
||||
/// One independent Devnet validation scenario exposed to applications.
|
||||
#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
|
||||
pub struct DevnetSplValidationScenario {
|
||||
/// Stable identifier used by scripts, tests and the frontend.
|
||||
pub id: std::string::String,
|
||||
/// Operator-visible label.
|
||||
pub label: std::string::String,
|
||||
/// Scenario family.
|
||||
pub family: crate::DevnetSplValidationFamily,
|
||||
/// Stable executor operation code.
|
||||
pub operation_code: std::string::String,
|
||||
/// Current implementation status.
|
||||
pub implementation_status: crate::DevnetSplValidationImplementationStatus,
|
||||
/// Whether this scenario mutates on-chain state.
|
||||
pub destructive: bool,
|
||||
/// Whether explicit operator confirmation is mandatory before submission.
|
||||
pub operator_confirmation_required: bool,
|
||||
/// Whether cryptographic proof material is required.
|
||||
pub proof_required: bool,
|
||||
/// Ordered fixture variables required by the scenario.
|
||||
pub required_fixture_variables: std::vec::Vec<std::string::String>,
|
||||
}
|
||||
|
||||
/// Returns the complete ordered Devnet scenario inventory for milestone 0.4.6.
|
||||
pub fn devnet_spl_validation_scenarios() -> std::vec::Vec<crate::DevnetSplValidationScenario> {
|
||||
return vec![
|
||||
public_scenario("token_2022_mint_to_checked", "Token-2022 MintToChecked", ks_lib::EX_SPL_TOKEN_2022_MINT_TO_CHECKED_OPERATION, &["TOKEN_2022_MINT", "TOKEN_2022_SOURCE", "TOKEN_2022_AUTHORITY", "TOKEN_2022_DECIMALS", "TOKEN_2022_MINT_AMOUNT_RAW"]),
|
||||
public_scenario("token_2022_transfer_checked", "Token-2022 TransferChecked", ks_lib::EX_SPL_TOKEN_2022_TRANSFER_CHECKED_OPERATION, &["TOKEN_2022_SOURCE", "TOKEN_2022_MINT", "TOKEN_2022_DESTINATION", "TOKEN_2022_AUTHORITY", "TOKEN_2022_DECIMALS", "TOKEN_2022_TRANSFER_AMOUNT_RAW"]),
|
||||
public_scenario("token_2022_approve_checked", "Token-2022 ApproveChecked", ks_lib::EX_SPL_TOKEN_2022_APPROVE_CHECKED_OPERATION, &["TOKEN_2022_SOURCE", "TOKEN_2022_MINT", "TOKEN_2022_DELEGATE", "TOKEN_2022_AUTHORITY", "TOKEN_2022_DECIMALS", "TOKEN_2022_APPROVE_AMOUNT_RAW"]),
|
||||
public_scenario("token_2022_revoke", "Token-2022 Revoke", ks_lib::EX_SPL_TOKEN_2022_REVOKE_OPERATION, &["TOKEN_2022_SOURCE", "TOKEN_2022_AUTHORITY"]),
|
||||
public_scenario("token_2022_burn_checked", "Token-2022 BurnChecked", ks_lib::EX_SPL_TOKEN_2022_BURN_CHECKED_OPERATION, &["TOKEN_2022_SOURCE", "TOKEN_2022_MINT", "TOKEN_2022_AUTHORITY", "TOKEN_2022_DECIMALS", "TOKEN_2022_BURN_AMOUNT_RAW"]),
|
||||
public_scenario("token_2022_freeze_account", "Token-2022 FreezeAccount", ks_lib::EX_SPL_TOKEN_2022_FREEZE_ACCOUNT_OPERATION, &["TOKEN_2022_SOURCE", "TOKEN_2022_MINT", "TOKEN_2022_FREEZE_AUTHORITY"]),
|
||||
public_scenario("token_2022_thaw_account", "Token-2022 ThawAccount", ks_lib::EX_SPL_TOKEN_2022_THAW_ACCOUNT_OPERATION, &["TOKEN_2022_SOURCE", "TOKEN_2022_MINT", "TOKEN_2022_FREEZE_AUTHORITY"]),
|
||||
public_scenario("token_2022_close_destination", "Token-2022 CloseAccount destination", ks_lib::EX_SPL_TOKEN_2022_CLOSE_ACCOUNT_OPERATION, &["TOKEN_2022_DESTINATION", "KB_DEVNET_WALLET_ADDRESS", "TOKEN_2022_AUTHORITY"]),
|
||||
registry_scenario("elgamal_registry_create", "ElGamal Registry CreateRegistry", "spl.elgamal_registry.create_registry", &["ELGAMAL_REGISTRY_ADDRESS", "ELGAMAL_PUBKEY_BASE64", "PUBKEY_VALIDITY_PROOF_CONTEXT"]),
|
||||
registry_scenario("elgamal_registry_update", "ElGamal Registry UpdateRegistry", "spl.elgamal_registry.update_registry", &["ELGAMAL_REGISTRY_ADDRESS", "ELGAMAL_PUBKEY_BASE64", "PUBKEY_VALIDITY_PROOF_CONTEXT"]),
|
||||
confidential_scenario("token_2022_configure_confidential_account", "Token-2022 ConfigureAccountWithRegistry", ks_lib::EX_SPL_TOKEN_2022_CONFIGURE_CONFIDENTIAL_TRANSFER_ACCOUNT_WITH_REGISTRY_OPERATION, &["TOKEN_2022_SOURCE", "TOKEN_2022_MINT", "ELGAMAL_REGISTRY_ADDRESS", "PUBKEY_VALIDITY_PROOF_CONTEXT"]),
|
||||
];
|
||||
}
|
||||
|
||||
fn public_scenario(
|
||||
id: &str,
|
||||
label: &str,
|
||||
operation_code: &str,
|
||||
variables: &[&str],
|
||||
) -> crate::DevnetSplValidationScenario {
|
||||
return scenario(
|
||||
id,
|
||||
label,
|
||||
crate::DevnetSplValidationFamily::Token2022Public,
|
||||
operation_code,
|
||||
crate::DevnetSplValidationImplementationStatus::Executable,
|
||||
true,
|
||||
false,
|
||||
variables,
|
||||
);
|
||||
}
|
||||
|
||||
fn registry_scenario(
|
||||
id: &str,
|
||||
label: &str,
|
||||
operation_code: &str,
|
||||
variables: &[&str],
|
||||
) -> crate::DevnetSplValidationScenario {
|
||||
return scenario(
|
||||
id,
|
||||
label,
|
||||
crate::DevnetSplValidationFamily::ElGamalRegistry,
|
||||
operation_code,
|
||||
crate::DevnetSplValidationImplementationStatus::ProofFixtureRequired,
|
||||
true,
|
||||
true,
|
||||
variables,
|
||||
);
|
||||
}
|
||||
|
||||
fn confidential_scenario(
|
||||
id: &str,
|
||||
label: &str,
|
||||
operation_code: &str,
|
||||
variables: &[&str],
|
||||
) -> crate::DevnetSplValidationScenario {
|
||||
return scenario(
|
||||
id,
|
||||
label,
|
||||
crate::DevnetSplValidationFamily::Token2022Confidential,
|
||||
operation_code,
|
||||
crate::DevnetSplValidationImplementationStatus::ProofFixtureRequired,
|
||||
true,
|
||||
true,
|
||||
variables,
|
||||
);
|
||||
}
|
||||
|
||||
fn scenario(
|
||||
id: &str,
|
||||
label: &str,
|
||||
family: crate::DevnetSplValidationFamily,
|
||||
operation_code: &str,
|
||||
implementation_status: crate::DevnetSplValidationImplementationStatus,
|
||||
destructive: bool,
|
||||
proof_required: bool,
|
||||
variables: &[&str],
|
||||
) -> crate::DevnetSplValidationScenario {
|
||||
return crate::DevnetSplValidationScenario {
|
||||
id: id.to_string(),
|
||||
label: label.to_string(),
|
||||
family,
|
||||
operation_code: operation_code.to_string(),
|
||||
implementation_status,
|
||||
destructive,
|
||||
operator_confirmation_required: destructive,
|
||||
proof_required,
|
||||
required_fixture_variables: variables
|
||||
.iter()
|
||||
.map(|value| return (*value).to_string())
|
||||
.collect(),
|
||||
};
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#[test]
|
||||
fn milestone_inventory_is_stable_unique_and_keeps_registry_separate() {
|
||||
let scenarios = crate::devnet_spl_validation_scenarios();
|
||||
assert_eq!(scenarios.len(), 11);
|
||||
let ids = scenarios
|
||||
.iter()
|
||||
.map(|scenario| return scenario.id.as_str())
|
||||
.collect::<std::collections::BTreeSet<&str>>();
|
||||
assert_eq!(ids.len(), scenarios.len());
|
||||
assert!(
|
||||
scenarios.iter().any(|scenario| return scenario.family
|
||||
== crate::DevnetSplValidationFamily::ElGamalRegistry)
|
||||
);
|
||||
assert!(
|
||||
scenarios
|
||||
.iter()
|
||||
.filter(|scenario| return scenario.family
|
||||
== crate::DevnetSplValidationFamily::Token2022Public)
|
||||
.all(|scenario| return !scenario.proof_required)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_mutating_scenario_requires_operator_confirmation() {
|
||||
assert!(crate::devnet_spl_validation_scenarios().iter().all(|scenario| {
|
||||
return !scenario.destructive || scenario.operator_confirmation_required;
|
||||
}));
|
||||
}
|
||||
}
|
||||
462
ks-pipeline-demo-scenarios/src/spl/token_2022/fixture.rs
Normal file
462
ks-pipeline-demo-scenarios/src/spl/token_2022/fixture.rs
Normal file
@@ -0,0 +1,462 @@
|
||||
// file: ks-pipeline-demo-scenarios/src/spl/token_2022/fixture.rs
|
||||
// version: 4
|
||||
|
||||
//! Idempotent Token-2022 public scenario fixture preparation.
|
||||
|
||||
/// Default raw amount minted by the public MintToChecked scenario.
|
||||
pub const DEFAULT_TOKEN_2022_MINT_AMOUNT_RAW: u64 = 10_000_000_000;
|
||||
/// Default raw amount transferred by the public TransferChecked scenario.
|
||||
pub const DEFAULT_TOKEN_2022_TRANSFER_AMOUNT_RAW: u64 = 1_000_000_000;
|
||||
/// Default raw amount delegated by the public ApproveChecked scenario.
|
||||
pub const DEFAULT_TOKEN_2022_APPROVE_AMOUNT_RAW: u64 = 2_000_000_000;
|
||||
/// Default raw amount burned by the public BurnChecked scenario.
|
||||
pub const DEFAULT_TOKEN_2022_BURN_AMOUNT_RAW: u64 = 1_000_000_000;
|
||||
|
||||
macro_rules! ks_try {
|
||||
($expression:expr) => {
|
||||
match $expression {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/// Command-line options for one Token-2022 fixture preparation.
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub struct Token2022FixturePreparationOptions {
|
||||
/// Devnet RPC URL used by Solana CLI commands.
|
||||
pub rpc_url: std::string::String,
|
||||
/// Persistent operator wallet keypair path.
|
||||
pub wallet_path: std::path::PathBuf,
|
||||
/// Wallet directory receiving public fixture files and temporary keypairs.
|
||||
pub wallet_dir: std::path::PathBuf,
|
||||
/// Mint decimals used by the public scenarios.
|
||||
pub decimals: u8,
|
||||
}
|
||||
|
||||
/// Public values written to one Token-2022 fixture file.
|
||||
#[derive(Clone, Debug, Eq, PartialEq, serde::Serialize)]
|
||||
pub struct Token2022FixturePreparationSummary {
|
||||
/// Generated fixture path.
|
||||
pub fixture_path: std::path::PathBuf,
|
||||
/// Canonical Token-2022 Program ID.
|
||||
pub program_id: std::string::String,
|
||||
/// Token-2022 mint address.
|
||||
pub mint: std::string::String,
|
||||
/// Source Token-2022 account address.
|
||||
pub source: std::string::String,
|
||||
/// Destination Token-2022 account address.
|
||||
pub destination: std::string::String,
|
||||
/// Empty Token-2022 account reserved for CloseAccount.
|
||||
pub close_account: std::string::String,
|
||||
/// Delegate public key.
|
||||
pub delegate: std::string::String,
|
||||
/// Wallet authority public key.
|
||||
pub authority: std::string::String,
|
||||
/// Freeze authority public key.
|
||||
pub freeze_authority: std::string::String,
|
||||
/// Mint decimals.
|
||||
pub decimals: u8,
|
||||
/// Whether the fixture file was replaced during this invocation.
|
||||
pub fixture_written: bool,
|
||||
}
|
||||
|
||||
/// Creates or reuses one complete Token-2022 public scenario fixture.
|
||||
pub async fn prepare_token_2022_fixture(
|
||||
options: &crate::Token2022FixturePreparationOptions,
|
||||
) -> ks_core::Result<crate::Token2022FixturePreparationSummary> {
|
||||
ks_try!(validate_options(options));
|
||||
let fixture_dir = options.wallet_dir.join("spl_token_2022_validation");
|
||||
if let std::result::Result::Err(error) = tokio::fs::create_dir_all(&fixture_dir).await {
|
||||
return std::result::Result::Err(ks_core::Error::io(format!(
|
||||
"unable to create Token-2022 fixture directory {}: {error}",
|
||||
fixture_dir.display()
|
||||
)));
|
||||
}
|
||||
let wallet_path = ks_try!(path_text(&options.wallet_path));
|
||||
let authority =
|
||||
ks_try!(command_stdout("solana-keygen", &["pubkey", wallet_path.as_str()]).await);
|
||||
let mint_keypair = fixture_dir.join("mint.json");
|
||||
let source_keypair = fixture_dir.join("source-account.json");
|
||||
let destination_keypair = fixture_dir.join("destination-account.json");
|
||||
let close_account_keypair = fixture_dir.join("close-account.json");
|
||||
let delegate_keypair = fixture_dir.join("delegate.json");
|
||||
ks_try!(ensure_keypair(&mint_keypair).await);
|
||||
ks_try!(ensure_keypair(&source_keypair).await);
|
||||
ks_try!(ensure_keypair(&destination_keypair).await);
|
||||
ks_try!(ensure_keypair(&close_account_keypair).await);
|
||||
ks_try!(ensure_keypair(&delegate_keypair).await);
|
||||
let mint = ks_try!(keypair_pubkey(&mint_keypair).await);
|
||||
let source = ks_try!(keypair_pubkey(&source_keypair).await);
|
||||
let destination = ks_try!(keypair_pubkey(&destination_keypair).await);
|
||||
let close_account = ks_try!(keypair_pubkey(&close_account_keypair).await);
|
||||
let delegate = ks_try!(keypair_pubkey(&delegate_keypair).await);
|
||||
let mint_exists = ks_try!(account_exists(options.rpc_url.as_str(), mint.as_str()).await);
|
||||
if !mint_exists {
|
||||
let decimals = options.decimals.to_string();
|
||||
let wallet_path = ks_try!(path_text(&options.wallet_path));
|
||||
let mint_keypair_path = ks_try!(path_text(&mint_keypair));
|
||||
ks_try!(
|
||||
run_command(
|
||||
"spl-token",
|
||||
&[
|
||||
"--url",
|
||||
options.rpc_url.as_str(),
|
||||
"--program-id",
|
||||
ks_program_ids::SPL_TOKEN_2022_PROGRAM_ID,
|
||||
"create-token",
|
||||
"--enable-freeze",
|
||||
"--decimals",
|
||||
decimals.as_str(),
|
||||
"--fee-payer",
|
||||
wallet_path.as_str(),
|
||||
"--mint-authority",
|
||||
authority.as_str(),
|
||||
mint_keypair_path.as_str()
|
||||
]
|
||||
)
|
||||
.await
|
||||
);
|
||||
}
|
||||
ks_try!(
|
||||
ensure_token_account(
|
||||
options,
|
||||
mint.as_str(),
|
||||
authority.as_str(),
|
||||
&source_keypair,
|
||||
source.as_str()
|
||||
)
|
||||
.await
|
||||
);
|
||||
ks_try!(
|
||||
ensure_token_account(
|
||||
options,
|
||||
mint.as_str(),
|
||||
authority.as_str(),
|
||||
&destination_keypair,
|
||||
destination.as_str()
|
||||
)
|
||||
.await
|
||||
);
|
||||
ks_try!(
|
||||
ensure_token_account(
|
||||
options,
|
||||
mint.as_str(),
|
||||
authority.as_str(),
|
||||
&close_account_keypair,
|
||||
close_account.as_str()
|
||||
)
|
||||
.await
|
||||
);
|
||||
let fixture_path = fixture_dir.join("fixture.env");
|
||||
let summary = crate::Token2022FixturePreparationSummary {
|
||||
fixture_path: fixture_path.clone(),
|
||||
program_id: ks_program_ids::SPL_TOKEN_2022_PROGRAM_ID.to_string(),
|
||||
mint,
|
||||
source,
|
||||
destination,
|
||||
close_account,
|
||||
delegate,
|
||||
authority: authority.clone(),
|
||||
freeze_authority: authority,
|
||||
decimals: options.decimals,
|
||||
fixture_written: true,
|
||||
};
|
||||
let contents = render_fixture(&summary);
|
||||
let fixture_written =
|
||||
ks_try!(write_fixture_atomically(&fixture_path, contents.as_bytes()).await);
|
||||
return std::result::Result::Ok(crate::Token2022FixturePreparationSummary {
|
||||
fixture_path,
|
||||
program_id: summary.program_id,
|
||||
mint: summary.mint,
|
||||
source: summary.source,
|
||||
destination: summary.destination,
|
||||
close_account: summary.close_account,
|
||||
delegate: summary.delegate,
|
||||
authority: summary.authority,
|
||||
freeze_authority: summary.freeze_authority,
|
||||
decimals: summary.decimals,
|
||||
fixture_written,
|
||||
});
|
||||
}
|
||||
|
||||
fn validate_options(options: &crate::Token2022FixturePreparationOptions) -> ks_core::Result<()> {
|
||||
if options.rpc_url.trim().is_empty() {
|
||||
return std::result::Result::Err(ks_core::Error::config(
|
||||
"Token-2022 fixture RPC URL must not be empty",
|
||||
));
|
||||
}
|
||||
if !options.wallet_path.is_file() {
|
||||
return std::result::Result::Err(ks_core::Error::config(format!(
|
||||
"Token-2022 fixture wallet does not exist: {}",
|
||||
options.wallet_path.display()
|
||||
)));
|
||||
}
|
||||
if options.decimals > 18 {
|
||||
return std::result::Result::Err(ks_core::Error::config(
|
||||
"Token-2022 fixture decimals must be at most 18",
|
||||
));
|
||||
}
|
||||
return std::result::Result::Ok(());
|
||||
}
|
||||
|
||||
async fn ensure_keypair(path: &std::path::Path) -> ks_core::Result<()> {
|
||||
if path.is_file() {
|
||||
return std::result::Result::Ok(());
|
||||
}
|
||||
let keypair_path = ks_try!(path_text(path));
|
||||
ks_try!(
|
||||
run_command(
|
||||
"solana-keygen",
|
||||
&[
|
||||
"new",
|
||||
"--no-bip39-passphrase",
|
||||
"--force",
|
||||
"--silent",
|
||||
"--outfile",
|
||||
keypair_path.as_str()
|
||||
]
|
||||
)
|
||||
.await
|
||||
);
|
||||
ks_try!(set_private_permissions(path).await);
|
||||
return std::result::Result::Ok(());
|
||||
}
|
||||
|
||||
async fn set_private_permissions(path: &std::path::Path) -> ks_core::Result<()> {
|
||||
#[cfg(unix)]
|
||||
{
|
||||
let metadata = match tokio::fs::metadata(path).await {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => {
|
||||
return std::result::Result::Err(ks_core::Error::io(format!(
|
||||
"unable to read keypair metadata {}: {error}",
|
||||
path.display()
|
||||
)));
|
||||
},
|
||||
};
|
||||
let mut permissions = metadata.permissions();
|
||||
std::os::unix::fs::PermissionsExt::set_mode(&mut permissions, 0o600);
|
||||
if let std::result::Result::Err(error) = tokio::fs::set_permissions(path, permissions).await
|
||||
{
|
||||
return std::result::Result::Err(ks_core::Error::io(format!(
|
||||
"unable to protect keypair {}: {error}",
|
||||
path.display()
|
||||
)));
|
||||
}
|
||||
}
|
||||
return std::result::Result::Ok(());
|
||||
}
|
||||
|
||||
async fn keypair_pubkey(path: &std::path::Path) -> ks_core::Result<std::string::String> {
|
||||
let keypair_path = ks_try!(path_text(path));
|
||||
return command_stdout("solana-keygen", &["pubkey", keypair_path.as_str()]).await;
|
||||
}
|
||||
|
||||
async fn account_exists(rpc_url: &str, address: &str) -> ks_core::Result<bool> {
|
||||
let output = match tokio::process::Command::new("solana")
|
||||
.args(["account", address, "--url", rpc_url, "--output", "json"])
|
||||
.output()
|
||||
.await
|
||||
{
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => {
|
||||
return std::result::Result::Err(ks_core::Error::io(format!(
|
||||
"unable to execute solana account: {error}"
|
||||
)));
|
||||
},
|
||||
};
|
||||
return std::result::Result::Ok(output.status.success());
|
||||
}
|
||||
|
||||
async fn ensure_token_account(
|
||||
options: &crate::Token2022FixturePreparationOptions,
|
||||
mint: &str,
|
||||
authority: &str,
|
||||
keypair_path: &std::path::Path,
|
||||
address: &str,
|
||||
) -> ks_core::Result<()> {
|
||||
let exists = ks_try!(account_exists(options.rpc_url.as_str(), address).await);
|
||||
if exists {
|
||||
return std::result::Result::Ok(());
|
||||
}
|
||||
let token_account_keypair = ks_try!(path_text(keypair_path));
|
||||
let wallet_path = ks_try!(path_text(&options.wallet_path));
|
||||
ks_try!(
|
||||
run_command(
|
||||
"spl-token",
|
||||
&[
|
||||
"--url",
|
||||
options.rpc_url.as_str(),
|
||||
"--program-id",
|
||||
ks_program_ids::SPL_TOKEN_2022_PROGRAM_ID,
|
||||
"create-account",
|
||||
mint,
|
||||
token_account_keypair.as_str(),
|
||||
"--owner",
|
||||
authority,
|
||||
"--fee-payer",
|
||||
wallet_path.as_str()
|
||||
]
|
||||
)
|
||||
.await
|
||||
);
|
||||
return std::result::Result::Ok(());
|
||||
}
|
||||
|
||||
async fn command_stdout(program: &str, arguments: &[&str]) -> ks_core::Result<std::string::String> {
|
||||
let output = match tokio::process::Command::new(program).args(arguments).output().await {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => {
|
||||
return std::result::Result::Err(ks_core::Error::io(format!(
|
||||
"unable to execute {program}: {error}"
|
||||
)));
|
||||
},
|
||||
};
|
||||
if !output.status.success() {
|
||||
return std::result::Result::Err(command_error(program, arguments, &output));
|
||||
}
|
||||
let value = match std::string::String::from_utf8(output.stdout) {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => {
|
||||
return std::result::Result::Err(ks_core::Error::invalid_state(format!(
|
||||
"{program} returned non-UTF-8 output: {error}"
|
||||
)));
|
||||
},
|
||||
};
|
||||
return std::result::Result::Ok(value.trim().to_string());
|
||||
}
|
||||
|
||||
async fn run_command(program: &str, arguments: &[&str]) -> ks_core::Result<()> {
|
||||
let output = match tokio::process::Command::new(program).args(arguments).output().await {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => {
|
||||
return std::result::Result::Err(ks_core::Error::io(format!(
|
||||
"unable to execute {program}: {error}"
|
||||
)));
|
||||
},
|
||||
};
|
||||
if !output.status.success() {
|
||||
return std::result::Result::Err(command_error(program, arguments, &output));
|
||||
}
|
||||
return std::result::Result::Ok(());
|
||||
}
|
||||
|
||||
fn command_error(
|
||||
program: &str,
|
||||
arguments: &[&str],
|
||||
output: &std::process::Output,
|
||||
) -> ks_core::Error {
|
||||
let stderr = std::string::String::from_utf8_lossy(output.stderr.as_slice())
|
||||
.trim()
|
||||
.to_string();
|
||||
let stdout = std::string::String::from_utf8_lossy(output.stdout.as_slice())
|
||||
.trim()
|
||||
.to_string();
|
||||
let detail = if stderr.is_empty() { stdout } else { stderr };
|
||||
return ks_core::Error::new(
|
||||
"token_2022_fixture_command_failed",
|
||||
format!(
|
||||
"command {program} {} failed with status {}: {detail}",
|
||||
arguments.join(" "),
|
||||
output.status
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
fn path_text(path: &std::path::Path) -> ks_core::Result<std::string::String> {
|
||||
return match path.to_str() {
|
||||
std::option::Option::Some(value) => std::result::Result::Ok(value.to_string()),
|
||||
std::option::Option::None => std::result::Result::Err(ks_core::Error::config(format!(
|
||||
"path is not valid UTF-8: {}",
|
||||
path.display()
|
||||
))),
|
||||
};
|
||||
}
|
||||
|
||||
fn render_fixture(summary: &crate::Token2022FixturePreparationSummary) -> std::string::String {
|
||||
return format!(
|
||||
"export TOKEN_2022_PROGRAM={}\nexport TOKEN_2022_MINT={}\nexport TOKEN_2022_SOURCE={}\nexport TOKEN_2022_DESTINATION={}\nexport TOKEN_2022_CLOSE_ACCOUNT={}\nexport TOKEN_2022_DELEGATE={}\nexport TOKEN_2022_AUTHORITY={}\nexport TOKEN_2022_FREEZE_AUTHORITY={}\nexport TOKEN_2022_DECIMALS={}\nexport TOKEN_2022_MINT_AMOUNT_RAW={}\nexport TOKEN_2022_TRANSFER_AMOUNT_RAW={}\nexport TOKEN_2022_APPROVE_AMOUNT_RAW={}\nexport TOKEN_2022_BURN_AMOUNT_RAW={}\nexport KB_DEVNET_WALLET_ADDRESS={}\n",
|
||||
summary.program_id,
|
||||
summary.mint,
|
||||
summary.source,
|
||||
summary.destination,
|
||||
summary.close_account,
|
||||
summary.delegate,
|
||||
summary.authority,
|
||||
summary.freeze_authority,
|
||||
summary.decimals,
|
||||
crate::DEFAULT_TOKEN_2022_MINT_AMOUNT_RAW,
|
||||
crate::DEFAULT_TOKEN_2022_TRANSFER_AMOUNT_RAW,
|
||||
crate::DEFAULT_TOKEN_2022_APPROVE_AMOUNT_RAW,
|
||||
crate::DEFAULT_TOKEN_2022_BURN_AMOUNT_RAW,
|
||||
summary.authority
|
||||
);
|
||||
}
|
||||
|
||||
async fn write_fixture_atomically(
|
||||
path: &std::path::Path,
|
||||
contents: &[u8],
|
||||
) -> ks_core::Result<bool> {
|
||||
if let std::result::Result::Ok(existing) = tokio::fs::read(path).await {
|
||||
if existing == contents {
|
||||
return std::result::Result::Ok(false);
|
||||
}
|
||||
}
|
||||
let temporary = path.with_extension("env.tmp");
|
||||
if let std::result::Result::Err(error) = tokio::fs::write(&temporary, contents).await {
|
||||
return std::result::Result::Err(ks_core::Error::io(format!(
|
||||
"unable to write temporary Token-2022 fixture {}: {error}",
|
||||
temporary.display()
|
||||
)));
|
||||
}
|
||||
if let std::result::Result::Err(error) = tokio::fs::rename(&temporary, path).await {
|
||||
return std::result::Result::Err(ks_core::Error::io(format!(
|
||||
"unable to replace Token-2022 fixture {}: {error}",
|
||||
path.display()
|
||||
)));
|
||||
}
|
||||
return std::result::Result::Ok(true);
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#[test]
|
||||
fn rendered_fixture_contains_every_public_scenario_variable() {
|
||||
let summary = super::Token2022FixturePreparationSummary {
|
||||
fixture_path: std::path::PathBuf::from("fixture.env"),
|
||||
program_id: ks_program_ids::SPL_TOKEN_2022_PROGRAM_ID.to_string(),
|
||||
mint: "mint".to_string(),
|
||||
source: "source".to_string(),
|
||||
destination: "destination".to_string(),
|
||||
close_account: "close".to_string(),
|
||||
delegate: "delegate".to_string(),
|
||||
authority: "authority".to_string(),
|
||||
freeze_authority: "authority".to_string(),
|
||||
decimals: 9,
|
||||
fixture_written: true,
|
||||
};
|
||||
let fixture = super::render_fixture(&summary);
|
||||
for scenario in crate::devnet_spl_validation_scenarios().into_iter().filter(|scenario| {
|
||||
return scenario.family == crate::DevnetSplValidationFamily::Token2022Public;
|
||||
}) {
|
||||
for variable in scenario.required_fixture_variables {
|
||||
assert!(fixture.lines().any(|line| return line.starts_with(format!("export {variable}=").as_str())), "missing {variable}");
|
||||
}
|
||||
}
|
||||
assert!(fixture.contains("export TOKEN_2022_CLOSE_ACCOUNT=close\n"));
|
||||
assert!(fixture.contains("export KB_DEVNET_WALLET_ADDRESS=authority\n"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn options_reject_excessive_decimals() {
|
||||
let options = super::Token2022FixturePreparationOptions {
|
||||
rpc_url: "https://api.devnet.solana.com".to_string(),
|
||||
wallet_path: std::path::PathBuf::from("missing.json"),
|
||||
wallet_dir: std::path::PathBuf::from("wallets"),
|
||||
decimals: 19,
|
||||
};
|
||||
let error = super::validate_options(&options);
|
||||
assert!(error.is_err());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
// file: ks-pipeline-demo-scenarios/src/spl/token_2022/metadata.rs
|
||||
// version: 2
|
||||
|
||||
//! Token-2022 embedded Token Metadata fixtures, campaigns and validation.
|
||||
|
||||
pub(crate) mod campaign;
|
||||
pub(crate) mod fixture;
|
||||
pub(crate) mod validation;
|
||||
@@ -0,0 +1,586 @@
|
||||
// file: ks-pipeline-demo-scenarios/src/spl/token_2022/metadata/campaign.rs
|
||||
// version: 5
|
||||
|
||||
//! Ordered confirmed Devnet campaign for the five Token-2022 Token Metadata instructions.
|
||||
|
||||
/// One confirmed Token Metadata campaign step and its authoritative postcondition.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct DevnetToken2022MetadataCampaignStepSummary {
|
||||
/// Stable step identifier.
|
||||
pub step_id: std::string::String,
|
||||
/// Canonical operation code built by `ks-lib`.
|
||||
pub operation_code: std::string::String,
|
||||
/// Complete simulation, submission, replay and instruction-materialization evidence.
|
||||
pub execution: crate::DevnetSplToken2022ExecutionSummary,
|
||||
/// Authoritative mint snapshot read at or after the confirmed transaction slot.
|
||||
pub stateful_snapshot: ks_pipeline::Token2022StatefulReadResult,
|
||||
/// Validated return-data evidence for the `Emit` step only.
|
||||
pub emit_evidence: std::option::Option<ks_pipeline::Token2022MetadataEmitEvidence>,
|
||||
/// Explicit stateful or return-data postcondition.
|
||||
pub postcondition: ks_pipeline::Token2022ExecutionPostcondition,
|
||||
}
|
||||
|
||||
/// Complete evidence retained by the five-step Token Metadata campaign.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct DevnetToken2022MetadataCampaignSummary {
|
||||
/// Fresh Token-2022 mint prepared for this campaign.
|
||||
pub fixture: crate::Token2022MetadataFixturePreparationSummary,
|
||||
/// Five confirmed steps in interface order.
|
||||
pub steps: std::vec::Vec<crate::DevnetToken2022MetadataCampaignStepSummary>,
|
||||
}
|
||||
|
||||
/// Returns the exact ordered operation names exercised by the Devnet campaign.
|
||||
pub fn token_2022_metadata_campaign_operation_names() -> &'static [&'static str; 5] {
|
||||
return &[
|
||||
"initialize_token_metadata",
|
||||
"update_token_metadata_field",
|
||||
"emit_token_metadata",
|
||||
"remove_token_metadata_key",
|
||||
"update_token_metadata_authority",
|
||||
];
|
||||
}
|
||||
|
||||
/// Prepares a fresh mint and executes all five Token Metadata interface instructions on Devnet.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub async fn execute_devnet_token_2022_metadata_campaign<S, O>(
|
||||
http_pool: &ks_onchain_transport::HttpEndpointPool,
|
||||
store: &S,
|
||||
profile: &ks_config::ProfileConfig,
|
||||
workspace_root: &std::path::Path,
|
||||
options: &crate::Token2022MetadataFixturePreparationOptions,
|
||||
observer: &O,
|
||||
) -> ks_core::Result<crate::DevnetToken2022MetadataCampaignSummary>
|
||||
where
|
||||
S: ks_store::RawTransactionStore
|
||||
+ ks_store::CoreExtractionStore
|
||||
+ ks_store::DecodePipelineStore
|
||||
+ Sync,
|
||||
O: crate::SolanaExecutionObserver,
|
||||
{
|
||||
if !options.operator_confirmed {
|
||||
return std::result::Result::Err(ks_core::Error::config(
|
||||
"Token-2022 Token Metadata Devnet campaign requires explicit operator confirmation",
|
||||
));
|
||||
}
|
||||
let fixture = match crate::prepare_token_2022_metadata_fixture(
|
||||
http_pool,
|
||||
profile,
|
||||
workspace_root,
|
||||
options,
|
||||
)
|
||||
.await
|
||||
{
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let operations = campaign_operations(&fixture);
|
||||
let decoders: std::vec::Vec<std::sync::Arc<dyn ks_lib::DcApiInstructionDecoder>> =
|
||||
std::vec![std::sync::Arc::new(ks_lib::DcSplToken2022Decoder)];
|
||||
let materializers: std::vec::Vec<std::sync::Arc<dyn ks_lib::MtApiEventMaterializer>> =
|
||||
std::vec![std::sync::Arc::new(ks_lib::MtMetadataToken2022Materializer)];
|
||||
let mut steps = std::vec::Vec::with_capacity(operations.len());
|
||||
for (index, (step_id, operation)) in operations.into_iter().enumerate() {
|
||||
let operation_code = operation.operation_code().to_string();
|
||||
let mut request = crate::DevnetSplToken2022ExecutionRequest::new(
|
||||
format!("token-2022-metadata-devnet-{step_id}-{}", uuid::Uuid::new_v4()),
|
||||
operation.clone(),
|
||||
);
|
||||
request.query_role = options.query_role.clone();
|
||||
request.transaction_role = options.transaction_role.clone();
|
||||
request.submit = true;
|
||||
request.operator_confirmed = true;
|
||||
request.post_validation_max_retries = 20;
|
||||
let execution = match crate::execute_devnet_spl_token_2022(
|
||||
http_pool,
|
||||
store,
|
||||
profile,
|
||||
workspace_root,
|
||||
&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 = 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(
|
||||
"token_2022_metadata_campaign_step_not_confirmed",
|
||||
format!("campaign step {step_id} stopped at {:?}", value.status),
|
||||
));
|
||||
},
|
||||
std::option::Option::None => {
|
||||
return std::result::Result::Err(ks_core::Error::new(
|
||||
"token_2022_metadata_campaign_confirmation_missing",
|
||||
format!("campaign step {step_id} 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(
|
||||
"token_2022_metadata_campaign_post_execution_missing",
|
||||
format!("campaign step {step_id} has no post-execution diagnostic"),
|
||||
));
|
||||
},
|
||||
};
|
||||
if !execution.simulation.success
|
||||
|| !diagnostic.canonical_inserted
|
||||
|| !diagnostic.core_extracted
|
||||
|| !diagnostic.decode_replayed
|
||||
{
|
||||
return std::result::Result::Err(ks_core::Error::new(
|
||||
"token_2022_metadata_campaign_replay_incomplete",
|
||||
format!(
|
||||
"campaign step {step_id} did not complete simulation and canonical decode replay"
|
||||
),
|
||||
));
|
||||
}
|
||||
let idempotence_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(
|
||||
"token_2022_metadata_campaign_idempotence_missing",
|
||||
format!("campaign step {step_id} has no idempotence replay evidence"),
|
||||
));
|
||||
},
|
||||
};
|
||||
if idempotence_replay.failed_inputs != 0
|
||||
|| idempotence_replay.processing_error_inputs != 0
|
||||
|| idempotence_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(
|
||||
"token_2022_metadata_campaign_idempotence_failed",
|
||||
format!("campaign step {step_id} did not produce a clean second replay"),
|
||||
));
|
||||
}
|
||||
if !execution.plan.policy.post_execution_validation.materialization_required
|
||||
&& operation_code != ks_lib::EX_SPL_TOKEN_2022_EMIT_TOKEN_METADATA_OPERATION
|
||||
{
|
||||
return std::result::Result::Err(ks_core::Error::new(
|
||||
"token_2022_metadata_campaign_materialization_policy_invalid",
|
||||
format!("campaign step {step_id} unexpectedly disabled materialization"),
|
||||
));
|
||||
}
|
||||
if execution.plan.policy.post_execution_validation.materialization_required
|
||||
&& execution.materializations.is_empty()
|
||||
{
|
||||
return std::result::Result::Err(ks_core::Error::new(
|
||||
"token_2022_metadata_campaign_materialization_missing",
|
||||
format!("campaign step {step_id} produced no metadata materialization"),
|
||||
));
|
||||
}
|
||||
let stateful_snapshot = match ks_pipeline::read_token_2022_stateful_snapshot(
|
||||
http_pool,
|
||||
&ks_pipeline::Token2022StatefulReadRequest {
|
||||
query_role: options.query_role.clone(),
|
||||
account: fixture.mint.clone(),
|
||||
kind: ks_lib::DcToken2022StateKind::Mint,
|
||||
min_context_slot: confirmation.slot,
|
||||
max_data_bytes: ks_pipeline::MAX_TOKEN_2022_STATEFUL_ACCOUNT_BYTES,
|
||||
context: ks_pipeline::Token2022StatefulContext::default(),
|
||||
},
|
||||
)
|
||||
.await
|
||||
{
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let emit_evidence = match &operation {
|
||||
ks_lib::ExSplToken2022Operation::Instruction { value } => match value.as_ref() {
|
||||
ks_lib::ExSplTokenSingleOperation::EmitTokenMetadata { start, end, .. } => {
|
||||
match ks_pipeline::inspect_token_2022_metadata_emit_simulation(
|
||||
&execution.simulation,
|
||||
*start,
|
||||
*end,
|
||||
) {
|
||||
std::result::Result::Ok(value) => std::option::Option::Some(value),
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
}
|
||||
},
|
||||
_ => std::option::Option::None,
|
||||
},
|
||||
ks_lib::ExSplToken2022Operation::Batch { instructions: _ } => std::option::Option::None,
|
||||
};
|
||||
let postcondition = campaign_postcondition(
|
||||
index,
|
||||
&fixture,
|
||||
&stateful_snapshot.snapshot,
|
||||
emit_evidence.as_ref(),
|
||||
);
|
||||
if postcondition.status != ks_pipeline::Token2022ExecutionPostconditionStatus::Confirmed {
|
||||
return std::result::Result::Err(ks_core::Error::new(
|
||||
"token_2022_metadata_campaign_postcondition_failed",
|
||||
format!("campaign step {step_id}: {}", postcondition.diagnostic),
|
||||
));
|
||||
}
|
||||
steps.push(crate::DevnetToken2022MetadataCampaignStepSummary {
|
||||
step_id: step_id.to_string(),
|
||||
operation_code,
|
||||
execution,
|
||||
stateful_snapshot,
|
||||
emit_evidence,
|
||||
postcondition,
|
||||
});
|
||||
}
|
||||
return std::result::Result::Ok(crate::DevnetToken2022MetadataCampaignSummary {
|
||||
fixture,
|
||||
steps,
|
||||
});
|
||||
}
|
||||
|
||||
fn campaign_operations(
|
||||
fixture: &crate::Token2022MetadataFixturePreparationSummary,
|
||||
) -> std::vec::Vec<(&'static str, ks_lib::ExSplToken2022Operation)> {
|
||||
let metadata = fixture.mint.clone();
|
||||
let authority = fixture.initial_authority.clone();
|
||||
return std::vec![
|
||||
(
|
||||
"token_2022_metadata_initialize",
|
||||
instruction(ks_lib::ExSplTokenSingleOperation::InitializeTokenMetadata {
|
||||
metadata: metadata.clone(),
|
||||
update_authority: authority.clone(),
|
||||
mint: metadata.clone(),
|
||||
mint_authority: authority.clone(),
|
||||
name: crate::TOKEN_2022_METADATA_CAMPAIGN_NAME.to_string(),
|
||||
symbol: crate::TOKEN_2022_METADATA_CAMPAIGN_SYMBOL.to_string(),
|
||||
uri: crate::TOKEN_2022_METADATA_CAMPAIGN_URI.to_string(),
|
||||
}),
|
||||
),
|
||||
(
|
||||
"token_2022_metadata_update_field",
|
||||
instruction(ks_lib::ExSplTokenSingleOperation::UpdateTokenMetadataField {
|
||||
metadata: metadata.clone(),
|
||||
update_authority: authority.clone(),
|
||||
field: ks_lib::ExSplTokenMetadataField::Key(
|
||||
crate::TOKEN_2022_METADATA_CAMPAIGN_KEY.to_string(),
|
||||
),
|
||||
value: crate::TOKEN_2022_METADATA_CAMPAIGN_VALUE.to_string(),
|
||||
}),
|
||||
),
|
||||
(
|
||||
"token_2022_metadata_emit",
|
||||
instruction(ks_lib::ExSplTokenSingleOperation::EmitTokenMetadata {
|
||||
metadata: metadata.clone(),
|
||||
start: std::option::Option::None,
|
||||
end: std::option::Option::None,
|
||||
}),
|
||||
),
|
||||
(
|
||||
"token_2022_metadata_remove_key",
|
||||
instruction(ks_lib::ExSplTokenSingleOperation::RemoveTokenMetadataKey {
|
||||
metadata: metadata.clone(),
|
||||
update_authority: authority.clone(),
|
||||
key: crate::TOKEN_2022_METADATA_CAMPAIGN_KEY.to_string(),
|
||||
idempotent: false,
|
||||
}),
|
||||
),
|
||||
(
|
||||
"token_2022_metadata_update_authority",
|
||||
instruction(ks_lib::ExSplTokenSingleOperation::UpdateTokenMetadataAuthority {
|
||||
metadata,
|
||||
current_authority: authority,
|
||||
new_authority: std::option::Option::Some(fixture.final_authority.clone()),
|
||||
}),
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
fn instruction(value: ks_lib::ExSplTokenSingleOperation) -> ks_lib::ExSplToken2022Operation {
|
||||
return ks_lib::ExSplToken2022Operation::Instruction { value: std::boxed::Box::new(value) };
|
||||
}
|
||||
|
||||
fn campaign_postcondition(
|
||||
step_index: usize,
|
||||
fixture: &crate::Token2022MetadataFixturePreparationSummary,
|
||||
snapshot: &ks_pipeline::Token2022StatefulSnapshotBundle,
|
||||
emit_evidence: std::option::Option<&ks_pipeline::Token2022MetadataEmitEvidence>,
|
||||
) -> ks_pipeline::Token2022ExecutionPostcondition {
|
||||
if step_index == 4 {
|
||||
return ks_pipeline::inspect_token_2022_metadata_authority_postcondition(
|
||||
&fixture.mint,
|
||||
std::option::Option::Some(&fixture.final_authority),
|
||||
snapshot,
|
||||
);
|
||||
}
|
||||
let value_fields = metadata_value_fields(snapshot);
|
||||
let confirmed = match step_index {
|
||||
0 => value_fields.is_some_and(|value| {
|
||||
return required_fields_match(value, fixture.initial_authority.0.as_str())
|
||||
&& !contains_campaign_pair(value);
|
||||
}),
|
||||
1 => value_fields.is_some_and(|value| {
|
||||
return required_fields_match(value, fixture.initial_authority.0.as_str())
|
||||
&& contains_campaign_pair(value);
|
||||
}),
|
||||
2 => {
|
||||
let snapshot_matches = value_fields.is_some_and(|value| {
|
||||
return required_fields_match(value, fixture.initial_authority.0.as_str())
|
||||
&& contains_campaign_pair(value);
|
||||
});
|
||||
let emit_matches = emit_evidence
|
||||
.and_then(|evidence| return evidence.decoded_metadata.as_ref())
|
||||
.is_some_and(|value| {
|
||||
return required_fields_match(value, fixture.initial_authority.0.as_str())
|
||||
&& contains_campaign_pair(value);
|
||||
});
|
||||
snapshot_matches && emit_matches
|
||||
},
|
||||
3 => value_fields.is_some_and(|value| {
|
||||
return required_fields_match(value, fixture.initial_authority.0.as_str())
|
||||
&& !contains_campaign_pair(value);
|
||||
}),
|
||||
_ => false,
|
||||
};
|
||||
return ks_pipeline::Token2022ExecutionPostcondition {
|
||||
role: "metadata".to_string(),
|
||||
account: fixture.mint.clone(),
|
||||
status: if confirmed {
|
||||
ks_pipeline::Token2022ExecutionPostconditionStatus::Confirmed
|
||||
} else {
|
||||
ks_pipeline::Token2022ExecutionPostconditionStatus::Contradicted
|
||||
},
|
||||
diagnostic: if confirmed {
|
||||
format!("Token-2022 metadata campaign postcondition {step_index} is confirmed")
|
||||
} else {
|
||||
format!("Token-2022 metadata campaign postcondition {step_index} is contradicted")
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
fn metadata_value_fields(
|
||||
snapshot: &ks_pipeline::Token2022StatefulSnapshotBundle,
|
||||
) -> std::option::Option<&serde_json::Value> {
|
||||
return snapshot.outputs.iter().find_map(|output| {
|
||||
if output.family != ks_lib::MdMaterializedEventFamily::Metadata
|
||||
|| output.payload_json.get("projectionKind").and_then(serde_json::Value::as_str)
|
||||
!= std::option::Option::Some("token_metadata")
|
||||
{
|
||||
return std::option::Option::None;
|
||||
}
|
||||
return output.payload_json.get("valueFields");
|
||||
});
|
||||
}
|
||||
|
||||
fn required_fields_match(value: &serde_json::Value, authority: &str) -> bool {
|
||||
return value.get("updateAuthority").and_then(serde_json::Value::as_str)
|
||||
== std::option::Option::Some(authority)
|
||||
&& value.get("name").and_then(serde_json::Value::as_str)
|
||||
== std::option::Option::Some(crate::TOKEN_2022_METADATA_CAMPAIGN_NAME)
|
||||
&& value.get("symbol").and_then(serde_json::Value::as_str)
|
||||
== std::option::Option::Some(crate::TOKEN_2022_METADATA_CAMPAIGN_SYMBOL)
|
||||
&& value.get("uri").and_then(serde_json::Value::as_str)
|
||||
== std::option::Option::Some(crate::TOKEN_2022_METADATA_CAMPAIGN_URI);
|
||||
}
|
||||
|
||||
fn contains_campaign_pair(value: &serde_json::Value) -> bool {
|
||||
return value
|
||||
.get("additionalMetadata")
|
||||
.and_then(serde_json::Value::as_array)
|
||||
.is_some_and(|pairs| {
|
||||
return pairs.iter().any(|pair| {
|
||||
return pair.get("key").and_then(serde_json::Value::as_str)
|
||||
== std::option::Option::Some(crate::TOKEN_2022_METADATA_CAMPAIGN_KEY)
|
||||
&& pair.get("value").and_then(serde_json::Value::as_str)
|
||||
== std::option::Option::Some(crate::TOKEN_2022_METADATA_CAMPAIGN_VALUE);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
fn pubkey(byte: u8) -> ks_lib::MdPubkey {
|
||||
return ks_lib::MdPubkey(bs58::encode([byte; 32]).into_string());
|
||||
}
|
||||
|
||||
fn fixture() -> crate::Token2022MetadataFixturePreparationSummary {
|
||||
return crate::Token2022MetadataFixturePreparationSummary {
|
||||
mint_keypair_path: std::path::PathBuf::from("mint.json"),
|
||||
final_authority_keypair_path: std::path::PathBuf::from("authority.json"),
|
||||
mint: pubkey(1),
|
||||
initial_authority: pubkey(2),
|
||||
final_authority: pubkey(3),
|
||||
mint_space: 234,
|
||||
rent_budget_space: 512,
|
||||
rent_reserve_lamports: 1,
|
||||
preparation_signature: "signature".to_string(),
|
||||
initial_snapshot: ks_pipeline::Token2022StatefulReadResult {
|
||||
commitment: "confirmed".to_string(),
|
||||
context_slot: 1,
|
||||
snapshot: ks_pipeline::Token2022StatefulSnapshotBundle {
|
||||
account_key: pubkey(1).0,
|
||||
slot: 1,
|
||||
state_kind: "mint".to_string(),
|
||||
extension_names: vec!["metadata_pointer".to_string()],
|
||||
outputs: std::vec::Vec::new(),
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn campaign_contract_covers_five_interface_operations_once_in_order() {
|
||||
let operations = super::campaign_operations(&fixture());
|
||||
assert_eq!(
|
||||
operations
|
||||
.iter()
|
||||
.map(|(_, operation)| return operation.operation_code())
|
||||
.collect::<std::vec::Vec<_>>(),
|
||||
vec![
|
||||
ks_lib::EX_SPL_TOKEN_2022_INITIALIZE_TOKEN_METADATA_OPERATION,
|
||||
ks_lib::EX_SPL_TOKEN_2022_UPDATE_TOKEN_METADATA_FIELD_OPERATION,
|
||||
ks_lib::EX_SPL_TOKEN_2022_EMIT_TOKEN_METADATA_OPERATION,
|
||||
ks_lib::EX_SPL_TOKEN_2022_REMOVE_TOKEN_METADATA_KEY_OPERATION,
|
||||
ks_lib::EX_SPL_TOKEN_2022_UPDATE_TOKEN_METADATA_AUTHORITY_OPERATION,
|
||||
]
|
||||
);
|
||||
assert_eq!(
|
||||
crate::token_2022_metadata_campaign_operation_names(),
|
||||
&[
|
||||
"initialize_token_metadata",
|
||||
"update_token_metadata_field",
|
||||
"emit_token_metadata",
|
||||
"remove_token_metadata_key",
|
||||
"update_token_metadata_authority",
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn campaign_pair_detection_is_exact_and_removal_is_observable() {
|
||||
let with_pair = serde_json::json!({
|
||||
"additionalMetadata": [{
|
||||
"key": crate::TOKEN_2022_METADATA_CAMPAIGN_KEY,
|
||||
"value": crate::TOKEN_2022_METADATA_CAMPAIGN_VALUE
|
||||
}]
|
||||
});
|
||||
let without_pair = serde_json::json!({"additionalMetadata": []});
|
||||
assert!(super::contains_campaign_pair(&with_pair));
|
||||
assert!(!super::contains_campaign_pair(&without_pair));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn optional_devnet_token_2022_metadata_campaign_from_env() {
|
||||
if std::env::var("KB_DEVNET_TOKEN_2022_METADATA_CAMPAIGN_TEST").ok().as_deref()
|
||||
!= std::option::Option::Some("1")
|
||||
{
|
||||
return;
|
||||
}
|
||||
if std::env::var("KB_DEVNET_TOKEN_2022_METADATA_OPERATOR_CONFIRMED")
|
||||
.ok()
|
||||
.as_deref()
|
||||
!= std::option::Option::Some("1")
|
||||
{
|
||||
panic!(
|
||||
"KB_DEVNET_TOKEN_2022_METADATA_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_path = match std::env::var("KB_DEVNET_CONFIG_PATH") {
|
||||
std::result::Result::Ok(value) => {
|
||||
let path = std::path::PathBuf::from(value);
|
||||
if path.is_absolute() { path } else { workspace_root.join(path) }
|
||||
},
|
||||
std::result::Result::Err(_) => workspace_root.join("config/example.config.json"),
|
||||
};
|
||||
let config = ks_config::read_config_json_file_with_environment(
|
||||
config_path.as_path(),
|
||||
workspace_root,
|
||||
)
|
||||
.unwrap_or_else(|error| panic!("Devnet configuration loading failed: {error}"));
|
||||
let profile_name = std::env::var("KB_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("KB_POSTGRES_TEST_URL")
|
||||
.unwrap_or_else(|error| panic!("KB_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.wallet_dir.as_str());
|
||||
let wallet_dir = if configured_wallet_dir.is_absolute() {
|
||||
configured_wallet_dir
|
||||
} else {
|
||||
workspace_root.join(configured_wallet_dir)
|
||||
};
|
||||
let mut options = crate::Token2022MetadataFixturePreparationOptions::new(wallet_dir);
|
||||
options.operator_confirmed = true;
|
||||
let summary = crate::execute_devnet_token_2022_metadata_campaign(
|
||||
&pool,
|
||||
&store,
|
||||
&profile,
|
||||
workspace_root,
|
||||
&options,
|
||||
&crate::NoopSolanaExecutionObserver,
|
||||
)
|
||||
.await
|
||||
.unwrap_or_else(|error| {
|
||||
panic!("Token-2022 Token Metadata Devnet campaign failed: {error}")
|
||||
});
|
||||
assert_eq!(summary.steps.len(), 5);
|
||||
assert!(summary.steps.iter().all(|step| {
|
||||
return step.postcondition.status
|
||||
== ks_pipeline::Token2022ExecutionPostconditionStatus::Confirmed;
|
||||
}));
|
||||
println!(
|
||||
"TOKEN_2022_METADATA_FIXTURE mint={} preparation_signature={} initial_authority={} final_authority={}",
|
||||
summary.fixture.mint.0,
|
||||
summary.fixture.preparation_signature,
|
||||
summary.fixture.initial_authority.0,
|
||||
summary.fixture.final_authority.0,
|
||||
);
|
||||
for step in &summary.steps {
|
||||
let signature = step
|
||||
.execution
|
||||
.confirmation
|
||||
.as_ref()
|
||||
.map(|value| return value.signature.0.as_str())
|
||||
.unwrap_or("missing");
|
||||
let slot = step.execution.confirmation.as_ref().and_then(|value| return value.slot);
|
||||
println!(
|
||||
"TOKEN_2022_METADATA_STEP id={} operation={} signature={} slot={:?} postcondition={:?} materializations={} emit_bytes={}",
|
||||
step.step_id,
|
||||
step.operation_code,
|
||||
signature,
|
||||
slot,
|
||||
step.postcondition.status,
|
||||
step.execution.materializations.len(),
|
||||
step.emit_evidence.as_ref().map(|value| return value.data.len()).unwrap_or(0),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,661 @@
|
||||
// file: ks-pipeline-demo-scenarios/src/spl/token_2022/metadata/fixture.rs
|
||||
// version: 4
|
||||
|
||||
//! Native Token-2022 mint preparation for the embedded Token Metadata Devnet campaign.
|
||||
|
||||
/// Stable metadata name used by the `0.4.8-pre.012` campaign.
|
||||
pub const TOKEN_2022_METADATA_CAMPAIGN_NAME: &str = "Khadhroony Token-2022 Metadata";
|
||||
/// Stable metadata symbol used by the `0.4.8-pre.012` campaign.
|
||||
pub const TOKEN_2022_METADATA_CAMPAIGN_SYMBOL: &str = "KBTM";
|
||||
/// Opaque URI stored on-chain by the `0.4.8-pre.012` campaign.
|
||||
pub const TOKEN_2022_METADATA_CAMPAIGN_URI: &str =
|
||||
"https://example.invalid/khadhroony/token-2022-metadata.json";
|
||||
/// Additional metadata key created and removed by the campaign.
|
||||
pub const TOKEN_2022_METADATA_CAMPAIGN_KEY: &str = "campaign";
|
||||
/// Additional metadata value created and removed by the campaign.
|
||||
pub const TOKEN_2022_METADATA_CAMPAIGN_VALUE: &str = "0.4.8-pre.012";
|
||||
|
||||
const TOKEN_2022_TLV_HEADER_BYTES: usize = 4;
|
||||
|
||||
/// Options used to prepare one fresh Token-2022 mint for the metadata campaign.
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub struct Token2022MetadataFixturePreparationOptions {
|
||||
/// Endpoint role used for state, rent, balance, blockhash and fee calls.
|
||||
pub query_role: std::string::String,
|
||||
/// Endpoint role used for simulation, submission and confirmation polling.
|
||||
pub transaction_role: std::string::String,
|
||||
/// Directory receiving persistent fixture keypairs.
|
||||
pub wallet_dir: std::path::PathBuf,
|
||||
/// Explicit operator authorization for the lamport-spending fixture transaction.
|
||||
pub operator_confirmed: bool,
|
||||
}
|
||||
|
||||
impl crate::Token2022MetadataFixturePreparationOptions {
|
||||
/// Creates conservative defaults matching Devnet execution profiles.
|
||||
pub fn new(wallet_dir: std::path::PathBuf) -> Self {
|
||||
return Self {
|
||||
query_role: "http_queries".to_string(),
|
||||
transaction_role: "http_transactions".to_string(),
|
||||
wallet_dir,
|
||||
operator_confirmed: false,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// Fresh Token-2022 mint and authority identities used by the metadata campaign.
|
||||
#[derive(Clone, Debug, PartialEq, serde::Serialize)]
|
||||
pub struct Token2022MetadataFixturePreparationSummary {
|
||||
/// Persistent fixture mint keypair path.
|
||||
pub mint_keypair_path: std::path::PathBuf,
|
||||
/// Persistent final metadata-authority keypair path.
|
||||
pub final_authority_keypair_path: std::path::PathBuf,
|
||||
/// Fresh Token-2022 mint address. Embedded metadata uses this same account.
|
||||
pub mint: ks_lib::MdPubkey,
|
||||
/// Profile wallet used as mint and initial metadata update authority.
|
||||
pub initial_authority: ks_lib::MdPubkey,
|
||||
/// Fresh public key installed by the final `UpdateAuthority` campaign step.
|
||||
pub final_authority: ks_lib::MdPubkey,
|
||||
/// Initial mint account bytes containing only the fixed Metadata Pointer extension.
|
||||
pub mint_space: usize,
|
||||
/// Account-size budget used to pre-fund rent for the largest campaign metadata state.
|
||||
pub rent_budget_space: usize,
|
||||
/// Rent reserve deposited when the mint is created.
|
||||
pub rent_reserve_lamports: u64,
|
||||
/// Confirmed signature that created the Token-2022 mint fixture.
|
||||
pub preparation_signature: std::string::String,
|
||||
/// Authoritative snapshot after mint creation and before Token Metadata initialization.
|
||||
pub initial_snapshot: ks_pipeline::Token2022StatefulReadResult,
|
||||
}
|
||||
|
||||
/// Creates a fresh Token-2022 mint with Metadata Pointer initialized to the mint itself.
|
||||
///
|
||||
/// The mint account is created with the fixed Metadata Pointer size but is pre-funded
|
||||
/// for the largest state reached by this campaign so Token Metadata reallocations
|
||||
/// remain rent-exempt without introducing an additional payer instruction.
|
||||
pub async fn prepare_token_2022_metadata_fixture(
|
||||
http_pool: &ks_onchain_transport::HttpEndpointPool,
|
||||
profile: &ks_config::ProfileConfig,
|
||||
workspace_root: &std::path::Path,
|
||||
options: &crate::Token2022MetadataFixturePreparationOptions,
|
||||
) -> ks_core::Result<crate::Token2022MetadataFixturePreparationSummary> {
|
||||
if options.query_role.trim().is_empty() || options.transaction_role.trim().is_empty() {
|
||||
return std::result::Result::Err(ks_core::Error::config(
|
||||
"Token-2022 metadata fixture endpoint roles must not be empty",
|
||||
));
|
||||
}
|
||||
if profile.wallet.cluster != "devnet"
|
||||
|| !profile.wallet.temporary_wallet_enabled
|
||||
|| !profile.wallet.temporary_wallet_persist
|
||||
{
|
||||
return std::result::Result::Err(ks_core::Error::config(
|
||||
"Token-2022 metadata fixture requires a persistent Devnet wallet profile",
|
||||
));
|
||||
}
|
||||
if !profile.wallet.devnet_send_enabled || !options.operator_confirmed {
|
||||
return std::result::Result::Err(ks_core::Error::config(
|
||||
"Token-2022 metadata fixture requires enabled Devnet submission and explicit operator confirmation",
|
||||
));
|
||||
}
|
||||
let operator = 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 fixture_store = match ks_wallet::TemporaryWalletStore::new(
|
||||
options.wallet_dir.join("token_2022_metadata_validation"),
|
||||
) {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let fixture_epoch = match std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH) {
|
||||
std::result::Result::Ok(value) => value.as_millis(),
|
||||
std::result::Result::Err(error) => {
|
||||
return std::result::Result::Err(ks_core::Error::config(format!(
|
||||
"unable to create a unique Token-2022 metadata fixture identifier: {error}"
|
||||
)));
|
||||
},
|
||||
};
|
||||
let mint_alias = match ks_wallet::WalletAlias::parse(
|
||||
format!("token-2022-metadata-mint-{fixture_epoch}").as_str(),
|
||||
) {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let final_authority_alias = match ks_wallet::WalletAlias::parse(
|
||||
format!("token-2022-metadata-authority-{fixture_epoch}").as_str(),
|
||||
) {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let mint_wallet = match fixture_store.load_or_create(mint_alias.clone()).await {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let final_authority_wallet =
|
||||
match fixture_store.load_or_create(final_authority_alias.clone()).await {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let mint = ks_lib::MdPubkey(mint_wallet.public_key());
|
||||
let initial_authority = ks_lib::MdPubkey(operator.public_key());
|
||||
let final_authority = ks_lib::MdPubkey(final_authority_wallet.public_key());
|
||||
let mint_space = match token_2022_metadata_pointer_mint_space() {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let rent_budget_space = metadata_campaign_rent_budget_space(mint_space);
|
||||
let preparation_signature = match create_native_token_2022_metadata_mint(
|
||||
http_pool,
|
||||
profile,
|
||||
options,
|
||||
&operator,
|
||||
&mint_wallet,
|
||||
mint_space,
|
||||
rent_budget_space,
|
||||
)
|
||||
.await
|
||||
{
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let initial_snapshot = match ks_pipeline::read_token_2022_stateful_snapshot(
|
||||
http_pool,
|
||||
&ks_pipeline::Token2022StatefulReadRequest {
|
||||
query_role: options.query_role.clone(),
|
||||
account: mint.clone(),
|
||||
kind: ks_lib::DcToken2022StateKind::Mint,
|
||||
min_context_slot: std::option::Option::None,
|
||||
max_data_bytes: ks_pipeline::MAX_TOKEN_2022_STATEFUL_ACCOUNT_BYTES,
|
||||
context: ks_pipeline::Token2022StatefulContext::default(),
|
||||
},
|
||||
)
|
||||
.await
|
||||
{
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
if !initial_snapshot
|
||||
.snapshot
|
||||
.extension_names
|
||||
.iter()
|
||||
.any(|value| return value == "metadata_pointer")
|
||||
|| initial_snapshot
|
||||
.snapshot
|
||||
.extension_names
|
||||
.iter()
|
||||
.any(|value| return value == "token_metadata")
|
||||
|| !metadata_pointer_snapshot_matches(&initial_snapshot.snapshot, &mint, &initial_authority)
|
||||
{
|
||||
return std::result::Result::Err(ks_core::Error::new(
|
||||
"token_2022_metadata_fixture_state_invalid",
|
||||
"fresh metadata fixture must contain the expected self-referential Metadata Pointer and no Token Metadata extension",
|
||||
));
|
||||
}
|
||||
let rent = match http_pool
|
||||
.get_minimum_balance_for_rent_exemption_for_role(
|
||||
options.query_role.as_str(),
|
||||
match u64::try_from(rent_budget_space) {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => {
|
||||
return std::result::Result::Err(ks_core::Error::config(format!(
|
||||
"Token-2022 metadata rent budget does not fit u64: {error}"
|
||||
)));
|
||||
},
|
||||
},
|
||||
&ks_onchain_transport::GetMinimumBalanceForRentExemptionConfig::confirmed(),
|
||||
)
|
||||
.await
|
||||
{
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
return std::result::Result::Ok(crate::Token2022MetadataFixturePreparationSummary {
|
||||
mint_keypair_path: fixture_store.wallet_path(&mint_alias),
|
||||
final_authority_keypair_path: fixture_store.wallet_path(&final_authority_alias),
|
||||
mint,
|
||||
initial_authority,
|
||||
final_authority,
|
||||
mint_space,
|
||||
rent_budget_space,
|
||||
rent_reserve_lamports: rent.minimum_balance_lamports,
|
||||
preparation_signature,
|
||||
initial_snapshot,
|
||||
});
|
||||
}
|
||||
|
||||
fn metadata_pointer_snapshot_matches(
|
||||
snapshot: &ks_pipeline::Token2022StatefulSnapshotBundle,
|
||||
mint: &ks_lib::MdPubkey,
|
||||
authority: &ks_lib::MdPubkey,
|
||||
) -> bool {
|
||||
return snapshot.outputs.iter().any(|output| {
|
||||
return output.family == ks_lib::MdMaterializedEventFamily::Admin
|
||||
&& output.payload_json.get("extensionName").and_then(serde_json::Value::as_str)
|
||||
== std::option::Option::Some("metadata_pointer")
|
||||
&& output
|
||||
.payload_json
|
||||
.get("valueFields")
|
||||
.and_then(|value| return value.get("address"))
|
||||
.and_then(serde_json::Value::as_str)
|
||||
== std::option::Option::Some(mint.0.as_str())
|
||||
&& output
|
||||
.payload_json
|
||||
.get("valueFields")
|
||||
.and_then(|value| return value.get("authority"))
|
||||
.and_then(serde_json::Value::as_str)
|
||||
== std::option::Option::Some(authority.0.as_str());
|
||||
});
|
||||
}
|
||||
|
||||
fn token_2022_metadata_pointer_mint_space() -> ks_core::Result<usize> {
|
||||
return match spl_token_2022_interface::extension::ExtensionType::try_calculate_account_len::<
|
||||
spl_token_2022_interface::pod::PodMint,
|
||||
>(&[spl_token_2022_interface::extension::ExtensionType::MetadataPointer])
|
||||
{
|
||||
std::result::Result::Ok(value) => std::result::Result::Ok(value),
|
||||
std::result::Result::Err(error) => std::result::Result::Err(ks_core::Error::new(
|
||||
"token_2022_metadata_fixture_space_failed",
|
||||
format!("unable to calculate Metadata Pointer mint space: {error}"),
|
||||
)),
|
||||
};
|
||||
}
|
||||
|
||||
fn metadata_campaign_rent_budget_space(mint_space: usize) -> usize {
|
||||
return mint_space
|
||||
.saturating_add(TOKEN_2022_TLV_HEADER_BYTES)
|
||||
.saturating_add(metadata_campaign_max_value_bytes());
|
||||
}
|
||||
|
||||
fn metadata_campaign_max_value_bytes() -> usize {
|
||||
return 64_usize
|
||||
.saturating_add(4)
|
||||
.saturating_add(TOKEN_2022_METADATA_CAMPAIGN_NAME.len())
|
||||
.saturating_add(4)
|
||||
.saturating_add(TOKEN_2022_METADATA_CAMPAIGN_SYMBOL.len())
|
||||
.saturating_add(4)
|
||||
.saturating_add(TOKEN_2022_METADATA_CAMPAIGN_URI.len())
|
||||
.saturating_add(4)
|
||||
.saturating_add(4)
|
||||
.saturating_add(TOKEN_2022_METADATA_CAMPAIGN_KEY.len())
|
||||
.saturating_add(4)
|
||||
.saturating_add(TOKEN_2022_METADATA_CAMPAIGN_VALUE.len());
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
async fn create_native_token_2022_metadata_mint(
|
||||
http_pool: &ks_onchain_transport::HttpEndpointPool,
|
||||
profile: &ks_config::ProfileConfig,
|
||||
options: &crate::Token2022MetadataFixturePreparationOptions,
|
||||
operator: &ks_wallet::TemporaryWallet,
|
||||
mint_wallet: &ks_wallet::TemporaryWallet,
|
||||
mint_space: usize,
|
||||
rent_budget_space: usize,
|
||||
) -> ks_core::Result<std::string::String> {
|
||||
let rent_space = match u64::try_from(rent_budget_space) {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => {
|
||||
return std::result::Result::Err(ks_core::Error::config(format!(
|
||||
"Token-2022 metadata rent budget does not fit u64: {error}"
|
||||
)));
|
||||
},
|
||||
};
|
||||
let account_space = match u64::try_from(mint_space) {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => {
|
||||
return std::result::Result::Err(ks_core::Error::config(format!(
|
||||
"Token-2022 Metadata Pointer mint space does not fit u64: {error}"
|
||||
)));
|
||||
},
|
||||
};
|
||||
let rent = match http_pool
|
||||
.get_minimum_balance_for_rent_exemption_for_role(
|
||||
options.query_role.as_str(),
|
||||
rent_space,
|
||||
&ks_onchain_transport::GetMinimumBalanceForRentExemptionConfig::confirmed(),
|
||||
)
|
||||
.await
|
||||
{
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let payer = ks_lib::MdPubkey(operator.public_key());
|
||||
let mint = ks_lib::MdPubkey(mint_wallet.public_key());
|
||||
let policy =
|
||||
fixture_policy(profile, payer.clone(), mint.clone(), rent.minimum_balance_lamports);
|
||||
let system_intent = ks_lib::ExSolanaCoreExecutionIntent {
|
||||
intent_id: format!("token-2022-metadata-fixture-create-account-{}", mint.0),
|
||||
fee_payer: payer.clone(),
|
||||
policy: policy.clone(),
|
||||
operation: ks_lib::ExSolanaCoreOperation::SystemCreateAccount {
|
||||
from: payer.clone(),
|
||||
new_account: mint.clone(),
|
||||
lamports: rent.minimum_balance_lamports,
|
||||
space: account_space,
|
||||
owner: ks_lib::MdPubkey(ks_program_ids::SPL_TOKEN_2022_PROGRAM_ID.to_string()),
|
||||
},
|
||||
};
|
||||
let system_plan = match ks_lib::ExApiTypedInstructionExecutor::build_prepared_plan(
|
||||
&ks_lib::ExSolanaCoreExecutor,
|
||||
&system_intent,
|
||||
) {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let token_program = match <solana_pubkey::Pubkey as std::str::FromStr>::from_str(
|
||||
ks_program_ids::SPL_TOKEN_2022_PROGRAM_ID,
|
||||
) {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => {
|
||||
return std::result::Result::Err(ks_core::Error::config(format!(
|
||||
"invalid Token-2022 Program ID: {error}"
|
||||
)));
|
||||
},
|
||||
};
|
||||
let mint_pubkey = match <solana_pubkey::Pubkey as std::str::FromStr>::from_str(mint.0.as_str())
|
||||
{
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => {
|
||||
return std::result::Result::Err(ks_core::Error::config(format!(
|
||||
"invalid Token-2022 metadata fixture mint: {error}"
|
||||
)));
|
||||
},
|
||||
};
|
||||
let authority_pubkey =
|
||||
match <solana_pubkey::Pubkey as std::str::FromStr>::from_str(payer.0.as_str()) {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => {
|
||||
return std::result::Result::Err(ks_core::Error::config(format!(
|
||||
"invalid Token-2022 metadata fixture authority: {error}"
|
||||
)));
|
||||
},
|
||||
};
|
||||
let pointer_instruction =
|
||||
match spl_token_2022_interface::extension::metadata_pointer::instruction::initialize(
|
||||
&token_program,
|
||||
&mint_pubkey,
|
||||
std::option::Option::Some(authority_pubkey),
|
||||
std::option::Option::Some(mint_pubkey),
|
||||
) {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => {
|
||||
return std::result::Result::Err(ks_core::Error::new(
|
||||
"token_2022_metadata_fixture_pointer_build_failed",
|
||||
error.to_string(),
|
||||
));
|
||||
},
|
||||
};
|
||||
let initialize_mint = match spl_token_2022_interface::instruction::initialize_mint2(
|
||||
&token_program,
|
||||
&mint_pubkey,
|
||||
&authority_pubkey,
|
||||
std::option::Option::Some(&authority_pubkey),
|
||||
0,
|
||||
) {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => {
|
||||
return std::result::Result::Err(ks_core::Error::new(
|
||||
"token_2022_metadata_fixture_initialize_mint_build_failed",
|
||||
error.to_string(),
|
||||
));
|
||||
},
|
||||
};
|
||||
let plan = merge_fixture_instructions(
|
||||
system_plan,
|
||||
std::vec![
|
||||
planned_instruction("spl.token_2022.initialize_metadata_pointer", &pointer_instruction,),
|
||||
planned_instruction("spl.token_2022.initialize_mint2", &initialize_mint),
|
||||
],
|
||||
policy,
|
||||
rent.minimum_balance_lamports,
|
||||
);
|
||||
let plan_evaluation = match ks_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 == ks_lib::ExSafetyDecision::Deny {
|
||||
return std::result::Result::Err(ks_core::Error::new(
|
||||
"token_2022_metadata_fixture_plan_denied",
|
||||
crate::violation_message(plan_evaluation.violations.as_slice()),
|
||||
));
|
||||
}
|
||||
let latest_blockhash = match http_pool
|
||||
.get_latest_blockhash_for_role(
|
||||
options.query_role.as_str(),
|
||||
&ks_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 ks_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 fee = match http_pool
|
||||
.get_fee_for_message_for_role(
|
||||
options.query_role.as_str(),
|
||||
unsigned.message_base64().as_str(),
|
||||
&ks_onchain_transport::GetFeeForMessageConfig::new(
|
||||
ks_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 ks_onchain_transport::SimulateTransactionConfig::new(
|
||||
ks_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),
|
||||
};
|
||||
let simulation_rpc = match http_pool
|
||||
.simulate_transaction_for_role(
|
||||
options.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(
|
||||
ks_lib::ExApiExecutionCluster::Devnet,
|
||||
ks_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),
|
||||
);
|
||||
if !simulation.success {
|
||||
return std::result::Result::Err(ks_core::Error::new(
|
||||
"token_2022_metadata_fixture_simulation_failed",
|
||||
crate::simulation_failure_message(&simulation),
|
||||
));
|
||||
}
|
||||
let send_evaluation = match ks_lib::ExSafetyChecker.evaluate_send(&plan, &simulation) {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
if send_evaluation.decision == ks_lib::ExSafetyDecision::Deny {
|
||||
return std::result::Result::Err(ks_core::Error::new(
|
||||
"token_2022_metadata_fixture_send_denied",
|
||||
crate::violation_message(send_evaluation.violations.as_slice()),
|
||||
));
|
||||
}
|
||||
let evidence = unsigned.bind_simulation(simulation);
|
||||
let signed = match unsigned
|
||||
.sign_after_simulation(&evidence, &[operator.as_signer(), mint_wallet.as_signer()])
|
||||
{
|
||||
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 send_config = match ks_onchain_transport::SendTransactionConfig::from_execution_config(
|
||||
&profile.execution,
|
||||
std::option::Option::Some(latest_blockhash.context.slot),
|
||||
) {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
if let std::result::Result::Err(error) = http_pool
|
||||
.send_transaction_for_role(
|
||||
options.transaction_role.as_str(),
|
||||
signed.transaction_base64().as_str(),
|
||||
&signature,
|
||||
&send_config,
|
||||
)
|
||||
.await
|
||||
{
|
||||
return std::result::Result::Err(error);
|
||||
}
|
||||
let confirmation_config =
|
||||
match ks_onchain_transport::ConfirmTransactionConfig::from_execution_config(
|
||||
&profile.execution,
|
||||
std::option::Option::Some(latest_blockhash.last_valid_block_height),
|
||||
std::option::Option::Some(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(
|
||||
options.transaction_role.as_str(),
|
||||
options.query_role.as_str(),
|
||||
ks_lib::ExApiExecutionCluster::Devnet,
|
||||
&signature,
|
||||
&confirmation_config,
|
||||
)
|
||||
.await
|
||||
{
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
if !matches!(
|
||||
confirmation.status,
|
||||
ks_lib::ExApiExecutionConfirmationStatus::Confirmed
|
||||
| ks_lib::ExApiExecutionConfirmationStatus::Finalized
|
||||
) {
|
||||
return std::result::Result::Err(ks_core::Error::new(
|
||||
"token_2022_metadata_fixture_confirmation_failed",
|
||||
format!("Token-2022 metadata mint preparation stopped at {:?}", confirmation.status),
|
||||
));
|
||||
}
|
||||
return std::result::Result::Ok(signature.0);
|
||||
}
|
||||
|
||||
fn planned_instruction(
|
||||
operation_code: &str,
|
||||
instruction: &solana_instruction::Instruction,
|
||||
) -> ks_lib::ExApiPlannedInstruction {
|
||||
return ks_lib::ExApiPlannedInstruction {
|
||||
program_id: ks_lib::MdProgramId(instruction.program_id.to_string()),
|
||||
operation_code: operation_code.to_string(),
|
||||
accounts: instruction
|
||||
.accounts
|
||||
.iter()
|
||||
.map(|account| {
|
||||
return ks_lib::ExApiPlannedAccount {
|
||||
pubkey: ks_lib::MdPubkey(account.pubkey.to_string()),
|
||||
is_signer: account.is_signer,
|
||||
is_writable: account.is_writable,
|
||||
};
|
||||
})
|
||||
.collect(),
|
||||
data: instruction.data.clone(),
|
||||
};
|
||||
}
|
||||
|
||||
fn fixture_policy(
|
||||
profile: &ks_config::ProfileConfig,
|
||||
payer: ks_lib::MdPubkey,
|
||||
mint: ks_lib::MdPubkey,
|
||||
_rent_lamports: u64,
|
||||
) -> ks_lib::ExApiExecutionPolicy {
|
||||
return ks_lib::ExApiExecutionPolicy {
|
||||
cluster: ks_lib::ExApiExecutionClusterPolicy {
|
||||
expected_cluster: ks_lib::ExApiExecutionCluster::Devnet,
|
||||
allow_mainnet: false,
|
||||
mainnet_confirmation: false,
|
||||
},
|
||||
simulation: ks_lib::ExApiExecutionSimulationPolicy::Required,
|
||||
blockhash: ks_lib::ExApiExecutionBlockhashPolicy {
|
||||
kind: ks_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: ks_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: std::vec![payer, mint],
|
||||
dry_run: false,
|
||||
post_execution_validation: ks_lib::ExApiPostExecutionValidationPolicy {
|
||||
canonical_insert_required: false,
|
||||
core_extraction_required: false,
|
||||
decode_replay_required: false,
|
||||
materialization_required: false,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
fn merge_fixture_instructions(
|
||||
mut system_plan: ks_lib::ExApiPreparedExecutionPlan,
|
||||
token_instructions: std::vec::Vec<ks_lib::ExApiPlannedInstruction>,
|
||||
policy: ks_lib::ExApiExecutionPolicy,
|
||||
rent_lamports: u64,
|
||||
) -> ks_lib::ExApiPreparedExecutionPlan {
|
||||
system_plan.instructions.extend(token_instructions);
|
||||
system_plan.executor_name =
|
||||
"ks-pipeline-demo-scenarios.token_2022_metadata_fixture".to_string();
|
||||
system_plan.operation_code = "spl.token_2022.metadata.prepare_mint".to_string();
|
||||
system_plan.policy = policy;
|
||||
system_plan.requested_spend_lamports = rent_lamports;
|
||||
return system_plan;
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#[test]
|
||||
fn metadata_rent_budget_covers_pointer_and_largest_campaign_value() {
|
||||
let mint_space = super::token_2022_metadata_pointer_mint_space()
|
||||
.unwrap_or_else(|error| panic!("metadata pointer mint size failed: {error}"));
|
||||
assert!(mint_space > 82);
|
||||
let budget = super::metadata_campaign_rent_budget_space(mint_space);
|
||||
assert_eq!(
|
||||
budget,
|
||||
mint_space
|
||||
+ super::TOKEN_2022_TLV_HEADER_BYTES
|
||||
+ super::metadata_campaign_max_value_bytes()
|
||||
);
|
||||
assert!(budget > mint_space);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,210 @@
|
||||
// file: ks-pipeline-demo-scenarios/src/spl/token_2022/metadata/validation.rs
|
||||
// version: 4
|
||||
|
||||
//! Conservative validation matrix for the `0.4.8-pre.012` Token Metadata campaign.
|
||||
|
||||
/// Maximum evidence items retained by one campaign scenario.
|
||||
pub const MAX_TOKEN_2022_METADATA_VALIDATION_EVIDENCE: usize = 16;
|
||||
|
||||
/// Exact network-validation status for one Token Metadata scenario.
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum Token2022MetadataValidationStatus {
|
||||
/// No real Devnet execution has been recorded.
|
||||
NotRun,
|
||||
/// The scenario completed with all declared evidence.
|
||||
Confirmed,
|
||||
/// The scenario is unavailable for an explicit recorded reason.
|
||||
Unavailable,
|
||||
}
|
||||
|
||||
/// One bounded evidence item retained by the validation matrix.
|
||||
#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Token2022MetadataValidationEvidence {
|
||||
/// Stable evidence kind.
|
||||
pub kind: std::string::String,
|
||||
/// Bounded evidence value such as a signature, slot or diagnostic.
|
||||
pub value: std::string::String,
|
||||
}
|
||||
|
||||
/// One ordered Token Metadata scenario in the canonical Devnet matrix.
|
||||
#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Token2022MetadataValidationScenario {
|
||||
/// Stable campaign step identifier.
|
||||
pub id: std::string::String,
|
||||
/// Short Token Metadata interface operation name.
|
||||
pub operation: std::string::String,
|
||||
/// Current evidence-qualified network status.
|
||||
pub status: crate::Token2022MetadataValidationStatus,
|
||||
/// Evidence kinds required before promotion to `confirmed`.
|
||||
pub required_evidence: std::vec::Vec<std::string::String>,
|
||||
/// Bounded observed evidence.
|
||||
pub evidence: std::vec::Vec<crate::Token2022MetadataValidationEvidence>,
|
||||
}
|
||||
|
||||
/// Canonical `0.4.8-pre.012` Token Metadata Devnet validation matrix.
|
||||
#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Token2022MetadataValidationMatrix {
|
||||
/// Matrix schema version.
|
||||
pub matrix_version: u32,
|
||||
/// Milestone owning the campaign.
|
||||
pub milestone: std::string::String,
|
||||
/// Token-2022 program ID.
|
||||
pub program_id: std::string::String,
|
||||
/// Five scenarios in campaign order.
|
||||
pub scenarios: std::vec::Vec<crate::Token2022MetadataValidationScenario>,
|
||||
}
|
||||
|
||||
/// Loads and validates the canonical Token Metadata Devnet matrix.
|
||||
pub fn load_token_2022_metadata_validation_matrix()
|
||||
-> ks_core::Result<crate::Token2022MetadataValidationMatrix> {
|
||||
let matrix = match serde_json::from_str::<crate::Token2022MetadataValidationMatrix>(
|
||||
include_str!(
|
||||
"../../../../../test-fixtures/contract-matrices/SPL_TOKEN_2022_METADATA_DEVNET_VALIDATION_MATRIX.json"
|
||||
),
|
||||
) {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => {
|
||||
return std::result::Result::Err(ks_core::Error::new(
|
||||
"token_2022_metadata_validation_matrix_invalid_json",
|
||||
error.to_string(),
|
||||
));
|
||||
},
|
||||
};
|
||||
return match crate::validate_token_2022_metadata_validation_matrix(&matrix) {
|
||||
std::result::Result::Ok(()) => std::result::Result::Ok(matrix),
|
||||
std::result::Result::Err(error) => std::result::Result::Err(error),
|
||||
};
|
||||
}
|
||||
|
||||
/// Validates campaign coverage and prevents promotion without complete evidence.
|
||||
pub fn validate_token_2022_metadata_validation_matrix(
|
||||
matrix: &crate::Token2022MetadataValidationMatrix,
|
||||
) -> ks_core::Result<()> {
|
||||
if matrix.matrix_version != 1
|
||||
|| matrix.milestone != "0.4.8-pre.012"
|
||||
|| matrix.program_id != ks_program_ids::SPL_TOKEN_2022_PROGRAM_ID
|
||||
{
|
||||
return std::result::Result::Err(ks_core::Error::config(
|
||||
"Token-2022 metadata validation matrix identity is invalid",
|
||||
));
|
||||
}
|
||||
let expected = crate::token_2022_metadata_campaign_operation_names();
|
||||
if matrix.scenarios.len() != expected.len() {
|
||||
return std::result::Result::Err(ks_core::Error::config(format!(
|
||||
"Token-2022 metadata validation matrix requires exactly {} scenarios",
|
||||
expected.len()
|
||||
)));
|
||||
}
|
||||
let mut ids = std::collections::BTreeSet::new();
|
||||
for (index, scenario) in matrix.scenarios.iter().enumerate() {
|
||||
if !ids.insert(scenario.id.as_str()) {
|
||||
return std::result::Result::Err(ks_core::Error::config(format!(
|
||||
"duplicate Token-2022 metadata validation scenario {}",
|
||||
scenario.id
|
||||
)));
|
||||
}
|
||||
if scenario.operation != expected[index] {
|
||||
return std::result::Result::Err(ks_core::Error::config(format!(
|
||||
"Token-2022 metadata scenario {} expected operation {}, got {}",
|
||||
scenario.id, expected[index], scenario.operation
|
||||
)));
|
||||
}
|
||||
if scenario.required_evidence.is_empty()
|
||||
|| scenario.required_evidence.len() > MAX_TOKEN_2022_METADATA_VALIDATION_EVIDENCE
|
||||
|| scenario.evidence.len() > MAX_TOKEN_2022_METADATA_VALIDATION_EVIDENCE
|
||||
{
|
||||
return std::result::Result::Err(ks_core::Error::config(format!(
|
||||
"Token-2022 metadata scenario {} has invalid evidence bounds",
|
||||
scenario.id
|
||||
)));
|
||||
}
|
||||
let mut required = std::collections::BTreeSet::new();
|
||||
if scenario
|
||||
.required_evidence
|
||||
.iter()
|
||||
.any(|kind| return kind.trim().is_empty() || !required.insert(kind.as_str()))
|
||||
{
|
||||
return std::result::Result::Err(ks_core::Error::config(format!(
|
||||
"Token-2022 metadata scenario {} has empty or duplicate required evidence",
|
||||
scenario.id
|
||||
)));
|
||||
}
|
||||
let mut observed = std::collections::BTreeSet::new();
|
||||
for evidence in &scenario.evidence {
|
||||
if evidence.kind.trim().is_empty()
|
||||
|| evidence.value.trim().is_empty()
|
||||
|| !observed.insert(evidence.kind.as_str())
|
||||
{
|
||||
return std::result::Result::Err(ks_core::Error::config(format!(
|
||||
"Token-2022 metadata scenario {} has invalid observed evidence",
|
||||
scenario.id
|
||||
)));
|
||||
}
|
||||
}
|
||||
match scenario.status {
|
||||
crate::Token2022MetadataValidationStatus::NotRun => {
|
||||
if !scenario.evidence.is_empty() {
|
||||
return std::result::Result::Err(ks_core::Error::config(format!(
|
||||
"not-run Token-2022 metadata scenario {} cannot claim evidence",
|
||||
scenario.id
|
||||
)));
|
||||
}
|
||||
},
|
||||
crate::Token2022MetadataValidationStatus::Confirmed => {
|
||||
if scenario
|
||||
.required_evidence
|
||||
.iter()
|
||||
.any(|kind| return !observed.contains(kind.as_str()))
|
||||
{
|
||||
return std::result::Result::Err(ks_core::Error::config(format!(
|
||||
"confirmed Token-2022 metadata scenario {} is missing required evidence",
|
||||
scenario.id
|
||||
)));
|
||||
}
|
||||
},
|
||||
crate::Token2022MetadataValidationStatus::Unavailable => {
|
||||
if !observed.contains("unavailable_reason") {
|
||||
return std::result::Result::Err(ks_core::Error::config(format!(
|
||||
"unavailable Token-2022 metadata scenario {} requires unavailable_reason",
|
||||
scenario.id
|
||||
)));
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
return std::result::Result::Ok(());
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#[test]
|
||||
fn canonical_matrix_is_closed_ordered_and_confirmed_after_network_execution() {
|
||||
let matrix = crate::load_token_2022_metadata_validation_matrix()
|
||||
.unwrap_or_else(|error| panic!("Token-2022 metadata matrix failed: {error}"));
|
||||
assert_eq!(matrix.scenarios.len(), 5);
|
||||
assert!(matrix.scenarios.iter().all(|scenario| {
|
||||
let observed = scenario
|
||||
.evidence
|
||||
.iter()
|
||||
.map(|evidence| return evidence.kind.as_str())
|
||||
.collect::<std::collections::BTreeSet<_>>();
|
||||
return scenario.status == crate::Token2022MetadataValidationStatus::Confirmed
|
||||
&& scenario
|
||||
.required_evidence
|
||||
.iter()
|
||||
.all(|kind| return observed.contains(kind.as_str()));
|
||||
}));
|
||||
assert_eq!(
|
||||
matrix
|
||||
.scenarios
|
||||
.iter()
|
||||
.map(|scenario| return scenario.operation.as_str())
|
||||
.collect::<std::vec::Vec<_>>(),
|
||||
crate::token_2022_metadata_campaign_operation_names().as_slice()
|
||||
);
|
||||
}
|
||||
}
|
||||
440
ks-pipeline-demo-scenarios/src/spl/token_2022/validation.rs
Normal file
440
ks-pipeline-demo-scenarios/src/spl/token_2022/validation.rs
Normal file
@@ -0,0 +1,440 @@
|
||||
// file: ks-pipeline-demo-scenarios/src/spl/token_2022/validation.rs
|
||||
// version: 8
|
||||
|
||||
//! Machine-readable validation evidence contract for the Token-2022 milestone.
|
||||
|
||||
/// Maximum number of evidence records accepted in one validation report.
|
||||
pub const MAX_TOKEN_2022_VALIDATION_EVIDENCE: usize = 64;
|
||||
|
||||
/// Required validation environment for one scenario.
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum Token2022ValidationEnvironment {
|
||||
/// Deterministic offline fixtures and builder comparisons.
|
||||
Offline,
|
||||
/// A local validator under operator control.
|
||||
Localnet,
|
||||
/// Solana Devnet.
|
||||
Devnet,
|
||||
/// Scenario may run on Localnet or Devnet according to deployment availability.
|
||||
LocalnetOrDevnet,
|
||||
/// Mainnet observations without mutable execution.
|
||||
MainnetObservation,
|
||||
/// PostgreSQL persistence and replay validation.
|
||||
Postgres,
|
||||
/// Tauri application smoke validation.
|
||||
Tauri,
|
||||
}
|
||||
|
||||
/// Exact status of one validation scenario.
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum Token2022ValidationStatus {
|
||||
/// Scenario is declared but has not been run.
|
||||
NotRun,
|
||||
/// Scenario was simulated without submission.
|
||||
Simulated,
|
||||
/// Scenario was submitted but confirmation evidence is incomplete.
|
||||
Submitted,
|
||||
/// Scenario was confirmed and its required postconditions passed.
|
||||
Confirmed,
|
||||
/// Scenario is not available in the selected environment.
|
||||
Unavailable,
|
||||
/// Scenario ran and failed.
|
||||
Failed,
|
||||
}
|
||||
|
||||
/// One bounded proof attached to a validation scenario.
|
||||
#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
|
||||
pub struct Token2022ValidationEvidence {
|
||||
/// Stable evidence kind such as `signature`, `test_suite`, or `replay`.
|
||||
pub kind: std::string::String,
|
||||
/// Bounded evidence value.
|
||||
pub value: std::string::String,
|
||||
}
|
||||
|
||||
/// One declared Token-2022 validation scenario and its observed evidence.
|
||||
#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
|
||||
pub struct Token2022ValidationScenario {
|
||||
/// Stable scenario identifier.
|
||||
pub id: std::string::String,
|
||||
/// Validation environment.
|
||||
pub environment: crate::Token2022ValidationEnvironment,
|
||||
/// Exact observed status.
|
||||
pub status: crate::Token2022ValidationStatus,
|
||||
/// Whether canonical hydration is required.
|
||||
pub requires_canonical_hydration: bool,
|
||||
/// Whether core extraction is required.
|
||||
pub requires_core_extraction: bool,
|
||||
/// Whether decode replay is required.
|
||||
pub requires_decode_replay: bool,
|
||||
/// Whether materialization is required.
|
||||
pub requires_materialization: bool,
|
||||
/// Whether a second idempotent replay is required.
|
||||
pub requires_second_replay: bool,
|
||||
/// Ordered evidence records.
|
||||
pub evidence: std::vec::Vec<crate::Token2022ValidationEvidence>,
|
||||
}
|
||||
|
||||
/// Machine-readable validation matrix loaded from the canonical JSON document.
|
||||
#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Token2022ValidationMatrix {
|
||||
/// Matrix schema version.
|
||||
pub matrix_version: u32,
|
||||
/// Milestone owning this matrix.
|
||||
pub milestone: std::string::String,
|
||||
/// Aggregate matrix status.
|
||||
pub status: std::string::String,
|
||||
/// Exact accepted status vocabulary.
|
||||
pub status_vocabulary: std::vec::Vec<std::string::String>,
|
||||
/// Scenarios in stable roadmap order.
|
||||
pub scenarios: std::vec::Vec<crate::Token2022ValidationMatrixScenario>,
|
||||
}
|
||||
|
||||
/// One scenario declared by the canonical validation matrix.
|
||||
#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Token2022ValidationMatrixScenario {
|
||||
/// Stable scenario identifier.
|
||||
pub id: std::string::String,
|
||||
/// Required execution environment.
|
||||
pub environment: crate::Token2022ValidationEnvironment,
|
||||
/// Exact observed status.
|
||||
pub status: crate::Token2022ValidationStatus,
|
||||
/// Evidence kinds required before this scenario may be confirmed.
|
||||
pub required_evidence: std::vec::Vec<std::string::String>,
|
||||
/// Observed bounded evidence.
|
||||
#[serde(default)]
|
||||
pub evidence: std::vec::Vec<crate::Token2022ValidationEvidence>,
|
||||
}
|
||||
|
||||
/// Loads and validates the canonical Token-2022 validation matrix.
|
||||
pub fn load_token_2022_validation_matrix() -> ks_core::Result<crate::Token2022ValidationMatrix> {
|
||||
let parsed = match serde_json::from_str::<crate::Token2022ValidationMatrix>(include_str!(
|
||||
"../../../../test-fixtures/contract-matrices/SPL_TOKEN_2022_VALIDATION_MATRIX.json"
|
||||
)) {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => {
|
||||
return std::result::Result::Err(ks_core::Error::new(
|
||||
"token_2022_validation_matrix_invalid_json",
|
||||
format!("Token-2022 validation matrix JSON is invalid: {error}"),
|
||||
));
|
||||
},
|
||||
};
|
||||
if let std::result::Result::Err(error) = validate_token_2022_validation_matrix(&parsed) {
|
||||
return std::result::Result::Err(error);
|
||||
}
|
||||
return std::result::Result::Ok(parsed);
|
||||
}
|
||||
|
||||
/// Validates schema, scenario inventory, statuses, and observed evidence.
|
||||
pub fn validate_token_2022_validation_matrix(
|
||||
matrix: &crate::Token2022ValidationMatrix,
|
||||
) -> ks_core::Result<()> {
|
||||
if matrix.matrix_version != 2 || matrix.milestone != "0.4.6" {
|
||||
return std::result::Result::Err(ks_core::Error::new(
|
||||
"token_2022_validation_matrix_contract_mismatch",
|
||||
"Token-2022 validation matrix must use version 2 for milestone 0.4.6",
|
||||
));
|
||||
}
|
||||
let expected_statuses =
|
||||
["not_run", "simulated", "submitted", "confirmed", "unavailable", "failed"];
|
||||
let actual_statuses = matrix
|
||||
.status_vocabulary
|
||||
.iter()
|
||||
.map(|status| return status.as_str())
|
||||
.collect::<std::vec::Vec<&str>>();
|
||||
if actual_statuses.as_slice() != expected_statuses {
|
||||
return std::result::Result::Err(ks_core::Error::new(
|
||||
"token_2022_validation_matrix_status_vocabulary_mismatch",
|
||||
"Token-2022 validation status vocabulary differs from the compiled contract",
|
||||
));
|
||||
}
|
||||
let expected_ids = [
|
||||
"offline_full_regression",
|
||||
"token_2022_public_devnet",
|
||||
"elgamal_registry_devnet",
|
||||
"confidential_transfer_localnet_or_devnet",
|
||||
"confidential_mint_burn_localnet_or_devnet",
|
||||
"permissioned_confidential_burn_localnet_or_devnet",
|
||||
"mainnet_observation_corpus",
|
||||
"postgres_double_replay",
|
||||
"tauri_smoke",
|
||||
];
|
||||
let actual_ids = matrix
|
||||
.scenarios
|
||||
.iter()
|
||||
.map(|scenario| return scenario.id.as_str())
|
||||
.collect::<std::vec::Vec<&str>>();
|
||||
if actual_ids.as_slice() != expected_ids {
|
||||
return std::result::Result::Err(ks_core::Error::new(
|
||||
"token_2022_validation_matrix_scenario_inventory_mismatch",
|
||||
"Token-2022 validation scenario inventory or order differs from the compiled contract",
|
||||
));
|
||||
}
|
||||
for scenario in &matrix.scenarios {
|
||||
if scenario.required_evidence.is_empty()
|
||||
|| scenario.required_evidence.len() > crate::MAX_TOKEN_2022_VALIDATION_EVIDENCE
|
||||
{
|
||||
return std::result::Result::Err(ks_core::Error::new(
|
||||
"token_2022_validation_matrix_required_evidence_invalid",
|
||||
"Every Token-2022 validation scenario must declare bounded required evidence",
|
||||
));
|
||||
}
|
||||
let mut required = std::collections::BTreeSet::<&str>::new();
|
||||
for evidence_kind in &scenario.required_evidence {
|
||||
if evidence_kind.trim().is_empty() || !required.insert(evidence_kind.as_str()) {
|
||||
return std::result::Result::Err(ks_core::Error::new(
|
||||
"token_2022_validation_matrix_required_evidence_invalid",
|
||||
"Required Token-2022 validation evidence must be non-empty and unique",
|
||||
));
|
||||
}
|
||||
}
|
||||
if scenario.status == crate::Token2022ValidationStatus::Confirmed {
|
||||
let observed = scenario
|
||||
.evidence
|
||||
.iter()
|
||||
.map(|evidence| return evidence.kind.as_str())
|
||||
.collect::<std::collections::BTreeSet<&str>>();
|
||||
if !required.iter().all(|kind| return observed.contains(kind)) {
|
||||
return std::result::Result::Err(ks_core::Error::new(
|
||||
"token_2022_validation_matrix_confirmed_without_required_evidence",
|
||||
format!(
|
||||
"Confirmed Token-2022 validation scenario {} lacks required evidence",
|
||||
scenario.id
|
||||
),
|
||||
));
|
||||
}
|
||||
} else if matches!(
|
||||
scenario.status,
|
||||
crate::Token2022ValidationStatus::NotRun
|
||||
| crate::Token2022ValidationStatus::Unavailable
|
||||
) && !scenario.evidence.is_empty()
|
||||
{
|
||||
return std::result::Result::Err(ks_core::Error::new(
|
||||
"token_2022_validation_matrix_unobserved_with_evidence",
|
||||
"Not-run or unavailable Token-2022 scenarios must not retain observed evidence",
|
||||
));
|
||||
}
|
||||
}
|
||||
return std::result::Result::Ok(());
|
||||
}
|
||||
|
||||
/// Complete validation report checked before milestone closure.
|
||||
#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
|
||||
pub struct Token2022ValidationReport {
|
||||
/// Scenarios in stable roadmap order.
|
||||
pub scenarios: std::vec::Vec<crate::Token2022ValidationScenario>,
|
||||
}
|
||||
|
||||
/// Validates that a milestone report is bounded, unique, and does not overclaim evidence.
|
||||
pub fn validate_token_2022_validation_report(
|
||||
report: &crate::Token2022ValidationReport,
|
||||
) -> ks_core::Result<()> {
|
||||
if report.scenarios.is_empty() {
|
||||
return std::result::Result::Err(ks_core::Error::config(
|
||||
"Token-2022 validation report must contain at least one scenario",
|
||||
));
|
||||
}
|
||||
let mut ids = std::collections::BTreeSet::<std::string::String>::new();
|
||||
for scenario in &report.scenarios {
|
||||
if scenario.id.trim().is_empty() || !ids.insert(scenario.id.clone()) {
|
||||
return std::result::Result::Err(ks_core::Error::new(
|
||||
"token_2022_validation_scenario_identity_invalid",
|
||||
"Token-2022 validation scenario ids must be non-empty and unique",
|
||||
));
|
||||
}
|
||||
if scenario.evidence.len() > crate::MAX_TOKEN_2022_VALIDATION_EVIDENCE {
|
||||
return std::result::Result::Err(ks_core::Error::new(
|
||||
"token_2022_validation_evidence_limit_exceeded",
|
||||
format!(
|
||||
"Token-2022 validation accepts at most {} evidence records per scenario",
|
||||
crate::MAX_TOKEN_2022_VALIDATION_EVIDENCE
|
||||
),
|
||||
));
|
||||
}
|
||||
for evidence in &scenario.evidence {
|
||||
if evidence.kind.trim().is_empty() || evidence.value.trim().is_empty() {
|
||||
return std::result::Result::Err(ks_core::Error::new(
|
||||
"token_2022_validation_evidence_invalid",
|
||||
"Token-2022 validation evidence kind and value must be non-empty",
|
||||
));
|
||||
}
|
||||
}
|
||||
let completed = matches!(
|
||||
scenario.status,
|
||||
crate::Token2022ValidationStatus::Simulated
|
||||
| crate::Token2022ValidationStatus::Submitted
|
||||
| crate::Token2022ValidationStatus::Confirmed
|
||||
| crate::Token2022ValidationStatus::Failed
|
||||
);
|
||||
if completed && scenario.evidence.is_empty() {
|
||||
return std::result::Result::Err(ks_core::Error::new(
|
||||
"token_2022_validation_completed_without_evidence",
|
||||
"A completed Token-2022 validation scenario must retain evidence",
|
||||
));
|
||||
}
|
||||
if scenario.status == crate::Token2022ValidationStatus::Confirmed
|
||||
&& (scenario.requires_canonical_hydration
|
||||
|| scenario.requires_core_extraction
|
||||
|| scenario.requires_decode_replay
|
||||
|| scenario.requires_materialization
|
||||
|| scenario.requires_second_replay)
|
||||
&& !has_pipeline_evidence(scenario)
|
||||
{
|
||||
return std::result::Result::Err(ks_core::Error::new(
|
||||
"token_2022_validation_confirmed_without_pipeline_evidence",
|
||||
"A confirmed end-to-end Token-2022 scenario must retain hydration, extraction, replay, materialization, and idempotence evidence",
|
||||
));
|
||||
}
|
||||
}
|
||||
return std::result::Result::Ok(());
|
||||
}
|
||||
|
||||
fn has_pipeline_evidence(scenario: &crate::Token2022ValidationScenario) -> bool {
|
||||
let kinds = scenario
|
||||
.evidence
|
||||
.iter()
|
||||
.map(|evidence| return evidence.kind.as_str())
|
||||
.collect::<std::collections::BTreeSet<&str>>();
|
||||
if scenario.requires_canonical_hydration && !kinds.contains("canonical_hydration") {
|
||||
return false;
|
||||
}
|
||||
if scenario.requires_core_extraction && !kinds.contains("core_extraction") {
|
||||
return false;
|
||||
}
|
||||
if scenario.requires_decode_replay && !kinds.contains("decode_replay") {
|
||||
return false;
|
||||
}
|
||||
if scenario.requires_materialization && !kinds.contains("materialization") {
|
||||
return false;
|
||||
}
|
||||
if scenario.requires_second_replay && !kinds.contains("second_replay") {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
fn scenario(status: crate::Token2022ValidationStatus) -> crate::Token2022ValidationScenario {
|
||||
return crate::Token2022ValidationScenario {
|
||||
id: "confidential_transfer_devnet".to_string(),
|
||||
environment: crate::Token2022ValidationEnvironment::Devnet,
|
||||
status,
|
||||
requires_canonical_hydration: true,
|
||||
requires_core_extraction: true,
|
||||
requires_decode_replay: true,
|
||||
requires_materialization: true,
|
||||
requires_second_replay: true,
|
||||
evidence: vec![
|
||||
crate::Token2022ValidationEvidence {
|
||||
kind: "signature".to_string(),
|
||||
value: "signature".to_string(),
|
||||
},
|
||||
crate::Token2022ValidationEvidence {
|
||||
kind: "canonical_hydration".to_string(),
|
||||
value: "1".to_string(),
|
||||
},
|
||||
crate::Token2022ValidationEvidence {
|
||||
kind: "core_extraction".to_string(),
|
||||
value: "1".to_string(),
|
||||
},
|
||||
crate::Token2022ValidationEvidence {
|
||||
kind: "decode_replay".to_string(),
|
||||
value: "1".to_string(),
|
||||
},
|
||||
crate::Token2022ValidationEvidence {
|
||||
kind: "materialization".to_string(),
|
||||
value: "1".to_string(),
|
||||
},
|
||||
crate::Token2022ValidationEvidence {
|
||||
kind: "second_replay".to_string(),
|
||||
value: "0_new_outputs".to_string(),
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn confirmed_end_to_end_scenario_requires_complete_pipeline_evidence() {
|
||||
let report = crate::Token2022ValidationReport {
|
||||
scenarios: vec![scenario(crate::Token2022ValidationStatus::Confirmed)],
|
||||
};
|
||||
assert!(crate::validate_token_2022_validation_report(&report).is_ok());
|
||||
let mut incomplete = scenario(crate::Token2022ValidationStatus::Confirmed);
|
||||
incomplete.evidence.retain(|evidence| return evidence.kind != "second_replay");
|
||||
assert!(
|
||||
crate::validate_token_2022_validation_report(&crate::Token2022ValidationReport {
|
||||
scenarios: vec![incomplete]
|
||||
})
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn not_run_and_unavailable_scenarios_do_not_invent_evidence() {
|
||||
let mut not_run = scenario(crate::Token2022ValidationStatus::NotRun);
|
||||
not_run.evidence.clear();
|
||||
let mut unavailable = scenario(crate::Token2022ValidationStatus::Unavailable);
|
||||
unavailable.id = "permissioned_burn_devnet".to_string();
|
||||
unavailable.evidence.clear();
|
||||
assert!(
|
||||
crate::validate_token_2022_validation_report(&crate::Token2022ValidationReport {
|
||||
scenarios: vec![not_run, unavailable]
|
||||
})
|
||||
.is_ok()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn duplicate_ids_empty_evidence_and_completed_without_evidence_fail_closed() {
|
||||
let first = scenario(crate::Token2022ValidationStatus::Confirmed);
|
||||
let duplicate = first.clone();
|
||||
assert!(
|
||||
crate::validate_token_2022_validation_report(&crate::Token2022ValidationReport {
|
||||
scenarios: vec![first, duplicate]
|
||||
})
|
||||
.is_err()
|
||||
);
|
||||
let mut failed = scenario(crate::Token2022ValidationStatus::Failed);
|
||||
failed.evidence.clear();
|
||||
assert!(
|
||||
crate::validate_token_2022_validation_report(&crate::Token2022ValidationReport {
|
||||
scenarios: vec![failed]
|
||||
})
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn canonical_matrix_matches_compiled_inventory_and_observed_offline_evidence() {
|
||||
let matrix = crate::load_token_2022_validation_matrix();
|
||||
assert!(matrix.is_ok());
|
||||
let matrix = match matrix {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => panic!("unexpected matrix error: {error}"),
|
||||
};
|
||||
assert_eq!(matrix.scenarios.len(), 9);
|
||||
assert_eq!(matrix.scenarios[0].status, crate::Token2022ValidationStatus::Confirmed);
|
||||
assert_eq!(matrix.scenarios[0].evidence.len(), 4);
|
||||
assert!(
|
||||
matrix.scenarios[1..]
|
||||
.iter()
|
||||
.all(|scenario| return scenario.status == crate::Token2022ValidationStatus::NotRun)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn confirmed_matrix_scenario_requires_every_declared_evidence_kind() {
|
||||
let matrix = crate::load_token_2022_validation_matrix();
|
||||
assert!(matrix.is_ok());
|
||||
let mut matrix = match matrix {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => panic!("unexpected matrix error: {error}"),
|
||||
};
|
||||
matrix.scenarios[0].evidence.retain(|evidence| return evidence.kind != "clippy");
|
||||
assert!(crate::validate_token_2022_validation_matrix(&matrix).is_err());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user