diff --git a/kb-pipeline/Cargo.toml b/kb-pipeline/Cargo.toml index a8bd48b..1dae74b 100644 --- a/kb-pipeline/Cargo.toml +++ b/kb-pipeline/Cargo.toml @@ -1,5 +1,5 @@ # file: kb-pipeline/Cargo.toml -# version: 11 +# version: 12 [package] name = "kb-pipeline" @@ -25,6 +25,7 @@ serde_json.workspace = true solana-address-lookup-table-interface.workspace = true solana-pubkey.workspace = true spl-elgamal-registry-interface.workspace = true +spl-associated-token-account-interface.workspace = true sha2.workspace = true tokio.workspace = true tracing.workspace = true diff --git a/kb-pipeline/src/lib.rs b/kb-pipeline/src/lib.rs index 849263d..0843a45 100644 --- a/kb-pipeline/src/lib.rs +++ b/kb-pipeline/src/lib.rs @@ -1,5 +1,5 @@ // file: kb-pipeline/src/lib.rs -// version: 12 +// version: 13 #![forbid(unsafe_code)] #![deny(unreachable_pub)] @@ -12,6 +12,8 @@ mod constants; mod core_extraction; mod decode_replay; mod plan; +mod solana_ata_execution; +mod solana_ata_stateful; mod solana_elgamal_registry_stateful; mod solana_execution; mod solana_memo_execution; @@ -104,6 +106,30 @@ pub use self::decode_replay::new_decode_campaign_id; pub use self::plan::PipelineStage; /// Replay selection scope. pub use self::plan::ReplayScope; +/// Complete request for one Devnet Associated Token Account execution. +pub use self::solana_ata_execution::DevnetSplAssociatedTokenAccountExecutionRequest; +/// Complete result of one Devnet Associated Token Account execution. +pub use self::solana_ata_execution::DevnetSplAssociatedTokenAccountExecutionSummary; +/// Executes one Devnet Associated Token Account simulation or authorized submission. +pub use self::solana_ata_execution::execute_devnet_spl_associated_token_account; +/// Simulates one Devnet Associated Token Account operation after stateful preflight. +pub use self::solana_ata_execution::simulate_devnet_spl_associated_token_account; +/// Stateful invariants observed after one confirmed Associated Token Account execution. +pub use self::solana_ata_stateful::SplAssociatedTokenAccountPostExecutionReport; +/// One machine-readable Associated Token Account stateful check. +pub use self::solana_ata_stateful::SplAssociatedTokenAccountStatefulCheck; +/// One contextual Associated Token Account stateful fact. +pub use self::solana_ata_stateful::SplAssociatedTokenAccountStatefulFact; +/// Complete Associated Token Account stateful readiness report. +pub use self::solana_ata_stateful::SplAssociatedTokenAccountStatefulReadinessReport; +/// Complete request for one Associated Token Account stateful readiness inspection. +pub use self::solana_ata_stateful::SplAssociatedTokenAccountStatefulReadinessRequest; +/// Associated Token Account stateful readiness outcome. +pub use self::solana_ata_stateful::SplAssociatedTokenAccountStatefulReadinessStatus; +/// Verifies final Associated Token Account relationships after confirmed execution. +pub use self::solana_ata_stateful::inspect_spl_associated_token_account_post_execution; +/// Inspects state required before simulating one Associated Token Account operation. +pub use self::solana_ata_stateful::inspect_spl_associated_token_account_stateful_readiness; /// Migrated ELGAMAL_REGISTRY_STATEFUL_ACCOUNT_BYTES contract. pub use self::solana_elgamal_registry_stateful::ELGAMAL_REGISTRY_STATEFUL_ACCOUNT_BYTES; /// Migrated ElGamalRegistryStatefulReadRequest contract. diff --git a/kb-pipeline/src/solana_ata_execution.rs b/kb-pipeline/src/solana_ata_execution.rs new file mode 100644 index 0000000..e455545 --- /dev/null +++ b/kb-pipeline/src/solana_ata_execution.rs @@ -0,0 +1,1201 @@ +// file: kb-pipeline/src/solana_ata_execution.rs +// version: 5 + +//! Devnet ATA execution with stateful and canonical post-validation. + +/// Complete request for one Devnet SPL Associated Token Account execution. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct DevnetSplAssociatedTokenAccountExecutionRequest { + /// 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 SPL Associated Token Account operation. + pub operation: kb_lib::ExSplAssociatedTokenAccountOperation, + /// 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 DevnetSplAssociatedTokenAccountExecutionRequest { + /// Creates a conservative simulation-only request. + pub fn new( + intent_id: impl std::convert::Into, + operation: kb_lib::ExSplAssociatedTokenAccountOperation, + ) -> 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 Associated Token Account 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 Associated Token Account 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 SPL Associated Token Account execution. +#[derive(Clone, Debug, PartialEq)] +pub struct DevnetSplAssociatedTokenAccountExecutionSummary { + /// 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::SplAssociatedTokenAccountStatefulReadinessReport, + /// 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, + /// Stateful account relationships observed after confirmation. + pub post_state_validation: + 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 PreparedAtaExecution { + wallet: kb_wallet::TemporaryWallet, + unsigned: kb_lib::ExSolanaUnsignedTransaction, + evidence: kb_lib::ExSolanaSimulationEvidence, + summary: DevnetSplAssociatedTokenAccountExecutionSummary, +} + +/// Simulates one Devnet SPL Associated Token Account operation after stateful preflight. +pub async fn simulate_devnet_spl_associated_token_account( + http_pool: &kb_onchain_transport::HttpEndpointPool, + profile: &kb_config::ProfileConfig, + workspace_root: &std::path::Path, + request: &DevnetSplAssociatedTokenAccountExecutionRequest, + observer: &O, +) -> kb_core::Result +where + O: crate::SolanaExecutionObserver, +{ + if request.submit { + return std::result::Result::Err(kb_core::Error::config( + "simulate_devnet_spl_associated_token_account 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 SPL Associated Token Account simulation or authorized submission. +#[allow(clippy::too_many_arguments)] +pub async fn execute_devnet_spl_associated_token_account( + http_pool: &kb_onchain_transport::HttpEndpointPool, + store: &S, + profile: &kb_config::ProfileConfig, + workspace_root: &std::path::Path, + request: &DevnetSplAssociatedTokenAccountExecutionRequest, + 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 Associated Token Account 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 Associated Token Account 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 Associated Token Account post-validation stopped at confirmation status {confirmation_status:?}")); + summary.post_execution = std::option::Option::Some(diagnostic); + return std::result::Result::Ok(summary); + } + let post_state = match validate_post_state_with_retries( + http_pool, + profile, + request.query_role.as_str(), + request.post_validation_max_retries, + &request.operation, + ) + .await + { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => { + diagnostic.diagnostics.push(format!( + "SPL Associated Token Account post-state validation failed: {error}" + )); + summary.post_execution = std::option::Option::Some(diagnostic); + return std::result::Result::Ok(summary); + }, + }; + let post_state_ready = + post_state.status == crate::SplAssociatedTokenAccountStatefulReadinessStatus::Ready; + summary.post_state_validation = std::option::Option::Some(post_state); + if !post_state_ready { + diagnostic.diagnostics.push("confirmed SPL Associated Token Account execution did not satisfy final account relationships".to_string()); + summary.post_execution = std::option::Option::Some(diagnostic); + return std::result::Result::Ok(summary); + } + let backfill = match 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 Associated Token Account hydration failed: {error}")); + summary.post_execution = std::option::Option::Some(diagnostic); + return std::result::Result::Ok(summary); + }, + }; + diagnostic.canonical_inserted = canonical_available(&backfill); + summary.backfill = std::option::Option::Some(backfill); + if !diagnostic.canonical_inserted { + diagnostic.diagnostics.push("confirmed SPL Associated Token Account 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 Associated Token Account 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 Associated Token Account core extraction did not complete".to_string()); + summary.post_execution = std::option::Option::Some(diagnostic); + return std::result::Result::Ok(summary); + } + let replay_program_ids = replay_program_ids(&request.operation); + let first_replay = match replay_program( + store, + &signature, + false, + request.force_post_validation_replay, + replay_program_ids.as_slice(), + decoders, + materializers, + observer, + ) + .await + { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => { + diagnostic + .diagnostics + .push(format!("SPL Associated Token Account decode replay failed: {error}")); + summary.post_execution = std::option::Option::Some(diagnostic); + return std::result::Result::Ok(summary); + }, + }; + diagnostic.decode_replayed = decode_completed(&first_replay); + summary.decode_replay = std::option::Option::Some(first_replay); + let filter = match 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 Associated Token Account 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_associated_token_account") + .collect(); + diagnostic.materialized = + !summary.plan.policy.post_execution_validation.materialization_required + || !summary.materializations.is_empty(); + let second_replay = match replay_program( + store, + &signature, + true, + request.force_post_validation_replay, + replay_program_ids.as_slice(), + decoders, + materializers, + observer, + ) + .await + { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => { + diagnostic + .diagnostics + .push(format!("SPL Associated Token Account 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 Associated Token Account 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 Associated Token Account 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: &DevnetSplAssociatedTokenAccountExecutionRequest, + 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, "ata_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 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 intent = build_intent(profile, request, fee_payer.clone()); + let intent = match intent { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + let plan = match kb_lib::ExApiTypedInstructionExecutor::build_prepared_plan( + &kb_lib::ExSplAssociatedTokenAccountExecutor, + &intent, + ) { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + let readiness = match crate::inspect_spl_associated_token_account_stateful_readiness( + http_pool, + &crate::SplAssociatedTokenAccountStatefulReadinessRequest { + query_role: request.query_role.clone(), + cluster: kb_lib::ExApiExecutionCluster::Devnet, + intent, + available_signers: std::vec![fee_payer.clone()], + }, + ) + .await + { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + if readiness.status != crate::SplAssociatedTokenAccountStatefulReadinessStatus::Ready { + return std::result::Result::Err(kb_core::Error::new( + "execution_spl_associated_token_account_stateful_blocked", + failed_readiness_message(&readiness), + )); + } + 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_associated_token_account_simulation", + format!( + "simulating exact SPL Associated Token Account 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 = DevnetSplAssociatedTokenAccountExecutionSummary { + 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, + post_state_validation: 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(PreparedAtaExecution { wallet, unsigned, evidence, summary }); +} + +fn validate_profile( + profile: &kb_config::ProfileConfig, + request: &DevnetSplAssociatedTokenAccountExecutionRequest, +) -> kb_core::Result<()> { + if profile.wallet.cluster != "devnet" { + return std::result::Result::Err(kb_core::Error::config( + "SPL Associated Token Account 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 Associated Token Account Devnet orchestration requires an enabled persistent temporary wallet", + )); + } + if !profile.execution.require_simulation { + return std::result::Result::Err(kb_core::Error::config( + "SPL Associated Token Account 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 Associated Token Account Devnet submission requires explicit operator confirmation", + )); + } + return std::result::Result::Ok(()); +} + +fn build_intent( + profile: &kb_config::ProfileConfig, + request: &DevnetSplAssociatedTokenAccountExecutionRequest, + fee_payer: kb_lib::MdPubkey, +) -> kb_core::Result { + let max_rent_lamports = if matches!( + &request.operation, + kb_lib::ExSplAssociatedTokenAccountOperation::RecoverNested { .. } + ) { + 0 + } else { + profile.execution.devnet_max_spend_lamports + }; + let mut authorized_signers = std::vec![fee_payer.clone()]; + if let kb_lib::ExSplAssociatedTokenAccountOperation::RecoverNested { wallet_owner, .. } = + &request.operation + { + if !authorized_signers.contains(wallet_owner) { + authorized_signers.push(wallet_owner.clone()); + } + } + let intent = kb_lib::ExSplAssociatedTokenAccountExecutionIntent { + intent_id: request.intent_id.clone(), + fee_payer, + max_rent_lamports, + 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(max_rent_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: true, + }, + }, + operation: request.operation.clone(), + }; + return std::result::Result::Ok(intent); +} + +fn failed_readiness_message( + readiness: &crate::SplAssociatedTokenAccountStatefulReadinessReport, +) -> 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 Associated Token Account stateful readiness returned Blocked without diagnostics".to_string(); + } + return failures.join("; "); +} + +fn replay_program_ids( + operation: &kb_lib::ExSplAssociatedTokenAccountOperation, +) -> std::vec::Vec<&'static str> { + let mut output = std::vec![kb_program_ids::ASSOCIATED_TOKEN_PROGRAM_ID]; + if operation.token_program() == kb_lib::ExSplAssociatedTokenProgram::Classic { + output.push(kb_program_ids::SPL_TOKEN_PROGRAM_ID); + } + return output; +} + +async fn validate_post_state_with_retries( + http_pool: &kb_onchain_transport::HttpEndpointPool, + profile: &kb_config::ProfileConfig, + query_role: &str, + maximum_retries: u32, + operation: &kb_lib::ExSplAssociatedTokenAccountOperation, +) -> kb_core::Result { + let mut retry = 0_u32; + loop { + let result = crate::inspect_spl_associated_token_account_post_execution( + http_pool, + query_role, + kb_lib::ExApiExecutionCluster::Devnet, + operation, + ) + .await; + match result { + std::result::Result::Ok(report) + if report.status + == crate::SplAssociatedTokenAccountStatefulReadinessStatus::Ready + || retry >= maximum_retries => + { + return std::result::Result::Ok(report); + }, + std::result::Result::Err(error) if retry >= maximum_retries => { + return std::result::Result::Err(error); + }, + std::result::Result::Ok(_) | std::result::Result::Err(_) => {}, + } + retry = retry.saturating_add(1); + tokio::time::sleep(std::time::Duration::from_millis(std::cmp::max( + profile.execution.confirmation_poll_interval_ms, + 500, + ))) + .await; + } +} + +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; +} + +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; +} + +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 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; + } +} + +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; +} + +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_associated_token_account_external_signer_unavailable", + format!( + "the Devnet ATA orchestrator can sign only with profile wallet {}; required signers are {}", + wallet_pubkey, + required_signers.join(",") + ), + )); + } + return std::result::Result::Ok(()); +} + +#[cfg(test)] +mod tests { + fn required_environment_value(name: &str) -> std::string::String { + return match std::env::var(name) { + std::result::Result::Ok(value) if !value.trim().is_empty() => value, + std::result::Result::Ok(_) | std::result::Result::Err(_) => { + panic!("{name} is required") + }, + }; + } + + 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 create_operation(wallet: &str) -> kb_lib::ExSplAssociatedTokenAccountOperation { + return kb_lib::ExSplAssociatedTokenAccountOperation::CreateIdempotent { + wallet_owner: kb_lib::MdPubkey(wallet.to_string()), + mint: kb_lib::MdPubkey(kb_program_ids::VOTE_PROGRAM_ID.to_string()), + token_program: kb_lib::ExSplAssociatedTokenProgram::Classic, + }; + } + + #[test] + fn request_is_simulation_only_and_bounded_by_default() { + let request = crate::DevnetSplAssociatedTokenAccountExecutionRequest::new( + "ata-1", + create_operation(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 creation_and_recovery_intents_preserve_exact_spend_and_signers() { + let profile = local_devnet_profile(); + let fee_payer = kb_lib::MdPubkey(kb_program_ids::SYSTEM_PROGRAM_ID.to_string()); + let create = crate::DevnetSplAssociatedTokenAccountExecutionRequest::new( + "ata-2", + create_operation(fee_payer.0.as_str()), + ); + let create_intent = super::build_intent(&profile, &create, fee_payer.clone()) + .unwrap_or_else(|error| panic!("create intent failed: {error}")); + assert_eq!(create_intent.max_rent_lamports, profile.execution.devnet_max_spend_lamports); + assert!(create_intent.policy.dry_run); + assert_eq!(create_intent.policy.authorized_signers, std::vec![fee_payer.clone()]); + + let recovery = crate::DevnetSplAssociatedTokenAccountExecutionRequest::new( + "ata-3", + kb_lib::ExSplAssociatedTokenAccountOperation::RecoverNested { + wallet_owner: kb_lib::MdPubkey(kb_program_ids::STAKE_PROGRAM_ID.to_string()), + owner_mint: kb_lib::MdPubkey(kb_program_ids::VOTE_PROGRAM_ID.to_string()), + nested_mint: kb_lib::MdPubkey(kb_program_ids::CONFIG_PROGRAM_ID.to_string()), + token_program: kb_lib::ExSplAssociatedTokenProgram::Token2022, + }, + ); + let recovery_intent = super::build_intent(&profile, &recovery, fee_payer) + .unwrap_or_else(|error| panic!("recovery intent failed: {error}")); + assert_eq!(recovery_intent.max_rent_lamports, 0); + assert_eq!(recovery_intent.policy.authorized_signers.len(), 2); + } + + #[test] + fn submission_signers_must_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 error = super::validate_profile_wallet_signers( + &[wallet.clone(), kb_program_ids::VOTE_PROGRAM_ID.to_string()], + wallet.as_str(), + ); + assert!(error.is_err()); + } + + #[tokio::test] + async fn optional_devnet_operation_from_env() { + if std::env::var("KB_DEVNET_SPL_ATA_EXECUTION_TEST").ok().as_deref() + != std::option::Option::Some("1") + { + return; + } + let operation_code = std::env::var("KB_DEVNET_SPL_ATA_OPERATION") + .unwrap_or_else(|_| return "create_idempotent".to_string()); + let token_program = match std::env::var("KB_DEVNET_SPL_ATA_TOKEN_PROGRAM").ok().as_deref() { + std::option::Option::Some("token_2022") => { + kb_lib::ExSplAssociatedTokenProgram::Token2022 + }, + std::option::Option::Some("classic") | std::option::Option::None => { + kb_lib::ExSplAssociatedTokenProgram::Classic + }, + std::option::Option::Some(value) => panic!("unsupported Token Program {value}"), + }; + let submit = std::env::var("KB_DEVNET_SPL_ATA_SUBMIT").ok().as_deref() + == std::option::Option::Some("1"); + let profile = local_devnet_profile(); + let workspace_root = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .parent() + .map(std::path::Path::to_path_buf) + .unwrap_or_else(|| panic!("workspace root missing")); + let wallet = crate::load_profile_wallet(&profile, workspace_root.as_path()) + .await + .unwrap_or_else(|error| panic!("profile wallet failed: {error}")); + let wallet_pubkey = wallet.public_key(); + let operation = match operation_code.as_str() { + "create_idempotent" => kb_lib::ExSplAssociatedTokenAccountOperation::CreateIdempotent { + wallet_owner: kb_lib::MdPubkey(wallet_pubkey), + mint: kb_lib::MdPubkey(required_environment_value("KB_DEVNET_SPL_ATA_MINT")), + token_program, + }, + "recover_nested" => kb_lib::ExSplAssociatedTokenAccountOperation::RecoverNested { + wallet_owner: kb_lib::MdPubkey(wallet_pubkey), + owner_mint: kb_lib::MdPubkey(required_environment_value( + "KB_DEVNET_SPL_ATA_OWNER_MINT", + )), + nested_mint: kb_lib::MdPubkey(required_environment_value( + "KB_DEVNET_SPL_ATA_NESTED_MINT", + )), + token_program, + }, + value => panic!("unsupported ATA operation {value}"), + }; + let mut request = crate::DevnetSplAssociatedTokenAccountExecutionRequest::new( + "optional-devnet-ata", + operation, + ); + request.submit = submit; + request.operator_confirmed = submit; + request.post_validation_max_retries = 20; + let pool = kb_onchain_transport::HttpEndpointPool::from_profile(&profile) + .unwrap_or_else(|error| panic!("HTTP pool failed: {error}")); + let store_options = kb_store::PostgresStoreOptions::new( + profile.database.postgres.url.clone(), + profile.database.postgres.max_connections, + profile.database.postgres.connect_timeout_ms, + false, + ) + .unwrap_or_else(|error| panic!("PostgreSQL options failed: {error}")); + let store = kb_store::PostgresStore::connect(store_options) + .await + .unwrap_or_else(|error| panic!("PostgreSQL connection failed: {error}")); + store + .initialize_store_schema() + .await + .unwrap_or_else(|error| panic!("schema initialization failed: {error}")); + let decoders: std::vec::Vec> = std::vec![ + std::sync::Arc::new(kb_lib::DcSplAssociatedTokenAccountDecoder), + std::sync::Arc::new(kb_lib::DcSplTokenDecoder), + ]; + let materializers: std::vec::Vec> = std::vec![ + std::sync::Arc::new(kb_lib::MtTokenAccountsMaterializer), + std::sync::Arc::new(kb_lib::MtRiskMaterializer), + ]; + let summary = crate::execute_devnet_spl_associated_token_account( + &pool, + &store, + &profile, + workspace_root.as_path(), + &request, + decoders.as_slice(), + materializers.as_slice(), + &crate::NoopSolanaExecutionObserver, + ) + .await + .unwrap_or_else(|error| panic!("Devnet ATA execution failed: {error}")); + assert_eq!( + summary.stateful_readiness.status, + crate::SplAssociatedTokenAccountStatefulReadinessStatus::Ready + ); + assert!(summary.simulation.success, "{:?}", summary.simulation.error); + if submit { + let send_result = match summary.send_result.as_ref() { + std::option::Option::Some(value) => value, + std::option::Option::None => { + panic!("submitted ATA execution returned no signature") + }, + }; + println!( + "Devnet ATA operation={} signature={} materializations={}", + operation_code, + send_result.signature.0, + summary.materializations.len() + ); + assert!(summary.post_execution.as_ref().is_some_and(|value| { + return value.canonical_inserted + && value.core_extracted + && value.decode_replayed + && value.materialized; + })); + assert!(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 operation_code == "recover_nested" { + assert!(summary.materializations.iter().any(|row| { + return row.materialized_family == "token_account" + && row.payload_json["lifecycleKind"] == "nested_ata_recovered"; + })); + assert!(summary.materializations.iter().any(|row| { + return row.materialized_family == "risk" + && row.payload_json["riskKind"] == "nested_ata_anti_pattern_recovered"; + })); + } + } + } +} diff --git a/kb-pipeline/src/solana_ata_stateful.rs b/kb-pipeline/src/solana_ata_stateful.rs new file mode 100644 index 0000000..14fc727 --- /dev/null +++ b/kb-pipeline/src/solana_ata_stateful.rs @@ -0,0 +1,1177 @@ +// file: kb-pipeline/src/solana_ata_stateful.rs +// version: 2 + +//! Stateful Localnet and Devnet readiness checks for ATA operations. + +const BASE_MINT_LEN: usize = 82; +const BASE_TOKEN_ACCOUNT_LEN: usize = 165; +const MAX_TOKEN_2022_ACCOUNT_BYTES: usize = 16_384; + +macro_rules! result_value { + ($expression:expr) => { + match $expression { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error), + } + }; +} + +/// ATA stateful readiness outcome. +#[derive(Clone, Copy, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)] +#[serde(rename_all = "snake_case")] +pub enum SplAssociatedTokenAccountStatefulReadinessStatus { + /// Every stateful check passed. + Ready, + /// At least one stateful check failed. + Blocked, +} + +/// One machine-readable ATA stateful check. +#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)] +pub struct SplAssociatedTokenAccountStatefulCheck { + /// Stable diagnostic code. + pub code: std::string::String, + /// Whether the check passed. + pub passed: bool, + /// Operator-readable explanation. + pub message: std::string::String, +} + +/// One contextual ATA stateful fact. +#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)] +pub struct SplAssociatedTokenAccountStatefulFact { + /// Stable fact key. + pub key: std::string::String, + /// Exact string representation. + pub value: std::string::String, +} + +/// Complete request for one ATA stateful readiness inspection. +#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)] +pub struct SplAssociatedTokenAccountStatefulReadinessRequest { + /// HTTP endpoint role used for every state read. + pub query_role: std::string::String, + /// Expected Localnet or Devnet cluster. + pub cluster: kb_lib::ExApiExecutionCluster, + /// Typed simulation-first ATA intent. + pub intent: kb_lib::ExSplAssociatedTokenAccountExecutionIntent, + /// Signer public keys currently available to the caller. + pub available_signers: std::vec::Vec, +} + +/// Complete ATA stateful readiness report. +#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)] +pub struct SplAssociatedTokenAccountStatefulReadinessReport { + /// Expected cluster. + pub cluster: kb_lib::ExApiExecutionCluster, + /// Stable operation code. + pub operation_code: std::string::String, + /// Aggregate readiness status. + pub status: SplAssociatedTokenAccountStatefulReadinessStatus, + /// Highest contextual slot observed. + pub context_slot: std::option::Option, + /// Ordered checks. + pub checks: std::vec::Vec, + /// Ordered facts. + pub facts: std::vec::Vec, +} + +/// Stateful ATA invariants observed after a confirmed execution. +#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)] +pub struct SplAssociatedTokenAccountPostExecutionReport { + /// Expected Localnet or Devnet cluster. + pub cluster: kb_lib::ExApiExecutionCluster, + /// Stable operation code. + pub operation_code: std::string::String, + /// Aggregate postcondition status. + pub status: SplAssociatedTokenAccountStatefulReadinessStatus, + /// Highest contextual slot observed. + pub context_slot: std::option::Option, + /// Ordered state checks. + pub checks: std::vec::Vec, + /// Ordered derived-address facts. + pub facts: std::vec::Vec, +} + +type AccountCache = std::collections::BTreeMap< + std::string::String, + std::option::Option, +>; + +/// Inspects Localnet or Devnet ATA state required before simulation. +pub async fn inspect_spl_associated_token_account_stateful_readiness( + pool: &kb_onchain_transport::HttpEndpointPool, + request: &SplAssociatedTokenAccountStatefulReadinessRequest, +) -> kb_core::Result { + if request.query_role.trim().is_empty() { + return std::result::Result::Err(kb_core::Error::config( + "ATA stateful readiness query_role must not be empty", + )); + } + match validate_cluster_kind(request.cluster) { + std::result::Result::Ok(()) => {}, + std::result::Result::Err(error) => return std::result::Result::Err(error), + } + let genesis = match 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), + }; + match validate_genesis(request.cluster, &genesis) { + std::result::Result::Ok(()) => {}, + std::result::Result::Err(error) => return std::result::Result::Err(error), + } + let plan = match kb_lib::ExApiTypedInstructionExecutor::build_prepared_plan( + &kb_lib::ExSplAssociatedTokenAccountExecutor, + &request.intent, + ) { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + let addresses = match operation_addresses(&request.intent.operation) { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + let config = match kb_onchain_transport::GetAccountInfoConfig::confirmed_with_data( + MAX_TOKEN_2022_ACCOUNT_BYTES, + ) { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + let mut cache = AccountCache::new(); + let mut context_slot: std::option::Option = std::option::Option::None; + for address in &addresses { + let result = match pool + .get_account_info_for_role(request.query_role.as_str(), address, &config) + .await + { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + context_slot = std::option::Option::Some(match context_slot { + std::option::Option::Some(slot) => slot.max(result.context.slot), + std::option::Option::None => result.context.slot, + }); + cache.insert(address.0.clone(), result.account); + } + let rent = match pool + .get_minimum_balance_for_rent_exemption_for_role( + request.query_role.as_str(), + BASE_TOKEN_ACCOUNT_LEN as u64, + &kb_onchain_transport::GetMinimumBalanceForRentExemptionConfig::confirmed(), + ) + .await + { + std::result::Result::Ok(value) => value.minimum_balance_lamports, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + let balance_config = kb_onchain_transport::GetBalanceConfig::confirmed(); + let payer_balance = match pool + .get_balance_for_role( + request.query_role.as_str(), + &request.intent.fee_payer, + &balance_config, + ) + .await + { + std::result::Result::Ok(value) => value.lamports, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + return evaluate( + request, + &plan, + &genesis.genesis_hash, + context_slot, + &cache, + rent, + payer_balance, + ); +} + +/// Verifies final ATA account relationships after a confirmed execution. +pub async fn inspect_spl_associated_token_account_post_execution( + pool: &kb_onchain_transport::HttpEndpointPool, + query_role: &str, + cluster: kb_lib::ExApiExecutionCluster, + operation: &kb_lib::ExSplAssociatedTokenAccountOperation, +) -> kb_core::Result { + if query_role.trim().is_empty() { + return std::result::Result::Err(kb_core::Error::config( + "ATA post-execution query role must not be empty", + )); + } + if let std::result::Result::Err(error) = validate_cluster_kind(cluster) { + return std::result::Result::Err(error); + } + let genesis = match pool.get_genesis_hash_for_role(query_role).await { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + if let std::result::Result::Err(error) = validate_genesis(cluster, &genesis) { + return std::result::Result::Err(error); + } + let addresses = match operation_addresses(operation) { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + let config = match kb_onchain_transport::GetAccountInfoConfig::confirmed_with_data( + MAX_TOKEN_2022_ACCOUNT_BYTES, + ) { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + let mut cache = AccountCache::new(); + let mut context_slot: std::option::Option = std::option::Option::None; + for address in &addresses { + let result = match pool.get_account_info_for_role(query_role, address, &config).await { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + context_slot = std::option::Option::Some(match context_slot { + std::option::Option::Some(slot) => slot.max(result.context.slot), + std::option::Option::None => result.context.slot, + }); + cache.insert(address.0.clone(), result.account); + } + return evaluate_post_execution(cluster, operation, context_slot, &cache); +} + +fn validate_cluster_kind(cluster: kb_lib::ExApiExecutionCluster) -> kb_core::Result<()> { + return match cluster { + kb_lib::ExApiExecutionCluster::Localnet | kb_lib::ExApiExecutionCluster::Devnet => { + std::result::Result::Ok(()) + }, + kb_lib::ExApiExecutionCluster::Testnet | kb_lib::ExApiExecutionCluster::Mainnet => { + std::result::Result::Err(kb_core::Error::new( + "spl_ata_stateful_cluster_unsupported", + "ATA stateful readiness is restricted to Localnet and Devnet", + )) + }, + }; +} + +fn validate_genesis( + expected: kb_lib::ExApiExecutionCluster, + genesis: &kb_onchain_transport::GenesisHashResult, +) -> kb_core::Result<()> { + return match expected { + kb_lib::ExApiExecutionCluster::Devnet + if genesis.classified_cluster + == std::option::Option::Some(kb_lib::ExApiExecutionCluster::Devnet) => + { + std::result::Result::Ok(()) + }, + kb_lib::ExApiExecutionCluster::Localnet if genesis.classified_cluster.is_none() => { + std::result::Result::Ok(()) + }, + kb_lib::ExApiExecutionCluster::Devnet => std::result::Result::Err(kb_core::Error::new( + "spl_ata_stateful_devnet_genesis_mismatch", + "ATA Devnet readiness requires the official Devnet genesis hash", + )), + kb_lib::ExApiExecutionCluster::Localnet => std::result::Result::Err(kb_core::Error::new( + "spl_ata_stateful_localnet_public_cluster", + "ATA Localnet readiness refuses endpoints classified as a public cluster", + )), + kb_lib::ExApiExecutionCluster::Testnet | kb_lib::ExApiExecutionCluster::Mainnet => { + validate_cluster_kind(expected) + }, + }; +} + +fn operation_addresses( + operation: &kb_lib::ExSplAssociatedTokenAccountOperation, +) -> kb_core::Result> { + let mut values = std::vec::Vec::new(); + match operation { + kb_lib::ExSplAssociatedTokenAccountOperation::Create { + wallet_owner, + mint, + token_program, + } + | kb_lib::ExSplAssociatedTokenAccountOperation::CreateIdempotent { + wallet_owner, + mint, + token_program, + } => { + values.push(mint.clone()); + values.push(result_value!(derive_ata(wallet_owner, mint, token_program.program_id(),))); + }, + kb_lib::ExSplAssociatedTokenAccountOperation::RecoverNested { + wallet_owner, + owner_mint, + nested_mint, + token_program, + } => { + let owner_ata = + result_value!(derive_ata(wallet_owner, owner_mint, token_program.program_id(),)); + let nested_ata = + result_value!(derive_ata(&owner_ata, nested_mint, token_program.program_id(),)); + let destination_ata = + result_value!(derive_ata(wallet_owner, nested_mint, token_program.program_id(),)); + values.extend([ + owner_mint.clone(), + nested_mint.clone(), + owner_ata, + nested_ata, + destination_ata, + ]); + }, + } + values.sort_by(|left, right| return left.0.cmp(&right.0)); + values.dedup(); + return std::result::Result::Ok(values); +} + +fn derive_ata( + wallet: &kb_lib::MdPubkey, + mint: &kb_lib::MdPubkey, + token_program: &str, +) -> kb_core::Result { + let wallet: solana_pubkey::Pubkey = match wallet.0.parse() { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => { + return std::result::Result::Err(kb_core::Error::new( + "spl_ata_stateful_wallet_invalid", + error.to_string(), + )); + }, + }; + let mint: solana_pubkey::Pubkey = match mint.0.parse() { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => { + return std::result::Result::Err(kb_core::Error::new( + "spl_ata_stateful_mint_invalid", + error.to_string(), + )); + }, + }; + let token_program: solana_pubkey::Pubkey = match token_program.parse() { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => { + return std::result::Result::Err(kb_core::Error::new( + "spl_ata_stateful_token_program_invalid", + error.to_string(), + )); + }, + }; + let derived = spl_associated_token_account_interface::address::get_associated_token_address_with_program_id( + &wallet, + &mint, + &token_program, + ); + return std::result::Result::Ok(kb_lib::MdPubkey(derived.to_string())); +} + +fn evaluate( + request: &SplAssociatedTokenAccountStatefulReadinessRequest, + plan: &kb_lib::ExApiPreparedExecutionPlan, + genesis_hash: &str, + context_slot: std::option::Option, + cache: &AccountCache, + base_rent_lamports: u64, + payer_balance_lamports: u64, +) -> kb_core::Result { + let mut report = SplAssociatedTokenAccountStatefulReadinessReport { + cluster: request.cluster, + operation_code: request.intent.operation.operation_code().to_string(), + status: SplAssociatedTokenAccountStatefulReadinessStatus::Ready, + context_slot, + checks: std::vec::Vec::new(), + facts: std::vec![ + SplAssociatedTokenAccountStatefulFact { + key: "genesis_hash".to_string(), + value: genesis_hash.to_string(), + }, + SplAssociatedTokenAccountStatefulFact { + key: "base_token_account_rent_lamports".to_string(), + value: base_rent_lamports.to_string(), + }, + ], + }; + push_check( + &mut report, + "ata_program_id", + plan.instructions.len() == 1 + && plan.instructions[0].program_id.0 == kb_program_ids::ASSOCIATED_TOKEN_PROGRAM_ID, + "plan must contain exactly one instruction for the canonical ATA Program", + ); + push_check( + &mut report, + "simulation_required", + plan.policy.simulation == kb_lib::ExApiExecutionSimulationPolicy::Required, + "ATA plan must require simulation", + ); + for signer in &plan.required_signers { + push_check( + &mut report, + "required_signer_available", + request.available_signers.contains(&signer.pubkey), + format!("required {} signer {} must be available", signer.role, signer.pubkey.0), + ); + } + let max_fee = match plan.policy.cost_limit.max_fee_lamports { + std::option::Option::Some(value) => value, + std::option::Option::None => 0, + }; + let total_ceiling = match plan.requested_spend_lamports.checked_add(max_fee) { + std::option::Option::Some(value) => value, + std::option::Option::None => u64::MAX, + }; + push_check( + &mut report, + "payer_balance_covers_ceiling", + payer_balance_lamports >= total_ceiling, + format!("payer balance must cover rent-plus-fee ceiling {total_ceiling}"), + ); + let operation = &request.intent.operation; + match operation { + kb_lib::ExSplAssociatedTokenAccountOperation::Create { + wallet_owner, + mint, + token_program, + } => { + check_mint(&mut report, cache, mint, token_program.program_id()); + let ata = result_value!(derive_ata(wallet_owner, mint, token_program.program_id(),)); + push_fact(&mut report, "derived_ata", ata.0.clone()); + check_creation_plan_accounts(&mut report, plan, &ata, token_program.program_id()); + push_check( + &mut report, + "create_ata_absent", + cache.get(ata.0.as_str()).is_some_and(|value| return value.is_none()), + "strict Create requires the canonical ATA to be absent", + ); + push_check( + &mut report, + "rent_ceiling_covers_base_account", + plan.requested_spend_lamports >= base_rent_lamports, + "creation rent ceiling must cover at least the base Token account rent", + ); + }, + kb_lib::ExSplAssociatedTokenAccountOperation::CreateIdempotent { + wallet_owner, + mint, + token_program, + } => { + check_mint(&mut report, cache, mint, token_program.program_id()); + let ata = result_value!(derive_ata(wallet_owner, mint, token_program.program_id(),)); + push_fact(&mut report, "derived_ata", ata.0.clone()); + check_creation_plan_accounts(&mut report, plan, &ata, token_program.program_id()); + if let std::option::Option::Some(std::option::Option::Some(account)) = + cache.get(ata.0.as_str()) + { + check_token_account( + &mut report, + account, + token_program.program_id(), + mint, + wallet_owner, + "idempotent_existing_ata", + ); + } else { + push_check( + &mut report, + "idempotent_absent_or_compatible", + cache.contains_key(ata.0.as_str()), + "idempotent ATA must be confirmed absent or validated as compatible", + ); + push_check( + &mut report, + "rent_ceiling_covers_base_account", + plan.requested_spend_lamports >= base_rent_lamports, + "absent idempotent ATA requires a ceiling covering base account rent", + ); + } + }, + kb_lib::ExSplAssociatedTokenAccountOperation::RecoverNested { + wallet_owner, + owner_mint, + nested_mint, + token_program, + } => { + check_mint(&mut report, cache, owner_mint, token_program.program_id()); + check_mint(&mut report, cache, nested_mint, token_program.program_id()); + let owner_ata = + result_value!(derive_ata(wallet_owner, owner_mint, token_program.program_id(),)); + let nested_ata = + result_value!(derive_ata(&owner_ata, nested_mint, token_program.program_id(),)); + let destination_ata = + result_value!(derive_ata(wallet_owner, nested_mint, token_program.program_id(),)); + for (key, value) in [ + ("derived_owner_ata", &owner_ata), + ("derived_nested_ata", &nested_ata), + ("derived_destination_ata", &destination_ata), + ] { + push_fact(&mut report, key, value.0.clone()); + } + let accounts_exact = match plan.instructions.first() { + std::option::Option::Some(instruction) => { + instruction.accounts.len() == 7 + && instruction.accounts[0].pubkey == nested_ata + && instruction.accounts[2].pubkey == destination_ata + && instruction.accounts[3].pubkey == owner_ata + && instruction.accounts[6].pubkey.0 == token_program.program_id() + }, + std::option::Option::None => false, + }; + push_check( + &mut report, + "recover_plan_accounts_exact", + accounts_exact, + "RecoverNested plan must retain all three derived ATA addresses and selected Token Program in official positions", + ); + check_cached_token_account( + &mut report, + cache, + &owner_ata, + token_program.program_id(), + owner_mint, + wallet_owner, + "owner_ata", + ); + check_cached_token_account( + &mut report, + cache, + &nested_ata, + token_program.program_id(), + nested_mint, + &owner_ata, + "nested_ata", + ); + check_cached_token_account( + &mut report, + cache, + &destination_ata, + token_program.program_id(), + nested_mint, + wallet_owner, + "destination_ata", + ); + push_check( + &mut report, + "recovery_zero_rent_spend", + plan.requested_spend_lamports == 0, + "RecoverNested must not reserve rent spending", + ); + }, + } + report.status = if report.checks.iter().all(|check| return check.passed) { + SplAssociatedTokenAccountStatefulReadinessStatus::Ready + } else { + SplAssociatedTokenAccountStatefulReadinessStatus::Blocked + }; + return std::result::Result::Ok(report); +} + +fn evaluate_post_execution( + cluster: kb_lib::ExApiExecutionCluster, + operation: &kb_lib::ExSplAssociatedTokenAccountOperation, + context_slot: std::option::Option, + cache: &AccountCache, +) -> kb_core::Result { + let mut report = SplAssociatedTokenAccountPostExecutionReport { + cluster, + operation_code: operation.operation_code().to_string(), + status: SplAssociatedTokenAccountStatefulReadinessStatus::Ready, + context_slot, + checks: std::vec::Vec::new(), + facts: std::vec::Vec::new(), + }; + match operation { + kb_lib::ExSplAssociatedTokenAccountOperation::Create { + wallet_owner, + mint, + token_program, + } + | kb_lib::ExSplAssociatedTokenAccountOperation::CreateIdempotent { + wallet_owner, + mint, + token_program, + } => { + let ata = match derive_ata(wallet_owner, mint, token_program.program_id()) { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + push_post_fact(&mut report, "derived_ata", ata.0.clone()); + check_post_cached_token_account( + &mut report, + cache, + &ata, + token_program.program_id(), + mint, + wallet_owner, + "post_execution_ata", + ); + }, + kb_lib::ExSplAssociatedTokenAccountOperation::RecoverNested { + wallet_owner, + owner_mint, + nested_mint, + token_program, + } => { + let owner_ata = + result_value!(derive_ata(wallet_owner, owner_mint, token_program.program_id(),)); + let nested_ata = + result_value!(derive_ata(&owner_ata, nested_mint, token_program.program_id(),)); + let destination_ata = + result_value!(derive_ata(wallet_owner, nested_mint, token_program.program_id(),)); + for (key, value) in [ + ("derived_owner_ata", &owner_ata), + ("derived_nested_ata", &nested_ata), + ("derived_destination_ata", &destination_ata), + ] { + push_post_fact(&mut report, key, value.0.clone()); + } + check_post_cached_token_account( + &mut report, + cache, + &owner_ata, + token_program.program_id(), + owner_mint, + wallet_owner, + "post_execution_owner_ata", + ); + push_post_check( + &mut report, + "post_execution_nested_ata_closed", + cache.get(nested_ata.0.as_str()).is_some_and(|account| return account.is_none()), + "confirmed RecoverNested must leave the nested ATA closed", + ); + check_post_cached_token_account( + &mut report, + cache, + &destination_ata, + token_program.program_id(), + nested_mint, + wallet_owner, + "post_execution_destination_ata", + ); + }, + } + report.status = if report.checks.iter().all(|check| return check.passed) { + SplAssociatedTokenAccountStatefulReadinessStatus::Ready + } else { + SplAssociatedTokenAccountStatefulReadinessStatus::Blocked + }; + return std::result::Result::Ok(report); +} + +fn check_post_cached_token_account( + report: &mut SplAssociatedTokenAccountPostExecutionReport, + cache: &AccountCache, + address: &kb_lib::MdPubkey, + token_program: &str, + expected_mint: &kb_lib::MdPubkey, + expected_owner: &kb_lib::MdPubkey, + role: &str, +) { + let account = cache.get(address.0.as_str()).and_then(|value| return value.as_ref()); + push_post_check( + report, + format!("{role}_exists"), + account.is_some(), + format!("{role} {} must exist after confirmed execution", address.0), + ); + if let std::option::Option::Some(account) = account { + push_post_check( + report, + format!("{role}_token_program_owner"), + account.owner.0 == token_program, + format!("{role} must be owned by the selected Token Program"), + ); + let layout_valid = account.data.len() >= BASE_TOKEN_ACCOUNT_LEN; + push_post_check( + report, + format!("{role}_base_layout"), + layout_valid, + format!("{role} must expose the complete base Token Account layout"), + ); + if layout_valid { + let mint = bs58::encode(&account.data[0..32]).into_string(); + let owner = bs58::encode(&account.data[32..64]).into_string(); + push_post_check( + report, + format!("{role}_mint"), + mint == expected_mint.0, + format!("{role} mint must match {}", expected_mint.0), + ); + push_post_check( + report, + format!("{role}_wallet_owner"), + owner == expected_owner.0, + format!("{role} owner must match {}", expected_owner.0), + ); + push_post_check( + report, + format!("{role}_initialized"), + matches!(account.data[108], 1 | 2), + format!("{role} must be initialized or frozen"), + ); + } + } +} + +fn push_post_check( + report: &mut SplAssociatedTokenAccountPostExecutionReport, + code: impl std::convert::Into, + passed: bool, + message: impl std::convert::Into, +) { + report.checks.push(SplAssociatedTokenAccountStatefulCheck { + code: code.into(), + passed, + message: message.into(), + }); +} + +fn push_post_fact( + report: &mut SplAssociatedTokenAccountPostExecutionReport, + key: impl std::convert::Into, + value: impl std::convert::Into, +) { + report + .facts + .push(SplAssociatedTokenAccountStatefulFact { key: key.into(), value: value.into() }); +} + +fn check_creation_plan_accounts( + report: &mut SplAssociatedTokenAccountStatefulReadinessReport, + plan: &kb_lib::ExApiPreparedExecutionPlan, + derived_ata: &kb_lib::MdPubkey, + token_program: &str, +) { + let accounts_exact = match plan.instructions.first() { + std::option::Option::Some(instruction) => { + instruction.accounts.len() == 6 + && instruction.accounts[1].pubkey == derived_ata.clone() + && instruction.accounts[4].pubkey.0 == kb_program_ids::SYSTEM_PROGRAM_ID + && instruction.accounts[5].pubkey.0 == token_program + }, + std::option::Option::None => false, + }; + push_check( + report, + "creation_plan_accounts_exact", + accounts_exact, + "creation plan must retain derived ATA, System Program and selected Token Program in official positions", + ); +} + +fn check_mint( + report: &mut SplAssociatedTokenAccountStatefulReadinessReport, + cache: &AccountCache, + mint: &kb_lib::MdPubkey, + token_program: &str, +) { + let account = cache.get(mint.0.as_str()).and_then(|value| return value.as_ref()); + push_check(report, "mint_exists", account.is_some(), format!("mint {} must exist", mint.0)); + if let std::option::Option::Some(account) = account { + push_check( + report, + "mint_token_program_owner", + account.owner.0 == token_program, + format!("mint {} must be owned by selected Token Program", mint.0), + ); + push_check( + report, + "mint_base_layout", + account.data.len() >= BASE_MINT_LEN, + format!("mint {} must expose the complete base Mint layout", mint.0), + ); + push_check( + report, + "mint_initialized", + account.data.get(45) == std::option::Option::Some(&1), + format!("mint {} must be initialized", mint.0), + ); + } +} + +fn check_cached_token_account( + report: &mut SplAssociatedTokenAccountStatefulReadinessReport, + cache: &AccountCache, + address: &kb_lib::MdPubkey, + token_program: &str, + expected_mint: &kb_lib::MdPubkey, + expected_owner: &kb_lib::MdPubkey, + role: &str, +) { + let account = cache.get(address.0.as_str()).and_then(|value| return value.as_ref()); + push_check( + report, + format!("{role}_exists"), + account.is_some(), + format!("{role} {} must exist", address.0), + ); + if let std::option::Option::Some(account) = account { + check_token_account(report, account, token_program, expected_mint, expected_owner, role); + } +} + +fn check_token_account( + report: &mut SplAssociatedTokenAccountStatefulReadinessReport, + account: &kb_onchain_transport::AccountInfoValue, + token_program: &str, + expected_mint: &kb_lib::MdPubkey, + expected_owner: &kb_lib::MdPubkey, + role: &str, +) { + push_check( + report, + format!("{role}_token_program_owner"), + account.owner.0 == token_program, + format!("{role} must be owned by selected Token Program"), + ); + let layout_valid = account.data.len() >= BASE_TOKEN_ACCOUNT_LEN; + push_check( + report, + format!("{role}_base_layout"), + layout_valid, + format!("{role} must expose the complete base Token Account layout"), + ); + if layout_valid { + let mint = bs58::encode(&account.data[0..32]).into_string(); + let owner = bs58::encode(&account.data[32..64]).into_string(); + push_check( + report, + format!("{role}_mint"), + mint == expected_mint.0, + format!("{role} mint must match {}", expected_mint.0), + ); + push_check( + report, + format!("{role}_wallet_owner"), + owner == expected_owner.0, + format!("{role} owner must match {}", expected_owner.0), + ); + push_check( + report, + format!("{role}_initialized"), + matches!(account.data[108], 1 | 2), + format!("{role} must be initialized or frozen"), + ); + } +} + +fn push_check( + report: &mut SplAssociatedTokenAccountStatefulReadinessReport, + code: impl std::convert::Into, + passed: bool, + message: impl std::convert::Into, +) { + report.checks.push(SplAssociatedTokenAccountStatefulCheck { + code: code.into(), + passed, + message: message.into(), + }); +} + +fn push_fact( + report: &mut SplAssociatedTokenAccountStatefulReadinessReport, + key: impl std::convert::Into, + value: impl std::convert::Into, +) { + report + .facts + .push(SplAssociatedTokenAccountStatefulFact { key: key.into(), value: value.into() }); +} + +#[cfg(test)] +mod tests { + fn pubkey(value: &str) -> kb_lib::MdPubkey { + return kb_lib::MdPubkey(value.to_string()); + } + + fn policy(rent: u64, signers: std::vec::Vec) -> kb_lib::ExApiExecutionPolicy { + return kb_lib::ExApiExecutionPolicy { + cost_limit: kb_lib::ExApiExecutionCostLimit { + max_spend_lamports: std::option::Option::Some(rent), + max_fee_lamports: std::option::Option::Some(10_000), + max_compute_unit_price_micro_lamports: std::option::Option::None, + }, + authorized_signers: signers, + post_execution_validation: kb_lib::ExApiPostExecutionValidationPolicy { + canonical_insert_required: true, + core_extraction_required: true, + decode_replay_required: true, + materialization_required: true, + }, + ..kb_lib::ExApiExecutionPolicy::default() + }; + } + + fn request( + operation: kb_lib::ExSplAssociatedTokenAccountOperation, + rent: u64, + ) -> crate::SplAssociatedTokenAccountStatefulReadinessRequest { + let fee_payer = pubkey(kb_program_ids::SYSTEM_PROGRAM_ID); + let wallet = match &operation { + kb_lib::ExSplAssociatedTokenAccountOperation::Create { wallet_owner, .. } + | kb_lib::ExSplAssociatedTokenAccountOperation::CreateIdempotent { + wallet_owner, .. + } + | kb_lib::ExSplAssociatedTokenAccountOperation::RecoverNested { + wallet_owner, .. + } => wallet_owner.clone(), + }; + let mut signers = std::vec![fee_payer.clone()]; + if !signers.contains(&wallet) { + signers.push(wallet); + } + return crate::SplAssociatedTokenAccountStatefulReadinessRequest { + query_role: "query".to_string(), + cluster: kb_lib::ExApiExecutionCluster::Devnet, + intent: kb_lib::ExSplAssociatedTokenAccountExecutionIntent { + intent_id: "ata-stateful-1".to_string(), + fee_payer, + max_rent_lamports: rent, + policy: policy(rent, signers.clone()), + operation, + }, + available_signers: signers, + }; + } + + fn mint_account(token_program: &str) -> kb_onchain_transport::AccountInfoValue { + let mut data = std::vec![0; super::BASE_MINT_LEN]; + data[45] = 1; + return kb_onchain_transport::AccountInfoValue { + lamports: 1, + owner: kb_lib::MdProgramId(token_program.to_string()), + executable: false, + rent_epoch: 0, + space: data.len() as u64, + data, + }; + } + + fn token_account( + token_program: &str, + mint: &kb_lib::MdPubkey, + owner: &kb_lib::MdPubkey, + ) -> kb_onchain_transport::AccountInfoValue { + let mut data = std::vec![0; super::BASE_TOKEN_ACCOUNT_LEN]; + let mint_bytes = bs58::decode(&mint.0) + .into_vec() + .unwrap_or_else(|error| panic!("mint fixture decode failed: {error}")); + let owner_bytes = bs58::decode(&owner.0) + .into_vec() + .unwrap_or_else(|error| panic!("owner fixture decode failed: {error}")); + data[0..32].copy_from_slice(&mint_bytes); + data[32..64].copy_from_slice(&owner_bytes); + data[108] = 1; + return kb_onchain_transport::AccountInfoValue { + lamports: 1, + owner: kb_lib::MdProgramId(token_program.to_string()), + executable: false, + rent_epoch: 0, + space: data.len() as u64, + data, + }; + } + + fn plan( + request: &crate::SplAssociatedTokenAccountStatefulReadinessRequest, + ) -> kb_lib::ExApiPreparedExecutionPlan { + return kb_lib::ExApiTypedInstructionExecutor::build_prepared_plan( + &kb_lib::ExSplAssociatedTokenAccountExecutor, + &request.intent, + ) + .unwrap_or_else(|error| panic!("ATA test plan failed: {error}")); + } + + #[test] + fn absent_classic_create_is_ready_with_exact_derived_address_and_rent_ceiling() { + let wallet = pubkey(kb_program_ids::STAKE_PROGRAM_ID); + let mint = pubkey(kb_program_ids::VOTE_PROGRAM_ID); + let request = request( + kb_lib::ExSplAssociatedTokenAccountOperation::Create { + wallet_owner: wallet.clone(), + mint: mint.clone(), + token_program: kb_lib::ExSplAssociatedTokenProgram::Classic, + }, + 2_100_000, + ); + let ata = super::derive_ata(&wallet, &mint, kb_program_ids::SPL_TOKEN_PROGRAM_ID) + .unwrap_or_else(|error| panic!("ATA derivation failed: {error}")); + let mut cache = super::AccountCache::new(); + cache.insert( + mint.0.clone(), + std::option::Option::Some(mint_account(kb_program_ids::SPL_TOKEN_PROGRAM_ID)), + ); + cache.insert(ata.0, std::option::Option::None); + let report = super::evaluate( + &request, + &plan(&request), + "devnet", + std::option::Option::Some(1), + &cache, + 2_000_000, + 3_000_000, + ) + .unwrap_or_else(|error| panic!("ATA readiness failed: {error}")); + assert_eq!(report.status, crate::SplAssociatedTokenAccountStatefulReadinessStatus::Ready); + } + + #[test] + fn idempotent_existing_account_conflict_is_blocked() { + let wallet = pubkey(kb_program_ids::STAKE_PROGRAM_ID); + let mint = pubkey(kb_program_ids::VOTE_PROGRAM_ID); + let request = request( + kb_lib::ExSplAssociatedTokenAccountOperation::CreateIdempotent { + wallet_owner: wallet.clone(), + mint: mint.clone(), + token_program: kb_lib::ExSplAssociatedTokenProgram::Token2022, + }, + 2_100_000, + ); + let ata = super::derive_ata(&wallet, &mint, kb_program_ids::SPL_TOKEN2022_PROGRAM_ID) + .unwrap_or_else(|error| panic!("ATA derivation failed: {error}")); + let mut cache = super::AccountCache::new(); + cache.insert( + mint.0.clone(), + std::option::Option::Some(mint_account(kb_program_ids::SPL_TOKEN2022_PROGRAM_ID)), + ); + cache.insert( + ata.0, + std::option::Option::Some(token_account( + kb_program_ids::SPL_TOKEN2022_PROGRAM_ID, + &mint, + &pubkey(kb_program_ids::CONFIG_PROGRAM_ID), + )), + ); + let report = super::evaluate( + &request, + &plan(&request), + "devnet", + std::option::Option::Some(1), + &cache, + 2_000_000, + 3_000_000, + ) + .unwrap_or_else(|error| panic!("ATA readiness failed: {error}")); + assert_eq!(report.status, crate::SplAssociatedTokenAccountStatefulReadinessStatus::Blocked); + assert!( + report + .checks + .iter() + .any(|check| return check.code == "idempotent_existing_ata_wallet_owner" + && !check.passed) + ); + } + + #[test] + fn recover_nested_validates_all_three_canonical_accounts_and_signers() { + let wallet = pubkey(kb_program_ids::STAKE_PROGRAM_ID); + let owner_mint = pubkey(kb_program_ids::VOTE_PROGRAM_ID); + let nested_mint = pubkey(kb_program_ids::CONFIG_PROGRAM_ID); + let token_program = kb_program_ids::SPL_TOKEN_PROGRAM_ID; + let request = request( + kb_lib::ExSplAssociatedTokenAccountOperation::RecoverNested { + wallet_owner: wallet.clone(), + owner_mint: owner_mint.clone(), + nested_mint: nested_mint.clone(), + token_program: kb_lib::ExSplAssociatedTokenProgram::Classic, + }, + 0, + ); + let owner_ata = super::derive_ata(&wallet, &owner_mint, token_program) + .unwrap_or_else(|error| panic!("owner ATA failed: {error}")); + let nested_ata = super::derive_ata(&owner_ata, &nested_mint, token_program) + .unwrap_or_else(|error| panic!("nested ATA failed: {error}")); + let destination = super::derive_ata(&wallet, &nested_mint, token_program) + .unwrap_or_else(|error| panic!("destination ATA failed: {error}")); + let mut cache = super::AccountCache::new(); + cache.insert(owner_mint.0.clone(), std::option::Option::Some(mint_account(token_program))); + cache.insert(nested_mint.0.clone(), std::option::Option::Some(mint_account(token_program))); + cache.insert( + owner_ata.0.clone(), + std::option::Option::Some(token_account(token_program, &owner_mint, &wallet)), + ); + cache.insert( + nested_ata.0, + std::option::Option::Some(token_account(token_program, &nested_mint, &owner_ata)), + ); + cache.insert( + destination.0, + std::option::Option::Some(token_account(token_program, &nested_mint, &wallet)), + ); + let report = super::evaluate( + &request, + &plan(&request), + "devnet", + std::option::Option::Some(1), + &cache, + 2_000_000, + 20_000, + ) + .unwrap_or_else(|error| panic!("ATA readiness failed: {error}")); + assert_eq!(report.status, crate::SplAssociatedTokenAccountStatefulReadinessStatus::Ready); + } + + #[test] + fn creation_postcondition_requires_one_compatible_existing_ata() { + let wallet = pubkey(kb_program_ids::STAKE_PROGRAM_ID); + let mint = pubkey(kb_program_ids::VOTE_PROGRAM_ID); + let operation = kb_lib::ExSplAssociatedTokenAccountOperation::CreateIdempotent { + wallet_owner: wallet.clone(), + mint: mint.clone(), + token_program: kb_lib::ExSplAssociatedTokenProgram::Token2022, + }; + let ata = super::derive_ata(&wallet, &mint, kb_program_ids::SPL_TOKEN2022_PROGRAM_ID) + .unwrap_or_else(|error| panic!("ATA derivation failed: {error}")); + let mut cache = super::AccountCache::new(); + cache.insert( + ata.0, + std::option::Option::Some(token_account( + kb_program_ids::SPL_TOKEN2022_PROGRAM_ID, + &mint, + &wallet, + )), + ); + let report = super::evaluate_post_execution( + kb_lib::ExApiExecutionCluster::Devnet, + &operation, + std::option::Option::Some(12), + &cache, + ) + .unwrap_or_else(|error| panic!("ATA postcondition failed: {error}")); + assert_eq!(report.status, crate::SplAssociatedTokenAccountStatefulReadinessStatus::Ready); + } + + #[test] + fn recover_postcondition_requires_nested_closed_and_destination_compatible() { + let wallet = pubkey(kb_program_ids::STAKE_PROGRAM_ID); + let owner_mint = pubkey(kb_program_ids::VOTE_PROGRAM_ID); + let nested_mint = pubkey(kb_program_ids::CONFIG_PROGRAM_ID); + let token_program = kb_program_ids::SPL_TOKEN_PROGRAM_ID; + let operation = kb_lib::ExSplAssociatedTokenAccountOperation::RecoverNested { + wallet_owner: wallet.clone(), + owner_mint: owner_mint.clone(), + nested_mint: nested_mint.clone(), + token_program: kb_lib::ExSplAssociatedTokenProgram::Classic, + }; + let owner_ata = super::derive_ata(&wallet, &owner_mint, token_program) + .unwrap_or_else(|error| panic!("owner ATA failed: {error}")); + let nested_ata = super::derive_ata(&owner_ata, &nested_mint, token_program) + .unwrap_or_else(|error| panic!("nested ATA failed: {error}")); + let destination = super::derive_ata(&wallet, &nested_mint, token_program) + .unwrap_or_else(|error| panic!("destination ATA failed: {error}")); + let mut cache = super::AccountCache::new(); + cache.insert( + owner_ata.0, + std::option::Option::Some(token_account(token_program, &owner_mint, &wallet)), + ); + cache.insert(nested_ata.0, std::option::Option::None); + cache.insert( + destination.0, + std::option::Option::Some(token_account(token_program, &nested_mint, &wallet)), + ); + let report = super::evaluate_post_execution( + kb_lib::ExApiExecutionCluster::Devnet, + &operation, + std::option::Option::Some(13), + &cache, + ) + .unwrap_or_else(|error| panic!("RecoverNested postcondition failed: {error}")); + assert_eq!(report.status, crate::SplAssociatedTokenAccountStatefulReadinessStatus::Ready); + } +} diff --git a/kb-pipeline/src/solana_memo_execution.rs b/kb-pipeline/src/solana_memo_execution.rs index 0bdba10..5ab7e4a 100644 --- a/kb-pipeline/src/solana_memo_execution.rs +++ b/kb-pipeline/src/solana_memo_execution.rs @@ -1,5 +1,5 @@ // file: kb-pipeline/src/solana_memo_execution.rs -// version: 2 +// version: 1 //! Devnet SPL Memo v4 execution with canonical post-validation.