diff --git a/kb-pipeline/src/lib.rs b/kb-pipeline/src/lib.rs index 0843a45..744957e 100644 --- a/kb-pipeline/src/lib.rs +++ b/kb-pipeline/src/lib.rs @@ -1,5 +1,5 @@ // file: kb-pipeline/src/lib.rs -// version: 13 +// version: 14 #![forbid(unsafe_code)] #![deny(unreachable_pub)] @@ -26,6 +26,7 @@ mod solana_token2022_preflight; mod solana_token2022_proof_orchestration; mod solana_token2022_stateful; mod solana_token2022_validation; +mod solana_token_stateful; /// Emits one execution progress event for specialized pipeline orchestrators. pub(crate) use self::solana_execution::emit; @@ -176,6 +177,18 @@ pub use self::solana_stateful::SolanaCoreStatefulReadinessRequest; pub use self::solana_stateful::SolanaCoreStatefulReadinessStatus; /// Inspects state required before simulating one native Solana operation. pub use self::solana_stateful::inspect_solana_core_stateful_readiness; +/// One machine-readable classic SPL Token stateful check. +pub use self::solana_token_stateful::SplTokenStatefulCheck; +/// One contextual classic SPL Token stateful fact. +pub use self::solana_token_stateful::SplTokenStatefulFact; +/// Complete classic SPL Token stateful readiness report. +pub use self::solana_token_stateful::SplTokenStatefulReadinessReport; +/// Complete request for one classic SPL Token stateful readiness inspection. +pub use self::solana_token_stateful::SplTokenStatefulReadinessRequest; +/// Classic SPL Token stateful readiness outcome. +pub use self::solana_token_stateful::SplTokenStatefulReadinessStatus; +/// Inspects Localnet or Devnet classic SPL Token state required before simulation. +pub use self::solana_token_stateful::inspect_spl_token_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_token_stateful.rs b/kb-pipeline/src/solana_token_stateful.rs new file mode 100644 index 0000000..c7bf674 --- /dev/null +++ b/kb-pipeline/src/solana_token_stateful.rs @@ -0,0 +1,1696 @@ +// file: kb-pipeline/src/solana_token_stateful.rs +// version: 1 + +//! Stateful Localnet and Devnet readiness checks for classic SPL Token operations. + +const MINT_LEN: usize = 82; +const TOKEN_ACCOUNT_LEN: usize = 165; +const MULTISIG_LEN: usize = 355; +const MAX_STATE_ACCOUNTS: usize = 128; + +/// Stateful readiness outcome for one classic SPL Token operation. +#[derive(Clone, Copy, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)] +#[serde(rename_all = "snake_case")] +pub enum SplTokenStatefulReadinessStatus { + /// Every stateful preflight check passed. + Ready, + /// At least one stateful preflight check failed. + Blocked, +} + +/// One machine-readable classic SPL Token stateful check. +#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)] +pub struct SplTokenStatefulCheck { + /// 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 classic SPL Token state inspection. +#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)] +pub struct SplTokenStatefulFact { + /// Stable fact key. + pub key: std::string::String, + /// Exact string representation of the measured value. + pub value: std::string::String, +} + +/// Complete request for one classic SPL Token stateful readiness inspection. +#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)] +pub struct SplTokenStatefulReadinessRequest { + /// HTTP endpoint role used for all state reads. + pub query_role: std::string::String, + /// Expected Localnet or Devnet cluster. + pub cluster: kb_lib::ExApiExecutionCluster, + /// Typed operation whose external state must be inspected. + pub operation: kb_lib::ExSplClassicTokenOperation, +} + +/// Complete classic SPL Token stateful readiness report. +#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)] +pub struct SplTokenStatefulReadinessReport { + /// Expected cluster. + pub cluster: kb_lib::ExApiExecutionCluster, + /// Stable top-level operation code. + pub operation_code: std::string::String, + /// Aggregate readiness status. + pub status: crate::SplTokenStatefulReadinessStatus, + /// 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 MintState { + mint_authority: std::option::Option, + supply: u64, + decimals: u8, + initialized: bool, + freeze_authority: std::option::Option, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +struct TokenAccountState { + mint: std::string::String, + owner: std::string::String, + amount: u64, + delegate: std::option::Option, + state: u8, + native_reserve: std::option::Option, + delegated_amount: u64, + close_authority: std::option::Option, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +struct MultisigState { + threshold: u8, + member_count: u8, + initialized: bool, + members: std::vec::Vec, +} + +type AccountCache = std::collections::BTreeMap< + std::string::String, + std::option::Option, +>; + +/// Inspects Localnet or Devnet Token state required before simulation. +pub async fn inspect_spl_token_stateful_readiness( + pool: &kb_onchain_transport::HttpEndpointPool, + request: &crate::SplTokenStatefulReadinessRequest, +) -> kb_core::Result { + if request.query_role.trim().is_empty() { + return std::result::Result::Err(kb_core::Error::config( + "SPL Token stateful 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( + "spl_token_stateful_cluster_unsupported", + "SPL Token stateful 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 addresses = match operation_addresses(&request.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(MULTISIG_LEN) + { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + let mut cache = AccountCache::new(); + let mut highest_slot: std::option::Option = std::option::Option::None; + for address in addresses { + let result = pool + .get_account_info_for_role(request.query_role.as_str(), &address, &config) + .await; + let result = match result { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + highest_slot = std::option::Option::Some(match highest_slot { + std::option::Option::Some(slot) => slot.max(result.context.slot), + std::option::Option::None => result.context.slot, + }); + cache.insert(address.0, result.account); + } + let mut report = crate::SplTokenStatefulReadinessReport { + cluster: request.cluster, + operation_code: request.operation.operation_code().to_string(), + status: crate::SplTokenStatefulReadinessStatus::Ready, + context_slot: highest_slot, + checks: std::vec::Vec::new(), + facts: std::vec![crate::SplTokenStatefulFact { + key: "genesis_hash".to_string(), + value: genesis.genesis_hash, + }], + }; + let initialization_accounts = initialization_accounts(&request.operation); + for (address, data_length) in initialization_accounts { + let rent = pool + .get_minimum_balance_for_rent_exemption_for_role( + request.query_role.as_str(), + data_length as u64, + &kb_onchain_transport::GetMinimumBalanceForRentExemptionConfig::confirmed(), + ) + .await; + let rent = match rent { + std::result::Result::Ok(value) => value.minimum_balance_lamports, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + push_fact(&mut report, "rent_exempt_minimum_lamports", format!("{}:{rent}", address.0)); + let lamports = cache + .get(address.0.as_str()) + .and_then(|value| return value.as_ref()) + .map(|value| return value.lamports); + push_check( + &mut report, + "rent_exempt", + lamports.is_some_and(|value| return value >= rent), + format!("initialization account {} must be rent exempt", address.0), + ); + } + let withdraw_accounts = withdraw_excess_accounts(&request.operation); + for address in withdraw_accounts { + let account = + cache.get(address.0.as_str()).and_then(|value| return value.as_ref()).cloned(); + if let std::option::Option::Some(account) = account { + let rent = pool + .get_minimum_balance_for_rent_exemption_for_role( + request.query_role.as_str(), + account.space, + &kb_onchain_transport::GetMinimumBalanceForRentExemptionConfig::confirmed(), + ) + .await; + let rent = match rent { + std::result::Result::Ok(value) => value.minimum_balance_lamports, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + push_fact( + &mut report, + "withdraw_rent_exempt_minimum_lamports", + format!("{}:{rent}", address.0), + ); + push_check( + &mut report, + "excess_lamports_present", + account.lamports > rent, + format!("source account {} must contain lamports above rent exemption", address.0), + ); + } + } + match &request.operation { + kb_lib::ExSplClassicTokenOperation::Instruction { value } => { + inspect_single(&mut report, &cache, value); + }, + kb_lib::ExSplClassicTokenOperation::Batch { instructions } => { + for (index, value) in instructions.iter().enumerate() { + push_fact( + &mut report, + "batch_child", + format!("{index}:{}", value.operation_code()), + ); + inspect_single(&mut report, &cache, value); + } + }, + } + report.status = if report.checks.iter().all(|check| return check.passed) { + crate::SplTokenStatefulReadinessStatus::Ready + } else { + crate::SplTokenStatefulReadinessStatus::Blocked + }; + return std::result::Result::Ok(report); +} + +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( + "spl_token_stateful_devnet_genesis_mismatch", + "SPL Token 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( + "spl_token_stateful_localnet_public_cluster", + "Localnet readiness refuses endpoints classified as a public cluster", + )) + } + }, + kb_lib::ExApiExecutionCluster::Testnet | kb_lib::ExApiExecutionCluster::Mainnet => { + std::result::Result::Err(kb_core::Error::new( + "spl_token_stateful_cluster_unsupported", + "SPL Token stateful readiness is restricted to Localnet and Devnet", + )) + }, + }; +} + +fn operation_addresses( + operation: &kb_lib::ExSplClassicTokenOperation, +) -> kb_core::Result> { + let mut addresses = std::vec::Vec::new(); + match operation { + kb_lib::ExSplClassicTokenOperation::Instruction { value } => { + collect_single_addresses(&mut addresses, value); + }, + kb_lib::ExSplClassicTokenOperation::Batch { instructions } => { + for value in instructions { + collect_single_addresses(&mut addresses, value); + } + }, + } + if addresses.len() > MAX_STATE_ACCOUNTS { + return std::result::Result::Err(kb_core::Error::new( + "spl_token_stateful_account_limit", + format!("SPL Token stateful readiness exceeds {MAX_STATE_ACCOUNTS} unique accounts"), + )); + } + return std::result::Result::Ok(addresses); +} + +fn initialization_accounts( + operation: &kb_lib::ExSplClassicTokenOperation, +) -> std::vec::Vec<(kb_lib::MdPubkey, usize)> { + let mut values = std::vec::Vec::new(); + let instructions: std::vec::Vec<&kb_lib::ExSplClassicTokenSingleOperation> = match operation { + kb_lib::ExSplClassicTokenOperation::Instruction { value } => { + std::vec![value] + }, + kb_lib::ExSplClassicTokenOperation::Batch { instructions } => instructions.iter().collect(), + }; + for operation in instructions { + let candidate = match operation { + kb_lib::ExSplClassicTokenSingleOperation::InitializeMint { mint, .. } => { + std::option::Option::Some((mint.clone(), MINT_LEN)) + }, + kb_lib::ExSplClassicTokenSingleOperation::InitializeAccount { account, .. } => { + std::option::Option::Some((account.clone(), TOKEN_ACCOUNT_LEN)) + }, + kb_lib::ExSplClassicTokenSingleOperation::InitializeMultisig { multisig, .. } => { + std::option::Option::Some((multisig.clone(), MULTISIG_LEN)) + }, + _ => std::option::Option::None, + }; + if let std::option::Option::Some(candidate) = candidate { + if !values + .iter() + .any(|(address, length)| return address == &candidate.0 && *length == candidate.1) + { + values.push(candidate); + } + } + } + return values; +} + +fn withdraw_excess_accounts( + operation: &kb_lib::ExSplClassicTokenOperation, +) -> std::vec::Vec { + let instructions: std::vec::Vec<&kb_lib::ExSplClassicTokenSingleOperation> = match operation { + kb_lib::ExSplClassicTokenOperation::Instruction { value } => { + std::vec![value] + }, + kb_lib::ExSplClassicTokenOperation::Batch { instructions } => instructions.iter().collect(), + }; + let mut values = std::vec::Vec::new(); + for operation in instructions { + if let kb_lib::ExSplClassicTokenSingleOperation::WithdrawExcessLamports { + account, .. + } = operation + { + if !values.iter().any(|value| return value == account) { + values.push(account.clone()); + } + } + } + return values; +} + +fn add_address(addresses: &mut std::vec::Vec, value: &kb_lib::MdPubkey) { + if !addresses.iter().any(|existing| return existing == value) { + addresses.push(value.clone()); + } +} + +fn add_authority( + addresses: &mut std::vec::Vec, + authority: &kb_lib::ExSplClassicTokenAuthority, +) { + if !authority.multisig_signers.is_empty() { + add_address(addresses, &authority.authority); + } +} + +fn collect_single_addresses( + addresses: &mut std::vec::Vec, + operation: &kb_lib::ExSplClassicTokenSingleOperation, +) { + match operation { + kb_lib::ExSplClassicTokenSingleOperation::InitializeMint { mint, .. } + | kb_lib::ExSplClassicTokenSingleOperation::GetAccountDataSize { mint } + | kb_lib::ExSplClassicTokenSingleOperation::AmountToUiAmount { mint, .. } + | kb_lib::ExSplClassicTokenSingleOperation::UiAmountToAmount { mint, .. } => { + add_address(addresses, mint); + }, + kb_lib::ExSplClassicTokenSingleOperation::InitializeAccount { account, mint, .. } => { + add_address(addresses, account); + add_address(addresses, mint); + }, + kb_lib::ExSplClassicTokenSingleOperation::InitializeMultisig { multisig, .. } => { + add_address(addresses, multisig); + }, + kb_lib::ExSplClassicTokenSingleOperation::Transfer { + source, + destination, + authority, + .. + } => { + add_address(addresses, source); + add_address(addresses, destination); + add_authority(addresses, authority); + }, + kb_lib::ExSplClassicTokenSingleOperation::Approve { source, authority, .. } + | kb_lib::ExSplClassicTokenSingleOperation::Revoke { source, authority } => { + add_address(addresses, source); + add_authority(addresses, authority); + }, + kb_lib::ExSplClassicTokenSingleOperation::SetAuthority { + owned, current_authority, .. + } => { + add_address(addresses, owned); + add_authority(addresses, current_authority); + }, + kb_lib::ExSplClassicTokenSingleOperation::MintTo { + mint, destination, authority, .. + } + | kb_lib::ExSplClassicTokenSingleOperation::MintToChecked { + mint, + destination, + authority, + .. + } => { + add_address(addresses, mint); + add_address(addresses, destination); + add_authority(addresses, authority); + }, + kb_lib::ExSplClassicTokenSingleOperation::Burn { source, mint, authority, .. } + | kb_lib::ExSplClassicTokenSingleOperation::BurnChecked { + source, mint, authority, .. + } => { + add_address(addresses, source); + add_address(addresses, mint); + add_authority(addresses, authority); + }, + kb_lib::ExSplClassicTokenSingleOperation::CloseAccount { account, authority, .. } + | kb_lib::ExSplClassicTokenSingleOperation::UnwrapLamports { account, authority, .. } + | kb_lib::ExSplClassicTokenSingleOperation::WithdrawExcessLamports { + account, + authority, + .. + } => { + add_address(addresses, account); + add_authority(addresses, authority); + }, + kb_lib::ExSplClassicTokenSingleOperation::FreezeAccount { account, mint, authority } + | kb_lib::ExSplClassicTokenSingleOperation::ThawAccount { account, mint, authority } => { + add_address(addresses, account); + add_address(addresses, mint); + add_authority(addresses, authority); + }, + kb_lib::ExSplClassicTokenSingleOperation::TransferChecked { + source, + mint, + destination, + authority, + .. + } + | kb_lib::ExSplClassicTokenSingleOperation::ApproveChecked { + source, + mint, + delegate: destination, + authority, + .. + } => { + add_address(addresses, source); + add_address(addresses, mint); + if matches!(operation, kb_lib::ExSplClassicTokenSingleOperation::TransferChecked { .. }) + { + add_address(addresses, destination); + } + add_authority(addresses, authority); + }, + kb_lib::ExSplClassicTokenSingleOperation::SyncNative { account, .. } + | kb_lib::ExSplClassicTokenSingleOperation::InitializeImmutableOwner { account } => { + add_address(addresses, account); + }, + } +} + +fn inspect_single( + report: &mut crate::SplTokenStatefulReadinessReport, + cache: &AccountCache, + operation: &kb_lib::ExSplClassicTokenSingleOperation, +) { + match operation { + kb_lib::ExSplClassicTokenSingleOperation::InitializeMint { mint, .. } => { + let state = mint_state(report, cache, mint); + if let std::option::Option::Some(state) = state { + push_check( + report, + "mint_uninitialized", + !state.initialized, + "mint must be uninitialized before InitializeMint2", + ); + } + }, + kb_lib::ExSplClassicTokenSingleOperation::InitializeAccount { account, mint, .. } => { + let state = token_state(report, cache, account); + if let std::option::Option::Some(state) = state { + push_check( + report, + "account_uninitialized", + state.state == 0, + "token account must be uninitialized before InitializeAccount3", + ); + } + let mint_state = mint_state(report, cache, mint); + check_mint_initialized(report, mint_state.as_ref()); + }, + kb_lib::ExSplClassicTokenSingleOperation::InitializeMultisig { multisig, .. } => { + let state = multisig_state(report, cache, multisig); + if let std::option::Option::Some(state) = state { + push_check( + report, + "multisig_uninitialized", + !state.initialized, + "multisig must be uninitialized before InitializeMultisig2", + ); + } + }, + kb_lib::ExSplClassicTokenSingleOperation::Transfer { + source, + destination, + authority, + amount, + } => { + inspect_transfer( + report, + cache, + source, + destination, + std::option::Option::None, + authority, + amount, + std::option::Option::None, + ); + }, + kb_lib::ExSplClassicTokenSingleOperation::TransferChecked { + source, + mint, + destination, + authority, + amount, + decimals, + } => { + inspect_transfer( + report, + cache, + source, + destination, + std::option::Option::Some(mint), + authority, + amount, + std::option::Option::Some(*decimals), + ); + }, + kb_lib::ExSplClassicTokenSingleOperation::Approve { source, authority, amount, .. } => { + inspect_owner_amount(report, cache, source, authority, amount, false); + }, + kb_lib::ExSplClassicTokenSingleOperation::ApproveChecked { + source, + mint, + authority, + amount, + decimals, + .. + } => { + inspect_owner_amount(report, cache, source, authority, amount, false); + inspect_checked_mint(report, cache, source, mint, *decimals); + }, + kb_lib::ExSplClassicTokenSingleOperation::Revoke { source, authority } => { + let state = token_state(report, cache, source); + if let std::option::Option::Some(state) = state { + check_authority( + report, + cache, + authority, + std::option::Option::Some(state.owner.as_str()), + ); + push_check( + report, + "delegate_present", + state.delegate.is_some(), + "revoke requires an existing delegate", + ); + } + }, + kb_lib::ExSplClassicTokenSingleOperation::MintTo { + mint, + destination, + authority, + amount, + } + | kb_lib::ExSplClassicTokenSingleOperation::MintToChecked { + mint, + destination, + authority, + amount, + .. + } => { + let mint_value = mint_state(report, cache, mint); + let destination_value = token_state(report, cache, destination); + check_mint_initialized(report, mint_value.as_ref()); + if let ( + std::option::Option::Some(mint_value), + std::option::Option::Some(destination_value), + ) = (mint_value, destination_value) + { + push_check( + report, + "destination_mint_matches", + destination_value.mint == mint.0, + "destination token account must use the mint", + ); + check_authority(report, cache, authority, mint_value.mint_authority.as_deref()); + let parsed = amount_value(report, amount); + if let std::option::Option::Some(parsed) = parsed { + push_check( + report, + "mint_supply_no_overflow", + mint_value.supply.checked_add(parsed).is_some(), + "mint supply must not overflow u64", + ); + } + } + if let kb_lib::ExSplClassicTokenSingleOperation::MintToChecked { decimals, .. } = + operation + { + if let std::option::Option::Some(mint_value) = mint_state_quiet(cache, mint) { + push_check( + report, + "mint_decimals_match", + mint_value.decimals == *decimals, + "checked decimals must match mint state", + ); + } + } + }, + kb_lib::ExSplClassicTokenSingleOperation::Burn { source, mint, authority, amount } + | kb_lib::ExSplClassicTokenSingleOperation::BurnChecked { + source, + mint, + authority, + amount, + .. + } => { + inspect_owner_amount(report, cache, source, authority, amount, true); + inspect_checked_relation(report, cache, source, mint); + if let (std::option::Option::Some(mint_state), std::option::Option::Some(parsed)) = + (mint_state_quiet(cache, mint), amount.0.parse::().ok()) + { + push_check( + report, + "mint_supply_sufficient", + mint_state.supply >= parsed, + "mint supply must cover burned amount", + ); + } + if let kb_lib::ExSplClassicTokenSingleOperation::BurnChecked { decimals, .. } = + operation + { + inspect_checked_mint(report, cache, source, mint, *decimals); + } + }, + kb_lib::ExSplClassicTokenSingleOperation::CloseAccount { account, authority, .. } => { + let state = token_state(report, cache, account); + if let std::option::Option::Some(state) = state { + let expected = match state.close_authority.as_deref() { + std::option::Option::Some(value) => value, + std::option::Option::None => state.owner.as_str(), + }; + check_authority(report, cache, authority, std::option::Option::Some(expected)); + push_check( + report, + "close_balance_allowed", + state.native_reserve.is_some() || state.amount == 0, + "non-native token account must have zero token balance before close", + ); + } + }, + kb_lib::ExSplClassicTokenSingleOperation::FreezeAccount { account, mint, authority } => { + inspect_freeze_thaw(report, cache, account, mint, authority, 1); + }, + kb_lib::ExSplClassicTokenSingleOperation::ThawAccount { account, mint, authority } => { + inspect_freeze_thaw(report, cache, account, mint, authority, 2); + }, + kb_lib::ExSplClassicTokenSingleOperation::SyncNative { account, .. } => { + let state = token_state(report, cache, account); + if let std::option::Option::Some(state) = state { + push_check( + report, + "native_account", + state.native_reserve.is_some(), + "SyncNative requires a wrapped-SOL token account", + ); + } + }, + kb_lib::ExSplClassicTokenSingleOperation::GetAccountDataSize { mint } + | kb_lib::ExSplClassicTokenSingleOperation::AmountToUiAmount { mint, .. } + | kb_lib::ExSplClassicTokenSingleOperation::UiAmountToAmount { mint, .. } => { + let state = mint_state(report, cache, mint); + check_mint_initialized(report, state.as_ref()); + }, + kb_lib::ExSplClassicTokenSingleOperation::InitializeImmutableOwner { account } => { + let state = token_state(report, cache, account); + if let std::option::Option::Some(state) = state { + push_check( + report, + "account_uninitialized", + state.state == 0, + "classic immutable-owner compatibility no-op precedes account initialization", + ); + } + }, + kb_lib::ExSplClassicTokenSingleOperation::UnwrapLamports { + account, + authority, + amount_lamports, + .. + } => { + let state = token_state(report, cache, account); + if let std::option::Option::Some(state) = state { + push_check( + report, + "native_account", + state.native_reserve.is_some(), + "UnwrapLamports requires a wrapped-SOL token account", + ); + let expected = match state.close_authority.as_deref() { + std::option::Option::Some(value) => value, + std::option::Option::None => state.owner.as_str(), + }; + check_authority(report, cache, authority, std::option::Option::Some(expected)); + if let std::option::Option::Some(amount) = amount_lamports { + if let std::option::Option::Some(parsed) = amount_value(report, amount) { + push_check( + report, + "unwrap_balance_sufficient", + state.amount >= parsed, + "wrapped-SOL balance must cover requested unwrap amount", + ); + } + } + } + }, + kb_lib::ExSplClassicTokenSingleOperation::WithdrawExcessLamports { + account, + authority, + .. + } => { + let value = account_value(report, cache, account, std::option::Option::None); + if let std::option::Option::Some(value) = value { + match value.data.len() { + MINT_LEN => { + if let std::result::Result::Ok(state) = parse_mint(value.data.as_slice()) { + check_authority( + report, + cache, + authority, + state.mint_authority.as_deref(), + ); + } + }, + TOKEN_ACCOUNT_LEN => { + if let std::result::Result::Ok(state) = + parse_token_account(value.data.as_slice()) + { + check_authority( + report, + cache, + authority, + std::option::Option::Some(state.owner.as_str()), + ); + } + }, + MULTISIG_LEN => { + check_authority( + report, + cache, + authority, + std::option::Option::Some(account.0.as_str()), + ); + }, + _ => {}, + } + } + }, + kb_lib::ExSplClassicTokenSingleOperation::SetAuthority { + owned, + authority_type, + new_authority, + current_authority, + } => { + inspect_set_authority( + report, + cache, + owned, + *authority_type, + new_authority, + current_authority, + ); + }, + } +} + +fn inspect_transfer( + report: &mut crate::SplTokenStatefulReadinessReport, + cache: &AccountCache, + source: &kb_lib::MdPubkey, + destination: &kb_lib::MdPubkey, + explicit_mint: std::option::Option<&kb_lib::MdPubkey>, + authority: &kb_lib::ExSplClassicTokenAuthority, + amount: &kb_lib::ExSplClassicTokenAmount, + decimals: std::option::Option, +) { + let source_state = token_state(report, cache, source); + let destination_state = token_state(report, cache, destination); + if let (std::option::Option::Some(source_state), std::option::Option::Some(destination_state)) = + (source_state, destination_state) + { + push_check( + report, + "transfer_mint_matches", + source_state.mint == destination_state.mint, + "source and destination token accounts must share a mint", + ); + push_check( + report, + "source_not_frozen", + source_state.state == 1, + "source token account must be initialized and unfrozen", + ); + push_check( + report, + "destination_not_frozen", + destination_state.state == 1, + "destination token account must be initialized and unfrozen", + ); + if let std::option::Option::Some(parsed) = amount_value(report, amount) { + push_check( + report, + "source_balance_sufficient", + source_state.amount >= parsed, + "source token balance must cover transfer amount", + ); + if source_state.delegate.as_deref() + == std::option::Option::Some(authority.authority.0.as_str()) + { + push_check( + report, + "delegate_allowance_sufficient", + source_state.delegated_amount >= parsed, + "delegate allowance must cover transfer amount", + ); + } + } + check_token_authority(report, cache, authority, &source_state); + if let std::option::Option::Some(mint) = explicit_mint { + push_check( + report, + "explicit_mint_matches", + source_state.mint == mint.0, + "checked mint must match source account state", + ); + if let std::option::Option::Some(decimals) = decimals { + if let std::option::Option::Some(mint_state) = mint_state(report, cache, mint) { + push_check( + report, + "mint_decimals_match", + mint_state.decimals == decimals, + "checked decimals must match mint state", + ); + } + } + } + } +} + +fn inspect_owner_amount( + report: &mut crate::SplTokenStatefulReadinessReport, + cache: &AccountCache, + source: &kb_lib::MdPubkey, + authority: &kb_lib::ExSplClassicTokenAuthority, + amount: &kb_lib::ExSplClassicTokenAmount, + require_balance: bool, +) { + let state = token_state(report, cache, source); + if let std::option::Option::Some(state) = state { + push_check( + report, + "source_not_frozen", + state.state == 1, + "source token account must be initialized and unfrozen", + ); + check_token_authority(report, cache, authority, &state); + if let std::option::Option::Some(parsed) = amount_value(report, amount) { + if require_balance { + push_check( + report, + "source_balance_sufficient", + state.amount >= parsed, + "source token balance must cover requested amount", + ); + } + if state.delegate.as_deref() + == std::option::Option::Some(authority.authority.0.as_str()) + { + push_check( + report, + "delegate_allowance_sufficient", + state.delegated_amount >= parsed, + "delegate allowance must cover requested amount", + ); + } + } + } +} + +fn inspect_checked_relation( + report: &mut crate::SplTokenStatefulReadinessReport, + cache: &AccountCache, + source: &kb_lib::MdPubkey, + mint: &kb_lib::MdPubkey, +) { + if let std::option::Option::Some(state) = token_state_quiet(cache, source) { + push_check( + report, + "source_mint_matches", + state.mint == mint.0, + "source token account must use the explicit mint", + ); + } + let mint_state = mint_state(report, cache, mint); + check_mint_initialized(report, mint_state.as_ref()); +} + +fn inspect_checked_mint( + report: &mut crate::SplTokenStatefulReadinessReport, + cache: &AccountCache, + source: &kb_lib::MdPubkey, + mint: &kb_lib::MdPubkey, + decimals: u8, +) { + inspect_checked_relation(report, cache, source, mint); + if let std::option::Option::Some(state) = mint_state_quiet(cache, mint) { + push_check( + report, + "mint_decimals_match", + state.decimals == decimals, + "checked decimals must match mint state", + ); + } +} + +fn inspect_freeze_thaw( + report: &mut crate::SplTokenStatefulReadinessReport, + cache: &AccountCache, + account: &kb_lib::MdPubkey, + mint: &kb_lib::MdPubkey, + authority: &kb_lib::ExSplClassicTokenAuthority, + required_state: u8, +) { + let account_state = token_state(report, cache, account); + let mint_state = mint_state(report, cache, mint); + if let std::option::Option::Some(account_state) = account_state { + push_check( + report, + "account_mint_matches", + account_state.mint == mint.0, + "token account must use the supplied mint", + ); + push_check( + report, + "account_state_matches", + account_state.state == required_state, + "token account state must match freeze or thaw precondition", + ); + } + if let std::option::Option::Some(mint_state) = mint_state { + check_authority(report, cache, authority, mint_state.freeze_authority.as_deref()); + } +} + +fn inspect_set_authority( + report: &mut crate::SplTokenStatefulReadinessReport, + cache: &AccountCache, + owned: &kb_lib::MdPubkey, + authority_type: kb_lib::ExSplClassicTokenAuthorityType, + new_authority: &std::option::Option, + current: &kb_lib::ExSplClassicTokenAuthority, +) { + let value = account_value(report, cache, owned, std::option::Option::None); + if let std::option::Option::Some(value) = value { + match authority_type { + kb_lib::ExSplClassicTokenAuthorityType::MintTokens + | kb_lib::ExSplClassicTokenAuthorityType::FreezeAccount => { + if let std::result::Result::Ok(state) = parse_mint(value.data.as_slice()) { + let expected = + if authority_type == kb_lib::ExSplClassicTokenAuthorityType::MintTokens { + state.mint_authority.as_deref() + } else { + state.freeze_authority.as_deref() + }; + check_authority(report, cache, current, expected); + } else { + push_check( + report, + "authority_domain_layout", + false, + "mint authority domains require a Mint account", + ); + } + }, + kb_lib::ExSplClassicTokenAuthorityType::AccountOwner + | kb_lib::ExSplClassicTokenAuthorityType::CloseAccount => { + if let std::result::Result::Ok(state) = parse_token_account(value.data.as_slice()) { + let close_authority = match state.close_authority.as_deref() { + std::option::Option::Some(value) => value, + std::option::Option::None => state.owner.as_str(), + }; + let expected = + if authority_type == kb_lib::ExSplClassicTokenAuthorityType::AccountOwner { + std::option::Option::Some(state.owner.as_str()) + } else { + std::option::Option::Some(close_authority) + }; + check_authority(report, cache, current, expected); + if authority_type == kb_lib::ExSplClassicTokenAuthorityType::AccountOwner { + push_check( + report, + "account_owner_not_revoked", + new_authority.is_some(), + "token-account owner cannot be revoked", + ); + } + } else { + push_check( + report, + "authority_domain_layout", + false, + "account authority domains require a token Account", + ); + } + }, + } + } +} + +fn check_token_authority( + report: &mut crate::SplTokenStatefulReadinessReport, + cache: &AccountCache, + authority: &kb_lib::ExSplClassicTokenAuthority, + account: &TokenAccountState, +) { + let supplied = authority.authority.0.as_str(); + let expected = if account.delegate.as_deref() == std::option::Option::Some(supplied) { + account.delegate.as_deref() + } else { + std::option::Option::Some(account.owner.as_str()) + }; + check_authority(report, cache, authority, expected); +} + +fn check_authority( + report: &mut crate::SplTokenStatefulReadinessReport, + cache: &AccountCache, + authority: &kb_lib::ExSplClassicTokenAuthority, + expected: std::option::Option<&str>, +) { + push_check( + report, + "authority_present", + expected.is_some(), + "required authority must not be revoked", + ); + if let std::option::Option::Some(expected) = expected { + push_check( + report, + "authority_matches", + authority.authority.0 == expected, + "declared authority must match on-chain state", + ); + } + if authority.multisig_signers.is_empty() { + return; + } + let state = multisig_state(report, cache, &authority.authority); + if let std::option::Option::Some(state) = state { + push_check( + report, + "multisig_initialized", + state.initialized, + "multisig authority must be initialized", + ); + push_check( + report, + "multisig_shape", + state.threshold >= 1 + && state.threshold <= state.member_count + && state.member_count <= 11, + "multisig M/N must satisfy 1 <= M <= N <= 11", + ); + let mut unique = std::collections::BTreeSet::new(); + for signer in &authority.multisig_signers { + if state.members.iter().any(|member| return member == &signer.0) { + unique.insert(signer.0.clone()); + } + } + push_check( + report, + "multisig_threshold", + unique.len() >= usize::from(state.threshold), + "distinct supplied member signers must meet the multisig threshold", + ); + } +} + +fn check_mint_initialized( + report: &mut crate::SplTokenStatefulReadinessReport, + state: std::option::Option<&MintState>, +) { + if let std::option::Option::Some(state) = state { + push_check(report, "mint_initialized", state.initialized, "mint must be initialized"); + } +} + +fn account_value<'a>( + report: &mut crate::SplTokenStatefulReadinessReport, + cache: &'a AccountCache, + address: &kb_lib::MdPubkey, + expected_len: std::option::Option, +) -> std::option::Option<&'a kb_onchain_transport::AccountInfoValue> { + let value = cache.get(address.0.as_str()).and_then(|value| return value.as_ref()); + push_check( + report, + "account_exists", + value.is_some(), + format!("state account {} must exist", address.0), + ); + if let std::option::Option::Some(value) = value { + push_check( + report, + "classic_token_owner", + value.owner.0 == kb_program_ids::SPL_TOKEN_PROGRAM_ID, + format!("state account {} must be owned by classic SPL Token", address.0), + ); + if let std::option::Option::Some(expected_len) = expected_len { + push_check( + report, + "account_layout", + value.data.len() == expected_len && value.space == expected_len as u64, + format!( + "state account {} must use the exact {expected_len}-byte layout", + address.0 + ), + ); + } + } + return value; +} + +fn mint_state( + report: &mut crate::SplTokenStatefulReadinessReport, + cache: &AccountCache, + address: &kb_lib::MdPubkey, +) -> std::option::Option { + let value = account_value(report, cache, address, std::option::Option::Some(MINT_LEN)); + return match value { + std::option::Option::Some(value) => match parse_mint(value.data.as_slice()) { + std::result::Result::Ok(state) => { + push_fact(report, "mint_decimals", state.decimals); + push_fact(report, "mint_supply", state.supply); + std::option::Option::Some(state) + }, + std::result::Result::Err(message) => { + push_check(report, "mint_layout_valid", false, message); + std::option::Option::None + }, + }, + std::option::Option::None => std::option::Option::None, + }; +} + +fn mint_state_quiet( + cache: &AccountCache, + address: &kb_lib::MdPubkey, +) -> std::option::Option { + return cache + .get(address.0.as_str()) + .and_then(|value| return value.as_ref()) + .and_then(|value| return parse_mint(value.data.as_slice()).ok()); +} + +fn token_state( + report: &mut crate::SplTokenStatefulReadinessReport, + cache: &AccountCache, + address: &kb_lib::MdPubkey, +) -> std::option::Option { + let value = account_value(report, cache, address, std::option::Option::Some(TOKEN_ACCOUNT_LEN)); + return match value { + std::option::Option::Some(value) => match parse_token_account(value.data.as_slice()) { + std::result::Result::Ok(state) => { + push_fact(report, "token_amount", state.amount); + push_fact(report, "token_mint", state.mint.as_str()); + std::option::Option::Some(state) + }, + std::result::Result::Err(message) => { + push_check(report, "token_account_layout_valid", false, message); + std::option::Option::None + }, + }, + std::option::Option::None => std::option::Option::None, + }; +} + +fn token_state_quiet( + cache: &AccountCache, + address: &kb_lib::MdPubkey, +) -> std::option::Option { + return cache.get(address.0.as_str()).and_then(|value| return value.as_ref()).and_then( + |value| { + return parse_token_account(value.data.as_slice()).ok(); + }, + ); +} + +fn multisig_state( + report: &mut crate::SplTokenStatefulReadinessReport, + cache: &AccountCache, + address: &kb_lib::MdPubkey, +) -> std::option::Option { + let value = account_value(report, cache, address, std::option::Option::Some(MULTISIG_LEN)); + return match value { + std::option::Option::Some(value) => match parse_multisig(value.data.as_slice()) { + std::result::Result::Ok(state) => std::option::Option::Some(state), + std::result::Result::Err(message) => { + push_check(report, "multisig_layout_valid", false, message); + std::option::Option::None + }, + }, + std::option::Option::None => std::option::Option::None, + }; +} + +fn amount_value( + report: &mut crate::SplTokenStatefulReadinessReport, + amount: &kb_lib::ExSplClassicTokenAmount, +) -> std::option::Option { + return match amount.0.parse::() { + std::result::Result::Ok(value) => std::option::Option::Some(value), + std::result::Result::Err(_) => { + push_check(report, "amount_valid", false, "raw token amount must be a canonical u64"); + std::option::Option::None + }, + }; +} + +fn push_check( + report: &mut crate::SplTokenStatefulReadinessReport, + code: &str, + passed: bool, + message: impl std::convert::Into, +) { + report.checks.push(crate::SplTokenStatefulCheck { + code: code.to_string(), + passed, + message: message.into(), + }); +} + +fn push_fact( + report: &mut crate::SplTokenStatefulReadinessReport, + key: &str, + value: impl std::fmt::Display, +) { + report.facts.push(crate::SplTokenStatefulFact { + key: key.to_string(), + value: value.to_string(), + }); +} + +fn parse_mint(bytes: &[u8]) -> std::result::Result { + if bytes.len() != MINT_LEN { + return std::result::Result::Err("Mint data must contain exactly 82 bytes"); + } + let mint_authority = match parse_coption_pubkey(&bytes[0..36]) { + std::result::Result::Ok(value) => value, + std::result::Result::Err(message) => return std::result::Result::Err(message), + }; + let supply = match read_u64(&bytes[36..44]) { + std::result::Result::Ok(value) => value, + std::result::Result::Err(message) => return std::result::Result::Err(message), + }; + let initialized = match bytes[45] { + 0 => false, + 1 => true, + _ => return std::result::Result::Err("Mint initialized flag must be 0 or 1"), + }; + let freeze_authority = match parse_coption_pubkey(&bytes[46..82]) { + std::result::Result::Ok(value) => value, + std::result::Result::Err(message) => return std::result::Result::Err(message), + }; + return std::result::Result::Ok(MintState { + mint_authority, + supply, + decimals: bytes[44], + initialized, + freeze_authority, + }); +} + +fn parse_token_account(bytes: &[u8]) -> std::result::Result { + if bytes.len() != TOKEN_ACCOUNT_LEN { + return std::result::Result::Err("Token Account data must contain exactly 165 bytes"); + } + if bytes[108] > 2 { + return std::result::Result::Err( + "Token Account state must be Uninitialized, Initialized or Frozen", + ); + } + let amount = match read_u64(&bytes[64..72]) { + std::result::Result::Ok(value) => value, + std::result::Result::Err(message) => return std::result::Result::Err(message), + }; + let delegate = match parse_coption_pubkey(&bytes[72..108]) { + std::result::Result::Ok(value) => value, + std::result::Result::Err(message) => return std::result::Result::Err(message), + }; + let native_reserve = match parse_coption_u64(&bytes[109..121]) { + std::result::Result::Ok(value) => value, + std::result::Result::Err(message) => return std::result::Result::Err(message), + }; + let delegated_amount = match read_u64(&bytes[121..129]) { + std::result::Result::Ok(value) => value, + std::result::Result::Err(message) => return std::result::Result::Err(message), + }; + let close_authority = match parse_coption_pubkey(&bytes[129..165]) { + std::result::Result::Ok(value) => value, + std::result::Result::Err(message) => return std::result::Result::Err(message), + }; + return std::result::Result::Ok(TokenAccountState { + mint: bs58::encode(&bytes[0..32]).into_string(), + owner: bs58::encode(&bytes[32..64]).into_string(), + amount, + delegate, + state: bytes[108], + native_reserve, + delegated_amount, + close_authority, + }); +} + +fn parse_multisig(bytes: &[u8]) -> std::result::Result { + if bytes.len() != MULTISIG_LEN { + return std::result::Result::Err("Multisig data must contain exactly 355 bytes"); + } + let initialized = match bytes[2] { + 0 => false, + 1 => true, + _ => return std::result::Result::Err("Multisig initialized flag must be 0 or 1"), + }; + let count = usize::from(bytes[1].min(11)); + let mut members = std::vec::Vec::new(); + for index in 0..count { + let start = 3 + index * 32; + members.push(bs58::encode(&bytes[start..start + 32]).into_string()); + } + return std::result::Result::Ok(MultisigState { + threshold: bytes[0], + member_count: bytes[1], + initialized, + members, + }); +} + +fn parse_coption_pubkey( + bytes: &[u8], +) -> std::result::Result, &'static str> { + if bytes.len() != 36 { + return std::result::Result::Err("COption must contain exactly 36 bytes"); + } + return match &bytes[0..4] { + [0, 0, 0, 0] => std::result::Result::Ok(std::option::Option::None), + [1, 0, 0, 0] => std::result::Result::Ok(std::option::Option::Some( + bs58::encode(&bytes[4..36]).into_string(), + )), + _ => std::result::Result::Err("COption tag must be 0 or 1"), + }; +} + +fn parse_coption_u64(bytes: &[u8]) -> std::result::Result, &'static str> { + if bytes.len() != 12 { + return std::result::Result::Err("COption must contain exactly 12 bytes"); + } + return match &bytes[0..4] { + [0, 0, 0, 0] => std::result::Result::Ok(std::option::Option::None), + [1, 0, 0, 0] => match read_u64(&bytes[4..12]) { + std::result::Result::Ok(value) => { + std::result::Result::Ok(std::option::Option::Some(value)) + }, + std::result::Result::Err(message) => std::result::Result::Err(message), + }, + _ => std::result::Result::Err("COption tag must be 0 or 1"), + }; +} + +fn read_u64(bytes: &[u8]) -> std::result::Result { + let array = match <[u8; 8]>::try_from(bytes) { + std::result::Result::Ok(value) => value, + std::result::Result::Err(_) => { + return std::result::Result::Err("u64 field must contain exactly 8 bytes"); + }, + }; + return std::result::Result::Ok(u64::from_le_bytes(array)); +} + +#[cfg(test)] +mod tests { + fn empty_report() -> crate::SplTokenStatefulReadinessReport { + return crate::SplTokenStatefulReadinessReport { + cluster: kb_lib::ExApiExecutionCluster::Devnet, + operation_code: kb_lib::EX_SPL_TOKEN_TRANSFER_CHECKED_OPERATION.to_string(), + status: crate::SplTokenStatefulReadinessStatus::Ready, + context_slot: std::option::Option::Some(1), + checks: std::vec::Vec::new(), + facts: std::vec::Vec::new(), + }; + } + + fn account_info(data: std::vec::Vec) -> kb_onchain_transport::AccountInfoValue { + return kb_onchain_transport::AccountInfoValue { + lamports: 10_000_000, + owner: kb_lib::MdProgramId(kb_program_ids::SPL_TOKEN_PROGRAM_ID.to_string()), + executable: false, + rent_epoch: 0, + space: data.len() as u64, + data, + }; + } + + 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"); + } + + #[test] + fn exact_mint_account_and_multisig_layouts_are_parsed() { + let mut mint = [0_u8; super::MINT_LEN]; + mint[0..4].copy_from_slice(&1_u32.to_le_bytes()); + mint[4..36].copy_from_slice(&[1_u8; 32]); + mint[36..44].copy_from_slice(&u64::MAX.to_le_bytes()); + mint[44] = 9; + mint[45] = 1; + let parsed_mint = match super::parse_mint(&mint) { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => panic!("Mint parse failed: {error}"), + }; + assert_eq!(parsed_mint.supply, u64::MAX); + assert_eq!(parsed_mint.decimals, 9); + assert!(parsed_mint.initialized); + + let mut account = [0_u8; super::TOKEN_ACCOUNT_LEN]; + account[0..32].copy_from_slice(&[2_u8; 32]); + account[32..64].copy_from_slice(&[3_u8; 32]); + account[64..72].copy_from_slice(&42_u64.to_le_bytes()); + account[108] = 1; + account[109..113].copy_from_slice(&1_u32.to_le_bytes()); + account[113..121].copy_from_slice(&2_039_280_u64.to_le_bytes()); + let parsed_account = match super::parse_token_account(&account) { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => panic!("Account parse failed: {error}"), + }; + assert_eq!(parsed_account.amount, 42); + assert_eq!(parsed_account.native_reserve, std::option::Option::Some(2_039_280)); + + let mut multisig = [0_u8; super::MULTISIG_LEN]; + multisig[0] = 2; + multisig[1] = 3; + multisig[2] = 1; + multisig[3..35].copy_from_slice(&[4_u8; 32]); + multisig[35..67].copy_from_slice(&[5_u8; 32]); + multisig[67..99].copy_from_slice(&[6_u8; 32]); + let parsed_multisig = match super::parse_multisig(&multisig) { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => panic!("Multisig parse failed: {error}"), + }; + assert_eq!(parsed_multisig.threshold, 2); + assert_eq!(parsed_multisig.members.len(), 3); + } + + #[test] + fn malformed_layouts_and_option_tags_fail_closed() { + assert!(super::parse_mint(&[0_u8; 81]).is_err()); + let mut account = [0_u8; super::TOKEN_ACCOUNT_LEN]; + account[108] = 3; + assert!(super::parse_token_account(&account).is_err()); + assert!(super::parse_coption_pubkey(&[2_u8; 36]).is_err()); + assert!(super::parse_coption_u64(&[2_u8; 12]).is_err()); + } + + #[test] + fn checked_transfer_state_checks_exact_mint_decimals_balance_and_owner() { + let mint_key = kb_lib::MdPubkey(bs58::encode([2_u8; 32]).into_string()); + let owner_key = kb_lib::MdPubkey(bs58::encode([3_u8; 32]).into_string()); + let source_key = kb_lib::MdPubkey(bs58::encode([7_u8; 32]).into_string()); + let destination_key = kb_lib::MdPubkey(bs58::encode([8_u8; 32]).into_string()); + let mut mint = [0_u8; super::MINT_LEN]; + mint[44] = 9; + mint[45] = 1; + let mut source = [0_u8; super::TOKEN_ACCOUNT_LEN]; + source[0..32].copy_from_slice(&[2_u8; 32]); + source[32..64].copy_from_slice(&[3_u8; 32]); + source[64..72].copy_from_slice(&42_u64.to_le_bytes()); + source[108] = 1; + let mut destination = [0_u8; super::TOKEN_ACCOUNT_LEN]; + destination[0..32].copy_from_slice(&[2_u8; 32]); + destination[32..64].copy_from_slice(&[9_u8; 32]); + destination[108] = 1; + let mut cache = super::AccountCache::new(); + cache.insert(mint_key.0.clone(), std::option::Option::Some(account_info(mint.to_vec()))); + cache + .insert(source_key.0.clone(), std::option::Option::Some(account_info(source.to_vec()))); + cache.insert( + destination_key.0.clone(), + std::option::Option::Some(account_info(destination.to_vec())), + ); + let mut report = empty_report(); + super::inspect_single( + &mut report, + &cache, + &kb_lib::ExSplClassicTokenSingleOperation::TransferChecked { + source: source_key, + mint: mint_key, + destination: destination_key, + authority: kb_lib::ExSplClassicTokenAuthority { + authority: owner_key, + multisig_signers: std::vec::Vec::new(), + }, + amount: kb_lib::ExSplClassicTokenAmount("42".to_string()), + decimals: 9, + }, + ); + assert!(report.checks.iter().all(|check| return check.passed)); + assert!(report.checks.iter().any(|check| return check.code == "mint_decimals_match")); + assert!( + report + .checks + .iter() + .any(|check| return check.code == "source_balance_sufficient") + ); + } + + #[test] + fn multisig_members_are_deduplicated_before_threshold_validation() { + let multisig_key = kb_lib::MdPubkey(bs58::encode([10_u8; 32]).into_string()); + let member_one = kb_lib::MdPubkey(bs58::encode([11_u8; 32]).into_string()); + let member_two = kb_lib::MdPubkey(bs58::encode([12_u8; 32]).into_string()); + let mut multisig = [0_u8; super::MULTISIG_LEN]; + multisig[0] = 2; + multisig[1] = 2; + multisig[2] = 1; + multisig[3..35].copy_from_slice(&[11_u8; 32]); + multisig[35..67].copy_from_slice(&[12_u8; 32]); + let mut cache = super::AccountCache::new(); + cache.insert( + multisig_key.0.clone(), + std::option::Option::Some(account_info(multisig.to_vec())), + ); + let mut report = empty_report(); + super::check_authority( + &mut report, + &cache, + &kb_lib::ExSplClassicTokenAuthority { + authority: multisig_key.clone(), + multisig_signers: std::vec![member_one.clone(), member_one, member_two,], + }, + std::option::Option::Some(multisig_key.0.as_str()), + ); + assert!(report.checks.iter().all(|check| return check.passed)); + assert!(report.checks.iter().any(|check| return check.code == "multisig_threshold")); + } + + #[test] + fn operation_account_collection_is_bounded_and_deduplicated() { + let key = kb_lib::MdPubkey(kb_program_ids::SYSTEM_PROGRAM_ID.to_string()); + let operation = kb_lib::ExSplClassicTokenOperation::Instruction { + value: kb_lib::ExSplClassicTokenSingleOperation::TransferChecked { + source: key.clone(), + mint: key.clone(), + destination: key.clone(), + authority: kb_lib::ExSplClassicTokenAuthority { + authority: key.clone(), + multisig_signers: std::vec::Vec::new(), + }, + amount: kb_lib::ExSplClassicTokenAmount("1".to_string()), + decimals: 9, + }, + }; + let addresses = match super::operation_addresses(&operation) { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => panic!("address collection failed: {error}"), + }; + assert_eq!(addresses, std::vec![key]); + } + + #[tokio::test] + async fn optional_devnet_checked_transfer_readiness_from_env() { + if std::env::var("KB_DEVNET_SPL_TOKEN_PREFLIGHT_TEST").ok().as_deref() + != std::option::Option::Some("1") + { + return; + } + let source = match std::env::var("KB_DEVNET_SPL_TOKEN_SOURCE") { + std::result::Result::Ok(value) => kb_lib::MdPubkey(value), + std::result::Result::Err(error) => { + panic!("KB_DEVNET_SPL_TOKEN_SOURCE is required: {error}"); + }, + }; + let mint = match std::env::var("KB_DEVNET_SPL_TOKEN_MINT") { + std::result::Result::Ok(value) => kb_lib::MdPubkey(value), + std::result::Result::Err(error) => { + panic!("KB_DEVNET_SPL_TOKEN_MINT is required: {error}"); + }, + }; + let destination = match std::env::var("KB_DEVNET_SPL_TOKEN_DESTINATION") { + std::result::Result::Ok(value) => kb_lib::MdPubkey(value), + std::result::Result::Err(error) => { + panic!("KB_DEVNET_SPL_TOKEN_DESTINATION is required: {error}"); + }, + }; + let authority = match std::env::var("KB_DEVNET_SPL_TOKEN_AUTHORITY") { + std::result::Result::Ok(value) => kb_lib::MdPubkey(value), + std::result::Result::Err(error) => { + panic!("KB_DEVNET_SPL_TOKEN_AUTHORITY is required: {error}"); + }, + }; + let decimals = match std::env::var("KB_DEVNET_SPL_TOKEN_DECIMALS") { + std::result::Result::Ok(value) => match value.parse::() { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => { + panic!("KB_DEVNET_SPL_TOKEN_DECIMALS must be a u8: {error}"); + }, + }, + std::result::Result::Err(_) => 9, + }; + let amount = match std::env::var("KB_DEVNET_SPL_TOKEN_AMOUNT") { + std::result::Result::Ok(value) => value, + std::result::Result::Err(_) => "1".to_string(), + }; + let pool = + match kb_onchain_transport::HttpEndpointPool::from_profile(&local_devnet_profile()) { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => panic!("HTTP pool creation failed: {error}"), + }; + let report = match crate::inspect_spl_token_stateful_readiness( + &pool, + &crate::SplTokenStatefulReadinessRequest { + query_role: "http_queries".to_string(), + cluster: kb_lib::ExApiExecutionCluster::Devnet, + operation: kb_lib::ExSplClassicTokenOperation::Instruction { + value: kb_lib::ExSplClassicTokenSingleOperation::TransferChecked { + source, + mint, + destination, + authority: kb_lib::ExSplClassicTokenAuthority { + authority, + multisig_signers: std::vec::Vec::new(), + }, + amount: kb_lib::ExSplClassicTokenAmount(amount), + decimals, + }, + }, + }, + ) + .await + { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => panic!("Devnet Token preflight failed: {error}"), + }; + assert_eq!( + report.status, + crate::SplTokenStatefulReadinessStatus::Ready, + "{:#?}", + report.checks + ); + } +}