1546 lines
63 KiB
Rust
1546 lines
63 KiB
Rust
// file: ks-pipeline-demo-scenarios/src/metadata/metaplex_token_metadata/fixture.rs
|
|
// version: 22
|
|
|
|
//! Native Metaplex Token Metadata fixture preparation for Devnet demos.
|
|
|
|
/// Options used to create one fresh classic SPL mint for a Metaplex Create demo.
|
|
#[derive(Clone, Debug, Eq, PartialEq)]
|
|
pub struct MetaplexCreateFixturePreparationOptions {
|
|
/// 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 the persistent fixture mint keypair.
|
|
pub wallet_dir: std::path::PathBuf,
|
|
/// Asset family whose mint and Create arguments must remain coherent.
|
|
pub asset_family: crate::MetaplexTokenMetadataAssetFamily,
|
|
/// Optional collection parent mint linked to this asset as an initially unverified member.
|
|
pub collection_mint: std::option::Option<std::string::String>,
|
|
/// Optional limited print supply reserved for one printable NFT master edition.
|
|
pub print_supply_limit: std::option::Option<u64>,
|
|
/// Optional bounded `Multiple` uses metadata reserved for the current Use probe.
|
|
pub uses: std::option::Option<mpl_token_metadata::types::Uses>,
|
|
}
|
|
|
|
impl crate::MetaplexCreateFixturePreparationOptions {
|
|
/// Creates conservative defaults matching the 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,
|
|
asset_family: crate::MetaplexTokenMetadataAssetFamily::Nft,
|
|
collection_mint: std::option::Option::None,
|
|
print_supply_limit: std::option::Option::None,
|
|
uses: std::option::Option::None,
|
|
};
|
|
}
|
|
|
|
/// Selects the asset family prepared by this fixture.
|
|
pub fn with_asset_family(
|
|
mut self,
|
|
asset_family: crate::MetaplexTokenMetadataAssetFamily,
|
|
) -> Self {
|
|
self.asset_family = asset_family;
|
|
return self;
|
|
}
|
|
|
|
/// Links the prepared asset to one existing collection mint as an unverified member.
|
|
pub fn with_unverified_collection(
|
|
mut self,
|
|
collection_mint: impl std::convert::Into<std::string::String>,
|
|
) -> Self {
|
|
self.collection_mint = std::option::Option::Some(collection_mint.into());
|
|
return self;
|
|
}
|
|
|
|
/// Configures a bounded limited print supply for one printable NFT master edition.
|
|
pub fn with_limited_print_supply(mut self, limit: u64) -> Self {
|
|
self.print_supply_limit = std::option::Option::Some(limit);
|
|
return self;
|
|
}
|
|
|
|
/// Configures a bounded `Multiple` uses budget with `remaining == total`.
|
|
pub fn with_multiple_uses(mut self, total: u64) -> Self {
|
|
self.uses = std::option::Option::Some(mpl_token_metadata::types::Uses {
|
|
use_method: mpl_token_metadata::types::UseMethod::Multiple,
|
|
remaining: total,
|
|
total,
|
|
});
|
|
return self;
|
|
}
|
|
}
|
|
|
|
/// Public fixture values and generated Create intent JSON.
|
|
#[derive(Clone, Debug, Eq, PartialEq, serde::Serialize)]
|
|
pub struct MetaplexCreateFixturePreparationSummary {
|
|
/// Fixture mint keypair path.
|
|
pub mint_keypair_path: std::path::PathBuf,
|
|
/// Asset family prepared by this fixture.
|
|
pub asset_family: crate::MetaplexTokenMetadataAssetFamily,
|
|
/// Classic SPL mint address.
|
|
pub mint: std::string::String,
|
|
/// Exact decimals encoded in the classic SPL mint.
|
|
pub mint_decimals: u8,
|
|
/// Canonical Metaplex metadata PDA.
|
|
pub metadata: std::string::String,
|
|
/// Canonical Metaplex master-edition PDA.
|
|
pub master_edition: std::string::String,
|
|
/// Whether Create must allocate the canonical master-edition PDA.
|
|
pub master_edition_requested: bool,
|
|
/// Canonical classic SPL associated token account owned by the operator.
|
|
pub token_account: std::string::String,
|
|
/// Canonical programmable token-record PDA when the fixture is a pNFT.
|
|
pub token_record: std::option::Option<std::string::String>,
|
|
/// Optional collection parent mint linked to this asset as initially unverified.
|
|
pub collection_mint: std::option::Option<std::string::String>,
|
|
/// Operator public key used as mint, token owner and update authority.
|
|
pub authority: std::string::String,
|
|
/// Family-specific raw amount reserved for the Metaplex `Mint` operation.
|
|
pub mint_amount_raw: u64,
|
|
/// Family-specific typed Create operation JSON.
|
|
pub operation_json: std::string::String,
|
|
/// Family-specific typed Mint operation JSON executed only after Create.
|
|
pub mint_operation_json: std::string::String,
|
|
/// Whether the mint account was created during this call.
|
|
pub mint_created: bool,
|
|
/// Confirmed signature of native mint plus operator-ATA preparation when newly created.
|
|
pub preparation_signature: std::option::Option<std::string::String>,
|
|
}
|
|
|
|
/// Creates one family-consistent classic SPL mint and derives its Metaplex PDAs.
|
|
pub async fn prepare_metaplex_create_fixture(
|
|
http_pool: &ks_onchain_transport::HttpEndpointPool,
|
|
profile: &ks_config::ProfileConfig,
|
|
workspace_root: &std::path::Path,
|
|
options: &crate::MetaplexCreateFixturePreparationOptions,
|
|
) -> ks_core::Result<crate::MetaplexCreateFixturePreparationSummary> {
|
|
if options.query_role.trim().is_empty() || options.transaction_role.trim().is_empty() {
|
|
return std::result::Result::Err(ks_core::Error::config(
|
|
"Metaplex fixture endpoint roles must not be empty",
|
|
));
|
|
}
|
|
if !profile.execution.devnet_send_enabled {
|
|
return std::result::Result::Err(ks_core::Error::config(
|
|
"Metaplex fixture preparation requires devnet_send_enabled=true",
|
|
));
|
|
}
|
|
if let std::option::Option::Some(limit) = options.print_supply_limit {
|
|
if options.asset_family != crate::MetaplexTokenMetadataAssetFamily::Nft {
|
|
return std::result::Result::Err(ks_core::Error::config(
|
|
"Metaplex limited print supply is supported only for the NFT fixture family",
|
|
));
|
|
}
|
|
if limit == 0 || limit > 100 {
|
|
return std::result::Result::Err(ks_core::Error::config(
|
|
"Metaplex limited print supply must be between 1 and 100",
|
|
));
|
|
}
|
|
}
|
|
if let std::option::Option::Some(uses) = options.uses.as_ref() {
|
|
if options.asset_family != crate::MetaplexTokenMetadataAssetFamily::Nft {
|
|
return std::result::Result::Err(ks_core::Error::config(
|
|
"Metaplex Multiple-use fixture is supported only for the NFT family",
|
|
));
|
|
}
|
|
if !matches!(&uses.use_method, mpl_token_metadata::types::UseMethod::Multiple)
|
|
|| uses.total == 0
|
|
|| uses.total > 100
|
|
|| uses.remaining != uses.total
|
|
{
|
|
return std::result::Result::Err(ks_core::Error::config(
|
|
"Metaplex Multiple-use fixture requires 1..=100 total uses and remaining == total",
|
|
));
|
|
}
|
|
}
|
|
if let std::option::Option::Some(collection_mint) = options.collection_mint.as_ref() {
|
|
if options.asset_family == crate::MetaplexTokenMetadataAssetFamily::Collection
|
|
|| options.asset_family == crate::MetaplexTokenMetadataAssetFamily::Fungible
|
|
{
|
|
return std::result::Result::Err(ks_core::Error::config(
|
|
"Metaplex collection members must not use the Collection or Fungible asset family",
|
|
));
|
|
}
|
|
if let std::result::Result::Err(error) = ks_onchain_transport::validate_solana_pubkey_text(
|
|
collection_mint.as_str(),
|
|
"Metaplex collection parent mint",
|
|
) {
|
|
return std::result::Result::Err(error);
|
|
}
|
|
}
|
|
let operator = match crate::load_profile_temporary_wallet(profile, workspace_root).await {
|
|
std::result::Result::Ok(value) => value,
|
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
|
};
|
|
let fixture_dir = options.wallet_dir.join("metaplex_token_metadata_validation");
|
|
let fixture_store = match ks_wallet::TemporaryWalletStore::new(fixture_dir.clone()) {
|
|
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_nanos(),
|
|
std::result::Result::Err(error) => {
|
|
return std::result::Result::Err(ks_core::Error::config(format!(
|
|
"unable to create a unique Metaplex fixture identifier: {error}"
|
|
)));
|
|
},
|
|
};
|
|
let fixture_alias =
|
|
match ks_wallet::WalletAlias::parse(format!("create-mint-native-{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.create(fixture_alias.clone()).await {
|
|
std::result::Result::Ok(value) => value,
|
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
|
};
|
|
let mint_keypair_path = fixture_store.wallet_path(&fixture_alias);
|
|
let authority = operator.public_key();
|
|
let mint = mint_wallet.public_key();
|
|
let mint_decimals = fixture_mint_decimals(options.asset_family);
|
|
let existing =
|
|
match read_mint_account(http_pool, options.query_role.as_str(), mint.as_str()).await {
|
|
std::result::Result::Ok(value) => value,
|
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
|
};
|
|
if existing.is_some() {
|
|
return std::result::Result::Err(ks_core::Error::new(
|
|
"metaplex_fixture_fresh_mint_already_exists",
|
|
format!(
|
|
"fresh Metaplex fixture mint {mint} unexpectedly already exists on Devnet; refusing to reuse persisted fixture state"
|
|
),
|
|
));
|
|
}
|
|
let preparation = match create_native_classic_mint(
|
|
http_pool,
|
|
profile,
|
|
options,
|
|
&operator,
|
|
&mint_wallet,
|
|
mint_decimals,
|
|
)
|
|
.await
|
|
{
|
|
std::result::Result::Ok(value) => value,
|
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
|
};
|
|
let created = match read_mint_account_at_or_after(
|
|
http_pool,
|
|
options.query_role.as_str(),
|
|
mint.as_str(),
|
|
std::option::Option::Some(preparation.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_fixture_mint_missing_after_confirmation",
|
|
format!(
|
|
"native Metaplex fixture mint {mint} is unavailable at or after preparation slot {}",
|
|
preparation.confirmation_slot
|
|
),
|
|
));
|
|
},
|
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
|
};
|
|
if let std::result::Result::Err(error) =
|
|
validate_classic_fixture_mint(&created, authority.as_str(), mint_decimals)
|
|
{
|
|
return std::result::Result::Err(ks_core::Error::new(
|
|
"metaplex_fixture_created_mint_state_mismatch",
|
|
format!(
|
|
"fresh fixture mint {mint} prepared by authority {authority} at slot {} failed immediate state validation: {error}",
|
|
preparation.confirmation_slot
|
|
),
|
|
));
|
|
}
|
|
let mint_pubkey = match <solana_pubkey::Pubkey as std::str::FromStr>::from_str(mint.as_str()) {
|
|
std::result::Result::Ok(value) => value,
|
|
std::result::Result::Err(error) => {
|
|
return std::result::Result::Err(ks_core::Error::config(format!(
|
|
"invalid generated mint public key: {error}"
|
|
)));
|
|
},
|
|
};
|
|
let program_id = match <solana_pubkey::Pubkey as std::str::FromStr>::from_str(
|
|
ks_program_ids::METADATA_METAPLEX_TOKEN_METADATA_PROGRAM_ID,
|
|
) {
|
|
std::result::Result::Ok(value) => value,
|
|
std::result::Result::Err(error) => {
|
|
return std::result::Result::Err(ks_core::Error::config(format!(
|
|
"invalid Metaplex Program ID: {error}"
|
|
)));
|
|
},
|
|
};
|
|
let (metadata, _) = solana_pubkey::Pubkey::find_program_address(
|
|
&[b"metadata", program_id.as_ref(), mint_pubkey.as_ref()],
|
|
&program_id,
|
|
);
|
|
let (master_edition, _) = solana_pubkey::Pubkey::find_program_address(
|
|
&[b"metadata", program_id.as_ref(), mint_pubkey.as_ref(), b"edition"],
|
|
&program_id,
|
|
);
|
|
let master_edition_requested = fixture_requires_master_edition(options.asset_family);
|
|
let token_program = match <solana_pubkey::Pubkey as std::str::FromStr>::from_str(
|
|
ks_program_ids::SPL_TOKEN_PROGRAM_ID,
|
|
) {
|
|
std::result::Result::Ok(value) => value,
|
|
std::result::Result::Err(error) => {
|
|
return std::result::Result::Err(ks_core::Error::config(format!(
|
|
"invalid classic SPL Token Program ID: {error}"
|
|
)));
|
|
},
|
|
};
|
|
let authority_pubkey =
|
|
match <solana_pubkey::Pubkey as std::str::FromStr>::from_str(authority.as_str()) {
|
|
std::result::Result::Ok(value) => value,
|
|
std::result::Result::Err(error) => {
|
|
return std::result::Result::Err(ks_core::Error::config(format!(
|
|
"invalid Metaplex fixture authority public key: {error}"
|
|
)));
|
|
},
|
|
};
|
|
let token_account = spl_associated_token_account_interface::address::get_associated_token_address_with_program_id(
|
|
&authority_pubkey,
|
|
&mint_pubkey,
|
|
&token_program,
|
|
);
|
|
let mint_text = mint_pubkey.to_string();
|
|
let token_account_text = token_account.to_string();
|
|
let token_account_state = match read_token_account_at_or_after(
|
|
http_pool,
|
|
options.query_role.as_str(),
|
|
token_account_text.as_str(),
|
|
std::option::Option::Some(preparation.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_fixture_token_account_missing_after_confirmation",
|
|
"native Metaplex fixture ATA is unavailable after confirmation",
|
|
));
|
|
},
|
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
|
};
|
|
if let std::result::Result::Err(error) = validate_classic_fixture_token_account(
|
|
&token_account_state,
|
|
mint_text.as_str(),
|
|
authority.as_str(),
|
|
) {
|
|
return std::result::Result::Err(error);
|
|
}
|
|
let token_record =
|
|
fixture_token_record(options.asset_family, &program_id, &mint_pubkey, &token_account);
|
|
let mint_amount_raw = fixture_mint_amount_raw(options.asset_family);
|
|
let operation = create_operation_json(
|
|
mint_text.clone(),
|
|
metadata.to_string(),
|
|
master_edition.to_string(),
|
|
authority.clone(),
|
|
options.asset_family,
|
|
options.collection_mint.as_deref(),
|
|
options.print_supply_limit,
|
|
options.uses.as_ref(),
|
|
);
|
|
let mint_operation = mint_operation_json(
|
|
mint_text.clone(),
|
|
metadata.to_string(),
|
|
master_edition.to_string(),
|
|
token_account_text.clone(),
|
|
token_record.as_ref().map(|value| return value.to_string()),
|
|
authority.clone(),
|
|
mint_amount_raw,
|
|
options.asset_family,
|
|
);
|
|
let operation_json = match serde_json::to_string_pretty(&operation) {
|
|
std::result::Result::Ok(value) => value,
|
|
std::result::Result::Err(error) => {
|
|
return std::result::Result::Err(ks_core::Error::new(
|
|
"metaplex_fixture_serialization_failed",
|
|
format!("unable to serialize Metaplex Create fixture: {error}"),
|
|
));
|
|
},
|
|
};
|
|
let mint_operation_json = match serde_json::to_string_pretty(&mint_operation) {
|
|
std::result::Result::Ok(value) => value,
|
|
std::result::Result::Err(error) => {
|
|
return std::result::Result::Err(ks_core::Error::new(
|
|
"metaplex_fixture_serialization_failed",
|
|
format!("unable to serialize Metaplex Mint fixture: {error}"),
|
|
));
|
|
},
|
|
};
|
|
return std::result::Result::Ok(crate::MetaplexCreateFixturePreparationSummary {
|
|
mint_keypair_path,
|
|
asset_family: options.asset_family,
|
|
mint: mint_text,
|
|
mint_decimals,
|
|
metadata: metadata.to_string(),
|
|
master_edition: master_edition.to_string(),
|
|
master_edition_requested,
|
|
token_account: token_account_text,
|
|
token_record: token_record.map(|value| return value.to_string()),
|
|
collection_mint: options.collection_mint.clone(),
|
|
authority,
|
|
mint_amount_raw,
|
|
operation_json,
|
|
mint_operation_json,
|
|
mint_created: true,
|
|
preparation_signature: std::option::Option::Some(preparation.signature),
|
|
});
|
|
}
|
|
|
|
#[derive(Clone, Debug, Eq, PartialEq)]
|
|
struct NativeFixturePreparationEvidence {
|
|
signature: std::string::String,
|
|
confirmation_slot: u64,
|
|
}
|
|
|
|
async fn create_native_classic_mint(
|
|
http_pool: &ks_onchain_transport::HttpEndpointPool,
|
|
profile: &ks_config::ProfileConfig,
|
|
options: &crate::MetaplexCreateFixturePreparationOptions,
|
|
operator: &ks_wallet::TemporaryWallet,
|
|
mint_wallet: &ks_wallet::TemporaryWallet,
|
|
mint_decimals: u8,
|
|
) -> ks_core::Result<NativeFixturePreparationEvidence> {
|
|
const MINT_SPACE: u64 = 82;
|
|
let rent = match http_pool
|
|
.get_minimum_balance_for_rent_exemption_for_role(
|
|
options.query_role.as_str(),
|
|
MINT_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 token_account_rent = match http_pool
|
|
.get_minimum_balance_for_rent_exemption_for_role(
|
|
options.query_role.as_str(),
|
|
165,
|
|
&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!("metaplex-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: MINT_SPACE,
|
|
owner: ks_lib::MdPubkey(ks_program_ids::SPL_TOKEN_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_PROGRAM_ID,
|
|
) {
|
|
std::result::Result::Ok(value) => value,
|
|
std::result::Result::Err(error) => {
|
|
return std::result::Result::Err(ks_core::Error::config(format!(
|
|
"invalid classic SPL Token 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 native fixture mint public key: {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 native fixture authority public key: {error}"
|
|
)));
|
|
},
|
|
};
|
|
let token_instruction = match spl_token_interface::instruction::initialize_mint2(
|
|
&token_program,
|
|
&mint_pubkey,
|
|
&authority_pubkey,
|
|
std::option::Option::Some(&authority_pubkey),
|
|
mint_decimals,
|
|
) {
|
|
std::result::Result::Ok(value) => value,
|
|
std::result::Result::Err(error) => {
|
|
return std::result::Result::Err(ks_core::Error::new(
|
|
"metaplex_fixture_initialize_mint_build_failed",
|
|
error.to_string(),
|
|
));
|
|
},
|
|
};
|
|
let ata_instruction =
|
|
spl_associated_token_account_interface::instruction::create_associated_token_account(
|
|
&authority_pubkey,
|
|
&authority_pubkey,
|
|
&mint_pubkey,
|
|
&token_program,
|
|
);
|
|
let requested_spend_lamports = rent
|
|
.minimum_balance_lamports
|
|
.saturating_add(token_account_rent.minimum_balance_lamports);
|
|
let plan = merge_fixture_instructions(
|
|
system_plan,
|
|
&[
|
|
planned_instruction("spl.token.initialize_mint2", &token_instruction),
|
|
planned_instruction("spl.associated_token_account.create", &ata_instruction),
|
|
],
|
|
policy,
|
|
requested_spend_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(
|
|
"metaplex_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(
|
|
"metaplex_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(
|
|
"metaplex_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(
|
|
"metaplex_fixture_confirmation_failed",
|
|
format!("native mint preparation stopped at {:?}", confirmation.status),
|
|
));
|
|
}
|
|
let confirmation_slot = match confirmation.slot {
|
|
std::option::Option::Some(value) => value,
|
|
std::option::Option::None => {
|
|
return std::result::Result::Err(ks_core::Error::new(
|
|
"metaplex_fixture_confirmation_slot_missing",
|
|
format!(
|
|
"native mint preparation {} was confirmed without a confirmation slot",
|
|
signature.0
|
|
),
|
|
));
|
|
},
|
|
};
|
|
tracing::info!(
|
|
target: "ks-pipeline-demo-scenarios.metaplex_fixture",
|
|
action = "prepare_native_metaplex_mint",
|
|
mint = mint_wallet.public_key(),
|
|
authority = operator.public_key(),
|
|
signature = signature.0.as_str(),
|
|
confirmation_slot,
|
|
"created native classic SPL mint and operator ATA for Metaplex fixture"
|
|
);
|
|
return std::result::Result::Ok(NativeFixturePreparationEvidence {
|
|
signature: signature.0,
|
|
confirmation_slot,
|
|
});
|
|
}
|
|
|
|
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,
|
|
additional_instructions: &[ks_lib::ExApiPlannedInstruction],
|
|
policy: ks_lib::ExApiExecutionPolicy,
|
|
requested_spend_lamports: u64,
|
|
) -> ks_lib::ExApiPreparedExecutionPlan {
|
|
system_plan.instructions.extend_from_slice(additional_instructions);
|
|
system_plan.executor_name = "ks-pipeline-demo-scenarios.metaplex_fixture".to_string();
|
|
system_plan.operation_code =
|
|
"metadata.metaplex_token_metadata.prepare_native_mint_and_ata".to_string();
|
|
system_plan.policy = policy;
|
|
system_plan.requested_spend_lamports = requested_spend_lamports;
|
|
return system_plan;
|
|
}
|
|
|
|
async fn read_mint_account(
|
|
http_pool: &ks_onchain_transport::HttpEndpointPool,
|
|
query_role: &str,
|
|
mint: &str,
|
|
) -> ks_core::Result<std::option::Option<ks_onchain_transport::AccountInfoValue>> {
|
|
return read_mint_account_at_or_after(http_pool, query_role, mint, std::option::Option::None)
|
|
.await;
|
|
}
|
|
|
|
pub(crate) async fn read_mint_account_at_or_after(
|
|
http_pool: &ks_onchain_transport::HttpEndpointPool,
|
|
query_role: &str,
|
|
mint: &str,
|
|
min_context_slot: std::option::Option<u64>,
|
|
) -> ks_core::Result<std::option::Option<ks_onchain_transport::AccountInfoValue>> {
|
|
let config = match ks_onchain_transport::GetAccountInfoConfig::new_with_data(
|
|
ks_onchain_transport::RpcCommitmentLevel::Confirmed,
|
|
min_context_slot,
|
|
82,
|
|
) {
|
|
std::result::Result::Ok(value) => value,
|
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
|
};
|
|
let mint_pubkey = ks_lib::MdPubkey(mint.to_string());
|
|
let result = match http_pool.get_account_info_for_role(query_role, &mint_pubkey, &config).await
|
|
{
|
|
std::result::Result::Ok(value) => value,
|
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
|
};
|
|
return std::result::Result::Ok(result.account);
|
|
}
|
|
|
|
fn validate_classic_fixture_mint(
|
|
account: &ks_onchain_transport::AccountInfoValue,
|
|
expected_authority: &str,
|
|
expected_decimals: u8,
|
|
) -> ks_core::Result<()> {
|
|
return validate_classic_fixture_mint_state(account, expected_authority, expected_decimals, 0);
|
|
}
|
|
|
|
pub(crate) fn classic_fixture_mint_supply(
|
|
account: &ks_onchain_transport::AccountInfoValue,
|
|
) -> ks_core::Result<u64> {
|
|
let bytes = account.data.as_slice();
|
|
if bytes.len() != 82 {
|
|
return std::result::Result::Err(ks_core::Error::config(format!(
|
|
"Metaplex Create fixture mint must use the classic 82-byte layout, got {} bytes",
|
|
bytes.len()
|
|
)));
|
|
}
|
|
let supply_bytes = match <[u8; 8]>::try_from(&bytes[36..44]) {
|
|
std::result::Result::Ok(value) => value,
|
|
std::result::Result::Err(_) => {
|
|
return std::result::Result::Err(ks_core::Error::config(
|
|
"invalid fixture mint supply bytes",
|
|
));
|
|
},
|
|
};
|
|
return std::result::Result::Ok(u64::from_le_bytes(supply_bytes));
|
|
}
|
|
|
|
pub(crate) fn validate_classic_fixture_mint_state(
|
|
account: &ks_onchain_transport::AccountInfoValue,
|
|
expected_authority: &str,
|
|
expected_decimals: u8,
|
|
expected_supply: u64,
|
|
) -> ks_core::Result<()> {
|
|
if account.owner.0 != ks_program_ids::SPL_TOKEN_PROGRAM_ID || account.executable {
|
|
return std::result::Result::Err(ks_core::Error::config(format!(
|
|
"Metaplex Create fixture mint owner mismatch: expected classic SPL Token, got {}",
|
|
account.owner.0
|
|
)));
|
|
}
|
|
let bytes = account.data.as_slice();
|
|
if bytes.len() != 82 {
|
|
return std::result::Result::Err(ks_core::Error::config(format!(
|
|
"Metaplex Create fixture mint must use the classic 82-byte layout, got {} bytes",
|
|
bytes.len()
|
|
)));
|
|
}
|
|
let mint_authority = match decode_coption_pubkey(&bytes[0..36], "mint authority") {
|
|
std::result::Result::Ok(value) => value,
|
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
|
};
|
|
let supply = match classic_fixture_mint_supply(account) {
|
|
std::result::Result::Ok(value) => value,
|
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
|
};
|
|
let decimals = bytes[44];
|
|
let initialized = bytes[45] == 1;
|
|
let freeze_authority = match decode_coption_pubkey(&bytes[46..82], "freeze authority") {
|
|
std::result::Result::Ok(value) => value,
|
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
|
};
|
|
if decimals != expected_decimals || supply != expected_supply || !initialized {
|
|
return std::result::Result::Err(ks_core::Error::config(format!(
|
|
"Metaplex fixture mint requires initialized=true, decimals={expected_decimals} and supply={expected_supply}, got initialized={initialized}, decimals={decimals}, supply={supply}"
|
|
)));
|
|
}
|
|
if mint_authority.as_deref() != std::option::Option::Some(expected_authority)
|
|
|| freeze_authority.as_deref() != std::option::Option::Some(expected_authority)
|
|
{
|
|
return std::result::Result::Err(ks_core::Error::config(format!(
|
|
"Metaplex fixture mint requires mint and freeze authorities to equal {expected_authority}; mint_authority={mint_authority:?}, freeze_authority={freeze_authority:?}"
|
|
)));
|
|
}
|
|
return std::result::Result::Ok(());
|
|
}
|
|
|
|
fn decode_coption_pubkey(
|
|
bytes: &[u8],
|
|
label: &str,
|
|
) -> ks_core::Result<std::option::Option<std::string::String>> {
|
|
if bytes.len() != 36 {
|
|
return std::result::Result::Err(ks_core::Error::config(format!(
|
|
"invalid {label} field length: {}",
|
|
bytes.len()
|
|
)));
|
|
}
|
|
let tag = u32::from_le_bytes([bytes[0], bytes[1], bytes[2], bytes[3]]);
|
|
if tag == 0 {
|
|
return std::result::Result::Ok(std::option::Option::None);
|
|
}
|
|
if tag != 1 {
|
|
return std::result::Result::Err(ks_core::Error::config(format!(
|
|
"invalid {label} option tag: {tag}"
|
|
)));
|
|
}
|
|
let key = match <[u8; 32]>::try_from(&bytes[4..36]) {
|
|
std::result::Result::Ok(value) => solana_pubkey::Pubkey::new_from_array(value),
|
|
std::result::Result::Err(_) => {
|
|
return std::result::Result::Err(ks_core::Error::config(format!(
|
|
"invalid {label} public key bytes"
|
|
)));
|
|
},
|
|
};
|
|
return std::result::Result::Ok(std::option::Option::Some(key.to_string()));
|
|
}
|
|
|
|
pub(crate) async fn read_token_account_at_or_after(
|
|
http_pool: &ks_onchain_transport::HttpEndpointPool,
|
|
query_role: &str,
|
|
token_account: &str,
|
|
min_context_slot: std::option::Option<u64>,
|
|
) -> ks_core::Result<std::option::Option<ks_onchain_transport::AccountInfoValue>> {
|
|
let config = match ks_onchain_transport::GetAccountInfoConfig::new_with_data(
|
|
ks_onchain_transport::RpcCommitmentLevel::Confirmed,
|
|
min_context_slot,
|
|
165,
|
|
) {
|
|
std::result::Result::Ok(value) => value,
|
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
|
};
|
|
let address = ks_lib::MdPubkey(token_account.to_string());
|
|
let result = match http_pool.get_account_info_for_role(query_role, &address, &config).await {
|
|
std::result::Result::Ok(value) => value,
|
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
|
};
|
|
return std::result::Result::Ok(result.account);
|
|
}
|
|
|
|
fn validate_classic_fixture_token_account(
|
|
account: &ks_onchain_transport::AccountInfoValue,
|
|
expected_mint: &str,
|
|
expected_owner: &str,
|
|
) -> ks_core::Result<()> {
|
|
return validate_classic_fixture_token_account_state(
|
|
account,
|
|
expected_mint,
|
|
expected_owner,
|
|
0,
|
|
spl_token_interface::state::AccountState::Initialized,
|
|
);
|
|
}
|
|
|
|
pub(crate) fn classic_fixture_token_account_state(
|
|
account: &ks_onchain_transport::AccountInfoValue,
|
|
) -> ks_core::Result<u8> {
|
|
let bytes = account.data.as_slice();
|
|
if account.space != 165 || bytes.len() != 165 {
|
|
return std::result::Result::Err(ks_core::Error::config(format!(
|
|
"Metaplex fixture token account must use the classic 165-byte layout, got space={} and {} decoded bytes",
|
|
account.space,
|
|
bytes.len()
|
|
)));
|
|
}
|
|
let state = bytes[108];
|
|
if state != spl_token_interface::state::AccountState::Initialized as u8
|
|
&& state != spl_token_interface::state::AccountState::Frozen as u8
|
|
{
|
|
return std::result::Result::Err(ks_core::Error::config(format!(
|
|
"Metaplex fixture token account must be initialized or frozen, got state={state}"
|
|
)));
|
|
}
|
|
return std::result::Result::Ok(state);
|
|
}
|
|
|
|
pub(crate) fn classic_fixture_token_amount(
|
|
account: &ks_onchain_transport::AccountInfoValue,
|
|
) -> ks_core::Result<u64> {
|
|
let bytes = account.data.as_slice();
|
|
if account.space != 165 || bytes.len() != 165 {
|
|
return std::result::Result::Err(ks_core::Error::config(format!(
|
|
"Metaplex fixture token account must use the classic 165-byte layout, got space={} and {} decoded bytes",
|
|
account.space,
|
|
bytes.len()
|
|
)));
|
|
}
|
|
let amount_bytes = match <[u8; 8]>::try_from(&bytes[64..72]) {
|
|
std::result::Result::Ok(value) => value,
|
|
std::result::Result::Err(_) => {
|
|
return std::result::Result::Err(ks_core::Error::config(
|
|
"invalid fixture token account amount bytes",
|
|
));
|
|
},
|
|
};
|
|
return std::result::Result::Ok(u64::from_le_bytes(amount_bytes));
|
|
}
|
|
|
|
pub(crate) fn validate_classic_fixture_token_account_state(
|
|
account: &ks_onchain_transport::AccountInfoValue,
|
|
expected_mint: &str,
|
|
expected_owner: &str,
|
|
expected_amount: u64,
|
|
expected_state: spl_token_interface::state::AccountState,
|
|
) -> ks_core::Result<()> {
|
|
if account.owner.0 != ks_program_ids::SPL_TOKEN_PROGRAM_ID || account.executable {
|
|
return std::result::Result::Err(ks_core::Error::config(format!(
|
|
"Metaplex fixture token account owner mismatch: expected classic SPL Token, got {}",
|
|
account.owner.0
|
|
)));
|
|
}
|
|
let bytes = account.data.as_slice();
|
|
if account.space != 165 || bytes.len() != 165 {
|
|
return std::result::Result::Err(ks_core::Error::config(format!(
|
|
"Metaplex fixture token account must use the classic 165-byte layout, got space={} and {} decoded bytes",
|
|
account.space,
|
|
bytes.len()
|
|
)));
|
|
}
|
|
let mint = solana_pubkey::Pubkey::new_from_array(match <[u8; 32]>::try_from(&bytes[0..32]) {
|
|
std::result::Result::Ok(value) => value,
|
|
std::result::Result::Err(_) => {
|
|
return std::result::Result::Err(ks_core::Error::config(
|
|
"invalid fixture token account mint bytes",
|
|
));
|
|
},
|
|
});
|
|
let owner = solana_pubkey::Pubkey::new_from_array(match <[u8; 32]>::try_from(&bytes[32..64]) {
|
|
std::result::Result::Ok(value) => value,
|
|
std::result::Result::Err(_) => {
|
|
return std::result::Result::Err(ks_core::Error::config(
|
|
"invalid fixture token account owner bytes",
|
|
));
|
|
},
|
|
});
|
|
let amount = match classic_fixture_token_amount(account) {
|
|
std::result::Result::Ok(value) => value,
|
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
|
};
|
|
if mint.to_string() != expected_mint
|
|
|| owner.to_string() != expected_owner
|
|
|| amount != expected_amount
|
|
|| bytes[108] != expected_state as u8
|
|
{
|
|
return std::result::Result::Err(ks_core::Error::config(format!(
|
|
"Metaplex fixture ATA requires mint={expected_mint}, owner={expected_owner}, amount={expected_amount} and state={expected_state:?}; got mint={mint}, owner={owner}, amount={amount}, state={}",
|
|
bytes[108]
|
|
)));
|
|
}
|
|
return std::result::Result::Ok(());
|
|
}
|
|
|
|
fn fixture_mint_amount_raw(asset_family: crate::MetaplexTokenMetadataAssetFamily) -> u64 {
|
|
return match asset_family {
|
|
crate::MetaplexTokenMetadataAssetFamily::Nft
|
|
| crate::MetaplexTokenMetadataAssetFamily::Collection
|
|
| crate::MetaplexTokenMetadataAssetFamily::ProgrammableNft => 1,
|
|
crate::MetaplexTokenMetadataAssetFamily::Sft => 10,
|
|
crate::MetaplexTokenMetadataAssetFamily::Fungible => 1_000_000_000,
|
|
};
|
|
}
|
|
|
|
fn fixture_token_record(
|
|
asset_family: crate::MetaplexTokenMetadataAssetFamily,
|
|
program_id: &solana_pubkey::Pubkey,
|
|
mint: &solana_pubkey::Pubkey,
|
|
token_account: &solana_pubkey::Pubkey,
|
|
) -> std::option::Option<solana_pubkey::Pubkey> {
|
|
if asset_family != crate::MetaplexTokenMetadataAssetFamily::ProgrammableNft {
|
|
return std::option::Option::None;
|
|
}
|
|
let (token_record, _) = solana_pubkey::Pubkey::find_program_address(
|
|
&[
|
|
b"metadata",
|
|
program_id.as_ref(),
|
|
mint.as_ref(),
|
|
b"token_record",
|
|
token_account.as_ref(),
|
|
],
|
|
program_id,
|
|
);
|
|
return std::option::Option::Some(token_record);
|
|
}
|
|
|
|
fn fixture_mint_decimals(asset_family: crate::MetaplexTokenMetadataAssetFamily) -> u8 {
|
|
return match asset_family {
|
|
crate::MetaplexTokenMetadataAssetFamily::Fungible => 9,
|
|
crate::MetaplexTokenMetadataAssetFamily::Nft
|
|
| crate::MetaplexTokenMetadataAssetFamily::Sft
|
|
| crate::MetaplexTokenMetadataAssetFamily::Collection
|
|
| crate::MetaplexTokenMetadataAssetFamily::ProgrammableNft => 0,
|
|
};
|
|
}
|
|
|
|
fn fixture_requires_master_edition(asset_family: crate::MetaplexTokenMetadataAssetFamily) -> bool {
|
|
return matches!(
|
|
asset_family,
|
|
crate::MetaplexTokenMetadataAssetFamily::Nft
|
|
| crate::MetaplexTokenMetadataAssetFamily::Collection
|
|
| crate::MetaplexTokenMetadataAssetFamily::ProgrammableNft
|
|
);
|
|
}
|
|
|
|
fn create_operation_json(
|
|
mint: std::string::String,
|
|
metadata: std::string::String,
|
|
master_edition: std::string::String,
|
|
authority: std::string::String,
|
|
asset_family: crate::MetaplexTokenMetadataAssetFamily,
|
|
collection_mint: std::option::Option<&str>,
|
|
print_supply_limit: std::option::Option<u64>,
|
|
uses: std::option::Option<&mpl_token_metadata::types::Uses>,
|
|
) -> serde_json::Value {
|
|
let (name, symbol, token_standard, collection_details) = match asset_family {
|
|
crate::MetaplexTokenMetadataAssetFamily::Nft => {
|
|
("Khadhroony Devnet NFT", "KHNFT", "NonFungible", serde_json::Value::Null)
|
|
},
|
|
crate::MetaplexTokenMetadataAssetFamily::Sft => {
|
|
("Khadhroony Devnet SFT", "KHSFT", "FungibleAsset", serde_json::Value::Null)
|
|
},
|
|
crate::MetaplexTokenMetadataAssetFamily::Fungible => {
|
|
("Khadhroony Devnet Fungible", "KHFUN", "Fungible", serde_json::Value::Null)
|
|
},
|
|
crate::MetaplexTokenMetadataAssetFamily::Collection => (
|
|
"Khadhroony Devnet Collection",
|
|
"KHCOL",
|
|
"NonFungible",
|
|
serde_json::json!({"V1": {"size": 0}}),
|
|
),
|
|
crate::MetaplexTokenMetadataAssetFamily::ProgrammableNft => (
|
|
"Khadhroony Devnet pNFT",
|
|
"KHPNFT",
|
|
"ProgrammableNonFungible",
|
|
serde_json::Value::Null,
|
|
),
|
|
};
|
|
let requested_master_edition = if fixture_requires_master_edition(asset_family) {
|
|
serde_json::Value::String(master_edition)
|
|
} else {
|
|
serde_json::Value::Null
|
|
};
|
|
let collection = match collection_mint {
|
|
std::option::Option::Some(value) => serde_json::json!({"verified": false, "key": value}),
|
|
std::option::Option::None => serde_json::Value::Null,
|
|
};
|
|
let uses = match uses {
|
|
std::option::Option::Some(value) => {
|
|
let use_method = match &value.use_method {
|
|
mpl_token_metadata::types::UseMethod::Burn => "Burn",
|
|
mpl_token_metadata::types::UseMethod::Multiple => "Multiple",
|
|
mpl_token_metadata::types::UseMethod::Single => "Single",
|
|
};
|
|
serde_json::json!({
|
|
"use_method": use_method,
|
|
"remaining": value.remaining,
|
|
"total": value.total,
|
|
})
|
|
},
|
|
std::option::Option::None => serde_json::Value::Null,
|
|
};
|
|
return serde_json::json!({
|
|
"operation": "create",
|
|
"metadata": metadata,
|
|
"master_edition": requested_master_edition,
|
|
"mint": mint,
|
|
"mint_as_signer": false,
|
|
"authority": authority,
|
|
"update_authority": authority,
|
|
"update_authority_as_signer": true,
|
|
"spl_token_program": ks_program_ids::SPL_TOKEN_PROGRAM_ID,
|
|
"create_args": {"V1": {
|
|
"name": name,
|
|
"symbol": symbol,
|
|
"uri": "https://example.invalid/khadhroony-devnet.json",
|
|
"seller_fee_basis_points": 0,
|
|
"creators": null,
|
|
"primary_sale_happened": false,
|
|
"is_mutable": true,
|
|
"token_standard": token_standard,
|
|
"collection": collection,
|
|
"uses": uses,
|
|
"collection_details": collection_details,
|
|
"rule_set": null,
|
|
"decimals": null,
|
|
"print_supply": if let std::option::Option::Some(limit) = print_supply_limit {
|
|
serde_json::json!({"Limited": limit})
|
|
} else if fixture_requires_master_edition(asset_family) {
|
|
serde_json::json!("Zero")
|
|
} else {
|
|
serde_json::Value::Null
|
|
}
|
|
}}
|
|
});
|
|
}
|
|
|
|
fn mint_operation_json(
|
|
mint: std::string::String,
|
|
metadata: std::string::String,
|
|
master_edition: std::string::String,
|
|
token_account: std::string::String,
|
|
token_record: std::option::Option<std::string::String>,
|
|
authority: std::string::String,
|
|
amount: u64,
|
|
asset_family: crate::MetaplexTokenMetadataAssetFamily,
|
|
) -> serde_json::Value {
|
|
let master_edition = if fixture_requires_master_edition(asset_family) {
|
|
serde_json::Value::String(master_edition)
|
|
} else {
|
|
serde_json::Value::Null
|
|
};
|
|
return serde_json::json!({
|
|
"operation": "mint",
|
|
"token": token_account,
|
|
"token_owner": authority,
|
|
"metadata": metadata,
|
|
"master_edition": master_edition,
|
|
"token_record": token_record,
|
|
"mint": mint,
|
|
"authority": authority,
|
|
"delegate_record": null,
|
|
"payer": authority,
|
|
"system_program": ks_program_ids::SYSTEM_PROGRAM_ID,
|
|
"sysvar_instructions": ks_program_ids::SYSVAR_INSTRUCTIONS_PROGRAM_ID,
|
|
"spl_token_program": ks_program_ids::SPL_TOKEN_PROGRAM_ID,
|
|
"spl_ata_program": ks_program_ids::ASSOCIATED_TOKEN_PROGRAM_ID,
|
|
"authorization_rules_program": null,
|
|
"authorization_rules": null,
|
|
"args": {"V1": {
|
|
"amount": amount,
|
|
"authorization_data": null
|
|
}}
|
|
});
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
#[test]
|
|
fn native_fixture_contract_uses_profile_authorities() {
|
|
let authority = solana_pubkey::Pubkey::new_unique();
|
|
let mut mint = [0_u8; 82];
|
|
mint[0..4].copy_from_slice(&1_u32.to_le_bytes());
|
|
mint[4..36].copy_from_slice(authority.as_ref());
|
|
mint[44] = 0;
|
|
mint[45] = 1;
|
|
mint[46..50].copy_from_slice(&1_u32.to_le_bytes());
|
|
mint[50..82].copy_from_slice(authority.as_ref());
|
|
let decoded_mint = super::decode_coption_pubkey(&mint[0..36], "mint authority");
|
|
let decoded_freeze = super::decode_coption_pubkey(&mint[46..82], "freeze authority");
|
|
let authority_text = authority.to_string();
|
|
assert_eq!(
|
|
decoded_mint.ok().flatten().as_deref(),
|
|
std::option::Option::Some(authority_text.as_str())
|
|
);
|
|
assert_eq!(
|
|
decoded_freeze.ok().flatten().as_deref(),
|
|
std::option::Option::Some(authority_text.as_str())
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn family_profiles_keep_mint_decimals_and_master_edition_contracts_exact() {
|
|
let profiles = [
|
|
(crate::MetaplexTokenMetadataAssetFamily::Nft, 0, true),
|
|
(crate::MetaplexTokenMetadataAssetFamily::Sft, 0, false),
|
|
(crate::MetaplexTokenMetadataAssetFamily::Fungible, 9, false),
|
|
(crate::MetaplexTokenMetadataAssetFamily::Collection, 0, true),
|
|
(crate::MetaplexTokenMetadataAssetFamily::ProgrammableNft, 0, true),
|
|
];
|
|
for (family, decimals, master_edition) in profiles {
|
|
assert_eq!(super::fixture_mint_decimals(family), decimals);
|
|
assert_eq!(super::fixture_requires_master_edition(family), master_edition);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn family_create_contracts_deserialize_to_the_typed_executor_surface() {
|
|
let mint = solana_pubkey::Pubkey::new_unique().to_string();
|
|
let metadata = solana_pubkey::Pubkey::new_unique().to_string();
|
|
let master_edition = solana_pubkey::Pubkey::new_unique().to_string();
|
|
let authority = solana_pubkey::Pubkey::new_unique().to_string();
|
|
let profiles = [
|
|
(crate::MetaplexTokenMetadataAssetFamily::Nft, "NonFungible", true),
|
|
(crate::MetaplexTokenMetadataAssetFamily::Sft, "FungibleAsset", false),
|
|
(crate::MetaplexTokenMetadataAssetFamily::Fungible, "Fungible", false),
|
|
(crate::MetaplexTokenMetadataAssetFamily::Collection, "NonFungible", true),
|
|
(
|
|
crate::MetaplexTokenMetadataAssetFamily::ProgrammableNft,
|
|
"ProgrammableNonFungible",
|
|
true,
|
|
),
|
|
];
|
|
for (family, token_standard, expects_master_edition) in profiles {
|
|
let operation = super::create_operation_json(
|
|
mint.clone(),
|
|
metadata.clone(),
|
|
master_edition.clone(),
|
|
authority.clone(),
|
|
family,
|
|
std::option::Option::None,
|
|
std::option::Option::None,
|
|
std::option::Option::None,
|
|
);
|
|
assert_eq!(
|
|
operation
|
|
.pointer("/create_args/V1/token_standard")
|
|
.and_then(serde_json::Value::as_str),
|
|
std::option::Option::Some(token_standard),
|
|
);
|
|
assert_eq!(
|
|
operation.get("master_edition").is_some_and(|value| return !value.is_null()),
|
|
expects_master_edition
|
|
);
|
|
let parsed =
|
|
serde_json::from_value::<ks_lib::ExMetaplexTokenMetadataOperation>(operation);
|
|
assert!(parsed.is_ok());
|
|
assert_eq!(
|
|
parsed.ok().map(|value| return value.operation_code()),
|
|
std::option::Option::Some(ks_lib::EX_METAPLEX_TOKEN_METADATA_CREATE_OPERATION),
|
|
);
|
|
}
|
|
}
|
|
#[test]
|
|
fn collection_member_create_contract_keeps_relation_unverified() {
|
|
let mint = solana_pubkey::Pubkey::new_unique().to_string();
|
|
let metadata = solana_pubkey::Pubkey::new_unique().to_string();
|
|
let master_edition = solana_pubkey::Pubkey::new_unique().to_string();
|
|
let authority = solana_pubkey::Pubkey::new_unique().to_string();
|
|
let collection = solana_pubkey::Pubkey::new_unique().to_string();
|
|
let operation = super::create_operation_json(
|
|
mint,
|
|
metadata,
|
|
master_edition,
|
|
authority,
|
|
crate::MetaplexTokenMetadataAssetFamily::Nft,
|
|
std::option::Option::Some(collection.as_str()),
|
|
std::option::Option::None,
|
|
std::option::Option::None,
|
|
);
|
|
assert_eq!(
|
|
operation
|
|
.pointer("/create_args/V1/collection/key")
|
|
.and_then(serde_json::Value::as_str),
|
|
std::option::Option::Some(collection.as_str()),
|
|
);
|
|
assert_eq!(
|
|
operation
|
|
.pointer("/create_args/V1/collection/verified")
|
|
.and_then(serde_json::Value::as_bool),
|
|
std::option::Option::Some(false),
|
|
);
|
|
assert!(
|
|
serde_json::from_value::<ks_lib::ExMetaplexTokenMetadataOperation>(operation).is_ok()
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn printable_nft_create_contract_uses_bounded_limited_supply() {
|
|
let operation = super::create_operation_json(
|
|
solana_pubkey::Pubkey::new_unique().to_string(),
|
|
solana_pubkey::Pubkey::new_unique().to_string(),
|
|
solana_pubkey::Pubkey::new_unique().to_string(),
|
|
solana_pubkey::Pubkey::new_unique().to_string(),
|
|
crate::MetaplexTokenMetadataAssetFamily::Nft,
|
|
std::option::Option::None,
|
|
std::option::Option::Some(1),
|
|
std::option::Option::None,
|
|
);
|
|
assert_eq!(
|
|
operation
|
|
.pointer("/create_args/V1/print_supply/Limited")
|
|
.and_then(serde_json::Value::as_u64),
|
|
std::option::Option::Some(1),
|
|
);
|
|
assert!(
|
|
serde_json::from_value::<ks_lib::ExMetaplexTokenMetadataOperation>(operation).is_ok()
|
|
);
|
|
let invalid =
|
|
crate::MetaplexCreateFixturePreparationOptions::new(std::path::PathBuf::new())
|
|
.with_asset_family(crate::MetaplexTokenMetadataAssetFamily::Fungible)
|
|
.with_limited_print_supply(1);
|
|
assert_eq!(invalid.print_supply_limit, std::option::Option::Some(1));
|
|
}
|
|
|
|
#[test]
|
|
fn multiple_use_fixture_contract_is_bounded_and_typed() {
|
|
let uses = mpl_token_metadata::types::Uses {
|
|
use_method: mpl_token_metadata::types::UseMethod::Multiple,
|
|
remaining: 2,
|
|
total: 2,
|
|
};
|
|
let operation = super::create_operation_json(
|
|
solana_pubkey::Pubkey::new_unique().to_string(),
|
|
solana_pubkey::Pubkey::new_unique().to_string(),
|
|
solana_pubkey::Pubkey::new_unique().to_string(),
|
|
solana_pubkey::Pubkey::new_unique().to_string(),
|
|
crate::MetaplexTokenMetadataAssetFamily::Nft,
|
|
std::option::Option::None,
|
|
std::option::Option::None,
|
|
std::option::Option::Some(&uses),
|
|
);
|
|
assert_eq!(
|
|
operation
|
|
.pointer("/create_args/V1/uses/use_method")
|
|
.and_then(serde_json::Value::as_str),
|
|
std::option::Option::Some("Multiple"),
|
|
);
|
|
assert_eq!(
|
|
operation
|
|
.pointer("/create_args/V1/uses/remaining")
|
|
.and_then(serde_json::Value::as_u64),
|
|
std::option::Option::Some(2),
|
|
);
|
|
assert_eq!(
|
|
operation
|
|
.pointer("/create_args/V1/uses/total")
|
|
.and_then(serde_json::Value::as_u64),
|
|
std::option::Option::Some(2),
|
|
);
|
|
assert!(
|
|
serde_json::from_value::<ks_lib::ExMetaplexTokenMetadataOperation>(operation).is_ok()
|
|
);
|
|
let options = crate::MetaplexCreateFixturePreparationOptions::new(
|
|
std::path::PathBuf::from("wallets"),
|
|
)
|
|
.with_multiple_uses(2);
|
|
assert_eq!(options.uses, std::option::Option::Some(uses));
|
|
}
|
|
|
|
#[test]
|
|
fn classic_operator_ata_contract_is_exact_before_metaplex_mint() {
|
|
let mint = solana_pubkey::Pubkey::new_unique();
|
|
let owner = solana_pubkey::Pubkey::new_unique();
|
|
let mut data = std::vec![0_u8; 165];
|
|
data[0..32].copy_from_slice(mint.as_ref());
|
|
data[32..64].copy_from_slice(owner.as_ref());
|
|
data[64..72].copy_from_slice(&0_u64.to_le_bytes());
|
|
data[108] = 1;
|
|
let account = ks_onchain_transport::AccountInfoValue {
|
|
lamports: 0,
|
|
owner: ks_lib::MdProgramId(ks_program_ids::SPL_TOKEN_PROGRAM_ID.to_string()),
|
|
executable: false,
|
|
rent_epoch: 0,
|
|
space: 165,
|
|
data,
|
|
};
|
|
assert!(
|
|
super::validate_classic_fixture_token_account(
|
|
&account,
|
|
mint.to_string().as_str(),
|
|
owner.to_string().as_str(),
|
|
)
|
|
.is_ok()
|
|
);
|
|
let mut funded = account.clone();
|
|
funded.data[64..72].copy_from_slice(&1_u64.to_le_bytes());
|
|
assert!(
|
|
super::validate_classic_fixture_token_account(
|
|
&funded,
|
|
mint.to_string().as_str(),
|
|
owner.to_string().as_str(),
|
|
)
|
|
.is_err()
|
|
);
|
|
let mut frozen = account.clone();
|
|
frozen.data[108] = spl_token_interface::state::AccountState::Frozen as u8;
|
|
assert!(
|
|
super::validate_classic_fixture_token_account_state(
|
|
&frozen,
|
|
mint.to_string().as_str(),
|
|
owner.to_string().as_str(),
|
|
0,
|
|
spl_token_interface::state::AccountState::Frozen,
|
|
)
|
|
.is_ok()
|
|
);
|
|
assert!(
|
|
super::validate_classic_fixture_token_account_state(
|
|
&frozen,
|
|
mint.to_string().as_str(),
|
|
owner.to_string().as_str(),
|
|
0,
|
|
spl_token_interface::state::AccountState::Initialized,
|
|
)
|
|
.is_err()
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn family_mint_contracts_keep_amount_master_edition_and_token_record_exact() {
|
|
let program_id = match <solana_pubkey::Pubkey as std::str::FromStr>::from_str(
|
|
ks_program_ids::METADATA_METAPLEX_TOKEN_METADATA_PROGRAM_ID,
|
|
) {
|
|
std::result::Result::Ok(value) => value,
|
|
std::result::Result::Err(_) => return,
|
|
};
|
|
let mint = solana_pubkey::Pubkey::new_unique();
|
|
let metadata = solana_pubkey::Pubkey::new_unique();
|
|
let master_edition = solana_pubkey::Pubkey::new_unique();
|
|
let token_account = solana_pubkey::Pubkey::new_unique();
|
|
let authority = solana_pubkey::Pubkey::new_unique().to_string();
|
|
let profiles = [
|
|
(crate::MetaplexTokenMetadataAssetFamily::Nft, 1_u64, true, false),
|
|
(crate::MetaplexTokenMetadataAssetFamily::Sft, 10_u64, false, false),
|
|
(
|
|
crate::MetaplexTokenMetadataAssetFamily::Fungible,
|
|
1_000_000_000_u64,
|
|
false,
|
|
false,
|
|
),
|
|
(crate::MetaplexTokenMetadataAssetFamily::Collection, 1_u64, true, false),
|
|
(crate::MetaplexTokenMetadataAssetFamily::ProgrammableNft, 1_u64, true, true),
|
|
];
|
|
for (family, expected_amount, expects_master_edition, expects_token_record) in profiles {
|
|
let token_record =
|
|
super::fixture_token_record(family, &program_id, &mint, &token_account);
|
|
assert_eq!(token_record.is_some(), expects_token_record);
|
|
assert_eq!(super::fixture_mint_amount_raw(family), expected_amount);
|
|
let operation = super::mint_operation_json(
|
|
mint.to_string(),
|
|
metadata.to_string(),
|
|
master_edition.to_string(),
|
|
token_account.to_string(),
|
|
token_record.map(|value| return value.to_string()),
|
|
authority.clone(),
|
|
expected_amount,
|
|
family,
|
|
);
|
|
let parsed =
|
|
serde_json::from_value::<ks_lib::ExMetaplexTokenMetadataOperation>(operation);
|
|
assert!(matches!(
|
|
parsed,
|
|
std::result::Result::Ok(ks_lib::ExMetaplexTokenMetadataOperation::Mint {
|
|
token_owner: std::option::Option::Some(_),
|
|
master_edition,
|
|
token_record,
|
|
args: mpl_token_metadata::types::MintArgs::V1 {
|
|
amount,
|
|
authorization_data: std::option::Option::None,
|
|
},
|
|
..
|
|
}) if amount == expected_amount
|
|
&& master_edition.is_some() == expects_master_edition
|
|
&& token_record.is_some() == expects_token_record
|
|
));
|
|
}
|
|
}
|
|
}
|