diff --git a/CHANGELOG.md b/CHANGELOG.md index c600573..53b4fde 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,9 @@ ## 0.1.0-pre.031 +## 0.1.0-pre.036 + +- Migration de la préparation stateful des opérations Solana natives dans `kb-pipeline`. + - Migration complète de l’orchestration HTTP `backfill` dans `kb-pipeline`. - Conservation des sources explicites et historiques par adresse, des directions `before`/`after`, de la pagination bornée, des retries et de la reprise déterministe. - Adaptation aux façades `kb-onchain-transport`, `kb-store` et `kb-lib`. diff --git a/README.md b/README.md index a6f783f..9878dee 100644 --- a/README.md +++ b/README.md @@ -105,3 +105,8 @@ La tranche `0.1.0-pre.029` migre la planification et l’extraction core de `kb- La tranche `0.1.0-pre.031` ajoute l’orchestration HTTP de backfill : signatures explicites ou historiques par adresse, pagination bornée, hydration via `getTransaction`, retries, annulation, reprise déterministe et persistance des observations. La migration de `kb-pipeline` couvre désormais le backfill, l'extraction core, le replay de décodage, la validation Token-2022 et les lectures stateful/préflight Token-2022 et ElGamal Registry. + + +### Migration `0.1.0-pre.036` + +`kb-pipeline` inclut désormais les contrôles stateful bornés des opérations Solana natives. diff --git a/ROADMAP.md b/ROADMAP.md index bfff220..958f619 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -305,3 +305,6 @@ - [x] Lecture et validation du registre ElGamal. - [x] Préflight multi-comptes Token-2022 borné. - [ ] Orchestration des preuves et de l'exécution Token-2022. + + +- [x] `0.1.0-pre.036` — contrôles stateful Solana natifs migrés dans `kb-pipeline`. diff --git a/kb-pipeline/Cargo.toml b/kb-pipeline/Cargo.toml index b514127..3035e62 100644 --- a/kb-pipeline/Cargo.toml +++ b/kb-pipeline/Cargo.toml @@ -1,5 +1,5 @@ # file: kb-pipeline/Cargo.toml -# version: 9 +# version: 10 [package] name = "kb-pipeline" @@ -21,6 +21,7 @@ kb-store = { path = "../kb-store" } kb-wallet = { path = "../kb-wallet" } serde.workspace = true serde_json.workspace = true +solana-address-lookup-table-interface.workspace = true solana-pubkey.workspace = true spl-elgamal-registry-interface.workspace = true sha2.workspace = true diff --git a/kb-pipeline/src/lib.rs b/kb-pipeline/src/lib.rs index e2b0cfe..4935520 100644 --- a/kb-pipeline/src/lib.rs +++ b/kb-pipeline/src/lib.rs @@ -13,6 +13,7 @@ mod core_extraction; mod decode_replay; mod plan; mod solana_elgamal_registry_stateful; +mod solana_stateful; mod solana_token2022_correlation; mod solana_token2022_crypto_preflight; mod solana_token2022_devnet_scenarios; @@ -104,6 +105,18 @@ pub use self::solana_elgamal_registry_stateful::materialize_elgamal_registry_acc pub use self::solana_elgamal_registry_stateful::materialize_elgamal_registry_stateful_snapshot; /// Migrated read_elgamal_registry_stateful_snapshot contract. pub use self::solana_elgamal_registry_stateful::read_elgamal_registry_stateful_snapshot; +/// One machine-readable native Solana stateful readiness check. +pub use self::solana_stateful::SolanaCoreStatefulCheck; +/// One contextual fact measured during native Solana stateful readiness. +pub use self::solana_stateful::SolanaCoreStatefulFact; +/// Native Solana stateful readiness report. +pub use self::solana_stateful::SolanaCoreStatefulReadinessReport; +/// Native Solana stateful readiness request. +pub use self::solana_stateful::SolanaCoreStatefulReadinessRequest; +/// Native Solana stateful readiness status. +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; /// Correlation outcome between one committed instruction fact and one final state snapshot. pub use self::solana_token2022_correlation::Token2022CorrelationStatus; /// Deterministic correlation report for one instruction output and one final snapshot. diff --git a/kb-pipeline/src/solana_stateful.rs b/kb-pipeline/src/solana_stateful.rs new file mode 100644 index 0000000..9292550 --- /dev/null +++ b/kb-pipeline/src/solana_stateful.rs @@ -0,0 +1,1623 @@ +// file: kb-pipeline/src/solana_stateful.rs +// version: 2 + +//! Stateful Localnet and Devnet readiness checks for native Solana execution plans. + +use std::str::FromStr; // rust-rules: trait-import + +const COMPLETE_ACCOUNT_DATA_LIMIT: usize = 65_536; +const ALT_META_SIZE: usize = 56; +const ALT_MAX_ADDRESSES: usize = 256; +const FEATURE_ACCOUNT_SIZE: u64 = 9; +const SLASHING_REPORT_HEADER_SIZE: usize = 114; +const MAX_SHRED_SIZE: usize = 1_232; +const ZK_CONTEXT_META_SIZE: usize = 33; + +/// Stateful readiness outcome for one native Solana operation. +#[derive(Clone, Copy, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)] +#[serde(rename_all = "snake_case")] +pub enum SolanaCoreStatefulReadinessStatus { + /// The operation has no stateful preflight contract in this layer. + NotRequired, + /// Every stateful preflight check passed. + Ready, + /// At least one stateful preflight check failed. + Blocked, +} + +/// One machine-readable stateful readiness check. +#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)] +pub struct SolanaCoreStatefulCheck { + /// 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 fact measured during stateful readiness inspection. +#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)] +pub struct SolanaCoreStatefulFact { + /// Stable fact key. + pub key: std::string::String, + /// String representation of the measured value. + pub value: std::string::String, +} + +/// Complete request for one native Solana stateful readiness inspection. +#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)] +pub struct SolanaCoreStatefulReadinessRequest { + /// HTTP endpoint role used for all state reads. + pub query_role: std::string::String, + /// Expected Localnet or Devnet cluster. + pub cluster: kb_lib::ExApiExecutionCluster, + /// Native operation whose external state must be inspected. + pub operation: kb_lib::ExSolanaCoreOperation, +} + +/// Complete stateful readiness report produced before simulation. +#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)] +pub struct SolanaCoreStatefulReadinessReport { + /// Expected cluster. + pub cluster: kb_lib::ExApiExecutionCluster, + /// Stable native operation code. + pub operation_code: std::string::String, + /// Aggregate stateful readiness status. + pub status: crate::SolanaCoreStatefulReadinessStatus, + /// Highest contextual slot observed while reading accounts. + pub context_slot: std::option::Option, + /// Ordered stateful checks. + pub checks: std::vec::Vec, + /// Ordered measured facts. + pub facts: std::vec::Vec, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +struct AddressLookupTableState { + deactivation_slot: u64, + authority: std::option::Option, + address_count: usize, +} + +/// Inspects Localnet or Devnet state required before simulating one native operation. +pub async fn inspect_solana_core_stateful_readiness( + pool: &kb_onchain_transport::HttpEndpointPool, + request: &crate::SolanaCoreStatefulReadinessRequest, +) -> kb_core::Result { + if request.query_role.trim().is_empty() { + return std::result::Result::Err(kb_core::Error::config( + "stateful execution readiness query_role must not be empty", + )); + } + match request.cluster { + kb_lib::ExApiExecutionCluster::Localnet | kb_lib::ExApiExecutionCluster::Devnet => {}, + kb_lib::ExApiExecutionCluster::Testnet | kb_lib::ExApiExecutionCluster::Mainnet => { + return std::result::Result::Err(kb_core::Error::new( + "execution_stateful_cluster_unsupported", + "stateful native execution readiness is restricted to Localnet and Devnet", + )); + }, + } + 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_cluster(request.cluster, &genesis) { + std::result::Result::Ok(()) => {}, + std::result::Result::Err(error) => return std::result::Result::Err(error), + } + let mut report = new_report(request); + push_fact(&mut report, "genesis_hash", genesis.genesis_hash.as_str()); + match &request.operation { + kb_lib::ExSolanaCoreOperation::AddressLookupTableCreate { + authority, + payer, + recent_slot, + } => { + match inspect_alt_create(pool, request, &mut report, authority, payer, *recent_slot) + .await + { + std::result::Result::Ok(()) => {}, + std::result::Result::Err(error) => { + return std::result::Result::Err(error); + }, + } + }, + kb_lib::ExSolanaCoreOperation::AddressLookupTableExtend { + lookup_table, + authority, + payer, + new_addresses, + } => { + match inspect_alt_existing( + pool, + request, + &mut report, + lookup_table, + authority, + AltAction::Extend { + payer_present: payer.is_some(), + added_addresses: new_addresses.len(), + }, + ) + .await + { + std::result::Result::Ok(()) => {}, + std::result::Result::Err(error) => { + return std::result::Result::Err(error); + }, + } + }, + kb_lib::ExSolanaCoreOperation::AddressLookupTableFreeze { lookup_table, authority } => { + match inspect_alt_existing( + pool, + request, + &mut report, + lookup_table, + authority, + AltAction::Freeze, + ) + .await + { + std::result::Result::Ok(()) => {}, + std::result::Result::Err(error) => { + return std::result::Result::Err(error); + }, + } + }, + kb_lib::ExSolanaCoreOperation::AddressLookupTableDeactivate { lookup_table, authority } => { + match inspect_alt_existing( + pool, + request, + &mut report, + lookup_table, + authority, + AltAction::Deactivate, + ) + .await + { + std::result::Result::Ok(()) => {}, + std::result::Result::Err(error) => { + return std::result::Result::Err(error); + }, + } + }, + kb_lib::ExSolanaCoreOperation::AddressLookupTableClose { + lookup_table, authority, .. + } => { + match inspect_alt_existing( + pool, + request, + &mut report, + lookup_table, + authority, + AltAction::Close, + ) + .await + { + std::result::Result::Ok(()) => {}, + std::result::Result::Err(error) => { + return std::result::Result::Err(error); + }, + } + }, + kb_lib::ExSolanaCoreOperation::ConfigCreateAccount { + config_account, + lamports, + max_config_data_space, + max_keys, + .. + } => { + match inspect_config_create( + pool, + request, + &mut report, + config_account, + *lamports, + *max_config_data_space, + max_keys.len(), + ) + .await + { + std::result::Result::Ok(()) => {}, + std::result::Result::Err(error) => { + return std::result::Result::Err(error); + }, + } + }, + kb_lib::ExSolanaCoreOperation::ConfigStore { config_account, keys, data, .. } => { + match inspect_config_store( + pool, + request, + &mut report, + config_account, + keys.len(), + data.len(), + ) + .await + { + std::result::Result::Ok(()) => {}, + std::result::Result::Err(error) => { + return std::result::Result::Err(error); + }, + } + }, + kb_lib::ExSolanaCoreOperation::FeatureActivate { feature_account, lamports, .. } => { + match inspect_feature_activate(pool, request, &mut report, feature_account, *lamports) + .await + { + std::result::Result::Ok(()) => {}, + std::result::Result::Err(error) => { + return std::result::Result::Err(error); + }, + } + }, + kb_lib::ExSolanaCoreOperation::FeatureRevokePendingActivation { feature_account } => { + match inspect_feature_revoke(pool, request, &mut report, feature_account).await { + std::result::Result::Ok(()) => {}, + std::result::Result::Err(error) => { + return std::result::Result::Err(error); + }, + } + }, + kb_lib::ExSolanaCoreOperation::SlashingSubmitDuplicateBlockProof { + proof_account, + report_account, + report_lamports, + proof_offset, + slot, + .. + } => { + match inspect_slashing_submit( + pool, + request, + &mut report, + proof_account, + report_account, + *report_lamports, + *proof_offset, + *slot, + ) + .await + { + std::result::Result::Ok(()) => {}, + std::result::Result::Err(error) => { + return std::result::Result::Err(error); + }, + } + }, + kb_lib::ExSolanaCoreOperation::SlashingCloseViolationReport { + report_account, + destination, + } => { + match inspect_slashing_close(pool, request, &mut report, report_account, destination) + .await + { + std::result::Result::Ok(()) => {}, + std::result::Result::Err(error) => { + return std::result::Result::Err(error); + }, + } + }, + kb_lib::ExSolanaCoreOperation::ZkElGamalVerifyInline { + proof_type, context_state, .. + } => { + match inspect_zk_verify( + pool, + request, + &mut report, + *proof_type, + std::option::Option::None, + context_state.as_ref(), + ) + .await + { + std::result::Result::Ok(()) => {}, + std::result::Result::Err(error) => { + return std::result::Result::Err(error); + }, + } + }, + kb_lib::ExSolanaCoreOperation::ZkElGamalVerifyFromAccount { + proof_type, + proof_account, + proof_offset, + context_state, + } => { + match inspect_zk_verify( + pool, + request, + &mut report, + *proof_type, + std::option::Option::Some((proof_account, *proof_offset)), + context_state.as_ref(), + ) + .await + { + std::result::Result::Ok(()) => {}, + std::result::Result::Err(error) => { + return std::result::Result::Err(error); + }, + } + }, + kb_lib::ExSolanaCoreOperation::ZkElGamalCloseContextState { + context_state, + authority, + .. + } => match inspect_zk_close(pool, request, &mut report, context_state, authority).await { + std::result::Result::Ok(()) => {}, + std::result::Result::Err(error) => { + return std::result::Result::Err(error); + }, + }, + _ => { + report.status = crate::SolanaCoreStatefulReadinessStatus::NotRequired; + push_fact(&mut report, "stateful_policy", "not_required_for_operation"); + return std::result::Result::Ok(report); + }, + } + finalize_report(&mut report); + return std::result::Result::Ok(report); +} + +fn new_report( + request: &crate::SolanaCoreStatefulReadinessRequest, +) -> crate::SolanaCoreStatefulReadinessReport { + return crate::SolanaCoreStatefulReadinessReport { + cluster: request.cluster, + operation_code: request.operation.operation_code().to_string(), + status: crate::SolanaCoreStatefulReadinessStatus::Ready, + context_slot: std::option::Option::None, + checks: std::vec::Vec::new(), + facts: std::vec::Vec::new(), + }; +} + +fn validate_cluster( + 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(()) + } else { + std::result::Result::Err(kb_core::Error::new( + "execution_stateful_devnet_genesis_mismatch", + "stateful Devnet readiness requires the official Devnet genesis hash", + )) + } + }, + kb_lib::ExApiExecutionCluster::Localnet => { + if genesis.classified_cluster.is_none() { + std::result::Result::Ok(()) + } else { + std::result::Result::Err(kb_core::Error::new( + "execution_stateful_localnet_public_cluster", + "Localnet readiness refuses endpoints classified as an official public cluster", + )) + } + }, + kb_lib::ExApiExecutionCluster::Testnet | kb_lib::ExApiExecutionCluster::Mainnet => { + std::result::Result::Err(kb_core::Error::new( + "execution_stateful_cluster_unsupported", + "stateful native execution readiness is restricted to Localnet and Devnet", + )) + }, + }; +} + +fn push_check( + report: &mut crate::SolanaCoreStatefulReadinessReport, + code: &str, + passed: bool, + message: impl std::convert::Into, +) { + report.checks.push(crate::SolanaCoreStatefulCheck { + code: code.to_string(), + passed, + message: message.into(), + }); +} + +fn push_fact( + report: &mut crate::SolanaCoreStatefulReadinessReport, + key: &str, + value: impl std::fmt::Display, +) { + report.facts.push(crate::SolanaCoreStatefulFact { + key: key.to_string(), + value: value.to_string(), + }); +} + +fn update_context_slot(report: &mut crate::SolanaCoreStatefulReadinessReport, slot: u64) { + let current = match report.context_slot { + std::option::Option::Some(value) => value.max(slot), + std::option::Option::None => slot, + }; + report.context_slot = std::option::Option::Some(current); +} + +fn finalize_report(report: &mut crate::SolanaCoreStatefulReadinessReport) { + report.status = if report.checks.iter().all(|check| return check.passed) { + crate::SolanaCoreStatefulReadinessStatus::Ready + } else { + crate::SolanaCoreStatefulReadinessStatus::Blocked + }; +} + +async fn account_with_data( + pool: &kb_onchain_transport::HttpEndpointPool, + request: &crate::SolanaCoreStatefulReadinessRequest, + pubkey: &kb_lib::MdPubkey, +) -> kb_core::Result { + let config = match kb_onchain_transport::GetAccountInfoConfig::confirmed_with_data( + COMPLETE_ACCOUNT_DATA_LIMIT, + ) { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + return pool + .get_account_info_for_role(request.query_role.as_str(), pubkey, &config) + .await; +} + +async fn rent_minimum( + pool: &kb_onchain_transport::HttpEndpointPool, + request: &crate::SolanaCoreStatefulReadinessRequest, + data_length: u64, +) -> kb_core::Result { + let result = pool + .get_minimum_balance_for_rent_exemption_for_role( + request.query_role.as_str(), + data_length, + &kb_onchain_transport::GetMinimumBalanceForRentExemptionConfig::confirmed(), + ) + .await; + return match result { + std::result::Result::Ok(value) => std::result::Result::Ok(value.minimum_balance_lamports), + std::result::Result::Err(error) => std::result::Result::Err(error), + }; +} + +async fn epoch_info( + pool: &kb_onchain_transport::HttpEndpointPool, + request: &crate::SolanaCoreStatefulReadinessRequest, +) -> kb_core::Result { + return pool + .get_epoch_info_for_role( + request.query_role.as_str(), + &kb_onchain_transport::GetEpochInfoConfig::confirmed(), + ) + .await; +} + +async fn inspect_alt_create( + pool: &kb_onchain_transport::HttpEndpointPool, + request: &crate::SolanaCoreStatefulReadinessRequest, + report: &mut crate::SolanaCoreStatefulReadinessReport, + authority: &kb_lib::MdPubkey, + payer: &kb_lib::MdPubkey, + recent_slot: u64, +) -> kb_core::Result<()> { + let authority_address = match solana_pubkey::Pubkey::from_str(authority.0.as_str()) { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => { + return std::result::Result::Err(kb_core::Error::config(format!( + "invalid ALT authority: {error}" + ))); + }, + }; + let payer_address = match solana_pubkey::Pubkey::from_str(payer.0.as_str()) { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => { + return std::result::Result::Err(kb_core::Error::config(format!( + "invalid ALT payer: {error}" + ))); + }, + }; + let (_, table_address) = + solana_address_lookup_table_interface::instruction::create_lookup_table( + authority_address, + payer_address, + recent_slot, + ); + let table = kb_lib::MdPubkey(table_address.to_string()); + let account_result = match account_with_data(pool, request, &table).await { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + update_context_slot(report, account_result.context.slot); + push_fact(report, "lookup_table", table.0.as_str()); + push_check( + report, + "alt_create_target_absent", + account_result.account.is_none(), + "derived lookup table account must not already exist", + ); + let epoch = match epoch_info(pool, request).await { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + push_fact(report, "absolute_slot", epoch.absolute_slot); + let last_valid = + solana_address_lookup_table_interface::state::estimate_last_valid_slot(recent_slot); + push_fact(report, "recent_slot_last_valid", last_valid); + push_check( + report, + "alt_create_recent_slot_not_future", + recent_slot <= epoch.absolute_slot, + "ALT recent slot must not be ahead of the current confirmed slot", + ); + push_check( + report, + "alt_create_recent_slot_still_recent", + epoch.absolute_slot <= last_valid, + "ALT recent slot must remain in the runtime slot-hashes window", + ); + let rent = match rent_minimum(pool, request, ALT_META_SIZE as u64).await { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + push_fact(report, "minimum_rent_lamports", rent); + return std::result::Result::Ok(()); +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum AltAction { + Extend { + payer_present: bool, + added_addresses: usize, + }, + Freeze, + Deactivate, + Close, +} + +async fn inspect_alt_existing( + pool: &kb_onchain_transport::HttpEndpointPool, + request: &crate::SolanaCoreStatefulReadinessRequest, + report: &mut crate::SolanaCoreStatefulReadinessReport, + lookup_table: &kb_lib::MdPubkey, + authority: &kb_lib::MdPubkey, + action: AltAction, +) -> kb_core::Result<()> { + let account_result = match account_with_data(pool, request, lookup_table).await { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + update_context_slot(report, account_result.context.slot); + let account = match account_result.account { + std::option::Option::Some(value) => value, + std::option::Option::None => { + push_check(report, "alt_account_exists", false, "lookup table account does not exist"); + return std::result::Result::Ok(()); + }, + }; + push_check( + report, + "alt_owner_matches", + account.owner.0 == kb_program_ids::ADDRESS_LOOKUP_TABLE_PROGRAM_ID, + "lookup table account must be owned by the Address Lookup Table program", + ); + push_check( + report, + "alt_not_executable", + !account.executable, + "lookup table state account must not be executable", + ); + let state = match parse_alt_state(account.data.as_slice()) { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => { + push_check(report, "alt_state_decodes", false, error.to_string()); + return std::result::Result::Ok(()); + }, + }; + push_fact(report, "alt_address_count", state.address_count); + push_fact(report, "alt_deactivation_slot", state.deactivation_slot); + push_check( + report, + "alt_authority_matches", + state.authority.as_deref() == std::option::Option::Some(authority.0.as_str()), + "lookup table authority must match the requested authority", + ); + let active = state.deactivation_slot == u64::MAX; + match action { + AltAction::Extend { payer_present, added_addresses } => { + push_check( + report, + "alt_extend_active", + active, + "lookup table must be active before extension", + ); + let target_count = state.address_count.saturating_add(added_addresses); + push_fact(report, "alt_target_address_count", target_count); + push_check( + report, + "alt_extend_capacity", + target_count <= ALT_MAX_ADDRESSES, + "lookup table cannot exceed 256 addresses", + ); + let target_space = ALT_META_SIZE.saturating_add(target_count.saturating_mul(32)); + let rent = match rent_minimum(pool, request, target_space as u64).await { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + let top_up = rent.saturating_sub(account.lamports); + push_fact(report, "alt_target_space", target_space); + push_fact(report, "alt_rent_top_up_lamports", top_up); + push_check( + report, + "alt_extend_payer_available_when_needed", + top_up == 0 || payer_present, + "ALT extension requires the optional payer/system pair when rent top-up is needed", + ); + }, + AltAction::Freeze => { + push_check( + report, + "alt_freeze_active", + active, + "lookup table must be active before freezing", + ); + push_check( + report, + "alt_freeze_non_empty", + state.address_count > 0, + "empty lookup tables cannot be frozen", + ); + }, + AltAction::Deactivate => { + push_check( + report, + "alt_deactivate_active", + active, + "lookup table must still be active before deactivation", + ); + }, + AltAction::Close => { + push_check( + report, + "alt_close_deactivated", + !active, + "lookup table must be deactivated before closing", + ); + if !active { + let epoch = match epoch_info(pool, request).await { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + let last_valid = + solana_address_lookup_table_interface::state::estimate_last_valid_slot( + state.deactivation_slot, + ); + push_fact(report, "alt_close_last_valid_slot", last_valid); + push_check( + report, + "alt_close_cooldown_elapsed", + epoch.absolute_slot > last_valid, + "lookup table cooldown must be conservatively elapsed before close", + ); + } + }, + } + return std::result::Result::Ok(()); +} + +fn parse_alt_state(data: &[u8]) -> kb_core::Result { + if data.len() < ALT_META_SIZE { + return std::result::Result::Err(kb_core::Error::new( + "execution_stateful_alt_data_short", + "lookup table account data is shorter than the 56-byte metadata region", + )); + } + let tag = match read_u32(data, 0, "ALT program state tag") { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + if tag != 1 { + return std::result::Result::Err(kb_core::Error::new( + "execution_stateful_alt_uninitialized", + "lookup table account is not in the initialized LookupTable program state", + )); + } + let deactivation_slot = match read_u64(data, 4, "ALT deactivation slot") { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + let authority = match data[21] { + 0 => std::option::Option::None, + 1 => { + let bytes = match data.get(22..54) { + std::option::Option::Some(value) => value, + std::option::Option::None => { + return std::result::Result::Err(kb_core::Error::new( + "execution_stateful_alt_authority_short", + "lookup table authority bytes are truncated", + )); + }, + }; + let array = match <[u8; 32]>::try_from(bytes) { + std::result::Result::Ok(value) => value, + std::result::Result::Err(_) => { + return std::result::Result::Err(kb_core::Error::new( + "execution_stateful_alt_authority_short", + "lookup table authority bytes are not exactly 32 bytes", + )); + }, + }; + std::option::Option::Some(solana_pubkey::Pubkey::new_from_array(array).to_string()) + }, + value => { + return std::result::Result::Err(kb_core::Error::new( + "execution_stateful_alt_authority_option_invalid", + format!("lookup table authority option tag must be 0 or 1 but received {value}"), + )); + }, + }; + let address_bytes = data.len() - ALT_META_SIZE; + if address_bytes % 32 != 0 { + return std::result::Result::Err(kb_core::Error::new( + "execution_stateful_alt_addresses_misaligned", + "lookup table address bytes must be aligned to 32-byte public keys", + )); + } + let address_count = address_bytes / 32; + if address_count > ALT_MAX_ADDRESSES { + return std::result::Result::Err(kb_core::Error::new( + "execution_stateful_alt_capacity_invalid", + "lookup table account contains more than 256 addresses", + )); + } + return std::result::Result::Ok(AddressLookupTableState { + deactivation_slot, + authority, + address_count, + }); +} + +async fn inspect_config_create( + pool: &kb_onchain_transport::HttpEndpointPool, + request: &crate::SolanaCoreStatefulReadinessRequest, + report: &mut crate::SolanaCoreStatefulReadinessReport, + config_account: &kb_lib::MdPubkey, + lamports: u64, + max_data_space: u64, + max_key_count: usize, +) -> kb_core::Result<()> { + let account_result = match account_with_data(pool, request, config_account).await { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + update_context_slot(report, account_result.context.slot); + push_check( + report, + "config_create_target_absent", + account_result.account.is_none(), + "new Config account must not already exist", + ); + let key_bytes = + compact_u16_length(max_key_count).saturating_add(max_key_count.saturating_mul(33)); + let space = match max_data_space.checked_add(key_bytes as u64) { + std::option::Option::Some(value) => value, + std::option::Option::None => { + return std::result::Result::Err(kb_core::Error::new( + "execution_stateful_config_space_overflow", + "Config account allocation length overflow", + )); + }, + }; + let rent = match rent_minimum(pool, request, space).await { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + push_fact(report, "config_account_space", space); + push_fact(report, "minimum_rent_lamports", rent); + push_check( + report, + "config_create_rent_sufficient", + lamports >= rent, + "Config account lamports must meet the current rent-exempt minimum", + ); + return std::result::Result::Ok(()); +} + +async fn inspect_config_store( + pool: &kb_onchain_transport::HttpEndpointPool, + request: &crate::SolanaCoreStatefulReadinessRequest, + report: &mut crate::SolanaCoreStatefulReadinessReport, + config_account: &kb_lib::MdPubkey, + key_count: usize, + data_length: usize, +) -> kb_core::Result<()> { + let account_result = match account_with_data(pool, request, config_account).await { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + update_context_slot(report, account_result.context.slot); + let account = match account_result.account { + std::option::Option::Some(value) => value, + std::option::Option::None => { + push_check( + report, + "config_store_account_exists", + false, + "Config account does not exist", + ); + return std::result::Result::Ok(()); + }, + }; + push_check( + report, + "config_store_owner_matches", + account.owner.0 == kb_program_ids::CONFIG_PROGRAM_ID, + "Config account must be owned by the Config program", + ); + push_check( + report, + "config_store_not_executable", + !account.executable, + "Config state account must not be executable", + ); + let required = compact_u16_length(key_count) + .saturating_add(key_count.saturating_mul(33)) + .saturating_add(data_length); + push_fact(report, "config_required_space", required); + push_fact(report, "config_existing_space", account.space); + push_check( + report, + "config_store_space_sufficient", + account.space >= required as u64, + "Config account allocation must fit the complete encoded key list and opaque data", + ); + return std::result::Result::Ok(()); +} + +async fn inspect_feature_activate( + pool: &kb_onchain_transport::HttpEndpointPool, + request: &crate::SolanaCoreStatefulReadinessRequest, + report: &mut crate::SolanaCoreStatefulReadinessReport, + feature_account: &kb_lib::MdPubkey, + lamports: u64, +) -> kb_core::Result<()> { + let account_result = match account_with_data(pool, request, feature_account).await { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + update_context_slot(report, account_result.context.slot); + let existing_lamports = match account_result.account.as_ref() { + std::option::Option::Some(account) => account.lamports, + std::option::Option::None => 0, + }; + let reusable = account_result.account.as_ref().map(|account| { + return account.owner.0 == kb_program_ids::SYSTEM_PROGRAM_ID + && !account.executable + && account.space == 0 + && account.data.is_empty(); + }); + push_check( + report, + "feature_activate_target_available", + match reusable { + std::option::Option::Some(value) => value, + std::option::Option::None => true, + }, + "feature target must be absent or a prefunded zero-space System account", + ); + let rent = match rent_minimum(pool, request, FEATURE_ACCOUNT_SIZE).await { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + let required_top_up = rent.saturating_sub(existing_lamports); + push_fact(report, "feature_existing_lamports", existing_lamports); + push_fact(report, "feature_required_top_up_lamports", required_top_up); + push_check( + report, + "feature_activate_rent_sufficient", + lamports >= required_top_up, + "feature activation transfer must cover the measured rent shortfall", + ); + return std::result::Result::Ok(()); +} + +async fn inspect_feature_revoke( + pool: &kb_onchain_transport::HttpEndpointPool, + request: &crate::SolanaCoreStatefulReadinessRequest, + report: &mut crate::SolanaCoreStatefulReadinessReport, + feature_account: &kb_lib::MdPubkey, +) -> kb_core::Result<()> { + let account_result = match account_with_data(pool, request, feature_account).await { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + update_context_slot(report, account_result.context.slot); + let account = match account_result.account { + std::option::Option::Some(value) => value, + std::option::Option::None => { + push_check( + report, + "feature_revoke_account_exists", + false, + "feature account does not exist", + ); + return std::result::Result::Ok(()); + }, + }; + push_check( + report, + "feature_revoke_owner_matches", + account.owner.0 == kb_program_ids::FEATURE_PROGRAM_ID, + "feature account must be owned by the Feature program", + ); + push_check( + report, + "feature_revoke_not_executable", + !account.executable, + "feature state account must not be executable", + ); + let pending = account.data.len() == FEATURE_ACCOUNT_SIZE as usize + && account.data.first() == std::option::Option::Some(&0); + push_check( + report, + "feature_revoke_pending", + pending, + "only a pending feature state encoded as Feature { activated_at: None } may be revoked", + ); + return std::result::Result::Ok(()); +} + +async fn inspect_slashing_submit( + pool: &kb_onchain_transport::HttpEndpointPool, + request: &crate::SolanaCoreStatefulReadinessRequest, + report: &mut crate::SolanaCoreStatefulReadinessReport, + proof_account: &kb_lib::MdPubkey, + report_account: &kb_lib::MdPubkey, + report_lamports: u64, + proof_offset: u64, + slot: u64, +) -> kb_core::Result<()> { + let proof_result = match account_with_data(pool, request, proof_account).await { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + update_context_slot(report, proof_result.context.slot); + let proof = match proof_result.account { + std::option::Option::Some(value) => value, + std::option::Option::None => { + push_check( + report, + "slashing_proof_account_exists", + false, + "duplicate-block proof account does not exist", + ); + return std::result::Result::Ok(()); + }, + }; + push_check( + report, + "slashing_proof_not_executable", + !proof.executable, + "duplicate-block proof account must not be executable", + ); + let packed_length = match duplicate_block_proof_length(proof.data.as_slice(), proof_offset) { + std::result::Result::Ok(value) => { + push_check( + report, + "slashing_proof_shape_valid", + true, + "duplicate-block proof contains two bounded length-prefixed shreds", + ); + value + }, + std::result::Result::Err(error) => { + push_check(report, "slashing_proof_shape_valid", false, error.to_string()); + 0 + }, + }; + push_fact(report, "slashing_packed_proof_bytes", packed_length); + let report_result = match account_with_data(pool, request, report_account).await { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + update_context_slot(report, report_result.context.slot); + let report_reusable = report_result.account.as_ref().map(|account| { + return account.owner.0 == kb_program_ids::SYSTEM_PROGRAM_ID + && !account.executable + && account.space == 0 + && account.data.is_empty(); + }); + push_check( + report, + "slashing_report_target_available", + match report_reusable { + std::option::Option::Some(value) => value, + std::option::Option::None => true, + }, + "violation report PDA must be absent or an empty prefunded System account", + ); + let epoch = match epoch_info(pool, request).await { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + push_fact(report, "absolute_slot", epoch.absolute_slot); + push_fact(report, "slots_in_epoch", epoch.slots_in_epoch); + let age = epoch.absolute_slot.saturating_sub(slot); + push_check( + report, + "slashing_slot_not_future", + slot <= epoch.absolute_slot, + "reported duplicate-block slot must not be in the future", + ); + push_check( + report, + "slashing_slot_within_one_epoch", + slot <= epoch.absolute_slot && age <= epoch.slots_in_epoch, + "duplicate-block proof must be submitted within one epoch worth of slots", + ); + if packed_length > 0 { + let report_size = SLASHING_REPORT_HEADER_SIZE.saturating_add(packed_length); + let rent = match rent_minimum(pool, request, report_size as u64).await { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + push_fact(report, "slashing_report_space", report_size); + push_fact(report, "minimum_rent_lamports", rent); + push_check( + report, + "slashing_report_rent_sufficient", + report_lamports >= rent, + "report_lamports must cover the exact report header and retained proof bytes", + ); + } + return std::result::Result::Ok(()); +} + +async fn inspect_slashing_close( + pool: &kb_onchain_transport::HttpEndpointPool, + request: &crate::SolanaCoreStatefulReadinessRequest, + report: &mut crate::SolanaCoreStatefulReadinessReport, + report_account: &kb_lib::MdPubkey, + destination: &kb_lib::MdPubkey, +) -> kb_core::Result<()> { + let account_result = match account_with_data(pool, request, report_account).await { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + update_context_slot(report, account_result.context.slot); + let account = match account_result.account { + std::option::Option::Some(value) => value, + std::option::Option::None => { + push_check( + report, + "slashing_report_exists", + false, + "violation report account does not exist", + ); + return std::result::Result::Ok(()); + }, + }; + push_check( + report, + "slashing_report_owner_matches", + account.owner.0 == kb_program_ids::SLASHING_PROGRAM_ID, + "violation report must be owned by the Slashing program", + ); + push_check( + report, + "slashing_report_not_executable", + !account.executable, + "violation report state account must not be executable", + ); + if account.data.len() < SLASHING_REPORT_HEADER_SIZE { + push_check( + report, + "slashing_report_decodes", + false, + "violation report is shorter than its 114-byte fixed header", + ); + return std::result::Result::Ok(()); + } + let version = account.data[0]; + let retained_destination = match pubkey_text(&account.data[33..65]) { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => { + push_check(report, "slashing_report_decodes", false, error.to_string()); + return std::result::Result::Ok(()); + }, + }; + let report_epoch = match read_u64(account.data.as_slice(), 65, "Slashing report epoch") { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + push_fact(report, "slashing_report_version", version); + push_fact(report, "slashing_report_epoch", report_epoch); + push_check( + report, + "slashing_report_version_supported", + version == 1, + "violation report version must be 1", + ); + push_check( + report, + "slashing_report_destination_matches", + retained_destination == destination.0, + "close destination must match the destination retained in the violation report", + ); + let epoch = match epoch_info(pool, request).await { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + push_fact(report, "current_epoch", epoch.epoch); + push_check( + report, + "slashing_report_retention_elapsed", + epoch.epoch >= report_epoch.saturating_add(3), + "violation report must remain on-chain for at least three epochs", + ); + return std::result::Result::Ok(()); +} + +async fn inspect_zk_verify( + pool: &kb_onchain_transport::HttpEndpointPool, + request: &crate::SolanaCoreStatefulReadinessRequest, + report: &mut crate::SolanaCoreStatefulReadinessReport, + proof_type: kb_lib::ExSolanaCoreZkElGamalProofType, + proof_account: std::option::Option<(&kb_lib::MdPubkey, u32)>, + context_state: std::option::Option<&kb_lib::ExSolanaCoreZkElGamalContextState>, +) -> kb_core::Result<()> { + if let std::option::Option::Some((account_pubkey, offset)) = proof_account { + let proof_result = match account_with_data(pool, request, account_pubkey).await { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + update_context_slot(report, proof_result.context.slot); + let proof = match proof_result.account { + std::option::Option::Some(value) => value, + std::option::Option::None => { + push_check( + report, + "zk_proof_account_exists", + false, + "ZK proof account does not exist", + ); + return std::result::Result::Ok(()); + }, + }; + push_check( + report, + "zk_proof_account_not_executable", + !proof.executable, + "ZK proof account must not be executable", + ); + let end = (offset as usize).checked_add(proof_type.proof_data_size()); + push_check( + report, + "zk_proof_range_available", + match end { + std::option::Option::Some(end) => end <= proof.data.len(), + std::option::Option::None => false, + }, + "ZK proof account must contain the complete proof POD at the requested offset", + ); + } + let context = match context_state { + std::option::Option::Some(value) => value, + std::option::Option::None => { + push_fact(report, "zk_context_state", "not_requested"); + return std::result::Result::Ok(()); + }, + }; + let context_result = match account_with_data(pool, request, &context.account).await { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + update_context_slot(report, context_result.context.slot); + let account = match context_result.account { + std::option::Option::Some(value) => value, + std::option::Option::None => { + push_check( + report, + "zk_context_account_exists", + false, + "preallocated ZK context-state account does not exist", + ); + return std::result::Result::Ok(()); + }, + }; + let expected_size = proof_type.context_state_size(); + push_fact(report, "zk_context_expected_space", expected_size); + push_fact(report, "zk_context_existing_space", account.space); + push_check( + report, + "zk_context_owner_matches", + account.owner.0 == kb_program_ids::ZK_ELGAMAL_PROOF_PROGRAM_ID, + "ZK context-state account must be owned by the ZK ElGamal Proof program", + ); + push_check( + report, + "zk_context_not_executable", + !account.executable, + "ZK context-state account must not be executable", + ); + push_check( + report, + "zk_context_space_exact", + account.space == expected_size as u64 && account.data.len() == expected_size, + "ZK context-state allocation must exactly match the official ProofContextState size", + ); + push_check( + report, + "zk_context_uninitialized", + account.data.iter().all(|byte| return *byte == 0), + "preallocated ZK context-state account must still be zero-initialized", + ); + let rent = match rent_minimum(pool, request, expected_size as u64).await { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + push_fact(report, "minimum_rent_lamports", rent); + push_check( + report, + "zk_context_rent_exempt", + account.lamports >= rent, + "preallocated ZK context-state account must be rent exempt", + ); + return std::result::Result::Ok(()); +} + +async fn inspect_zk_close( + pool: &kb_onchain_transport::HttpEndpointPool, + request: &crate::SolanaCoreStatefulReadinessRequest, + report: &mut crate::SolanaCoreStatefulReadinessReport, + context_state: &kb_lib::MdPubkey, + authority: &kb_lib::MdPubkey, +) -> kb_core::Result<()> { + let account_result = match account_with_data(pool, request, context_state).await { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + update_context_slot(report, account_result.context.slot); + let account = match account_result.account { + std::option::Option::Some(value) => value, + std::option::Option::None => { + push_check( + report, + "zk_context_account_exists", + false, + "ZK context-state account does not exist", + ); + return std::result::Result::Ok(()); + }, + }; + push_check( + report, + "zk_context_owner_matches", + account.owner.0 == kb_program_ids::ZK_ELGAMAL_PROOF_PROGRAM_ID, + "ZK context-state account must be owned by the ZK ElGamal Proof program", + ); + push_check( + report, + "zk_context_not_executable", + !account.executable, + "ZK context-state account must not be executable", + ); + if account.data.len() < ZK_CONTEXT_META_SIZE { + push_check( + report, + "zk_context_meta_decodes", + false, + "ZK context-state account is shorter than its 33-byte generic metadata", + ); + return std::result::Result::Ok(()); + } + let retained_authority = match pubkey_text(&account.data[..32]) { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => { + push_check(report, "zk_context_meta_decodes", false, error.to_string()); + return std::result::Result::Ok(()); + }, + }; + let proof_type = proof_type_from_discriminator(account.data[32]); + push_check( + report, + "zk_context_authority_matches", + retained_authority == authority.0, + "close authority must match the authority retained in ProofContextState", + ); + push_check( + report, + "zk_context_proof_type_supported", + proof_type.is_some(), + "ProofContextState must contain a supported proof type discriminator", + ); + if let std::option::Option::Some(value) = proof_type { + let expected_size = value.context_state_size(); + push_fact(report, "zk_context_proof_type", value.discriminator()); + push_fact(report, "zk_context_expected_space", expected_size); + push_check( + report, + "zk_context_space_exact", + account.space == expected_size as u64 && account.data.len() == expected_size, + "stored ZK context-state allocation must match its proof type", + ); + } + return std::result::Result::Ok(()); +} + +fn proof_type_from_discriminator( + discriminator: u8, +) -> std::option::Option { + return match discriminator { + 1 => std::option::Option::Some(kb_lib::ExSolanaCoreZkElGamalProofType::ZeroCiphertext), + 2 => std::option::Option::Some( + kb_lib::ExSolanaCoreZkElGamalProofType::CiphertextCiphertextEquality, + ), + 3 => std::option::Option::Some( + kb_lib::ExSolanaCoreZkElGamalProofType::CiphertextCommitmentEquality, + ), + 4 => std::option::Option::Some(kb_lib::ExSolanaCoreZkElGamalProofType::PubkeyValidity), + 5 => std::option::Option::Some(kb_lib::ExSolanaCoreZkElGamalProofType::PercentageWithCap), + 6 => { + std::option::Option::Some(kb_lib::ExSolanaCoreZkElGamalProofType::BatchedRangeProofU64) + }, + 7 => { + std::option::Option::Some(kb_lib::ExSolanaCoreZkElGamalProofType::BatchedRangeProofU128) + }, + 8 => { + std::option::Option::Some(kb_lib::ExSolanaCoreZkElGamalProofType::BatchedRangeProofU256) + }, + 9 => std::option::Option::Some( + kb_lib::ExSolanaCoreZkElGamalProofType::GroupedCiphertext2HandlesValidity, + ), + 10 => std::option::Option::Some( + kb_lib::ExSolanaCoreZkElGamalProofType::BatchedGroupedCiphertext2HandlesValidity, + ), + 11 => std::option::Option::Some( + kb_lib::ExSolanaCoreZkElGamalProofType::GroupedCiphertext3HandlesValidity, + ), + 12 => std::option::Option::Some( + kb_lib::ExSolanaCoreZkElGamalProofType::BatchedGroupedCiphertext3HandlesValidity, + ), + _ => std::option::Option::None, + }; +} + +fn duplicate_block_proof_length(data: &[u8], offset: u64) -> kb_core::Result { + let start = match usize::try_from(offset) { + std::result::Result::Ok(value) => value, + std::result::Result::Err(_) => { + return std::result::Result::Err(kb_core::Error::new( + "execution_stateful_slashing_offset_invalid", + "duplicate-block proof offset does not fit the local address space", + )); + }, + }; + let first_length = match read_u32(data, start, "first duplicate-block shred length") { + std::result::Result::Ok(value) => value as usize, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + if first_length == 0 || first_length > MAX_SHRED_SIZE { + return std::result::Result::Err(kb_core::Error::new( + "execution_stateful_slashing_first_shred_length_invalid", + "first duplicate-block shred length must be between 1 and 1232 bytes", + )); + } + let second_length_offset = + match start.checked_add(4).and_then(|value| return value.checked_add(first_length)) { + std::option::Option::Some(value) => value, + std::option::Option::None => { + return std::result::Result::Err(kb_core::Error::new( + "execution_stateful_slashing_proof_overflow", + "duplicate-block proof length arithmetic overflow", + )); + }, + }; + let second_length = + match read_u32(data, second_length_offset, "second duplicate-block shred length") { + std::result::Result::Ok(value) => value as usize, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + if second_length == 0 || second_length > MAX_SHRED_SIZE { + return std::result::Result::Err(kb_core::Error::new( + "execution_stateful_slashing_second_shred_length_invalid", + "second duplicate-block shred length must be between 1 and 1232 bytes", + )); + } + let total = 4_usize + .checked_add(first_length) + .and_then(|value| return value.checked_add(4)) + .and_then(|value| return value.checked_add(second_length)); + let total = match total { + std::option::Option::Some(value) => value, + std::option::Option::None => { + return std::result::Result::Err(kb_core::Error::new( + "execution_stateful_slashing_proof_overflow", + "duplicate-block proof length arithmetic overflow", + )); + }, + }; + let end = match start.checked_add(total) { + std::option::Option::Some(value) => value, + std::option::Option::None => { + return std::result::Result::Err(kb_core::Error::new( + "execution_stateful_slashing_proof_overflow", + "duplicate-block proof end offset overflow", + )); + }, + }; + if end > data.len() { + return std::result::Result::Err(kb_core::Error::new( + "execution_stateful_slashing_proof_truncated", + "duplicate-block proof account does not contain both declared shreds", + )); + } + return std::result::Result::Ok(total); +} + +fn compact_u16_length(value: usize) -> usize { + if value < 0x80 { + return 1; + } + if value < 0x4000 { + return 2; + } + return 3; +} + +fn read_u32(data: &[u8], offset: usize, label: &str) -> kb_core::Result { + let end = match offset.checked_add(4) { + std::option::Option::Some(value) => value, + std::option::Option::None => { + return std::result::Result::Err(kb_core::Error::new( + "execution_stateful_integer_overflow", + format!("{label} offset overflow"), + )); + }, + }; + let bytes = match data.get(offset..end) { + std::option::Option::Some(value) => value, + std::option::Option::None => { + return std::result::Result::Err(kb_core::Error::new( + "execution_stateful_integer_truncated", + format!("{label} is truncated"), + )); + }, + }; + let array = match <[u8; 4]>::try_from(bytes) { + std::result::Result::Ok(value) => value, + std::result::Result::Err(_) => { + return std::result::Result::Err(kb_core::Error::new( + "execution_stateful_integer_truncated", + format!("{label} is not exactly four bytes"), + )); + }, + }; + return std::result::Result::Ok(u32::from_le_bytes(array)); +} + +fn read_u64(data: &[u8], offset: usize, label: &str) -> kb_core::Result { + let end = match offset.checked_add(8) { + std::option::Option::Some(value) => value, + std::option::Option::None => { + return std::result::Result::Err(kb_core::Error::new( + "execution_stateful_integer_overflow", + format!("{label} offset overflow"), + )); + }, + }; + let bytes = match data.get(offset..end) { + std::option::Option::Some(value) => value, + std::option::Option::None => { + return std::result::Result::Err(kb_core::Error::new( + "execution_stateful_integer_truncated", + format!("{label} is truncated"), + )); + }, + }; + let array = match <[u8; 8]>::try_from(bytes) { + std::result::Result::Ok(value) => value, + std::result::Result::Err(_) => { + return std::result::Result::Err(kb_core::Error::new( + "execution_stateful_integer_truncated", + format!("{label} is not exactly eight bytes"), + )); + }, + }; + return std::result::Result::Ok(u64::from_le_bytes(array)); +} + +fn pubkey_text(bytes: &[u8]) -> kb_core::Result { + let array = match <[u8; 32]>::try_from(bytes) { + std::result::Result::Ok(value) => value, + std::result::Result::Err(_) => { + return std::result::Result::Err(kb_core::Error::new( + "execution_stateful_pubkey_length_invalid", + "stateful account field is not exactly 32 bytes", + )); + }, + }; + return std::result::Result::Ok(solana_pubkey::Pubkey::new_from_array(array).to_string()); +} + +#[cfg(test)] +mod tests { + #[test] + fn cluster_policy_restricts_stateful_reads_to_localnet_and_devnet() { + let devnet = kb_onchain_transport::GenesisHashResult { + genesis_hash: std::string::String::from("devnet"), + classified_cluster: std::option::Option::Some(kb_lib::ExApiExecutionCluster::Devnet), + }; + assert!(super::validate_cluster(kb_lib::ExApiExecutionCluster::Devnet, &devnet,).is_ok()); + assert!( + super::validate_cluster(kb_lib::ExApiExecutionCluster::Localnet, &devnet,).is_err() + ); + let local = kb_onchain_transport::GenesisHashResult { + genesis_hash: std::string::String::from("local"), + classified_cluster: std::option::Option::None, + }; + assert!(super::validate_cluster(kb_lib::ExApiExecutionCluster::Localnet, &local,).is_ok()); + } + + #[test] + fn alt_state_parser_preserves_authority_lifecycle_and_capacity() { + let authority = solana_pubkey::Pubkey::new_unique(); + let mut data = vec![0_u8; 56 + 64]; + data[..4].copy_from_slice(&1_u32.to_le_bytes()); + data[4..12].copy_from_slice(&u64::MAX.to_le_bytes()); + data[21] = 1; + let authority_bytes = authority.to_bytes(); + data[22..54].copy_from_slice(&authority_bytes); + let state = super::parse_alt_state(data.as_slice()) + .unwrap_or_else(|error| panic!("unexpected ALT state error: {error}")); + assert_eq!(state.deactivation_slot, u64::MAX); + assert_eq!(state.authority, std::option::Option::Some(authority.to_string())); + assert_eq!(state.address_count, 2); + data.push(0); + assert!(super::parse_alt_state(data.as_slice()).is_err()); + } + + #[test] + fn slashing_proof_shape_is_bounded_and_exact() { + let mut data = std::vec::Vec::new(); + data.extend_from_slice(&3_u32.to_le_bytes()); + data.extend_from_slice(&[1, 2, 3]); + data.extend_from_slice(&2_u32.to_le_bytes()); + data.extend_from_slice(&[4, 5]); + assert_eq!( + super::duplicate_block_proof_length(data.as_slice(), 0) + .unwrap_or_else(|error| panic!("unexpected proof shape error: {error}")), + 13, + ); + assert!(super::duplicate_block_proof_length(&data[..12], 0).is_err()); + let mut too_large = std::vec::Vec::new(); + too_large.extend_from_slice(&1_233_u32.to_le_bytes()); + assert!(super::duplicate_block_proof_length(too_large.as_slice(), 0,).is_err()); + } + + #[test] + fn proof_discriminators_map_to_exact_context_sizes() { + for discriminator in 1_u8..=12 { + let proof_type = super::proof_type_from_discriminator(discriminator) + .unwrap_or_else(|| panic!("missing proof type {discriminator}")); + assert_eq!(proof_type.discriminator(), discriminator); + assert!(proof_type.context_state_size() > super::ZK_CONTEXT_META_SIZE); + } + assert_eq!( + kb_lib::ExSolanaCoreZkElGamalProofType::BatchedRangeProofU64.context_state_size(), + kb_lib::ExSolanaCoreZkElGamalProofType::BatchedRangeProofU256.context_state_size(), + ); + assert!(super::proof_type_from_discriminator(0).is_none()); + } + + #[test] + fn compact_u16_length_matches_config_key_boundaries() { + assert_eq!(super::compact_u16_length(0), 1); + assert_eq!(super::compact_u16_length(127), 1); + assert_eq!(super::compact_u16_length(128), 2); + assert_eq!(super::compact_u16_length(16_384), 3); + } +}