// file: ks-pipeline-demo-scenarios/src/spl/token/lifecycle.rs // version: 12 //! Controlled Devnet lifecycle for freshly prepared classic SPL Token accounts. const MINT_ACCOUNT_SPACE: u64 = 82; const TOKEN_ACCOUNT_SPACE: u64 = 165; /// Explicit request to prepare the three raw accounts used by one Devnet lifecycle. #[derive(Clone, Debug, Eq, PartialEq)] pub struct DevnetSplTokenLifecyclePreparationRequest { /// Stable caller-provided preparation identifier. pub intent_id: std::string::String, /// Endpoint role used for cluster, rent, blockhash and account reads. pub query_role: std::string::String, /// Endpoint role used for simulation, submission and confirmation. pub transaction_role: std::string::String, /// Explicitly authorizes three rent-bearing System account creations. pub submit: bool, /// Explicit operator confirmation for the preparation spend. pub operator_confirmed: bool, } impl crate::DevnetSplTokenLifecyclePreparationRequest { /// Creates a conservative preparation request that cannot submit by default. pub fn new(intent_id: impl std::convert::Into) -> Self { return Self { intent_id: intent_id.into(), query_role: "http_queries".to_string(), transaction_role: "http_transactions".to_string(), submit: false, operator_confirmed: false, }; } fn validate(&self) -> ks_core::Result<()> { if self.intent_id.trim().is_empty() { return std::result::Result::Err(ks_core::Error::config( "Devnet SPL Token lifecycle preparation 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 SPL Token lifecycle preparation endpoint roles must not be empty", )); } if !self.submit || !self.operator_confirmed { return std::result::Result::Err(ks_core::Error::new( "execution_spl_token_lifecycle_preparation_authorization_required", "raw account preparation requires submit=true and explicit operator confirmation", )); } return std::result::Result::Ok(()); } } /// Result retained for one confirmed raw System account creation. #[derive(Clone, Debug, Eq, PartialEq)] pub struct DevnetSplTokenLifecyclePreparationStep { /// Stable account role: `mint`, `source` or `destination`. pub account_role: std::string::String, /// Newly generated account public key. pub account: ks_lib::MdPubkey, /// Exact allocated data length. pub space: u64, /// Exact rent-exempt lamports funded into the account. pub rent_lamports: u64, /// Confirmed System account-creation transaction signature. pub signature: ks_lib::MdSignature, /// Terminal confirmation status. pub confirmation_status: ks_lib::ExApiExecutionConfirmationStatus, } /// Fresh raw accounts and identities ready for the controlled Token lifecycle. #[derive(Clone, Debug, Eq, PartialEq)] pub struct DevnetSplTokenLifecyclePreparationSummary { /// Profile wallet that will own and administer the lifecycle accounts. pub authority: ks_lib::MdPubkey, /// Fresh uninitialized 82-byte Token-owned mint account. pub mint: ks_lib::MdPubkey, /// Fresh uninitialized 165-byte Token-owned source account. pub source: ks_lib::MdPubkey, /// Fresh uninitialized 165-byte Token-owned destination account. pub destination: ks_lib::MdPubkey, /// Fresh off-chain delegate identity used only by approve/revoke. pub delegate: ks_lib::MdPubkey, /// Ordered confirmed account-creation results. pub steps: std::vec::Vec, } /// Request for one destructive, explicitly authorized SPL Token Devnet lifecycle. #[derive(Clone, Debug, Eq, PartialEq)] pub struct DevnetSplTokenLifecycleRequest { /// Stable caller-provided lifecycle identifier. pub intent_id: std::string::String, /// Endpoint role used for state reads and canonical hydration. pub query_role: std::string::String, /// Endpoint role used for simulation, submission and confirmation. pub transaction_role: std::string::String, /// Pre-created, rent-exempt, uninitialized 82-byte mint account. pub mint: ks_lib::MdPubkey, /// Pre-created, rent-exempt, uninitialized 165-byte source token account. pub source: ks_lib::MdPubkey, /// Pre-created, rent-exempt, uninitialized 165-byte destination token account. pub destination: ks_lib::MdPubkey, /// Delegate recorded by the approval and removed by the following revoke. pub delegate: ks_lib::MdPubkey, /// Mint authority and owner of both token accounts. pub authority: ks_lib::MdPubkey, /// Exact decimals used by all checked instructions. pub decimals: u8, /// Exact raw amount minted into the source account. pub mint_amount: ks_lib::ExSplClassicTokenAmount, /// Exact raw amount transferred from source to destination. pub transfer_amount: ks_lib::ExSplClassicTokenAmount, /// Exact raw allowance approved on the destination account. pub approve_amount: ks_lib::ExSplClassicTokenAmount, /// Explicitly authorizes every submitted lifecycle transaction. pub submit: bool, /// Explicit operator confirmation applied to every lifecycle transaction. pub operator_confirmed: bool, /// Number of canonical hydration retries after each confirmation. pub post_validation_max_retries: u32, /// First zero-based lifecycle step to execute. pub first_step_index: u8, /// Confirmed signature of the immediately preceding step when resuming. pub resume_predecessor_signature: std::option::Option, /// Bounded pause inserted between recovered or newly executed steps. pub inter_step_delay_ms: u64, } impl crate::DevnetSplTokenLifecycleRequest { /// Creates a lifecycle request that remains non-submittable until explicitly authorized. pub fn new( intent_id: impl std::convert::Into, mint: ks_lib::MdPubkey, source: ks_lib::MdPubkey, destination: ks_lib::MdPubkey, delegate: ks_lib::MdPubkey, authority: ks_lib::MdPubkey, ) -> Self { return Self { intent_id: intent_id.into(), query_role: "http_queries".to_string(), transaction_role: "http_transactions".to_string(), mint, source, destination, delegate, authority, decimals: 9, mint_amount: ks_lib::ExSplClassicTokenAmount("10".to_string()), transfer_amount: ks_lib::ExSplClassicTokenAmount("4".to_string()), approve_amount: ks_lib::ExSplClassicTokenAmount("2".to_string()), submit: false, operator_confirmed: false, post_validation_max_retries: 10, first_step_index: 0, resume_predecessor_signature: std::option::Option::None, inter_step_delay_ms: 2_000, }; } /// Validates lifecycle-local bounds and amount conservation. pub fn validate(&self) -> ks_core::Result<()> { if self.intent_id.trim().is_empty() { return std::result::Result::Err(ks_core::Error::config( "Devnet SPL Token lifecycle 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 SPL Token lifecycle endpoint roles must not be empty", )); } if self.post_validation_max_retries > 20 { return std::result::Result::Err(ks_core::Error::config( "lifecycle post-execution retries must not exceed 20", )); } if self.first_step_index > 10 { return std::result::Result::Err(ks_core::Error::config( "lifecycle first step index must be between 0 and 10", )); } if self.inter_step_delay_ms > 30_000 { return std::result::Result::Err(ks_core::Error::config( "lifecycle inter-step delay must not exceed 30000 milliseconds", )); } if (self.first_step_index == 0 && self.resume_predecessor_signature.is_some()) || (self.first_step_index > 0 && self.resume_predecessor_signature.is_none()) { return std::result::Result::Err(ks_core::Error::new( "execution_spl_token_lifecycle_resume_contract_invalid", "a resumed lifecycle requires exactly the confirmed predecessor signature", )); } if let std::option::Option::Some(signature) = &self.resume_predecessor_signature { if let std::result::Result::Err(error) = ks_onchain_transport::validate_transaction_signature_text( signature.0.as_str(), "lifecycle predecessor signature", ) { return std::result::Result::Err(error); } } if self.mint == self.source || self.mint == self.destination || self.source == self.destination { return std::result::Result::Err(ks_core::Error::new( "execution_spl_token_lifecycle_accounts_not_distinct", "lifecycle mint, source and destination accounts must be distinct", )); } if self.delegate == self.authority { return std::result::Result::Err(ks_core::Error::new( "execution_spl_token_lifecycle_delegate_is_authority", "lifecycle delegate must differ from the token-account authority", )); } let mint_amount = match canonical_amount(&self.mint_amount, "mint_amount") { std::result::Result::Ok(value) => value, std::result::Result::Err(error) => return std::result::Result::Err(error), }; let transfer_amount = match canonical_amount(&self.transfer_amount, "transfer_amount") { std::result::Result::Ok(value) => value, std::result::Result::Err(error) => return std::result::Result::Err(error), }; let approve_amount = match canonical_amount(&self.approve_amount, "approve_amount") { std::result::Result::Ok(value) => value, std::result::Result::Err(error) => return std::result::Result::Err(error), }; if mint_amount == 0 || transfer_amount == 0 || transfer_amount >= mint_amount || approve_amount == 0 || approve_amount > transfer_amount { return std::result::Result::Err(ks_core::Error::new( "execution_spl_token_lifecycle_amounts_invalid", "lifecycle requires mint > transfer > 0 and 0 < approval <= transfer", )); } return std::result::Result::Ok(()); } } /// Result retained for one fully post-validated lifecycle transaction. #[derive(Clone, Debug, Eq, PartialEq)] pub struct DevnetSplTokenLifecycleStepSummary { /// Zero-based position in the controlled lifecycle. pub index: u8, /// Stable SPL Token operation code. pub operation_code: std::string::String, /// Confirmed transaction signature. pub signature: ks_lib::MdSignature, /// Terminal confirmation status. pub confirmation_status: ks_lib::ExApiExecutionConfirmationStatus, /// Number of exact SPL Token materializations returned for the signature. pub materialization_count: u32, /// Whether the second replay produced no failure, refusal or new output. pub idempotence_validated: bool, } /// Complete result of the eleven-step controlled lifecycle. #[derive(Clone, Debug, Eq, PartialEq)] pub struct DevnetSplTokenLifecycleSummary { /// Stable lifecycle identifier. pub intent_id: std::string::String, /// Mint initialized and exercised by the lifecycle. pub mint: ks_lib::MdPubkey, /// Source token account closed by the lifecycle. pub source: ks_lib::MdPubkey, /// Destination token account closed by the lifecycle. pub destination: ks_lib::MdPubkey, /// Ordered, fully validated lifecycle transactions. pub steps: std::vec::Vec, } /// Creates and verifies the three raw Token-owned accounts required by one lifecycle. pub async fn prepare_devnet_spl_token_lifecycle_accounts( http_pool: &ks_onchain_transport::HttpEndpointPool, profile: &ks_config::ProfileConfig, workspace_root: &std::path::Path, request: &crate::DevnetSplTokenLifecyclePreparationRequest, observer: &O, ) -> ks_core::Result where O: crate::SolanaExecutionObserver, { if let std::result::Result::Err(error) = request.validate() { return std::result::Result::Err(error); } if profile.wallet.cluster != "devnet" || !profile.wallet.devnet_send_enabled || !profile.execution.require_simulation { return std::result::Result::Err(ks_core::Error::config( "lifecycle account preparation requires a simulation-enabled Devnet send profile", )); } if profile.execution.require_operator_confirmation && !request.operator_confirmed { return std::result::Result::Err(ks_core::Error::config( "lifecycle account preparation requires explicit operator confirmation", )); } 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", "lifecycle account preparation endpoint is not Devnet", )); } let payer = 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(payer.public_key()); let mint_wallet = match generated_wallet("token-lifecycle-mint") { std::result::Result::Ok(value) => value, std::result::Result::Err(error) => return std::result::Result::Err(error), }; let source_wallet = match generated_wallet("token-lifecycle-source") { std::result::Result::Ok(value) => value, std::result::Result::Err(error) => return std::result::Result::Err(error), }; let destination_wallet = match generated_wallet("token-lifecycle-destination") { std::result::Result::Ok(value) => value, std::result::Result::Err(error) => return std::result::Result::Err(error), }; let delegate_wallet = match generated_wallet("token-lifecycle-delegate") { std::result::Result::Ok(value) => value, std::result::Result::Err(error) => return std::result::Result::Err(error), }; let rent_config = ks_onchain_transport::GetMinimumBalanceForRentExemptionConfig::confirmed(); let mint_rent = match http_pool .get_minimum_balance_for_rent_exemption_for_role( request.query_role.as_str(), MINT_ACCOUNT_SPACE, &rent_config, ) .await { std::result::Result::Ok(value) => value.minimum_balance_lamports, std::result::Result::Err(error) => return std::result::Result::Err(error), }; let token_rent = match http_pool .get_minimum_balance_for_rent_exemption_for_role( request.query_role.as_str(), TOKEN_ACCOUNT_SPACE, &rent_config, ) .await { std::result::Result::Ok(value) => value.minimum_balance_lamports, std::result::Result::Err(error) => return std::result::Result::Err(error), }; let doubled_token_rent = token_rent.checked_mul(2); let tripled_fee_ceiling = profile.execution.max_fee_lamports.checked_mul(3); let required_balance = match doubled_token_rent .and_then(|value| return mint_rent.checked_add(value)) .and_then(|value| { return tripled_fee_ceiling.and_then(|fees| return value.checked_add(fees)); }) { std::option::Option::Some(value) => value, std::option::Option::None => { return std::result::Result::Err(ks_core::Error::new( "execution_spl_token_lifecycle_preparation_cost_overflow", "lifecycle preparation rent and fee ceiling overflow u64", )); }, }; let balance = match http_pool .get_balance_for_role( request.query_role.as_str(), &authority, &ks_onchain_transport::GetBalanceConfig::confirmed(), ) .await { std::result::Result::Ok(value) => value.lamports, std::result::Result::Err(error) => return std::result::Result::Err(error), }; if balance < required_balance { return std::result::Result::Err(ks_core::Error::new( "execution_balance_insufficient", format!( "lifecycle preparation requires at most {required_balance} lamports but wallet has {balance}" ), )); } let specifications = [ ("mint", &mint_wallet, MINT_ACCOUNT_SPACE, mint_rent), ("source", &source_wallet, TOKEN_ACCOUNT_SPACE, token_rent), ("destination", &destination_wallet, TOKEN_ACCOUNT_SPACE, token_rent), ]; let mut steps = std::vec::Vec::with_capacity(specifications.len()); for (account_role, wallet, space, rent_lamports) in specifications { let step = match create_raw_token_account( http_pool, profile, request, observer, &payer, wallet, account_role, space, rent_lamports, ) .await { std::result::Result::Ok(value) => value, std::result::Result::Err(error) => return std::result::Result::Err(error), }; steps.push(step); } return std::result::Result::Ok(crate::DevnetSplTokenLifecyclePreparationSummary { authority, mint: ks_lib::MdPubkey(mint_wallet.public_key()), source: ks_lib::MdPubkey(source_wallet.public_key()), destination: ks_lib::MdPubkey(destination_wallet.public_key()), delegate: ks_lib::MdPubkey(delegate_wallet.public_key()), steps, }); } /// Executes initialize, mint, transfer, approve/revoke, burn and close on Devnet. #[allow(clippy::too_many_arguments)] pub async fn execute_devnet_spl_token_lifecycle( http_pool: &ks_onchain_transport::HttpEndpointPool, store: &S, profile: &ks_config::ProfileConfig, workspace_root: &std::path::Path, request: &crate::DevnetSplTokenLifecycleRequest, decoders: &[std::sync::Arc], materializers: &[std::sync::Arc], observer: &O, ) -> ks_core::Result 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 !request.submit || !request.operator_confirmed { return std::result::Result::Err(ks_core::Error::new( "execution_spl_token_lifecycle_authorization_required", "the destructive lifecycle requires submit=true and explicit operator confirmation", )); } let profile_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), }; if profile_wallet.public_key() != request.authority.0 { return std::result::Result::Err(ks_core::Error::new( "execution_spl_token_lifecycle_authority_wallet_mismatch", "lifecycle authority must equal the selected profile wallet before the first mutation", )); } let operations = match lifecycle_operations(request) { 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(operations.len()); if request.first_step_index > 0 { let predecessor_index = usize::from(request.first_step_index.saturating_sub(1)); let predecessor_operation = match operations.get(predecessor_index) { std::option::Option::Some(value) => value.clone(), std::option::Option::None => { return std::result::Result::Err(ks_core::Error::new( "execution_spl_token_lifecycle_resume_index_missing", "lifecycle predecessor operation is unavailable", )); }, }; let predecessor_signature = match request.resume_predecessor_signature.as_ref() { std::option::Option::Some(value) => value, std::option::Option::None => { return std::result::Result::Err(ks_core::Error::new( "execution_spl_token_lifecycle_resume_signature_missing", "lifecycle predecessor signature is required for recovery", )); }, }; let recovered = match recover_predecessor_step( http_pool, store, profile, request, predecessor_index, predecessor_operation, predecessor_signature, decoders, materializers, observer, ) .await { std::result::Result::Ok(value) => value, std::result::Result::Err(error) => return std::result::Result::Err(error), }; emit_completed_step(observer, &recovered, true); steps.push(recovered); } for (index, operation) in operations.into_iter().enumerate().skip(usize::from(request.first_step_index)) { if !steps.is_empty() && request.inter_step_delay_ms > 0 { tokio::time::sleep(std::time::Duration::from_millis(request.inter_step_delay_ms)).await; } let operation_code = operation.operation_code().to_string(); let mut step_request = crate::DevnetSplTokenExecutionRequest::new( format!("{}-{index:02}-{}", request.intent_id, operation_code), operation, ); step_request.query_role = request.query_role.clone(); step_request.transaction_role = request.transaction_role.clone(); step_request.submit = true; step_request.operator_confirmed = true; step_request.post_validation_max_retries = request.post_validation_max_retries; let summary = match crate::execute_devnet_spl_token( http_pool, store, profile, workspace_root, &step_request, decoders, materializers, observer, ) .await { std::result::Result::Ok(value) => value, std::result::Result::Err(error) => { return std::result::Result::Err(ks_core::Error::new( "execution_spl_token_lifecycle_step_failed", format!("lifecycle step {index} {operation_code} failed: {error}"), )); }, }; let step = match validated_step(index, operation_code.as_str(), &summary) { std::result::Result::Ok(value) => value, std::result::Result::Err(error) => return std::result::Result::Err(error), }; emit_completed_step(observer, &step, false); steps.push(step); } return std::result::Result::Ok(crate::DevnetSplTokenLifecycleSummary { intent_id: request.intent_id.clone(), mint: request.mint.clone(), source: request.source.clone(), destination: request.destination.clone(), steps, }); } #[allow(clippy::too_many_arguments)] async fn recover_predecessor_step( http_pool: &ks_onchain_transport::HttpEndpointPool, store: &S, profile: &ks_config::ProfileConfig, lifecycle_request: &crate::DevnetSplTokenLifecycleRequest, index: usize, operation: ks_lib::ExSplClassicTokenOperation, signature: &ks_lib::MdSignature, decoders: &[std::sync::Arc], materializers: &[std::sync::Arc], observer: &O, ) -> ks_core::Result where S: ks_store::RawTransactionStore + ks_store::CoreExtractionStore + ks_store::DecodePipelineStore + Sync, O: crate::SolanaExecutionObserver, { let operation_code = operation.operation_code().to_string(); let expected_operation = materialized_operation_code(&operation).to_string(); let mut request = crate::DevnetSplTokenExecutionRequest::new( format!("{}-recover-{index:02}", lifecycle_request.intent_id), operation, ); request.query_role = lifecycle_request.query_role.clone(); request.transaction_role = lifecycle_request.transaction_role.clone(); request.post_validation_max_retries = lifecycle_request.post_validation_max_retries; let backfill = match crate::hydrate_signature( http_pool, store, profile, request.query_role.as_str(), request.post_validation_max_retries, observer, signature, ) .await { std::result::Result::Ok(value) => value, std::result::Result::Err(error) => return std::result::Result::Err(error), }; if !crate::canonical_available(&backfill) { return std::result::Result::Err(ks_core::Error::new( "execution_spl_token_lifecycle_resume_canonical_missing", "confirmed lifecycle predecessor is unavailable for canonical recovery", )); } 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: false, }, observer, ) .await { std::result::Result::Ok(value) => value, std::result::Result::Err(error) => return std::result::Result::Err(error), }; if extraction.failed != 0 || extraction.cancelled || extraction.selected != 1 || extraction.extracted.saturating_add(extraction.skipped) < 1 { return std::result::Result::Err(ks_core::Error::new( "execution_spl_token_lifecycle_resume_extraction_incomplete", "lifecycle predecessor core extraction did not complete", )); } let first_replay = match crate::replay_program( store, signature, false, request.force_post_validation_replay, &[ks_program_ids::SPL_TOKEN_PROGRAM_ID], decoders, materializers, observer, ) .await { std::result::Result::Ok(value) => value, std::result::Result::Err(error) => return std::result::Result::Err(error), }; if !crate::decode_completed(&first_replay) { return std::result::Result::Err(ks_core::Error::new( "execution_spl_token_lifecycle_resume_decode_incomplete", "lifecycle predecessor decode and materialization did not complete", )); } let filter = match ks_store::MaterializedEventFilter::new( std::option::Option::None, std::option::Option::None, std::option::Option::Some(signature.0.clone()), 64, ) { std::result::Result::Ok(value) => value, std::result::Result::Err(error) => return std::result::Result::Err(error), }; let rows = match ks_store::DecodePipelineStore::list_materialized_events(store, &filter).await { std::result::Result::Ok(value) => value, std::result::Result::Err(error) => return std::result::Result::Err(error), }; let recovered_count = rows .iter() .filter(|row| { return row.source_decoder_name == "spl.token" && row.payload_json.get("operation").and_then(serde_json::Value::as_str) == std::option::Option::Some(expected_operation.as_str()); }) .count(); if recovered_count == 0 { return std::result::Result::Err(ks_core::Error::new( "execution_spl_token_lifecycle_resume_materialization_missing", format!( "lifecycle predecessor signature did not materialize expected operation {expected_operation}" ), )); } let second_replay = match crate::replay_program( store, signature, true, request.force_post_validation_replay, &[ks_program_ids::SPL_TOKEN_PROGRAM_ID], decoders, materializers, observer, ) .await { std::result::Result::Ok(value) => value, std::result::Result::Err(error) => return std::result::Result::Err(error), }; 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; }); if !idempotent { return std::result::Result::Err(ks_core::Error::new( "execution_spl_token_lifecycle_resume_not_idempotent", "lifecycle predecessor second replay was not idempotent", )); } let materialization_count = match u32::try_from(recovered_count) { std::result::Result::Ok(value) => value, std::result::Result::Err(_) => { return std::result::Result::Err(ks_core::Error::new( "execution_spl_token_lifecycle_materializations_overflow", "recovered lifecycle materialization count exceeds u32", )); }, }; let step_index = match u8::try_from(index) { std::result::Result::Ok(value) => value, std::result::Result::Err(_) => { return std::result::Result::Err(ks_core::Error::new( "execution_spl_token_lifecycle_step_index_overflow", "recovered lifecycle step index exceeds u8", )); }, }; return std::result::Result::Ok(crate::DevnetSplTokenLifecycleStepSummary { index: step_index, operation_code, signature: signature.clone(), confirmation_status: ks_lib::ExApiExecutionConfirmationStatus::Confirmed, materialization_count, idempotence_validated: true, }); } fn materialized_operation_code(operation: &ks_lib::ExSplClassicTokenOperation) -> &str { return match operation { ks_lib::ExSplClassicTokenOperation::Instruction { value: ks_lib::ExSplClassicTokenSingleOperation::InitializeMint { .. }, } => "initialize_mint2", ks_lib::ExSplClassicTokenOperation::Instruction { value: ks_lib::ExSplClassicTokenSingleOperation::InitializeAccount { .. }, } => "initialize_account3", _ => match operation.operation_code().strip_prefix("spl.token.") { std::option::Option::Some(value) => value, std::option::Option::None => operation.operation_code(), }, }; } fn emit_completed_step( observer: &O, step: &crate::DevnetSplTokenLifecycleStepSummary, recovered: bool, ) where O: crate::SolanaExecutionObserver, { crate::emit( observer, crate::SolanaExecutionProgressLevel::Info, "spl_token_lifecycle_step", format!( "lifecycle step {} {} {}", step.index, step.operation_code, if recovered { "recovered" } else { "completed" } ), std::option::Option::Some(step.signature.0.clone()), ); } fn generated_wallet(alias: &str) -> ks_core::Result { let alias = match ks_wallet::WalletAlias::parse(alias) { std::result::Result::Ok(value) => value, std::result::Result::Err(error) => return std::result::Result::Err(error), }; return std::result::Result::Ok(ks_wallet::TemporaryWallet::generate(alias)); } #[allow(clippy::too_many_arguments)] async fn create_raw_token_account( http_pool: &ks_onchain_transport::HttpEndpointPool, profile: &ks_config::ProfileConfig, request: &crate::DevnetSplTokenLifecyclePreparationRequest, observer: &O, payer: &ks_wallet::TemporaryWallet, account_wallet: &ks_wallet::TemporaryWallet, account_role: &str, space: u64, rent_lamports: u64, ) -> ks_core::Result where O: crate::SolanaExecutionObserver, { if let std::result::Result::Err(error) = crate::ensure_not_cancelled(observer, "token_lifecycle_prepare") { return std::result::Result::Err(error); } let account = ks_lib::MdPubkey(account_wallet.public_key()); let preflight = match http_pool .get_account_info_for_role( request.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 preflight.account.is_some() { return std::result::Result::Err(ks_core::Error::new( "execution_spl_token_lifecycle_preparation_account_exists", format!("generated lifecycle {account_role} account already exists"), )); } let payer_pubkey = ks_lib::MdPubkey(payer.public_key()); let plan = match build_account_creation_plan( profile, format!("{}-{account_role}", request.intent_id), payer_pubkey, account.clone(), space, rent_lamports, ) { 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 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), }; if fee.fee_lamports.is_none() { return std::result::Result::Err(ks_core::Error::new( "execution_fee_unavailable", "lifecycle account-creation fee is unavailable", )); } 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( 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 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( "execution_send_denied", crate::violation_message(send_evaluation.violations.as_slice()), )); } let evidence = unsigned.bind_simulation(simulation); let signed = match unsigned .sign_after_simulation(&evidence, &[payer.as_signer(), account_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( request.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( 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), }; if !matches!( confirmation.status, ks_lib::ExApiExecutionConfirmationStatus::Confirmed | ks_lib::ExApiExecutionConfirmationStatus::Finalized ) { return std::result::Result::Err(ks_core::Error::new( "execution_spl_token_lifecycle_preparation_unconfirmed", format!("lifecycle {account_role} account creation was not confirmed"), )); } let data_limit = match usize::try_from(space) { std::result::Result::Ok(value) => value, std::result::Result::Err(_) => { return std::result::Result::Err(ks_core::Error::new( "execution_spl_token_lifecycle_preparation_space_overflow", "lifecycle account space exceeds usize", )); }, }; let account_config = match ks_onchain_transport::GetAccountInfoConfig::confirmed_with_data(data_limit) { std::result::Result::Ok(value) => value, std::result::Result::Err(error) => return std::result::Result::Err(error), }; let account_info = match http_pool .get_account_info_for_role(request.query_role.as_str(), &account, &account_config) .await { std::result::Result::Ok(value) => value, std::result::Result::Err(error) => return std::result::Result::Err(error), }; let value = match account_info.account { std::option::Option::Some(value) => value, std::option::Option::None => { return std::result::Result::Err(ks_core::Error::new( "execution_spl_token_lifecycle_preparation_account_missing", format!("confirmed lifecycle {account_role} account is unavailable"), )); }, }; if value.owner.0 != ks_program_ids::SPL_TOKEN_PROGRAM_ID || value.executable || value.space != space || value.data.len() != data_limit || value.data.iter().any(|byte| return *byte != 0) || value.lamports < rent_lamports { return std::result::Result::Err(ks_core::Error::new( "execution_spl_token_lifecycle_preparation_account_invalid", format!("prepared lifecycle {account_role} account failed exact state validation"), )); } return std::result::Result::Ok(crate::DevnetSplTokenLifecyclePreparationStep { account_role: account_role.to_string(), account, space, rent_lamports, signature, confirmation_status: confirmation.status, }); } fn build_account_creation_plan( profile: &ks_config::ProfileConfig, intent_id: std::string::String, payer: ks_lib::MdPubkey, new_account: ks_lib::MdPubkey, space: u64, rent_lamports: u64, ) -> ks_core::Result { let intent = ks_lib::ExSolanaCoreExecutionIntent { intent_id, fee_payer: 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: std::vec![payer.clone(), new_account.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, }, }, operation: ks_lib::ExSolanaCoreOperation::SystemCreateAccount { from: payer, new_account, lamports: rent_lamports, space, owner: ks_lib::MdPubkey(ks_program_ids::SPL_TOKEN_PROGRAM_ID.to_string()), }, }; return ks_lib::ExApiTypedInstructionExecutor::build_prepared_plan( &ks_lib::ExSolanaCoreExecutor, &intent, ); } fn canonical_amount(amount: &ks_lib::ExSplClassicTokenAmount, field: &str) -> ks_core::Result { return match amount.0.parse::() { std::result::Result::Ok(value) if value.to_string() == amount.0 => { std::result::Result::Ok(value) }, std::result::Result::Ok(_) | std::result::Result::Err(_) => { std::result::Result::Err(ks_core::Error::new( "execution_spl_token_lifecycle_amount_invalid", format!("{field} must be a canonical u64 decimal string"), )) }, }; } fn lifecycle_operations( request: &crate::DevnetSplTokenLifecycleRequest, ) -> ks_core::Result> { let mint_amount = match canonical_amount(&request.mint_amount, "mint_amount") { std::result::Result::Ok(value) => value, std::result::Result::Err(error) => return std::result::Result::Err(error), }; let transfer_amount = match canonical_amount(&request.transfer_amount, "transfer_amount") { std::result::Result::Ok(value) => value, std::result::Result::Err(error) => return std::result::Result::Err(error), }; let authority = ks_lib::ExSplClassicTokenAuthority { authority: request.authority.clone(), multisig_signers: std::vec::Vec::new(), }; let source_burn = mint_amount.saturating_sub(transfer_amount).to_string(); let operations = std::vec![ ks_lib::ExSplClassicTokenSingleOperation::InitializeMint { mint: request.mint.clone(), mint_authority: request.authority.clone(), freeze_authority: std::option::Option::Some(request.authority.clone()), decimals: request.decimals, }, ks_lib::ExSplClassicTokenSingleOperation::InitializeAccount { account: request.source.clone(), mint: request.mint.clone(), owner: request.authority.clone(), }, ks_lib::ExSplClassicTokenSingleOperation::InitializeAccount { account: request.destination.clone(), mint: request.mint.clone(), owner: request.authority.clone(), }, ks_lib::ExSplClassicTokenSingleOperation::MintToChecked { mint: request.mint.clone(), destination: request.source.clone(), authority: authority.clone(), amount: request.mint_amount.clone(), decimals: request.decimals, }, ks_lib::ExSplClassicTokenSingleOperation::TransferChecked { source: request.source.clone(), mint: request.mint.clone(), destination: request.destination.clone(), authority: authority.clone(), amount: request.transfer_amount.clone(), decimals: request.decimals, }, ks_lib::ExSplClassicTokenSingleOperation::ApproveChecked { source: request.destination.clone(), mint: request.mint.clone(), delegate: request.delegate.clone(), authority: authority.clone(), amount: request.approve_amount.clone(), decimals: request.decimals, }, ks_lib::ExSplClassicTokenSingleOperation::Revoke { source: request.destination.clone(), authority: authority.clone(), }, ks_lib::ExSplClassicTokenSingleOperation::BurnChecked { source: request.source.clone(), mint: request.mint.clone(), authority: authority.clone(), amount: ks_lib::ExSplClassicTokenAmount(source_burn), decimals: request.decimals, }, ks_lib::ExSplClassicTokenSingleOperation::BurnChecked { source: request.destination.clone(), mint: request.mint.clone(), authority: authority.clone(), amount: request.transfer_amount.clone(), decimals: request.decimals, }, ks_lib::ExSplClassicTokenSingleOperation::CloseAccount { account: request.source.clone(), destination: request.authority.clone(), authority: authority.clone(), }, ks_lib::ExSplClassicTokenSingleOperation::CloseAccount { account: request.destination.clone(), destination: request.authority.clone(), authority, }, ]; return std::result::Result::Ok( operations .into_iter() .map(|value| return ks_lib::ExSplClassicTokenOperation::Instruction { value }) .collect(), ); } fn validated_step( index: usize, operation_code: &str, summary: &crate::DevnetSplTokenExecutionSummary, ) -> ks_core::Result { let send = match summary.send_result.as_ref() { std::option::Option::Some(value) if value.submitted => value, std::option::Option::Some(_) | std::option::Option::None => { return std::result::Result::Err(incomplete_step(index, operation_code, "submission")); }, }; let confirmation = match summary.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(_) | std::option::Option::None => { return std::result::Result::Err(incomplete_step( index, operation_code, "confirmation", )); }, }; match summary.post_execution.as_ref() { std::option::Option::Some(value) if value.canonical_inserted && value.core_extracted && value.decode_replayed && value.materialized => {}, std::option::Option::Some(_) | std::option::Option::None => { return std::result::Result::Err(incomplete_step( index, operation_code, "post_validation", )); }, }; let idempotence = summary.idempotence_replay.as_ref().is_some_and(|value| { return value.failed_inputs == 0 && value.processing_error_inputs == 0 && value.processors.iter().all(|processor| { return processor.failed == 0 && processor.processing_errors == 0 && processor.materialized_outputs == 0 && processor.materialization_refused == 0; }); }); if !idempotence || summary.materializations.is_empty() { return std::result::Result::Err(incomplete_step( index, operation_code, "materialization_idempotence", )); } let materialization_count = match u32::try_from(summary.materializations.len()) { std::result::Result::Ok(value) => value, std::result::Result::Err(_) => { return std::result::Result::Err(ks_core::Error::new( "execution_spl_token_lifecycle_materializations_overflow", "lifecycle materialization count exceeds u32", )); }, }; let step_index = match u8::try_from(index) { std::result::Result::Ok(value) => value, std::result::Result::Err(_) => { return std::result::Result::Err(ks_core::Error::new( "execution_spl_token_lifecycle_step_index_overflow", "lifecycle step index exceeds u8", )); }, }; return std::result::Result::Ok(crate::DevnetSplTokenLifecycleStepSummary { index: step_index, operation_code: operation_code.to_string(), signature: send.signature.clone(), confirmation_status: confirmation.status, materialization_count, idempotence_validated: true, }); } fn incomplete_step(index: usize, operation_code: &str, stage: &str) -> ks_core::Error { return ks_core::Error::new( "execution_spl_token_lifecycle_post_validation_incomplete", format!("lifecycle step {index} {operation_code} did not validate {stage}"), ); } #[cfg(test)] mod tests { struct LifecycleTestObserver; impl ks_pipeline::BackfillObserver for LifecycleTestObserver { fn on_progress(&self, _event: &ks_pipeline::BackfillProgressEvent) { return; } fn is_cancelled(&self) -> bool { return false; } } impl ks_pipeline::CoreExtractionObserver for LifecycleTestObserver { fn on_progress(&self, _event: &ks_pipeline::CoreExtractionProgressEvent) { return; } fn is_cancelled(&self) -> bool { return false; } } impl ks_pipeline::DecodeReplayObserver for LifecycleTestObserver { fn on_progress(&self, _event: &ks_pipeline::DecodeReplayProgressEvent) { return; } fn is_cancelled(&self) -> bool { return false; } } impl crate::SolanaExecutionObserver for LifecycleTestObserver { fn on_execution_progress(&self, event: &crate::SolanaExecutionProgressEvent) { if event.stage == "spl_token_lifecycle_step" { let signature = match event.signature.as_deref() { std::option::Option::Some(value) => value, std::option::Option::None => "missing", }; println!("SPL Token lifecycle progress={} signature={}", event.message, signature,); } return; } fn is_execution_cancelled(&self) -> bool { return false; } } fn key(value: &str) -> ks_lib::MdPubkey { return ks_lib::MdPubkey(value.to_string()); } fn request() -> crate::DevnetSplTokenLifecycleRequest { return crate::DevnetSplTokenLifecycleRequest::new( "lifecycle-1", key(ks_program_ids::STAKE_PROGRAM_ID), key(ks_program_ids::VOTE_PROGRAM_ID), key(ks_program_ids::CONFIG_PROGRAM_ID), key(ks_program_ids::COMPUTE_BUDGET_PROGRAM_ID), key(ks_program_ids::SYSTEM_PROGRAM_ID), ); } #[test] fn conservative_request_requires_explicit_destructive_authorization() { let request = request(); assert!(!request.submit); assert!(!request.operator_confirmed); assert!(request.validate().is_ok()); } #[test] fn lifecycle_operations_preserve_order_and_conserve_raw_amounts() { let request = request(); let operations = match super::lifecycle_operations(&request) { std::result::Result::Ok(value) => value, std::result::Result::Err(error) => panic!("lifecycle construction failed: {error}"), }; let codes = operations .iter() .map(|value| return value.operation_code()) .collect::>(); assert_eq!( codes, std::vec![ "spl.token.initialize_mint", "spl.token.initialize_account", "spl.token.initialize_account", "spl.token.mint_to_checked", "spl.token.transfer_checked", "spl.token.approve_checked", "spl.token.revoke", "spl.token.burn_checked", "spl.token.burn_checked", "spl.token.close_account", "spl.token.close_account", ], ); let source_burn = match &operations[7] { ks_lib::ExSplClassicTokenOperation::Instruction { value: ks_lib::ExSplClassicTokenSingleOperation::BurnChecked { amount, .. }, } => amount.0.as_str(), _ => panic!("source burn step missing"), }; assert_eq!(source_burn, "6"); } #[test] fn invalid_amounts_aliases_and_delegate_fail_closed() { let mut request = request(); request.transfer_amount = ks_lib::ExSplClassicTokenAmount("10".to_string()); assert!(request.validate().is_err()); request.transfer_amount = ks_lib::ExSplClassicTokenAmount("04".to_string()); assert!(request.validate().is_err()); request.transfer_amount = ks_lib::ExSplClassicTokenAmount("4".to_string()); request.delegate = request.authority.clone(); assert!(request.validate().is_err()); request.delegate = key(ks_program_ids::COMPUTE_BUDGET_PROGRAM_ID); request.destination = request.source.clone(); assert!(request.validate().is_err()); } #[test] fn account_preparation_requires_explicit_spend_authorization() { let mut request = crate::DevnetSplTokenLifecyclePreparationRequest::new("prepare-1"); assert!(request.validate().is_err()); request.submit = true; assert!(request.validate().is_err()); request.operator_confirmed = true; assert!(request.validate().is_ok()); } #[test] fn account_creation_plan_uses_exact_token_owner_space_spend_and_signers() { let profile = example_devnet_profile(); let payer = key(ks_program_ids::SYSTEM_PROGRAM_ID); let account = key(ks_program_ids::STAKE_PROGRAM_ID); let plan = match super::build_account_creation_plan( &profile, "prepare-mint".to_string(), payer.clone(), account.clone(), super::MINT_ACCOUNT_SPACE, 1_461_600, ) { std::result::Result::Ok(value) => value, std::result::Result::Err(error) => panic!("account creation plan failed: {error}"), }; assert_eq!(plan.operation_code, "solana.core.system.create_account"); assert_eq!(plan.requested_spend_lamports, 1_461_600); assert_eq!(plan.instructions.len(), 1); assert_eq!(plan.instructions[0].accounts.len(), 2); assert_eq!(plan.instructions[0].accounts[0].pubkey, payer); assert_eq!(plan.instructions[0].accounts[1].pubkey, account); assert_eq!(plan.required_signers.len(), 2); assert_eq!(plan.policy.authorized_signers.len(), 2); assert!(!plan.policy.dry_run); } #[test] fn resume_requires_exact_predecessor_signature_and_bounded_delay() { let mut request = request(); request.first_step_index = 3; assert!(request.validate().is_err()); request.resume_predecessor_signature = std::option::Option::Some(ks_lib::MdSignature( "3yxsCf8tc1H2P9E3m6Kgu7J3XVeBw7wePuRTHvCaidQ74bsa2TP5xg4hpXAw1jkRSB3LrkLrXgx5eqvCQqRsXcfq".to_string(), )); assert!(request.validate().is_ok()); request.inter_step_delay_ms = 30_001; assert!(request.validate().is_err()); request.inter_step_delay_ms = 2_000; request.first_step_index = 0; assert!(request.validate().is_err()); } #[test] fn recovery_uses_current_initialization_wire_names() { let request = request(); let operations = match super::lifecycle_operations(&request) { std::result::Result::Ok(value) => value, std::result::Result::Err(error) => panic!("lifecycle construction failed: {error}"), }; assert_eq!(super::materialized_operation_code(&operations[0]), "initialize_mint2"); assert_eq!(super::materialized_operation_code(&operations[2]), "initialize_account3"); assert_eq!(super::materialized_operation_code(&operations[3]), "mint_to_checked"); } #[tokio::test] async fn optional_devnet_controlled_lifecycle_from_env() { if std::env::var("KS_DEVNET_SPL_TOKEN_LIFECYCLE_TEST").ok().as_deref() != std::option::Option::Some("1") { return; } if std::env::var("KS_DEVNET_SPL_TOKEN_LIFECYCLE_SUBMIT").ok().as_deref() != std::option::Option::Some("1") { panic!("controlled lifecycle requires KS_DEVNET_SPL_TOKEN_LIFECYCLE_SUBMIT=1"); } let mut profile = example_devnet_profile(); if let std::result::Result::Ok(directory) = std::env::var("KS_DEVNET_WALLET_DIR") { profile.wallet.wallet_dir = directory; } let database_url = match std::env::var("KS_SECRET_POSTGRES_TEST_URL") { std::result::Result::Ok(value) => value, std::result::Result::Err(error) => { panic!("KS_SECRET_POSTGRES_TEST_URL is required for lifecycle submission: {error}"); }, }; profile.database.backend = "postgres".to_string(); profile.database.postgres.url = database_url; 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 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 observer = LifecycleTestObserver; let prepared_accounts = if std::env::var("KS_DEVNET_SPL_TOKEN_LIFECYCLE_PREPARE") .ok() .as_deref() == std::option::Option::Some("1") { let mut preparation = crate::DevnetSplTokenLifecyclePreparationRequest::new(format!( "devnet-token-lifecycle-prepare-{}", uuid::Uuid::new_v4() )); preparation.submit = true; preparation.operator_confirmed = true; let summary = match crate::prepare_devnet_spl_token_lifecycle_accounts( &pool, &profile, workspace_root, &preparation, &observer, ) .await { std::result::Result::Ok(value) => value, std::result::Result::Err(error) => { panic!("Devnet Token lifecycle account preparation failed: {error}"); }, }; for step in &summary.steps { println!( "SPL Token lifecycle preparation role={} account={} space={} rent={} signature={} status={:?}", step.account_role, step.account.0, step.space, step.rent_lamports, step.signature.0, step.confirmation_status, ); } std::option::Option::Some(summary) } else { std::option::Option::None }; if prepared_accounts.is_some() { tokio::time::sleep(std::time::Duration::from_millis(optional_u64_env( "KS_DEVNET_SPL_TOKEN_LIFECYCLE_PREPARATION_SETTLE_DELAY_MS", 10_000, ))) .await; } let (mint, source, destination, delegate, authority) = match prepared_accounts.as_ref() { std::option::Option::Some(value) => ( value.mint.clone(), value.source.clone(), value.destination.clone(), value.delegate.clone(), value.authority.clone(), ), std::option::Option::None => ( required_pubkey_env("KS_DEVNET_SPL_TOKEN_LIFECYCLE_MINT"), required_pubkey_env("KS_DEVNET_SPL_TOKEN_LIFECYCLE_SOURCE"), required_pubkey_env("KS_DEVNET_SPL_TOKEN_LIFECYCLE_DESTINATION"), required_pubkey_env("KS_DEVNET_SPL_TOKEN_LIFECYCLE_DELEGATE"), required_pubkey_env("KS_DEVNET_SPL_TOKEN_LIFECYCLE_AUTHORITY"), ), }; let mut request = crate::DevnetSplTokenLifecycleRequest::new( format!("devnet-token-lifecycle-{}", uuid::Uuid::new_v4()), mint, source, destination, delegate, authority, ); request.decimals = optional_u8_env("KS_DEVNET_SPL_TOKEN_LIFECYCLE_DECIMALS", 9); request.mint_amount = ks_lib::ExSplClassicTokenAmount(optional_string_env( "KS_DEVNET_SPL_TOKEN_LIFECYCLE_MINT_AMOUNT", "10", )); request.transfer_amount = ks_lib::ExSplClassicTokenAmount(optional_string_env( "KS_DEVNET_SPL_TOKEN_LIFECYCLE_TRANSFER_AMOUNT", "4", )); request.approve_amount = ks_lib::ExSplClassicTokenAmount(optional_string_env( "KS_DEVNET_SPL_TOKEN_LIFECYCLE_APPROVE_AMOUNT", "2", )); request.submit = true; request.operator_confirmed = true; request.post_validation_max_retries = 20; request.first_step_index = optional_u8_env("KS_DEVNET_SPL_TOKEN_LIFECYCLE_FIRST_STEP_INDEX", 0); request.resume_predecessor_signature = if request.first_step_index > 0 { std::option::Option::Some(ks_lib::MdSignature(required_string_env( "KS_DEVNET_SPL_TOKEN_LIFECYCLE_RESUME_PREDECESSOR_SIGNATURE", ))) } else { std::option::Option::None }; request.inter_step_delay_ms = optional_u64_env("KS_DEVNET_SPL_TOKEN_LIFECYCLE_INTER_STEP_DELAY_MS", 2_000); let decoders: std::vec::Vec> = std::vec![std::sync::Arc::new(ks_lib::DcSplTokenDecoder)]; let materializers: std::vec::Vec> = std::vec![std::sync::Arc::new(ks_lib::MtTokenAccountsMaterializer,)]; let summary = match crate::execute_devnet_spl_token_lifecycle( &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) => panic!("Devnet Token lifecycle failed: {error}"), }; let expected_step_count = if request.first_step_index == 0 { 11 } else { 12_usize.saturating_sub(usize::from(request.first_step_index)) }; assert_eq!(summary.steps.len(), expected_step_count); assert!(summary.steps.iter().all(|step| { return step.idempotence_validated && step.materialization_count > 0; })); for step in summary.steps { println!( "SPL Token lifecycle step={} operation={} signature={} status={:?} materializations={} idempotent={}", step.index, step.operation_code, step.signature.0, step.confirmation_status, step.materialization_count, step.idempotence_validated, ); } } fn example_devnet_profile() -> ks_config::ProfileConfig { let config = match ks_config::parse_config_json(include_str!( "../../../../config/app.config.json" )) { std::result::Result::Ok(value) => value, std::result::Result::Err(error) => panic!("example config parse failed: {error}"), }; return match crate::resolve_demo_devnet_profile(&config, std::option::Option::None) { std::result::Result::Ok(profile) => profile, std::result::Result::Err(error) => panic!("Devnet profile resolution failed: {error}"), }; } fn required_pubkey_env(name: &str) -> ks_lib::MdPubkey { return match std::env::var(name) { std::result::Result::Ok(value) => ks_lib::MdPubkey(value), std::result::Result::Err(error) => panic!("{name} is required: {error}"), }; } fn optional_string_env(name: &str, default: &str) -> std::string::String { return match std::env::var(name) { std::result::Result::Ok(value) => value, std::result::Result::Err(_) => default.to_string(), }; } fn required_string_env(name: &str) -> std::string::String { return match std::env::var(name) { std::result::Result::Ok(value) => value, std::result::Result::Err(error) => panic!("{name} is required: {error}"), }; } fn optional_u64_env(name: &str, default: u64) -> u64 { return match std::env::var(name) { std::result::Result::Ok(value) => match value.parse::() { std::result::Result::Ok(value) => value, std::result::Result::Err(error) => panic!("{name} is invalid: {error}"), }, std::result::Result::Err(_) => default, }; } fn optional_u8_env(name: &str, default: u8) -> u8 { return match std::env::var(name) { std::result::Result::Ok(value) => match value.parse::() { std::result::Result::Ok(value) => value, std::result::Result::Err(error) => panic!("{name} is invalid: {error}"), }, std::result::Result::Err(_) => default, }; } }