diff --git a/kb-pipeline/src/lib.rs b/kb-pipeline/src/lib.rs index 744957e..4fb0c24 100644 --- a/kb-pipeline/src/lib.rs +++ b/kb-pipeline/src/lib.rs @@ -26,6 +26,8 @@ mod solana_token2022_preflight; mod solana_token2022_proof_orchestration; mod solana_token2022_stateful; mod solana_token2022_validation; +mod solana_token_execution; +mod solana_token_lifecycle; mod solana_token_stateful; /// Emits one execution progress event for specialized pipeline orchestrators. @@ -177,6 +179,38 @@ pub use self::solana_stateful::SolanaCoreStatefulReadinessRequest; pub use self::solana_stateful::SolanaCoreStatefulReadinessStatus; /// Inspects state required before simulating one native Solana operation. pub use self::solana_stateful::inspect_solana_core_stateful_readiness; +/// Complete request for one Devnet classic SPL Token execution. +pub use self::solana_token_execution::DevnetSplTokenExecutionRequest; +/// Complete result of one Devnet classic SPL Token execution. +pub use self::solana_token_execution::DevnetSplTokenExecutionSummary; +/// Canonical hydration availability helper shared by Token lifecycle orchestration. +pub(crate) use self::solana_token_execution::canonical_available; +/// Decode completion helper shared by Token lifecycle orchestration. +pub(crate) use self::solana_token_execution::decode_completed; +/// Executes one Devnet classic SPL Token simulation or authorized submission. +pub use self::solana_token_execution::execute_devnet_spl_token; +/// Canonical signature hydration helper shared by Token lifecycle orchestration. +pub(crate) use self::solana_token_execution::hydrate_signature; +/// Targeted program replay helper shared by Token lifecycle orchestration. +pub(crate) use self::solana_token_execution::replay_program; +/// Simulates one Devnet classic SPL Token operation after stateful preflight. +pub use self::solana_token_execution::simulate_devnet_spl_token; +/// Complete request to prepare raw accounts for one Devnet SPL Token lifecycle. +pub use self::solana_token_lifecycle::DevnetSplTokenLifecyclePreparationRequest; +/// One prepared raw account step. +pub use self::solana_token_lifecycle::DevnetSplTokenLifecyclePreparationStep; +/// Summary of raw-account preparation for one lifecycle. +pub use self::solana_token_lifecycle::DevnetSplTokenLifecyclePreparationSummary; +/// Complete request for one controlled Devnet SPL Token lifecycle. +pub use self::solana_token_lifecycle::DevnetSplTokenLifecycleRequest; +/// Summary of one lifecycle operation step. +pub use self::solana_token_lifecycle::DevnetSplTokenLifecycleStepSummary; +/// Complete controlled lifecycle summary. +pub use self::solana_token_lifecycle::DevnetSplTokenLifecycleSummary; +/// Executes one controlled Devnet SPL Token lifecycle. +pub use self::solana_token_lifecycle::execute_devnet_spl_token_lifecycle; +/// Prepares raw accounts for one controlled Devnet SPL Token lifecycle. +pub use self::solana_token_lifecycle::prepare_devnet_spl_token_lifecycle_accounts; /// One machine-readable classic SPL Token stateful check. pub use self::solana_token_stateful::SplTokenStatefulCheck; /// One contextual classic SPL Token stateful fact. diff --git a/kb-pipeline/src/solana_token_execution.rs b/kb-pipeline/src/solana_token_execution.rs new file mode 100644 index 0000000..ed1bec5 --- /dev/null +++ b/kb-pipeline/src/solana_token_execution.rs @@ -0,0 +1,1346 @@ +// file: kb-pipeline/src/solana_token_execution.rs +// version: 5 + +//! Devnet classic SPL Token execution with stateful and canonical post-validation. + +/// Complete request for one Devnet classic SPL Token execution. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct DevnetSplTokenExecutionRequest { + /// Stable caller-provided execution identifier. + pub intent_id: std::string::String, + /// Endpoint role used for state, balance, blockhash, fee and hydration calls. + pub query_role: std::string::String, + /// Endpoint role used for simulation, submission and confirmation polling. + pub transaction_role: std::string::String, + /// Exact typed classic SPL Token operation. + pub operation: kb_lib::ExSplClassicTokenOperation, + /// Explicitly authorizes signing and submission after successful simulation. + pub submit: bool, + /// Explicit operator confirmation required by the active profile. + pub operator_confirmed: bool, + /// Number of `getTransaction` retries after the first hydration attempt. + pub post_validation_max_retries: u32, + /// Replaces existing core and decode outputs for the submitted signature. + pub force_post_validation_replay: bool, +} + +impl crate::DevnetSplTokenExecutionRequest { + /// Creates a conservative simulation-only request. + pub fn new( + intent_id: impl std::convert::Into, + operation: kb_lib::ExSplClassicTokenOperation, + ) -> Self { + return Self { + intent_id: intent_id.into(), + query_role: "http_queries".to_string(), + transaction_role: "http_transactions".to_string(), + operation, + submit: false, + operator_confirmed: false, + post_validation_max_retries: 10, + force_post_validation_replay: false, + }; + } + + /// Validates request-local bounds independently from one profile. + pub fn validate(&self) -> kb_core::Result<()> { + if self.intent_id.trim().is_empty() { + return std::result::Result::Err(kb_core::Error::config( + "Devnet SPL Token execution intent id must not be empty", + )); + } + if self.query_role.trim().is_empty() || self.transaction_role.trim().is_empty() { + return std::result::Result::Err(kb_core::Error::config( + "Devnet SPL Token execution endpoint roles must not be empty", + )); + } + if self.post_validation_max_retries > 20 { + return std::result::Result::Err(kb_core::Error::config( + "post-execution getTransaction retries must not exceed 20", + )); + } + return std::result::Result::Ok(()); + } +} + +/// Complete result of one Devnet classic SPL Token execution. +#[derive(Clone, Debug, PartialEq)] +pub struct DevnetSplTokenExecutionSummary { + /// Profile used by the orchestration. + pub profile_name: std::string::String, + /// Exact classified cluster. + pub cluster: kb_lib::ExApiExecutionCluster, + /// Genesis hash returned by the selected endpoint. + pub genesis_hash: std::string::String, + /// Non-secret persistent wallet description. + pub wallet: kb_wallet::WalletSummary, + /// Wallet balance observed before planning. + pub balance_lamports: u64, + /// Stateful readiness report produced before plan simulation. + pub stateful_readiness: crate::SplTokenStatefulReadinessReport, + /// Exact prepared Token plan. + pub plan: kb_lib::ExApiPreparedExecutionPlan, + /// Recent blockhash used by the exact transaction. + pub latest_blockhash: kb_onchain_transport::LatestBlockhashResult, + /// Fee estimate for the exact compiled message. + pub fee: kb_onchain_transport::FeeForMessageResult, + /// Exact simulation result bound to the compiled message. + pub simulation: kb_lib::ExApiExecutionSimulationResult, + /// Submission result when explicitly authorized. + pub send_result: std::option::Option, + /// Confirmation result when submitted. + pub confirmation: std::option::Option, + /// Canonical hydration result for the exact signature. + pub backfill: std::option::Option, + /// Core extraction result for the exact signature. + pub core_extraction: std::option::Option, + /// First Token decode and materialization replay. + pub decode_replay: std::option::Option, + /// Second replay proving idempotence for the same decoder version and input. + pub idempotence_replay: std::option::Option, + /// Exact materialized rows produced for the submitted Token transaction. + pub materializations: std::vec::Vec, + /// Aggregated post-execution validation diagnostic. + pub post_execution: std::option::Option, +} + +struct PreparedTokenExecution { + wallet: kb_wallet::TemporaryWallet, + unsigned: kb_lib::ExSolanaUnsignedTransaction, + evidence: kb_lib::ExSolanaSimulationEvidence, + summary: crate::DevnetSplTokenExecutionSummary, +} + +/// Simulates one Devnet classic SPL Token operation after stateful preflight. +pub async fn simulate_devnet_spl_token( + http_pool: &kb_onchain_transport::HttpEndpointPool, + profile: &kb_config::ProfileConfig, + workspace_root: &std::path::Path, + request: &crate::DevnetSplTokenExecutionRequest, + observer: &O, +) -> kb_core::Result +where + O: crate::SolanaExecutionObserver, +{ + if request.submit { + return std::result::Result::Err(kb_core::Error::config( + "simulate_devnet_spl_token requires submit=false", + )); + } + let prepared = + match prepare_execution(http_pool, profile, workspace_root, request, observer).await { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + return std::result::Result::Ok(prepared.summary); +} + +/// Executes one Devnet classic SPL Token simulation or authorized submission. +#[allow(clippy::too_many_arguments)] +pub async fn execute_devnet_spl_token( + http_pool: &kb_onchain_transport::HttpEndpointPool, + store: &S, + profile: &kb_config::ProfileConfig, + workspace_root: &std::path::Path, + request: &crate::DevnetSplTokenExecutionRequest, + decoders: &[std::sync::Arc], + materializers: &[std::sync::Arc], + observer: &O, +) -> kb_core::Result +where + S: kb_store::RawTransactionStore + + kb_store::CoreExtractionStore + + kb_store::DecodePipelineStore + + Sync, + O: crate::SolanaExecutionObserver, +{ + let prepared = + match prepare_execution(http_pool, profile, workspace_root, request, observer).await { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + if !request.submit { + return std::result::Result::Ok(prepared.summary); + } + if !prepared.summary.simulation.success { + return std::result::Result::Err(kb_core::Error::new( + "execution_simulation_failed", + crate::simulation_failure_message(&prepared.summary.simulation), + )); + } + if let std::result::Result::Err(error) = validate_profile_wallet_signers( + prepared.unsigned.required_signer_pubkeys(), + prepared.summary.wallet.public_key.as_str(), + ) { + return std::result::Result::Err(error); + } + let send_evaluation = match kb_lib::ExSafetyChecker + .evaluate_send(&prepared.summary.plan, &prepared.summary.simulation) + { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + if send_evaluation.decision == kb_lib::ExSafetyDecision::Deny { + return std::result::Result::Err(kb_core::Error::new( + "execution_send_denied", + crate::violation_message(send_evaluation.violations.as_slice()), + )); + } + let signed = match prepared + .unsigned + .sign_after_simulation(&prepared.evidence, &[prepared.wallet.as_signer()]) + { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + if let std::result::Result::Err(error) = signed.verify_signatures() { + return std::result::Result::Err(error); + } + let signature = signed.primary_signature().clone(); + let mut summary = prepared.summary; + let mut diagnostic = kb_lib::ExApiPostExecutionDiagnostic { + signature: signature.clone(), + canonical_inserted: false, + core_extracted: false, + decode_replayed: false, + materialized: false, + diagnostics: std::vec::Vec::new(), + }; + let send_config = match kb_onchain_transport::SendTransactionConfig::from_execution_config( + &profile.execution, + std::option::Option::Some(summary.latest_blockhash.context.slot), + ) { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + let sent = match http_pool + .send_transaction_for_role( + request.transaction_role.as_str(), + signed.transaction_base64().as_str(), + &signature, + &send_config, + ) + .await + { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => { + diagnostic.diagnostics.push(format!("SPL Token submission failed: {error}")); + summary.post_execution = std::option::Option::Some(diagnostic); + return std::result::Result::Ok(summary); + }, + }; + summary.send_result = + std::option::Option::Some(sent.to_execution_result(kb_lib::ExApiExecutionCluster::Devnet)); + let confirmation_config = + match kb_onchain_transport::ConfirmTransactionConfig::from_execution_config( + &profile.execution, + std::option::Option::Some(summary.latest_blockhash.last_valid_block_height), + std::option::Option::Some(summary.latest_blockhash.context.slot), + ) { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + let confirmation = match http_pool + .confirm_transaction_for_roles( + request.transaction_role.as_str(), + request.query_role.as_str(), + kb_lib::ExApiExecutionCluster::Devnet, + &signature, + &confirmation_config, + ) + .await + { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => { + diagnostic.diagnostics.push(format!("SPL Token confirmation failed: {error}")); + summary.post_execution = std::option::Option::Some(diagnostic); + return std::result::Result::Ok(summary); + }, + }; + let confirmation_status = confirmation.status; + summary.confirmation = std::option::Option::Some(confirmation); + if !matches!( + confirmation_status, + kb_lib::ExApiExecutionConfirmationStatus::Confirmed + | kb_lib::ExApiExecutionConfirmationStatus::Finalized + ) { + diagnostic.diagnostics.push(format!( + "SPL Token post-validation stopped at confirmation status {confirmation_status:?}" + )); + summary.post_execution = std::option::Option::Some(diagnostic); + return std::result::Result::Ok(summary); + } + let backfill = match 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) => { + diagnostic.diagnostics.push(format!("SPL Token hydration failed: {error}")); + summary.post_execution = std::option::Option::Some(diagnostic); + return std::result::Result::Ok(summary); + }, + }; + diagnostic.canonical_inserted = crate::canonical_available(&backfill); + summary.backfill = std::option::Option::Some(backfill); + if !diagnostic.canonical_inserted { + diagnostic.diagnostics.push( + "confirmed SPL Token transaction was unavailable for canonical hydration".to_string(), + ); + summary.post_execution = std::option::Option::Some(diagnostic); + return std::result::Result::Ok(summary); + } + let extraction = match crate::execute_core_extraction( + store, + &crate::CoreExtractionRequest { + source: crate::CoreExtractionSource::Signatures(std::vec![signature.0.clone()]), + limit: 1, + max_concurrent_extractions: 1, + force_replay: request.force_post_validation_replay, + }, + observer, + ) + .await + { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => { + diagnostic + .diagnostics + .push(format!("SPL Token core extraction failed: {error}")); + summary.post_execution = std::option::Option::Some(diagnostic); + return std::result::Result::Ok(summary); + }, + }; + diagnostic.core_extracted = extraction.failed == 0 + && !extraction.cancelled + && extraction.selected == 1 + && extraction.extracted.saturating_add(extraction.skipped) >= 1; + summary.core_extraction = std::option::Option::Some(extraction); + if !diagnostic.core_extracted { + diagnostic + .diagnostics + .push("SPL Token core extraction did not complete".to_string()); + summary.post_execution = std::option::Option::Some(diagnostic); + return std::result::Result::Ok(summary); + } + let first_replay = match crate::replay_program( + store, + &signature, + false, + request.force_post_validation_replay, + &[kb_program_ids::SPL_TOKEN_PROGRAM_ID], + decoders, + materializers, + observer, + ) + .await + { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => { + diagnostic.diagnostics.push(format!("SPL Token decode replay failed: {error}")); + summary.post_execution = std::option::Option::Some(diagnostic); + return std::result::Result::Ok(summary); + }, + }; + diagnostic.decode_replayed = crate::decode_completed(&first_replay); + summary.decode_replay = std::option::Option::Some(first_replay); + let filter = match kb_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 kb_store::DecodePipelineStore::list_materialized_events(store, &filter).await { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => { + diagnostic + .diagnostics + .push(format!("SPL Token materialization query failed: {error}")); + summary.post_execution = std::option::Option::Some(diagnostic); + return std::result::Result::Ok(summary); + }, + }; + summary.materializations = rows + .into_iter() + .filter(|row| return row.source_decoder_name == "spl_token") + .collect(); + diagnostic.materialized = + !summary.plan.policy.post_execution_validation.materialization_required + || !summary.materializations.is_empty(); + let second_replay = match crate::replay_program( + store, + &signature, + true, + request.force_post_validation_replay, + &[kb_program_ids::SPL_TOKEN_PROGRAM_ID], + decoders, + materializers, + observer, + ) + .await + { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => { + diagnostic + .diagnostics + .push(format!("SPL Token idempotence replay failed: {error}")); + summary.post_execution = std::option::Option::Some(diagnostic); + return std::result::Result::Ok(summary); + }, + }; + let idempotent = second_replay.failed_inputs == 0 + && second_replay.processors.iter().all(|processor| { + return processor.failed == 0 + && processor.materialized_outputs == 0 + && processor.materialization_refused == 0; + }); + summary.idempotence_replay = std::option::Option::Some(second_replay); + if !idempotent { + diagnostic + .diagnostics + .push("second SPL Token replay did not prove a clean idempotent skip".to_string()); + } else if diagnostic.canonical_inserted + && diagnostic.core_extracted + && diagnostic.decode_replayed + && diagnostic.materialized + { + diagnostic.diagnostics.push( + "SPL Token completed canonical hydration, core extraction, decode, materialization and idempotence validation" + .to_string(), + ); + } + summary.post_execution = std::option::Option::Some(diagnostic); + return std::result::Result::Ok(summary); +} + +async fn prepare_execution( + http_pool: &kb_onchain_transport::HttpEndpointPool, + profile: &kb_config::ProfileConfig, + workspace_root: &std::path::Path, + request: &crate::DevnetSplTokenExecutionRequest, + observer: &O, +) -> kb_core::Result +where + O: crate::SolanaExecutionObserver, +{ + if let std::result::Result::Err(error) = request.validate() { + return std::result::Result::Err(error); + } + if let std::result::Result::Err(error) = validate_profile(profile, request) { + return std::result::Result::Err(error); + } + if let std::result::Result::Err(error) = crate::ensure_not_cancelled(observer, "token_validate") + { + return std::result::Result::Err(error); + } + let genesis = match http_pool.get_genesis_hash_for_role(request.query_role.as_str()).await { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + if genesis.classified_cluster + != std::option::Option::Some(kb_lib::ExApiExecutionCluster::Devnet) + { + return std::result::Result::Err(kb_core::Error::new( + "execution_cluster_mismatch", + format!( + "expected Devnet genesis hash but endpoint returned {} classified as {:?}", + genesis.genesis_hash, genesis.classified_cluster + ), + )); + } + let readiness = match crate::inspect_spl_token_stateful_readiness( + http_pool, + &crate::SplTokenStatefulReadinessRequest { + query_role: request.query_role.clone(), + cluster: kb_lib::ExApiExecutionCluster::Devnet, + operation: request.operation.clone(), + }, + ) + .await + { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + if readiness.status != crate::SplTokenStatefulReadinessStatus::Ready { + return std::result::Result::Err(kb_core::Error::new( + "execution_spl_token_stateful_blocked", + failed_readiness_message(&readiness), + )); + } + let wallet = match crate::load_profile_wallet(profile, workspace_root).await { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + let wallet_summary = wallet.summary(); + let fee_payer = kb_lib::MdPubkey(wallet_summary.public_key.clone()); + let balance = match http_pool + .get_balance_for_role( + request.query_role.as_str(), + &fee_payer, + &kb_onchain_transport::GetBalanceConfig::confirmed(), + ) + .await + { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + if balance.lamports < profile.execution.max_fee_lamports { + return std::result::Result::Err(kb_core::Error::new( + "execution_balance_insufficient", + format!( + "Devnet wallet balance {} is below the configured fee ceiling {}", + balance.lamports, profile.execution.max_fee_lamports + ), + )); + } + let plan = match build_plan(profile, request, fee_payer.clone()) { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + let plan_evaluation = match kb_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 == kb_lib::ExSafetyDecision::Deny { + return std::result::Result::Err(kb_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(), + &kb_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 kb_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(), + &kb_onchain_transport::GetFeeForMessageConfig::new( + kb_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(kb_core::Error::new( + "execution_fee_unavailable", + "getFeeForMessage returned null for the selected recent blockhash", + )); + } + let unsigned_base64 = match unsigned.transaction_base64() { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + let simulation_config = match kb_onchain_transport::SimulateTransactionConfig::new( + kb_onchain_transport::RpcCommitmentLevel::Confirmed, + false, + false, + std::option::Option::Some(latest_blockhash.context.slot), + true, + std::option::Option::None, + ) { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + crate::emit( + observer, + crate::SolanaExecutionProgressLevel::Info, + "spl_token_simulation", + format!("simulating exact SPL Token message {}", unsigned.message_hash()), + std::option::Option::None, + ); + let simulation_rpc = match http_pool + .simulate_transaction_for_role( + request.transaction_role.as_str(), + unsigned_base64.as_str(), + &simulation_config, + ) + .await + { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + let simulation = simulation_rpc.to_execution_result( + kb_lib::ExApiExecutionCluster::Devnet, + kb_lib::ExApiExecutionBlockhashKind::Latest, + std::option::Option::Some( + simulation_rpc.context.slot.saturating_sub(latest_blockhash.context.slot), + ), + std::option::Option::None, + std::option::Option::None, + std::option::Option::Some(&fee), + ); + let evidence = unsigned.bind_simulation(simulation.clone()); + let summary = crate::DevnetSplTokenExecutionSummary { + profile_name: profile.name.clone(), + cluster: kb_lib::ExApiExecutionCluster::Devnet, + genesis_hash: genesis.genesis_hash, + wallet: wallet_summary, + balance_lamports: balance.lamports, + stateful_readiness: readiness, + plan, + latest_blockhash, + fee, + simulation, + send_result: std::option::Option::None, + confirmation: std::option::Option::None, + backfill: std::option::Option::None, + core_extraction: std::option::Option::None, + decode_replay: std::option::Option::None, + idempotence_replay: std::option::Option::None, + materializations: std::vec::Vec::new(), + post_execution: std::option::Option::None, + }; + return std::result::Result::Ok(PreparedTokenExecution { wallet, unsigned, evidence, summary }); +} + +fn validate_profile( + profile: &kb_config::ProfileConfig, + request: &crate::DevnetSplTokenExecutionRequest, +) -> kb_core::Result<()> { + if profile.wallet.cluster != "devnet" { + return std::result::Result::Err(kb_core::Error::config( + "SPL Token Devnet orchestration requires a Devnet wallet profile", + )); + } + if !profile.wallet.temporary_wallet_enabled || !profile.wallet.temporary_wallet_persist { + return std::result::Result::Err(kb_core::Error::config( + "SPL Token Devnet orchestration requires an enabled persistent temporary wallet", + )); + } + if !profile.execution.require_simulation { + return std::result::Result::Err(kb_core::Error::config( + "SPL Token Devnet orchestration requires simulation", + )); + } + if request.submit && !profile.wallet.devnet_send_enabled { + return std::result::Result::Err(kb_core::Error::config( + "Devnet transaction submission is disabled by the wallet profile", + )); + } + if request.submit + && profile.execution.require_operator_confirmation + && !request.operator_confirmed + { + return std::result::Result::Err(kb_core::Error::config( + "SPL Token Devnet submission requires explicit operator confirmation", + )); + } + return std::result::Result::Ok(()); +} + +fn build_plan( + profile: &kb_config::ProfileConfig, + request: &crate::DevnetSplTokenExecutionRequest, + fee_payer: kb_lib::MdPubkey, +) -> kb_core::Result { + let materialization_required = operation_requires_materialization(&request.operation); + let mut authorized_signers = std::vec![fee_payer.clone()]; + collect_operation_signers(&request.operation, &mut authorized_signers); + let intent = kb_lib::ExSplClassicTokenExecutionIntent { + intent_id: request.intent_id.clone(), + fee_payer, + policy: kb_lib::ExApiExecutionPolicy { + cluster: kb_lib::ExApiExecutionClusterPolicy { + expected_cluster: kb_lib::ExApiExecutionCluster::Devnet, + allow_mainnet: false, + mainnet_confirmation: false, + }, + simulation: kb_lib::ExApiExecutionSimulationPolicy::Required, + blockhash: kb_lib::ExApiExecutionBlockhashPolicy { + kind: kb_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: kb_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, + dry_run: !request.submit, + post_execution_validation: kb_lib::ExApiPostExecutionValidationPolicy { + canonical_insert_required: true, + core_extraction_required: true, + decode_replay_required: true, + materialization_required, + }, + }, + operation: request.operation.clone(), + }; + return kb_lib::ExApiTypedInstructionExecutor::build_prepared_plan( + &kb_lib::ExSplTokenExecutor, + &intent, + ); +} + +fn collect_operation_signers( + operation: &kb_lib::ExSplClassicTokenOperation, + signers: &mut std::vec::Vec, +) { + match operation { + kb_lib::ExSplClassicTokenOperation::Instruction { value } => { + collect_single_signers(value, signers); + }, + kb_lib::ExSplClassicTokenOperation::Batch { instructions } => { + for value in instructions { + collect_single_signers(value, signers); + } + }, + } +} + +fn collect_single_signers( + operation: &kb_lib::ExSplClassicTokenSingleOperation, + signers: &mut std::vec::Vec, +) { + let authority = match operation { + kb_lib::ExSplClassicTokenSingleOperation::Transfer { authority, .. } + | kb_lib::ExSplClassicTokenSingleOperation::Approve { authority, .. } + | kb_lib::ExSplClassicTokenSingleOperation::Revoke { authority, .. } + | kb_lib::ExSplClassicTokenSingleOperation::MintTo { authority, .. } + | kb_lib::ExSplClassicTokenSingleOperation::Burn { authority, .. } + | kb_lib::ExSplClassicTokenSingleOperation::CloseAccount { authority, .. } + | kb_lib::ExSplClassicTokenSingleOperation::FreezeAccount { authority, .. } + | kb_lib::ExSplClassicTokenSingleOperation::ThawAccount { authority, .. } + | kb_lib::ExSplClassicTokenSingleOperation::TransferChecked { authority, .. } + | kb_lib::ExSplClassicTokenSingleOperation::ApproveChecked { authority, .. } + | kb_lib::ExSplClassicTokenSingleOperation::MintToChecked { authority, .. } + | kb_lib::ExSplClassicTokenSingleOperation::BurnChecked { authority, .. } + | kb_lib::ExSplClassicTokenSingleOperation::WithdrawExcessLamports { authority, .. } + | kb_lib::ExSplClassicTokenSingleOperation::UnwrapLamports { authority, .. } => { + std::option::Option::Some(authority) + }, + kb_lib::ExSplClassicTokenSingleOperation::SetAuthority { current_authority, .. } => { + std::option::Option::Some(current_authority) + }, + kb_lib::ExSplClassicTokenSingleOperation::InitializeMint { .. } + | kb_lib::ExSplClassicTokenSingleOperation::InitializeAccount { .. } + | kb_lib::ExSplClassicTokenSingleOperation::InitializeMultisig { .. } + | kb_lib::ExSplClassicTokenSingleOperation::SyncNative { .. } + | kb_lib::ExSplClassicTokenSingleOperation::GetAccountDataSize { .. } + | kb_lib::ExSplClassicTokenSingleOperation::InitializeImmutableOwner { .. } + | kb_lib::ExSplClassicTokenSingleOperation::AmountToUiAmount { .. } + | kb_lib::ExSplClassicTokenSingleOperation::UiAmountToAmount { .. } => { + std::option::Option::None + }, + }; + if let std::option::Option::Some(authority) = authority { + if authority.multisig_signers.is_empty() { + add_signer(signers, &authority.authority); + } else { + for signer in &authority.multisig_signers { + add_signer(signers, signer); + } + } + } +} + +fn add_signer(signers: &mut std::vec::Vec, signer: &kb_lib::MdPubkey) { + if !signers.iter().any(|value| return value == signer) { + signers.push(signer.clone()); + } +} + +fn operation_requires_materialization(operation: &kb_lib::ExSplClassicTokenOperation) -> bool { + return match operation { + kb_lib::ExSplClassicTokenOperation::Instruction { value } => { + single_requires_materialization(value) + }, + kb_lib::ExSplClassicTokenOperation::Batch { instructions } => { + instructions.iter().any(|value| { + return single_requires_materialization(value); + }) + }, + }; +} + +fn single_requires_materialization(operation: &kb_lib::ExSplClassicTokenSingleOperation) -> bool { + return !matches!( + operation, + kb_lib::ExSplClassicTokenSingleOperation::GetAccountDataSize { .. } + | kb_lib::ExSplClassicTokenSingleOperation::InitializeImmutableOwner { .. } + | kb_lib::ExSplClassicTokenSingleOperation::AmountToUiAmount { .. } + | kb_lib::ExSplClassicTokenSingleOperation::UiAmountToAmount { .. } + ); +} + +fn failed_readiness_message( + readiness: &crate::SplTokenStatefulReadinessReport, +) -> std::string::String { + let failures = readiness + .checks + .iter() + .filter(|check| return !check.passed) + .take(8) + .map(|check| return format!("{}: {}", check.code, check.message)) + .collect::>(); + if failures.is_empty() { + return "SPL Token stateful readiness returned Blocked without diagnostics".to_string(); + } + return failures.join("; "); +} + +fn validate_profile_wallet_signers( + required_signers: &[std::string::String], + wallet_pubkey: &str, +) -> kb_core::Result<()> { + if required_signers.iter().any(|value| return value.as_str() != wallet_pubkey) { + return std::result::Result::Err(kb_core::Error::new( + "execution_spl_token_external_signer_unavailable", + format!( + "the Devnet Token orchestrator can sign only with profile wallet {}; required signers are {}", + wallet_pubkey, + required_signers.join(",") + ), + )); + } + return std::result::Result::Ok(()); +} + +pub(crate) async fn hydrate_signature( + http_pool: &kb_onchain_transport::HttpEndpointPool, + store: &S, + profile: &kb_config::ProfileConfig, + query_role: &str, + post_validation_max_retries: u32, + observer: &O, + signature: &kb_lib::MdSignature, +) -> kb_core::Result +where + S: kb_store::RawTransactionStore + Sync, + O: crate::SolanaExecutionObserver, +{ + let mut retry = 0_u32; + loop { + let result = match crate::execute_http_backfill( + http_pool, + store, + &crate::BackfillRequest { + role: query_role.to_string(), + commitment: "confirmed".to_string(), + source: crate::BackfillSource::ExplicitSignatures(std::vec![signature.0.clone()]), + page_size: 1, + max_pages: 1, + max_concurrent_requests: 1, + max_retries: 2, + }, + observer, + ) + .await + { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + if crate::canonical_available(&result) + || retry >= post_validation_max_retries + || observer.is_execution_cancelled() + { + return std::result::Result::Ok(result); + } + retry = retry.saturating_add(1); + tokio::time::sleep(std::time::Duration::from_millis(std::cmp::max( + profile.execution.confirmation_poll_interval_ms, + 500, + ))) + .await; + } +} + +pub(crate) fn canonical_available(summary: &crate::BackfillSummary) -> bool { + return summary.failed == 0 + && summary.missing == 0 + && summary.candidates_completed == 1 + && summary.candidates_cancelled == 0 + && summary.candidates_not_started == 0 + && summary + .canonical_inserted + .saturating_add(summary.canonical_skipped) + .saturating_add(summary.existing_skipped) + >= 1; +} + +pub(crate) async fn replay_program( + store: &S, + signature: &kb_lib::MdSignature, + include_materialized_state: bool, + force_post_validation_replay: bool, + program_ids: &[&str], + decoders: &[std::sync::Arc], + materializers: &[std::sync::Arc], + observer: &O, +) -> kb_core::Result +where + S: kb_store::DecodePipelineStore + Sync, + O: crate::SolanaExecutionObserver, +{ + let mut states = std::vec![ + kb_store::CoreInstructionProcessingState::Pending, + kb_store::CoreInstructionProcessingState::Failed, + kb_store::CoreInstructionProcessingState::ReplayRequested, + ]; + if include_materialized_state { + states.push(kb_store::CoreInstructionProcessingState::Materialized); + } + let selection = match kb_store::DecodeSelectionFilter::new( + std::vec![signature.0.clone()], + states, + std::option::Option::None, + std::option::Option::None, + program_ids.iter().map(|value| return (*value).to_string()).collect(), + std::vec::Vec::new(), + false, + 64, + ) { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + return crate::execute_decode_replay( + store, + &crate::DecodeReplayRequest { + campaign_id: crate::new_decode_campaign_id(), + selection, + decoder_names: std::vec::Vec::new(), + dispatch_policy: crate::DecodeDispatchPolicy::HighestPriority, + max_concurrent_inputs: 1, + force_replay: if include_materialized_state { + false + } else { + force_post_validation_replay + }, + force_replay_all_matching: false, + materialize_after_decode: true, + }, + decoders, + materializers, + observer, + ) + .await; +} + +pub(crate) fn decode_completed(summary: &crate::DecodeReplaySummary) -> bool { + return summary.failed_inputs == 0 + && summary.unmatched == 0 + && !summary.cancelled + && summary.completed >= 1 + && summary.processors.iter().all(|processor| { + return processor.failed == 0 + && processor.unsupported == 0 + && processor.materialization_refused == 0; + }) + && summary.processors.iter().map(|processor| return processor.decoded).sum::() >= 1; +} + +#[cfg(test)] +mod tests { + fn local_devnet_profile() -> kb_config::ProfileConfig { + let config = + match kb_config::parse_config_json(include_str!("../../config/example.config.json")) { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => panic!("example config parse failed: {error}"), + }; + for profile in config.profiles { + if profile.name == "local_devnet" { + return profile; + } + } + panic!("local_devnet profile missing"); + } + + fn checked_transfer(fee_authority: &str) -> kb_lib::ExSplClassicTokenOperation { + return kb_lib::ExSplClassicTokenOperation::Instruction { + value: kb_lib::ExSplClassicTokenSingleOperation::TransferChecked { + source: kb_lib::MdPubkey(kb_program_ids::VOTE_PROGRAM_ID.to_string()), + mint: kb_lib::MdPubkey(kb_program_ids::STAKE_PROGRAM_ID.to_string()), + destination: kb_lib::MdPubkey(kb_program_ids::CONFIG_PROGRAM_ID.to_string()), + authority: kb_lib::ExSplClassicTokenAuthority { + authority: kb_lib::MdPubkey(fee_authority.to_string()), + multisig_signers: std::vec::Vec::new(), + }, + amount: kb_lib::ExSplClassicTokenAmount("1".to_string()), + decimals: 9, + }, + }; + } + + #[test] + fn request_is_simulation_only_and_bounded_by_default() { + let request = crate::DevnetSplTokenExecutionRequest::new( + "token-1", + checked_transfer(kb_program_ids::SYSTEM_PROGRAM_ID), + ); + assert!(!request.submit); + assert!(request.validate().is_ok()); + let mut invalid = request; + invalid.post_validation_max_retries = 21; + assert!(invalid.validate().is_err()); + } + + #[test] + fn checked_transfer_plan_is_simulation_first_and_authorizes_exact_signers() { + let profile = local_devnet_profile(); + let fee_payer = kb_lib::MdPubkey(kb_program_ids::SYSTEM_PROGRAM_ID.to_string()); + let authority = kb_program_ids::VOTE_PROGRAM_ID; + let request = + crate::DevnetSplTokenExecutionRequest::new("token-2", checked_transfer(authority)); + let plan = match super::build_plan(&profile, &request, fee_payer.clone()) { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => panic!("Token plan failed: {error}"), + }; + assert!(plan.policy.dry_run); + assert_eq!(plan.fee_payer, fee_payer); + assert!(plan.policy.authorized_signers.iter().any(|value| return value.0 == authority)); + assert!(plan.policy.post_execution_validation.materialization_required); + assert_eq!(plan.instructions.len(), 1); + } + + #[test] + fn conversion_plan_does_not_invent_materialization_requirement() { + let profile = local_devnet_profile(); + let request = crate::DevnetSplTokenExecutionRequest::new( + "token-3", + kb_lib::ExSplClassicTokenOperation::Instruction { + value: kb_lib::ExSplClassicTokenSingleOperation::AmountToUiAmount { + mint: kb_lib::MdPubkey(kb_program_ids::STAKE_PROGRAM_ID.to_string()), + amount: kb_lib::ExSplClassicTokenAmount("1".to_string()), + }, + }, + ); + let plan = match super::build_plan( + &profile, + &request, + kb_lib::MdPubkey(kb_program_ids::SYSTEM_PROGRAM_ID.to_string()), + ) { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => panic!("conversion plan failed: {error}"), + }; + assert!(!plan.policy.post_execution_validation.materialization_required); + } + + #[test] + fn submission_signers_must_all_resolve_to_the_profile_wallet() { + let wallet = kb_program_ids::SYSTEM_PROGRAM_ID.to_string(); + assert!( + super::validate_profile_wallet_signers(std::slice::from_ref(&wallet), wallet.as_str(),) + .is_ok() + ); + let external = kb_program_ids::VOTE_PROGRAM_ID.to_string(); + let error = + super::validate_profile_wallet_signers(&[wallet.clone(), external], wallet.as_str()); + assert!(error.is_err()); + } + + #[tokio::test] + async fn optional_devnet_checked_transfer_simulation_and_submission_from_env() { + if std::env::var("KB_DEVNET_SPL_TOKEN_EXECUTION_TEST").ok().as_deref() + != std::option::Option::Some("1") + { + return; + } + let source = required_pubkey_env("KB_DEVNET_SPL_TOKEN_SOURCE"); + let mint = required_pubkey_env("KB_DEVNET_SPL_TOKEN_MINT"); + let destination = required_pubkey_env("KB_DEVNET_SPL_TOKEN_DESTINATION"); + let authority = required_pubkey_env("KB_DEVNET_SPL_TOKEN_AUTHORITY"); + let decimals = match std::env::var("KB_DEVNET_SPL_TOKEN_DECIMALS") { + std::result::Result::Ok(value) => match value.parse::() { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => panic!("invalid Token decimals: {error}"), + }, + std::result::Result::Err(_) => 9, + }; + let amount = match std::env::var("KB_DEVNET_SPL_TOKEN_AMOUNT") { + std::result::Result::Ok(value) => value, + std::result::Result::Err(_) => "1".to_string(), + }; + let mut profile = local_devnet_profile(); + if let std::result::Result::Ok(directory) = std::env::var("KB_DEVNET_WALLET_DIR") { + profile.wallet.wallet_dir = directory; + } + let pool = match kb_onchain_transport::HttpEndpointPool::from_profile(&profile) { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => panic!("HTTP pool creation 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 mut request = crate::DevnetSplTokenExecutionRequest::new( + format!("devnet-token-test-{}", uuid::Uuid::new_v4()), + kb_lib::ExSplClassicTokenOperation::Instruction { + value: kb_lib::ExSplClassicTokenSingleOperation::TransferChecked { + source, + mint, + destination, + authority: kb_lib::ExSplClassicTokenAuthority { + authority, + multisig_signers: std::vec::Vec::new(), + }, + amount: kb_lib::ExSplClassicTokenAmount(amount), + decimals, + }, + }, + ); + if std::env::var("KB_DEVNET_SPL_TOKEN_SUBMIT").ok().as_deref() + != std::option::Option::Some("1") + { + let summary = match crate::simulate_devnet_spl_token( + &pool, + &profile, + workspace_root, + &request, + &crate::NoopSolanaExecutionObserver, + ) + .await + { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => { + panic!("Devnet Token simulation failed: {error}"); + }, + }; + assert_eq!( + summary.stateful_readiness.status, + crate::SplTokenStatefulReadinessStatus::Ready + ); + assert!(summary.simulation.success, "{:#?}", summary.simulation); + return; + } + request.submit = true; + request.operator_confirmed = true; + request.post_validation_max_retries = 20; + let database_url = match std::env::var("KB_POSTGRES_TEST_URL") { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => { + panic!("KB_POSTGRES_TEST_URL is required for submission: {error}"); + }, + }; + profile.database.backend = "postgres".to_string(); + profile.database.postgres.url = database_url; + let store_options = match kb_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 kb_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 decoders: std::vec::Vec> = + std::vec![std::sync::Arc::new(kb_lib::DcSplTokenDecoder)]; + let materializers: std::vec::Vec> = + std::vec![std::sync::Arc::new(kb_lib::MtTokenAccountsMaterializer,)]; + let summary = match crate::execute_devnet_spl_token( + &pool, + &store, + &profile, + workspace_root, + &request, + decoders.as_slice(), + materializers.as_slice(), + &crate::NoopSolanaExecutionObserver, + ) + .await + { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => panic!("Devnet Token execution failed: {error}"), + }; + assert!(summary.simulation.success, "{:#?}", summary.simulation); + let send_result = match summary.send_result.as_ref() { + std::option::Option::Some(value) => value, + std::option::Option::None => panic!("Token submission result missing"), + }; + assert!(send_result.submitted); + let confirmation = match summary.confirmation.as_ref() { + std::option::Option::Some(value) => value, + std::option::Option::None => panic!("Token confirmation result missing"), + }; + assert!(matches!( + confirmation.status, + kb_lib::ExApiExecutionConfirmationStatus::Confirmed + | kb_lib::ExApiExecutionConfirmationStatus::Finalized + )); + let first_replay = match summary.decode_replay.as_ref() { + std::option::Option::Some(value) => value, + std::option::Option::None => panic!("Token first decode replay missing"), + }; + assert_eq!(first_replay.failed_inputs, 0); + assert!(first_replay.processors.iter().all(|processor| { + return processor.failed == 0 && processor.materialization_refused == 0; + })); + let idempotence_replay = match summary.idempotence_replay.as_ref() { + std::option::Option::Some(value) => value, + std::option::Option::None => panic!("Token idempotence replay missing"), + }; + assert_eq!(idempotence_replay.failed_inputs, 0); + assert!(idempotence_replay.processors.iter().all(|processor| { + return processor.failed == 0 + && processor.materialized_outputs == 0 + && processor.materialization_refused == 0; + })); + assert!(!summary.materializations.is_empty()); + let diagnostic = match summary.post_execution.as_ref() { + std::option::Option::Some(value) => value, + std::option::Option::None => panic!("Token post-execution diagnostic missing"), + }; + assert!(diagnostic.canonical_inserted); + assert!(diagnostic.core_extracted); + assert!(diagnostic.decode_replayed); + assert!(diagnostic.materialized); + println!( + "SPL Token Devnet signature={} status={:?} materializations={} idempotent=true", + send_result.signature.0, + confirmation.status, + summary.materializations.len(), + ); + } + + #[tokio::test] + async fn optional_devnet_recent_instruction_probe_from_env() { + if std::env::var("KB_DEVNET_SPL_TOKEN_RECENT_PROBE_TEST").ok().as_deref() + != std::option::Option::Some("1") + { + return; + } + let source = required_pubkey_env("KB_DEVNET_SPL_TOKEN_SOURCE"); + let mint = required_pubkey_env("KB_DEVNET_SPL_TOKEN_MINT"); + let destination = required_pubkey_env("KB_DEVNET_SPL_TOKEN_DESTINATION"); + let authority = required_pubkey_env("KB_DEVNET_SPL_TOKEN_AUTHORITY"); + let native_account = required_pubkey_env("KB_DEVNET_SPL_TOKEN_NATIVE_ACCOUNT"); + let decimals = match std::env::var("KB_DEVNET_SPL_TOKEN_DECIMALS") { + std::result::Result::Ok(value) => match value.parse::() { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => panic!("invalid Token decimals: {error}"), + }, + std::result::Result::Err(_) => 9, + }; + let mut profile = local_devnet_profile(); + if let std::result::Result::Ok(directory) = std::env::var("KB_DEVNET_WALLET_DIR") { + profile.wallet.wallet_dir = directory; + } + let pool = match kb_onchain_transport::HttpEndpointPool::from_profile(&profile) { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => panic!("HTTP pool creation 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 token_authority = kb_lib::ExSplClassicTokenAuthority { + authority: authority.clone(), + multisig_signers: std::vec::Vec::new(), + }; + let batch_request = crate::DevnetSplTokenExecutionRequest::new( + format!("devnet-token-batch-probe-{}", uuid::Uuid::new_v4()), + kb_lib::ExSplClassicTokenOperation::Batch { + instructions: std::vec![ + kb_lib::ExSplClassicTokenSingleOperation::TransferChecked { + source, + mint, + destination, + authority: token_authority.clone(), + amount: kb_lib::ExSplClassicTokenAmount("0".to_string()), + decimals, + }, + ], + }, + ); + let batch = match crate::simulate_devnet_spl_token( + &pool, + &profile, + workspace_root, + &batch_request, + &crate::NoopSolanaExecutionObserver, + ) + .await + { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => panic!("Devnet Batch probe failed: {error}"), + }; + assert!(batch.simulation.simulated); + print_recent_probe("batch", &batch.simulation); + + let unwrap_request = crate::DevnetSplTokenExecutionRequest::new( + format!("devnet-token-unwrap-probe-{}", uuid::Uuid::new_v4()), + kb_lib::ExSplClassicTokenOperation::Instruction { + value: kb_lib::ExSplClassicTokenSingleOperation::UnwrapLamports { + account: native_account, + destination: authority, + authority: token_authority, + amount_lamports: std::option::Option::Some(kb_lib::ExSplClassicTokenAmount( + "1".to_string(), + )), + }, + }, + ); + let unwrap = match crate::simulate_devnet_spl_token( + &pool, + &profile, + workspace_root, + &unwrap_request, + &crate::NoopSolanaExecutionObserver, + ) + .await + { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => { + panic!("Devnet UnwrapLamports probe failed: {error}") + }, + }; + assert!(unwrap.simulation.simulated); + print_recent_probe("unwrap_lamports", &unwrap.simulation); + } + + fn print_recent_probe(operation: &str, simulation: &kb_lib::ExApiExecutionSimulationResult) { + println!( + "SPL Token recent Devnet probe operation={operation} success={} error={:?} units={:?} logs={:?}", + simulation.success, simulation.error, simulation.units_consumed, simulation.logs, + ); + } + + fn required_pubkey_env(name: &str) -> kb_lib::MdPubkey { + return match std::env::var(name) { + std::result::Result::Ok(value) => kb_lib::MdPubkey(value), + std::result::Result::Err(error) => panic!("{name} is required: {error}"), + }; + } +} diff --git a/kb-pipeline/src/solana_token_lifecycle.rs b/kb-pipeline/src/solana_token_lifecycle.rs new file mode 100644 index 0000000..0809b58 --- /dev/null +++ b/kb-pipeline/src/solana_token_lifecycle.rs @@ -0,0 +1,1739 @@ +// file: kb-pipeline/src/solana_token_lifecycle.rs +// version: 4 + +//! 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) -> kb_core::Result<()> { + if self.intent_id.trim().is_empty() { + return std::result::Result::Err(kb_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(kb_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(kb_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: kb_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: kb_lib::MdSignature, + /// Terminal confirmation status. + pub confirmation_status: kb_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: kb_lib::MdPubkey, + /// Fresh uninitialized 82-byte Token-owned mint account. + pub mint: kb_lib::MdPubkey, + /// Fresh uninitialized 165-byte Token-owned source account. + pub source: kb_lib::MdPubkey, + /// Fresh uninitialized 165-byte Token-owned destination account. + pub destination: kb_lib::MdPubkey, + /// Fresh off-chain delegate identity used only by approve/revoke. + pub delegate: kb_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: kb_lib::MdPubkey, + /// Pre-created, rent-exempt, uninitialized 165-byte source token account. + pub source: kb_lib::MdPubkey, + /// Pre-created, rent-exempt, uninitialized 165-byte destination token account. + pub destination: kb_lib::MdPubkey, + /// Delegate recorded by the approval and removed by the following revoke. + pub delegate: kb_lib::MdPubkey, + /// Mint authority and owner of both token accounts. + pub authority: kb_lib::MdPubkey, + /// Exact decimals used by all checked instructions. + pub decimals: u8, + /// Exact raw amount minted into the source account. + pub mint_amount: kb_lib::ExSplClassicTokenAmount, + /// Exact raw amount transferred from source to destination. + pub transfer_amount: kb_lib::ExSplClassicTokenAmount, + /// Exact raw allowance approved on the destination account. + pub approve_amount: kb_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: kb_lib::MdPubkey, + source: kb_lib::MdPubkey, + destination: kb_lib::MdPubkey, + delegate: kb_lib::MdPubkey, + authority: kb_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: kb_lib::ExSplClassicTokenAmount("10".to_string()), + transfer_amount: kb_lib::ExSplClassicTokenAmount("4".to_string()), + approve_amount: kb_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) -> kb_core::Result<()> { + if self.intent_id.trim().is_empty() { + return std::result::Result::Err(kb_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(kb_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(kb_core::Error::config( + "lifecycle post-execution retries must not exceed 20", + )); + } + if self.first_step_index > 10 { + return std::result::Result::Err(kb_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(kb_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(kb_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) = + kb_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(kb_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(kb_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(kb_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: kb_lib::MdSignature, + /// Terminal confirmation status. + pub confirmation_status: kb_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: kb_lib::MdPubkey, + /// Source token account closed by the lifecycle. + pub source: kb_lib::MdPubkey, + /// Destination token account closed by the lifecycle. + pub destination: kb_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: &kb_onchain_transport::HttpEndpointPool, + profile: &kb_config::ProfileConfig, + workspace_root: &std::path::Path, + request: &crate::DevnetSplTokenLifecyclePreparationRequest, + observer: &O, +) -> kb_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(kb_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(kb_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(kb_lib::ExApiExecutionCluster::Devnet) + { + return std::result::Result::Err(kb_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 = kb_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 = kb_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(kb_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, + &kb_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(kb_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: kb_lib::MdPubkey(mint_wallet.public_key()), + source: kb_lib::MdPubkey(source_wallet.public_key()), + destination: kb_lib::MdPubkey(destination_wallet.public_key()), + delegate: kb_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: &kb_onchain_transport::HttpEndpointPool, + store: &S, + profile: &kb_config::ProfileConfig, + workspace_root: &std::path::Path, + request: &crate::DevnetSplTokenLifecycleRequest, + decoders: &[std::sync::Arc], + materializers: &[std::sync::Arc], + observer: &O, +) -> kb_core::Result +where + S: kb_store::RawTransactionStore + + kb_store::CoreExtractionStore + + kb_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(kb_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(kb_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(kb_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(kb_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(kb_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: &kb_onchain_transport::HttpEndpointPool, + store: &S, + profile: &kb_config::ProfileConfig, + lifecycle_request: &crate::DevnetSplTokenLifecycleRequest, + index: usize, + operation: kb_lib::ExSplClassicTokenOperation, + signature: &kb_lib::MdSignature, + decoders: &[std::sync::Arc], + materializers: &[std::sync::Arc], + observer: &O, +) -> kb_core::Result +where + S: kb_store::RawTransactionStore + + kb_store::CoreExtractionStore + + kb_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(kb_core::Error::new( + "execution_spl_token_lifecycle_resume_canonical_missing", + "confirmed lifecycle predecessor is unavailable for canonical recovery", + )); + } + let extraction = match crate::execute_core_extraction( + store, + &crate::CoreExtractionRequest { + source: crate::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(kb_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, + &[kb_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(kb_core::Error::new( + "execution_spl_token_lifecycle_resume_decode_incomplete", + "lifecycle predecessor decode and materialization did not complete", + )); + } + let filter = match kb_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 kb_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(kb_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, + &[kb_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.processors.iter().all(|processor| { + return processor.failed == 0 + && processor.materialized_outputs == 0 + && processor.materialization_refused == 0; + }); + if !idempotent { + return std::result::Result::Err(kb_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(kb_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(kb_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: kb_lib::ExApiExecutionConfirmationStatus::Confirmed, + materialization_count, + idempotence_validated: true, + }); +} + +fn materialized_operation_code(operation: &kb_lib::ExSplClassicTokenOperation) -> &str { + return match operation { + kb_lib::ExSplClassicTokenOperation::Instruction { + value: kb_lib::ExSplClassicTokenSingleOperation::InitializeMint { .. }, + } => "initialize_mint2", + kb_lib::ExSplClassicTokenOperation::Instruction { + value: kb_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) -> kb_core::Result { + let alias = match kb_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(kb_wallet::TemporaryWallet::generate(alias)); +} + +#[allow(clippy::too_many_arguments)] +async fn create_raw_token_account( + http_pool: &kb_onchain_transport::HttpEndpointPool, + profile: &kb_config::ProfileConfig, + request: &crate::DevnetSplTokenLifecyclePreparationRequest, + observer: &O, + payer: &kb_wallet::TemporaryWallet, + account_wallet: &kb_wallet::TemporaryWallet, + account_role: &str, + space: u64, + rent_lamports: u64, +) -> kb_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 = kb_lib::MdPubkey(account_wallet.public_key()); + let preflight = match http_pool + .get_account_info_for_role( + request.query_role.as_str(), + &account, + &kb_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(kb_core::Error::new( + "execution_spl_token_lifecycle_preparation_account_exists", + format!("generated lifecycle {account_role} account already exists"), + )); + } + let payer_pubkey = kb_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 kb_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 == kb_lib::ExSafetyDecision::Deny { + return std::result::Result::Err(kb_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(), + &kb_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 kb_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(), + &kb_onchain_transport::GetFeeForMessageConfig::new( + kb_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(kb_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 kb_onchain_transport::SimulateTransactionConfig::new( + kb_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( + kb_lib::ExApiExecutionCluster::Devnet, + kb_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 kb_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 == kb_lib::ExSafetyDecision::Deny { + return std::result::Result::Err(kb_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 kb_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 kb_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(), + kb_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, + kb_lib::ExApiExecutionConfirmationStatus::Confirmed + | kb_lib::ExApiExecutionConfirmationStatus::Finalized + ) { + return std::result::Result::Err(kb_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(kb_core::Error::new( + "execution_spl_token_lifecycle_preparation_space_overflow", + "lifecycle account space exceeds usize", + )); + }, + }; + let account_config = + match kb_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(kb_core::Error::new( + "execution_spl_token_lifecycle_preparation_account_missing", + format!("confirmed lifecycle {account_role} account is unavailable"), + )); + }, + }; + if value.owner.0 != kb_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(kb_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: &kb_config::ProfileConfig, + intent_id: std::string::String, + payer: kb_lib::MdPubkey, + new_account: kb_lib::MdPubkey, + space: u64, + rent_lamports: u64, +) -> kb_core::Result { + let intent = kb_lib::ExSolanaCoreExecutionIntent { + intent_id, + fee_payer: payer.clone(), + policy: kb_lib::ExApiExecutionPolicy { + cluster: kb_lib::ExApiExecutionClusterPolicy { + expected_cluster: kb_lib::ExApiExecutionCluster::Devnet, + allow_mainnet: false, + mainnet_confirmation: false, + }, + simulation: kb_lib::ExApiExecutionSimulationPolicy::Required, + blockhash: kb_lib::ExApiExecutionBlockhashPolicy { + kind: kb_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: kb_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: kb_lib::ExApiPostExecutionValidationPolicy { + canonical_insert_required: false, + core_extraction_required: false, + decode_replay_required: false, + materialization_required: false, + }, + }, + operation: kb_lib::ExSolanaCoreOperation::SystemCreateAccount { + from: payer, + new_account, + lamports: rent_lamports, + space, + owner: kb_lib::MdPubkey(kb_program_ids::SPL_TOKEN_PROGRAM_ID.to_string()), + }, + }; + return kb_lib::ExApiTypedInstructionExecutor::build_prepared_plan( + &kb_lib::ExSolanaCoreExecutor, + &intent, + ); +} + +fn canonical_amount(amount: &kb_lib::ExSplClassicTokenAmount, field: &str) -> kb_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(kb_core::Error::new( + "execution_spl_token_lifecycle_amount_invalid", + format!("{field} must be a canonical u64 decimal string"), + )) + }, + }; +} + +fn lifecycle_operations( + request: &crate::DevnetSplTokenLifecycleRequest, +) -> kb_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 = kb_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![ + kb_lib::ExSplClassicTokenSingleOperation::InitializeMint { + mint: request.mint.clone(), + mint_authority: request.authority.clone(), + freeze_authority: std::option::Option::Some(request.authority.clone()), + decimals: request.decimals, + }, + kb_lib::ExSplClassicTokenSingleOperation::InitializeAccount { + account: request.source.clone(), + mint: request.mint.clone(), + owner: request.authority.clone(), + }, + kb_lib::ExSplClassicTokenSingleOperation::InitializeAccount { + account: request.destination.clone(), + mint: request.mint.clone(), + owner: request.authority.clone(), + }, + kb_lib::ExSplClassicTokenSingleOperation::MintToChecked { + mint: request.mint.clone(), + destination: request.source.clone(), + authority: authority.clone(), + amount: request.mint_amount.clone(), + decimals: request.decimals, + }, + kb_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, + }, + kb_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, + }, + kb_lib::ExSplClassicTokenSingleOperation::Revoke { + source: request.destination.clone(), + authority: authority.clone(), + }, + kb_lib::ExSplClassicTokenSingleOperation::BurnChecked { + source: request.source.clone(), + mint: request.mint.clone(), + authority: authority.clone(), + amount: kb_lib::ExSplClassicTokenAmount(source_burn), + decimals: request.decimals, + }, + kb_lib::ExSplClassicTokenSingleOperation::BurnChecked { + source: request.destination.clone(), + mint: request.mint.clone(), + authority: authority.clone(), + amount: request.transfer_amount.clone(), + decimals: request.decimals, + }, + kb_lib::ExSplClassicTokenSingleOperation::CloseAccount { + account: request.source.clone(), + destination: request.authority.clone(), + authority: authority.clone(), + }, + kb_lib::ExSplClassicTokenSingleOperation::CloseAccount { + account: request.destination.clone(), + destination: request.authority.clone(), + authority, + }, + ]; + return std::result::Result::Ok( + operations + .into_iter() + .map(|value| return kb_lib::ExSplClassicTokenOperation::Instruction { value }) + .collect(), + ); +} + +fn validated_step( + index: usize, + operation_code: &str, + summary: &crate::DevnetSplTokenExecutionSummary, +) -> kb_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, + kb_lib::ExApiExecutionConfirmationStatus::Confirmed + | kb_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.processors.iter().all(|processor| { + return processor.failed == 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(kb_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(kb_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) -> kb_core::Error { + return kb_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 crate::BackfillObserver for LifecycleTestObserver { + fn on_progress(&self, _event: &crate::BackfillProgressEvent) { + return; + } + + fn is_cancelled(&self) -> bool { + return false; + } + } + + impl crate::CoreExtractionObserver for LifecycleTestObserver { + fn on_progress(&self, _event: &crate::CoreExtractionProgressEvent) { + return; + } + + fn is_cancelled(&self) -> bool { + return false; + } + } + + impl crate::DecodeReplayObserver for LifecycleTestObserver { + fn on_progress(&self, _event: &crate::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) -> kb_lib::MdPubkey { + return kb_lib::MdPubkey(value.to_string()); + } + + fn request() -> crate::DevnetSplTokenLifecycleRequest { + return crate::DevnetSplTokenLifecycleRequest::new( + "lifecycle-1", + key(kb_program_ids::STAKE_PROGRAM_ID), + key(kb_program_ids::VOTE_PROGRAM_ID), + key(kb_program_ids::CONFIG_PROGRAM_ID), + key(kb_program_ids::COMPUTE_BUDGET_PROGRAM_ID), + key(kb_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] { + kb_lib::ExSplClassicTokenOperation::Instruction { + value: kb_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 = kb_lib::ExSplClassicTokenAmount("10".to_string()); + assert!(request.validate().is_err()); + request.transfer_amount = kb_lib::ExSplClassicTokenAmount("04".to_string()); + assert!(request.validate().is_err()); + request.transfer_amount = kb_lib::ExSplClassicTokenAmount("4".to_string()); + request.delegate = request.authority.clone(); + assert!(request.validate().is_err()); + request.delegate = key(kb_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 = local_devnet_profile(); + let payer = key(kb_program_ids::SYSTEM_PROGRAM_ID); + let account = key(kb_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(kb_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("KB_DEVNET_SPL_TOKEN_LIFECYCLE_TEST").ok().as_deref() + != std::option::Option::Some("1") + { + return; + } + if std::env::var("KB_DEVNET_SPL_TOKEN_LIFECYCLE_SUBMIT").ok().as_deref() + != std::option::Option::Some("1") + { + panic!("controlled lifecycle requires KB_DEVNET_SPL_TOKEN_LIFECYCLE_SUBMIT=1"); + } + let mut profile = local_devnet_profile(); + if let std::result::Result::Ok(directory) = std::env::var("KB_DEVNET_WALLET_DIR") { + profile.wallet.wallet_dir = directory; + } + let database_url = match std::env::var("KB_POSTGRES_TEST_URL") { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => { + panic!("KB_POSTGRES_TEST_URL is required for lifecycle submission: {error}"); + }, + }; + profile.database.backend = "postgres".to_string(); + profile.database.postgres.url = database_url; + let pool = match kb_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 kb_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 kb_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("KB_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( + "KB_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("KB_DEVNET_SPL_TOKEN_LIFECYCLE_MINT"), + required_pubkey_env("KB_DEVNET_SPL_TOKEN_LIFECYCLE_SOURCE"), + required_pubkey_env("KB_DEVNET_SPL_TOKEN_LIFECYCLE_DESTINATION"), + required_pubkey_env("KB_DEVNET_SPL_TOKEN_LIFECYCLE_DELEGATE"), + required_pubkey_env("KB_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("KB_DEVNET_SPL_TOKEN_LIFECYCLE_DECIMALS", 9); + request.mint_amount = kb_lib::ExSplClassicTokenAmount(optional_string_env( + "KB_DEVNET_SPL_TOKEN_LIFECYCLE_MINT_AMOUNT", + "10", + )); + request.transfer_amount = kb_lib::ExSplClassicTokenAmount(optional_string_env( + "KB_DEVNET_SPL_TOKEN_LIFECYCLE_TRANSFER_AMOUNT", + "4", + )); + request.approve_amount = kb_lib::ExSplClassicTokenAmount(optional_string_env( + "KB_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("KB_DEVNET_SPL_TOKEN_LIFECYCLE_FIRST_STEP_INDEX", 0); + request.resume_predecessor_signature = if request.first_step_index > 0 { + std::option::Option::Some(kb_lib::MdSignature(required_string_env( + "KB_DEVNET_SPL_TOKEN_LIFECYCLE_RESUME_PREDECESSOR_SIGNATURE", + ))) + } else { + std::option::Option::None + }; + request.inter_step_delay_ms = + optional_u64_env("KB_DEVNET_SPL_TOKEN_LIFECYCLE_INTER_STEP_DELAY_MS", 2_000); + let decoders: std::vec::Vec> = + std::vec![std::sync::Arc::new(kb_lib::DcSplTokenDecoder)]; + let materializers: std::vec::Vec> = + std::vec![std::sync::Arc::new(kb_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 local_devnet_profile() -> kb_config::ProfileConfig { + let config = + match kb_config::parse_config_json(include_str!("../../config/example.config.json")) { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => panic!("example config parse failed: {error}"), + }; + for profile in config.profiles { + if profile.name == "local_devnet" { + return profile; + } + } + panic!("local_devnet profile missing"); + } + + fn required_pubkey_env(name: &str) -> kb_lib::MdPubkey { + return match std::env::var(name) { + std::result::Result::Ok(value) => kb_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, + }; + } +}