diff --git a/CHANGELOG.md b/CHANGELOG.md index e57b347..c600573 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -112,7 +112,9 @@ - Portage des matérialisateurs natifs lifecycle, administration, compliance et staking. - Conservation de leurs 50 tests historiques et ajout du premier test aval de matérialisation. -## 0.1.0-pre.032 +## 0.1.0-pre.033 -- Migration de l’inventaire Devnet Token-2022/ElGamal et des contrats de validation machine-readable dans `kb-pipeline`. -- Conservation des sept tests bot2 et de `docs/SPL_TOKEN_2022_VALIDATION_MATRIX.json`. +- Migre les lectures stateful Token-2022 et ElGamal Registry dans `kb-pipeline`. +- Migre le préflight Token-2022 borné et ses validations croisées. +- Conserve les 16 tests bot2 associés sans réduction fonctionnelle. +- Normalise les noms de fichiers, modules et symboles internes sur `token2022`. diff --git a/README.md b/README.md index 233fe55..a6f783f 100644 --- a/README.md +++ b/README.md @@ -104,4 +104,4 @@ 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 `0.1.0-pre.032` ajoute à `kb-pipeline` l’inventaire stable des scénarios Devnet Token-2022/ElGamal et la validation machine-readable du jalon 0.4.6. +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. diff --git a/ROADMAP.md b/ROADMAP.md index f1d8c61..bfff220 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -298,4 +298,10 @@ - [ ] Valider avec Cargo sur la machine de développement. - [ ] Porter ensuite les orchestrations Solana stateful et d’exécution. -- [x] `0.1.0-pre.032` — migrer les scénarios Devnet Token-2022/ElGamal et la matrice de validation machine-readable. +### 0.1.0-pre.033 — Pipeline stateful Token-2022 + +- [x] Lecture et validation stateful Token-2022. +- [x] Routage des projections matérialisées Token-2022. +- [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. diff --git a/kb-pipeline/Cargo.toml b/kb-pipeline/Cargo.toml index d85d337..b514127 100644 --- a/kb-pipeline/Cargo.toml +++ b/kb-pipeline/Cargo.toml @@ -1,5 +1,5 @@ # file: kb-pipeline/Cargo.toml -# version: 8 +# version: 9 [package] name = "kb-pipeline" @@ -10,6 +10,7 @@ publish.workspace = true [dependencies] async-trait.workspace = true +bs58.workspace = true chrono.workspace = true futures-util.workspace = true kb-core = { path = "../kb-core" } @@ -20,13 +21,12 @@ kb-store = { path = "../kb-store" } kb-wallet = { path = "../kb-wallet" } serde.workspace = true serde_json.workspace = true +solana-pubkey.workspace = true +spl-elgamal-registry-interface.workspace = true sha2.workspace = true tokio.workspace = true tracing.workspace = true uuid.workspace = true -[dev-dependencies] -bs58.workspace = true - [lints] workspace = true diff --git a/kb-pipeline/src/lib.rs b/kb-pipeline/src/lib.rs index cfe656f..0b99782 100644 --- a/kb-pipeline/src/lib.rs +++ b/kb-pipeline/src/lib.rs @@ -1,5 +1,5 @@ // file: kb-pipeline/src/lib.rs -// version: 7 +// version: 9 #![forbid(unsafe_code)] #![deny(unreachable_pub)] @@ -12,7 +12,10 @@ mod constants; mod core_extraction; mod decode_replay; mod plan; +mod solana_elgamal_registry_stateful; mod solana_token2022_devnet_scenarios; +mod solana_token2022_preflight; +mod solana_token2022_stateful; mod solana_token2022_validation; /// Address category used by one targeted backfill campaign. @@ -83,6 +86,20 @@ pub use self::decode_replay::new_decode_campaign_id; pub use self::plan::PipelineStage; /// Replay selection scope. pub use self::plan::ReplayScope; +/// Migrated ELGAMAL_REGISTRY_STATEFUL_ACCOUNT_BYTES contract. +pub use self::solana_elgamal_registry_stateful::ELGAMAL_REGISTRY_STATEFUL_ACCOUNT_BYTES; +/// Migrated ElGamalRegistryStatefulReadRequest contract. +pub use self::solana_elgamal_registry_stateful::ElGamalRegistryStatefulReadRequest; +/// Migrated ElGamalRegistryStatefulReadResult contract. +pub use self::solana_elgamal_registry_stateful::ElGamalRegistryStatefulReadResult; +/// Migrated ElGamalRegistryStatefulSnapshot contract. +pub use self::solana_elgamal_registry_stateful::ElGamalRegistryStatefulSnapshot; +/// Migrated materialize_elgamal_registry_account_info_result contract. +pub use self::solana_elgamal_registry_stateful::materialize_elgamal_registry_account_info_result; +/// Migrated materialize_elgamal_registry_stateful_snapshot contract. +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; /// Stable category of one independent Devnet validation scenario. pub use self::solana_token2022_devnet_scenarios::DevnetSplValidationFamily; /// Current implementation status of one Devnet validation scenario. @@ -91,6 +108,40 @@ pub use self::solana_token2022_devnet_scenarios::DevnetSplValidationImplementati pub use self::solana_token2022_devnet_scenarios::DevnetSplValidationScenario; /// Returns the complete ordered Devnet scenario inventory. pub use self::solana_token2022_devnet_scenarios::devnet_spl_validation_scenarios; +/// Migrated MAX_TOKEN2022_PREFLIGHT_ACCOUNTS contract. +pub use self::solana_token2022_preflight::MAX_TOKEN2022_PREFLIGHT_ACCOUNTS; +/// Migrated MAX_TOKEN2022_PREFLIGHT_TOTAL_BYTES contract. +pub use self::solana_token2022_preflight::MAX_TOKEN2022_PREFLIGHT_TOTAL_BYTES; +/// Migrated Token2022PreflightAccountReport contract. +pub use self::solana_token2022_preflight::Token2022PreflightAccountReport; +/// Migrated Token2022PreflightReport contract. +pub use self::solana_token2022_preflight::Token2022PreflightReport; +/// Migrated Token2022PreflightRequest contract. +pub use self::solana_token2022_preflight::Token2022PreflightRequest; +/// Migrated Token2022PreflightRequirement contract. +pub use self::solana_token2022_preflight::Token2022PreflightRequirement; +/// Migrated inspect_token2022_preflight contract. +pub use self::solana_token2022_preflight::inspect_token2022_preflight; +/// Migrated MAX_TOKEN2022_STATEFUL_ACCOUNT_BYTES contract. +pub use self::solana_token2022_stateful::MAX_TOKEN2022_STATEFUL_ACCOUNT_BYTES; +/// Migrated Token2022StatefulContext contract. +pub use self::solana_token2022_stateful::Token2022StatefulContext; +/// Migrated Token2022StatefulReadRequest contract. +pub use self::solana_token2022_stateful::Token2022StatefulReadRequest; +/// Migrated Token2022StatefulReadResult contract. +pub use self::solana_token2022_stateful::Token2022StatefulReadResult; +/// Migrated Token2022StatefulSnapshotBundle contract. +pub use self::solana_token2022_stateful::Token2022StatefulSnapshotBundle; +/// Migrated materialize_parsed_token2022_stateful_snapshot contract. +pub use self::solana_token2022_stateful::materialize_parsed_token2022_stateful_snapshot; +/// Migrated materialize_parsed_token2022_stateful_snapshot_with_context contract. +pub use self::solana_token2022_stateful::materialize_parsed_token2022_stateful_snapshot_with_context; +/// Migrated materialize_token2022_account_info_result contract. +pub use self::solana_token2022_stateful::materialize_token2022_account_info_result; +/// Migrated materialize_token2022_stateful_snapshot contract. +pub use self::solana_token2022_stateful::materialize_token2022_stateful_snapshot; +/// Migrated read_token2022_stateful_snapshot contract. +pub use self::solana_token2022_stateful::read_token2022_stateful_snapshot; /// Maximum number of evidence records accepted in one validation report. pub use self::solana_token2022_validation::MAX_TOKEN2022_VALIDATION_EVIDENCE; /// Required validation environment for one Token-2022 scenario. diff --git a/kb-pipeline/src/solana_elgamal_registry_stateful.rs b/kb-pipeline/src/solana_elgamal_registry_stateful.rs new file mode 100644 index 0000000..9b6bf4e --- /dev/null +++ b/kb-pipeline/src/solana_elgamal_registry_stateful.rs @@ -0,0 +1,393 @@ +// file: kb-pipeline/src/solana_elgamal_registry_stateful.rs +// version: 3 + +//! Contextual SPL ElGamal registry account-state validation and materialization routing. + +use std::str::FromStr; // rust-rules: trait-import + +/// Exact byte length of one SPL ElGamal registry account. +pub const ELGAMAL_REGISTRY_STATEFUL_ACCOUNT_BYTES: usize = 64; + +/// One bounded SPL ElGamal registry RPC read request. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct ElGamalRegistryStatefulReadRequest { + /// Endpoint role used for the HTTP RPC request. + pub query_role: std::string::String, + /// Canonical registry PDA. + pub registry_account: kb_lib::MdPubkey, + /// Optional minimum RPC context slot. + pub min_context_slot: std::option::Option, +} + +/// One bounded registry RPC read and validated administrative projection. +#[derive(Clone, Debug, PartialEq, serde::Deserialize, serde::Serialize)] +pub struct ElGamalRegistryStatefulReadResult { + /// Commitment used for the RPC read. + pub commitment: std::string::String, + /// Context slot returned by the endpoint. + pub context_slot: u64, + /// Complete validated registry snapshot. + pub snapshot: crate::ElGamalRegistryStatefulSnapshot, +} + +/// Reads, validates, and routes one exact SPL ElGamal registry account. +pub async fn read_elgamal_registry_stateful_snapshot( + pool: &kb_onchain_transport::HttpEndpointPool, + request: &crate::ElGamalRegistryStatefulReadRequest, +) -> kb_core::Result { + if request.query_role.trim().is_empty() { + return std::result::Result::Err(kb_core::Error::config( + "SPL ElGamal registry stateful read query_role must not be empty", + )); + } + let config = match kb_onchain_transport::GetAccountInfoConfig::new_with_data( + kb_onchain_transport::RpcCommitmentLevel::Confirmed, + request.min_context_slot, + crate::ELGAMAL_REGISTRY_STATEFUL_ACCOUNT_BYTES, + ) { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + let result = match pool + .get_account_info_for_role(request.query_role.as_str(), &request.registry_account, &config) + .await + { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + return crate::materialize_elgamal_registry_account_info_result(request, &result); +} + +/// Validates one complete RPC registry response before parsing and routing. +pub fn materialize_elgamal_registry_account_info_result( + request: &crate::ElGamalRegistryStatefulReadRequest, + result: &kb_onchain_transport::AccountInfoResult, +) -> kb_core::Result { + if let std::option::Option::Some(min_context_slot) = request.min_context_slot { + if result.context.slot < min_context_slot { + return std::result::Result::Err(kb_core::Error::new( + "elgamal_registry_stateful_context_slot_too_old", + format!( + "SPL ElGamal registry context slot {} is below requested minimum {min_context_slot}", + result.context.slot + ), + )); + } + } + let account = match result.account.as_ref() { + std::option::Option::Some(value) => value, + std::option::Option::None => { + return std::result::Result::Err(kb_core::Error::new( + "elgamal_registry_stateful_account_missing", + format!( + "SPL ElGamal registry account {} does not exist", + request.registry_account.0 + ), + )); + }, + }; + if account.executable { + return std::result::Result::Err(kb_core::Error::new( + "elgamal_registry_stateful_account_executable", + format!( + "SPL ElGamal registry state account {} must not be executable", + request.registry_account.0 + ), + )); + } + if account.owner.0 != kb_program_ids::SPL_TOKEN2022_ELGAMAL_REGISTRY_PROGRAM_ID { + return std::result::Result::Err(kb_core::Error::new( + "elgamal_registry_stateful_owner_mismatch", + format!( + "SPL ElGamal registry owner must be {}, got {}", + kb_program_ids::SPL_TOKEN2022_ELGAMAL_REGISTRY_PROGRAM_ID, + account.owner.0 + ), + )); + } + if account.space != crate::ELGAMAL_REGISTRY_STATEFUL_ACCOUNT_BYTES as u64 + || account.data.len() != crate::ELGAMAL_REGISTRY_STATEFUL_ACCOUNT_BYTES + { + return std::result::Result::Err(kb_core::Error::new( + "elgamal_registry_stateful_account_length_invalid", + format!( + "SPL ElGamal registry account must report and return exactly {} bytes, got space {} and data {}", + crate::ELGAMAL_REGISTRY_STATEFUL_ACCOUNT_BYTES, + account.space, + account.data.len() + ), + )); + } + let snapshot = match crate::materialize_elgamal_registry_stateful_snapshot( + request.registry_account.0.as_str(), + account.owner.0.as_str(), + result.context.slot, + account.data.as_slice(), + ) { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => { + return std::result::Result::Err(kb_core::Error::new( + "elgamal_registry_stateful_projection_failed", + error, + )); + }, + }; + return std::result::Result::Ok(crate::ElGamalRegistryStatefulReadResult { + commitment: "confirmed".to_string(), + context_slot: result.context.slot, + snapshot, + }); +} + +/// One contextually validated SPL ElGamal registry state snapshot. +#[derive(Clone, Debug, PartialEq, serde::Deserialize, serde::Serialize)] +pub struct ElGamalRegistryStatefulSnapshot { + /// Canonical registry PDA identity. + pub registry_account_key: std::string::String, + /// Wallet address stored as the registry owner. + pub owner: std::string::String, + /// Context slot associated with the account read. + pub slot: u64, + /// Processor-owned administrative projection. + pub output: kb_lib::MtApiMaterializedOutput, +} + +/// Parse, validate the official owner-derived PDA, and materialize one registry snapshot. +pub fn materialize_elgamal_registry_stateful_snapshot( + registry_account_key: &str, + owner_program_id: &str, + slot: u64, + data: &[u8], +) -> std::result::Result { + if owner_program_id != kb_program_ids::SPL_TOKEN2022_ELGAMAL_REGISTRY_PROGRAM_ID { + return std::result::Result::Err(format!( + "SPL ElGamal registry snapshot owner must be {}, got {owner_program_id}", + kb_program_ids::SPL_TOKEN2022_ELGAMAL_REGISTRY_PROGRAM_ID + )); + } + let state = match kb_lib::decoder_spl_elgamal_registry_parse_elgamal_registry_state(data) { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + let owner = match solana_pubkey::Pubkey::from_str(state.owner.as_str()) { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => { + return std::result::Result::Err(format!( + "SPL ElGamal registry owner is not a valid address: {error}" + )); + }, + }; + let program_id = match solana_pubkey::Pubkey::from_str( + kb_program_ids::SPL_TOKEN2022_ELGAMAL_REGISTRY_PROGRAM_ID, + ) { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => { + return std::result::Result::Err(format!( + "Configured SPL ElGamal registry program ID is invalid: {error}" + )); + }, + }; + let expected = + spl_elgamal_registry_interface::get_elgamal_registry_address(&owner, &program_id); + if expected.to_string() != registry_account_key { + return std::result::Result::Err(format!( + "SPL ElGamal registry account {registry_account_key} does not match owner-derived PDA {expected}" + )); + } + let output = match kb_lib::materializer_admin_materialize_elgamal_registry_state_snapshot( + registry_account_key, + slot, + &state, + ) { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + return std::result::Result::Ok(crate::ElGamalRegistryStatefulSnapshot { + registry_account_key: registry_account_key.to_string(), + owner: state.owner, + slot, + output, + }); +} + +#[cfg(test)] +mod tests { + use std::str::FromStr; // rust-rules: trait-import + + fn registry_fixture(owner: &solana_pubkey::Pubkey) -> (std::string::String, [u8; 64]) { + let program_id = match solana_pubkey::Pubkey::from_str( + kb_program_ids::SPL_TOKEN2022_ELGAMAL_REGISTRY_PROGRAM_ID, + ) { + std::result::Result::Ok(value) => value, + std::result::Result::Err(_) => solana_pubkey::Pubkey::default(), + }; + let registry = + spl_elgamal_registry_interface::get_elgamal_registry_address(owner, &program_id); + let mut data = [0u8; 64]; + data[..32].copy_from_slice(owner.as_ref()); + data[32..].copy_from_slice(&[9u8; 32]); + return (registry.to_string(), data); + } + + #[test] + fn exact_owner_derived_registry_pda_routes_one_admin_snapshot() { + let owner = solana_pubkey::Pubkey::new_from_array([7u8; 32]); + let (registry, data) = registry_fixture(&owner); + let snapshot = crate::materialize_elgamal_registry_stateful_snapshot( + registry.as_str(), + kb_program_ids::SPL_TOKEN2022_ELGAMAL_REGISTRY_PROGRAM_ID, + 55, + &data, + ); + assert_eq!( + snapshot.as_ref().map(|value| return value.owner.clone()), + std::result::Result::Ok(owner.to_string()) + ); + assert_eq!( + snapshot + .as_ref() + .map(|value| return value.output.payload_json["finalAccountStateCaptured"].clone()), + std::result::Result::Ok(serde_json::json!(true)) + ); + } + + #[test] + fn foreign_owner_program_wrong_pda_and_invalid_length_fail_closed() { + let owner = solana_pubkey::Pubkey::new_from_array([8u8; 32]); + let (registry, data) = registry_fixture(&owner); + let foreign = crate::materialize_elgamal_registry_stateful_snapshot( + registry.as_str(), + kb_program_ids::SPL_TOKEN2022_PROGRAM_ID, + 55, + &data, + ); + assert!(foreign.is_err()); + let wrong = crate::materialize_elgamal_registry_stateful_snapshot( + solana_pubkey::Pubkey::new_from_array([10u8; 32]).to_string().as_str(), + kb_program_ids::SPL_TOKEN2022_ELGAMAL_REGISTRY_PROGRAM_ID, + 55, + &data, + ); + assert!(wrong.is_err()); + let invalid = crate::materialize_elgamal_registry_stateful_snapshot( + registry.as_str(), + kb_program_ids::SPL_TOKEN2022_ELGAMAL_REGISTRY_PROGRAM_ID, + 55, + &data[..63], + ); + assert!(invalid.is_err()); + } + + #[test] + fn exact_rpc_registry_routes_after_context_owner_and_length_validation() { + let owner = solana_pubkey::Pubkey::new_from_array([13u8; 32]); + let (registry, data) = registry_fixture(&owner); + let request = crate::ElGamalRegistryStatefulReadRequest { + query_role: "execution".to_string(), + registry_account: kb_lib::MdPubkey(registry), + min_context_slot: std::option::Option::Some(70), + }; + let result = kb_onchain_transport::AccountInfoResult { + context: kb_onchain_transport::RpcResponseContext { + slot: 71, + api_version: std::option::Option::None, + }, + account: std::option::Option::Some(kb_onchain_transport::AccountInfoValue { + lamports: 1, + owner: kb_lib::MdProgramId( + kb_program_ids::SPL_TOKEN2022_ELGAMAL_REGISTRY_PROGRAM_ID.to_string(), + ), + executable: false, + rent_epoch: 0, + space: 64, + data: data.to_vec(), + }), + }; + let materialized = + crate::materialize_elgamal_registry_account_info_result(&request, &result); + assert_eq!( + materialized.as_ref().map(|value| return value.context_slot), + std::result::Result::Ok(71) + ); + assert_eq!( + materialized.as_ref().map(|value| return value.snapshot.owner.clone()), + std::result::Result::Ok(owner.to_string()) + ); + } + + #[test] + fn stale_missing_foreign_executable_and_wrong_length_registry_reads_fail_closed() { + let owner = solana_pubkey::Pubkey::new_from_array([14u8; 32]); + let (registry, data) = registry_fixture(&owner); + let request = crate::ElGamalRegistryStatefulReadRequest { + query_role: "execution".to_string(), + registry_account: kb_lib::MdPubkey(registry), + min_context_slot: std::option::Option::Some(80), + }; + let base = kb_onchain_transport::AccountInfoValue { + lamports: 1, + owner: kb_lib::MdProgramId( + kb_program_ids::SPL_TOKEN2022_ELGAMAL_REGISTRY_PROGRAM_ID.to_string(), + ), + executable: false, + rent_epoch: 0, + space: 64, + data: data.to_vec(), + }; + let stale = kb_onchain_transport::AccountInfoResult { + context: kb_onchain_transport::RpcResponseContext { + slot: 79, + api_version: std::option::Option::None, + }, + account: std::option::Option::Some(base.clone()), + }; + assert!(crate::materialize_elgamal_registry_account_info_result(&request, &stale).is_err()); + let missing = kb_onchain_transport::AccountInfoResult { + context: kb_onchain_transport::RpcResponseContext { + slot: 80, + api_version: std::option::Option::None, + }, + account: std::option::Option::None, + }; + assert!( + crate::materialize_elgamal_registry_account_info_result(&request, &missing).is_err() + ); + let mut foreign = base.clone(); + foreign.owner = kb_lib::MdProgramId(kb_program_ids::SPL_TOKEN2022_PROGRAM_ID.to_string()); + let foreign = kb_onchain_transport::AccountInfoResult { + context: kb_onchain_transport::RpcResponseContext { + slot: 80, + api_version: std::option::Option::None, + }, + account: std::option::Option::Some(foreign), + }; + assert!( + crate::materialize_elgamal_registry_account_info_result(&request, &foreign).is_err() + ); + let mut executable = base.clone(); + executable.executable = true; + let executable = kb_onchain_transport::AccountInfoResult { + context: kb_onchain_transport::RpcResponseContext { + slot: 80, + api_version: std::option::Option::None, + }, + account: std::option::Option::Some(executable), + }; + assert!( + crate::materialize_elgamal_registry_account_info_result(&request, &executable).is_err() + ); + let mut wrong_length = base; + wrong_length.data.pop(); + let wrong_length = kb_onchain_transport::AccountInfoResult { + context: kb_onchain_transport::RpcResponseContext { + slot: 80, + api_version: std::option::Option::None, + }, + account: std::option::Option::Some(wrong_length), + }; + assert!( + crate::materialize_elgamal_registry_account_info_result(&request, &wrong_length) + .is_err() + ); + } +} diff --git a/kb-pipeline/src/solana_token2022_preflight.rs b/kb-pipeline/src/solana_token2022_preflight.rs new file mode 100644 index 0000000..61cac1d --- /dev/null +++ b/kb-pipeline/src/solana_token2022_preflight.rs @@ -0,0 +1,429 @@ +// file: kb-pipeline/src/solana_token2022_preflight.rs +// version: 2 + +//! Bounded Token-2022 stateful preflight orchestration. + +/// Maximum distinct Token-2022 accounts accepted by one preflight inspection. +pub const MAX_TOKEN2022_PREFLIGHT_ACCOUNTS: usize = 16; +/// Maximum aggregate account-data budget accepted by one preflight inspection. +pub const MAX_TOKEN2022_PREFLIGHT_TOTAL_BYTES: usize = 262_144; + +/// One exact Token-2022 account requirement for a stateful preflight. +#[derive(Clone, Debug, PartialEq)] +pub struct Token2022PreflightRequirement { + /// Stable semantic role used in diagnostics. + pub role: std::string::String, + /// Canonical account address. + pub account: kb_lib::MdPubkey, + /// Expected Token-2022 state category. + pub kind: kb_lib::DcToken2022StateKind, + /// Maximum complete account bytes accepted for this requirement. + pub max_data_bytes: usize, + /// Optional expected mint for a Token Account. + pub expected_mint: std::option::Option, + /// Optional expected owner for a Token Account. + pub expected_owner: std::option::Option, + /// Optional exact decimals expected for a Mint. + pub expected_decimals: std::option::Option, + /// Published extension names required on the account. + pub required_extensions: std::vec::Vec, + /// Optional external identities needed by cross-account extensions. + pub context: crate::Token2022StatefulContext, +} + +/// One bounded Token-2022 preflight request. +#[derive(Clone, Debug, PartialEq)] +pub struct Token2022PreflightRequest { + /// Endpoint role used by all RPC reads. + pub query_role: std::string::String, + /// Optional minimum context slot shared by all reads. + pub min_context_slot: std::option::Option, + /// Maximum distinct accounts accepted after deduplication. + pub max_accounts: usize, + /// Maximum aggregate requested account bytes. + pub max_total_data_bytes: usize, + /// Ordered account requirements. + pub requirements: std::vec::Vec, + /// Optional ElGamal registry read required by the operation. + pub elgamal_registry: std::option::Option, +} + +/// One validated account result in a Token-2022 preflight report. +#[derive(Clone, Debug, PartialEq, serde::Deserialize, serde::Serialize)] +pub struct Token2022PreflightAccountReport { + /// Stable semantic role. + pub role: std::string::String, + /// Canonical account address. + pub account: kb_lib::MdPubkey, + /// State category observed after parsing. + pub state_kind: std::string::String, + /// Context slot returned by the endpoint. + pub context_slot: u64, + /// Ordered extension names observed on the account. + pub extension_names: std::vec::Vec, + /// Ordered successful semantic checks. + pub checks: std::vec::Vec, +} + +/// Complete bounded Token-2022 stateful preflight report. +#[derive(Clone, Debug, PartialEq, serde::Deserialize, serde::Serialize)] +pub struct Token2022PreflightReport { + /// Commitment used for all account reads. + pub commitment: std::string::String, + /// Highest context slot observed across all reads. + pub context_slot: u64, + /// Aggregate requested account-data budget. + pub requested_data_bytes: usize, + /// Ordered distinct Token-2022 account reports. + pub accounts: std::vec::Vec, + /// Whether an ElGamal registry was required and validated. + pub elgamal_registry_validated: bool, +} + +/// Inspects all bounded Token-2022 state required before simulation. +pub async fn inspect_token2022_preflight( + pool: &kb_onchain_transport::HttpEndpointPool, + request: &crate::Token2022PreflightRequest, +) -> kb_core::Result { + let requirements = match validate_and_deduplicate_requirements(request) { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + let requested_data_bytes = requirements + .iter() + .fold(0usize, |total, requirement| { + return total.saturating_add(requirement.max_data_bytes); + }) + .saturating_add(if request.elgamal_registry.is_some() { + crate::ELGAMAL_REGISTRY_STATEFUL_ACCOUNT_BYTES + } else { + 0 + }); + let mut context_slot = request.min_context_slot.unwrap_or(0); + let mut accounts = std::vec::Vec::with_capacity(requirements.len()); + for requirement in requirements { + let read_request = crate::Token2022StatefulReadRequest { + query_role: request.query_role.clone(), + account: requirement.account.clone(), + kind: requirement.kind, + min_context_slot: request.min_context_slot, + max_data_bytes: requirement.max_data_bytes, + context: requirement.context.clone(), + }; + let read_result = match crate::read_token2022_stateful_snapshot(pool, &read_request).await { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + let account_report = match validate_snapshot_requirement(&requirement, &read_result) { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + context_slot = context_slot.max(account_report.context_slot); + accounts.push(account_report); + } + let mut elgamal_registry_validated = false; + if let std::option::Option::Some(registry_request) = request.elgamal_registry.as_ref() { + if registry_request.query_role != request.query_role { + return std::result::Result::Err(kb_core::Error::config( + "Token-2022 preflight ElGamal registry query role must match the shared query role", + )); + } + let registry_result = + match crate::read_elgamal_registry_stateful_snapshot(pool, registry_request).await { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + context_slot = context_slot.max(registry_result.context_slot); + elgamal_registry_validated = true; + } + return std::result::Result::Ok(crate::Token2022PreflightReport { + commitment: "confirmed".to_string(), + context_slot, + requested_data_bytes, + accounts, + elgamal_registry_validated, + }); +} + +fn validate_and_deduplicate_requirements( + request: &crate::Token2022PreflightRequest, +) -> kb_core::Result> { + if request.query_role.trim().is_empty() { + return std::result::Result::Err(kb_core::Error::config( + "Token-2022 preflight query_role must not be empty", + )); + } + if request.max_accounts == 0 || request.max_accounts > crate::MAX_TOKEN2022_PREFLIGHT_ACCOUNTS { + return std::result::Result::Err(kb_core::Error::config(format!( + "Token-2022 preflight max_accounts must be between 1 and {}", + crate::MAX_TOKEN2022_PREFLIGHT_ACCOUNTS + ))); + } + if request.max_total_data_bytes == 0 + || request.max_total_data_bytes > crate::MAX_TOKEN2022_PREFLIGHT_TOTAL_BYTES + { + return std::result::Result::Err(kb_core::Error::config(format!( + "Token-2022 preflight max_total_data_bytes must be between 1 and {}", + crate::MAX_TOKEN2022_PREFLIGHT_TOTAL_BYTES + ))); + } + if request.requirements.is_empty() { + return std::result::Result::Err(kb_core::Error::config( + "Token-2022 preflight requires at least one account", + )); + } + let mut indexes = std::collections::BTreeMap::::new(); + let mut unique = std::vec::Vec::::new(); + for requirement in &request.requirements { + if requirement.role.trim().is_empty() { + return std::result::Result::Err(kb_core::Error::config( + "Token-2022 preflight requirement role must not be empty", + )); + } + if requirement.max_data_bytes == 0 + || requirement.max_data_bytes > crate::MAX_TOKEN2022_STATEFUL_ACCOUNT_BYTES + { + return std::result::Result::Err(kb_core::Error::config(format!( + "Token-2022 preflight account {} has invalid max_data_bytes {}", + requirement.account.0, requirement.max_data_bytes + ))); + } + if let std::option::Option::Some(index) = indexes.get(requirement.account.0.as_str()) { + if &unique[*index] != requirement { + return std::result::Result::Err(kb_core::Error::new( + "token2022_preflight_conflicting_duplicate", + format!( + "Token-2022 preflight account {} has conflicting requirements", + requirement.account.0 + ), + )); + } + continue; + } + indexes.insert(requirement.account.0.clone(), unique.len()); + unique.push(requirement.clone()); + } + let total_accounts = + unique + .len() + .saturating_add(if request.elgamal_registry.is_some() { 1 } else { 0 }); + if total_accounts > request.max_accounts { + return std::result::Result::Err(kb_core::Error::new( + "token2022_preflight_account_limit_exceeded", + format!( + "Token-2022 preflight requires {total_accounts} distinct accounts above limit {}", + request.max_accounts + ), + )); + } + let requested_data_bytes = unique + .iter() + .fold(0usize, |total, requirement| { + return total.saturating_add(requirement.max_data_bytes); + }) + .saturating_add(if request.elgamal_registry.is_some() { + crate::ELGAMAL_REGISTRY_STATEFUL_ACCOUNT_BYTES + } else { + 0 + }); + if requested_data_bytes > request.max_total_data_bytes { + return std::result::Result::Err(kb_core::Error::new( + "token2022_preflight_data_budget_exceeded", + format!( + "Token-2022 preflight requests {requested_data_bytes} bytes above aggregate limit {}", + request.max_total_data_bytes + ), + )); + } + return std::result::Result::Ok(unique); +} + +fn validate_snapshot_requirement( + requirement: &crate::Token2022PreflightRequirement, + result: &crate::Token2022StatefulReadResult, +) -> kb_core::Result { + let token_output = result + .snapshot + .outputs + .iter() + .find(|output| return output.payload_json["domain"] == "spl_token2022_account_state"); + let token_output = match token_output { + std::option::Option::Some(value) => value, + std::option::Option::None => { + return std::result::Result::Err(kb_core::Error::invalid_state( + "Token-2022 preflight snapshot is missing its token account owner projection", + )); + }, + }; + let base_fields = &token_output.payload_json["baseFields"]; + let mut checks = std::vec![ + "owner_program_id".to_string(), + "complete_account_data".to_string(), + "state_kind".to_string() + ]; + if let std::option::Option::Some(expected_mint) = requirement.expected_mint.as_ref() { + if base_fields["mint"].as_str() != std::option::Option::Some(expected_mint.0.as_str()) { + return std::result::Result::Err(kb_core::Error::new( + "token2022_preflight_mint_mismatch", + format!( + "Token-2022 account {} mint does not match expected {}", + requirement.account.0, expected_mint.0 + ), + )); + } + checks.push("mint_identity".to_string()); + } + if let std::option::Option::Some(expected_owner) = requirement.expected_owner.as_ref() { + if base_fields["owner"].as_str() != std::option::Option::Some(expected_owner.0.as_str()) { + return std::result::Result::Err(kb_core::Error::new( + "token2022_preflight_owner_mismatch", + format!( + "Token-2022 account {} owner does not match expected {}", + requirement.account.0, expected_owner.0 + ), + )); + } + checks.push("token_account_owner".to_string()); + } + if let std::option::Option::Some(expected_decimals) = requirement.expected_decimals { + if base_fields["decimals"].as_u64() != std::option::Option::Some(expected_decimals as u64) { + return std::result::Result::Err(kb_core::Error::new( + "token2022_preflight_decimals_mismatch", + format!( + "Token-2022 mint {} decimals do not match expected {expected_decimals}", + requirement.account.0 + ), + )); + } + checks.push("mint_decimals".to_string()); + } + for required_extension in &requirement.required_extensions { + if !result + .snapshot + .extension_names + .iter() + .any(|value| return value == required_extension) + { + return std::result::Result::Err(kb_core::Error::new( + "token2022_preflight_extension_missing", + format!( + "Token-2022 account {} is missing required extension {required_extension}", + requirement.account.0 + ), + )); + } + } + if !requirement.required_extensions.is_empty() { + checks.push("required_extensions".to_string()); + } + return std::result::Result::Ok(crate::Token2022PreflightAccountReport { + role: requirement.role.clone(), + account: requirement.account.clone(), + state_kind: result.snapshot.state_kind.clone(), + context_slot: result.context_slot, + extension_names: result.snapshot.extension_names.clone(), + checks, + }); +} + +#[cfg(test)] +mod tests { + fn pubkey(byte: u8) -> kb_lib::MdPubkey { + return kb_lib::MdPubkey(bs58::encode([byte; 32]).into_string()); + } + + fn requirement(account: kb_lib::MdPubkey) -> crate::Token2022PreflightRequirement { + return crate::Token2022PreflightRequirement { + role: "source".to_string(), + account, + kind: kb_lib::DcToken2022StateKind::Account, + max_data_bytes: 512, + expected_mint: std::option::Option::None, + expected_owner: std::option::Option::None, + expected_decimals: std::option::Option::None, + required_extensions: std::vec::Vec::new(), + context: crate::Token2022StatefulContext::default(), + }; + } + + #[test] + fn exact_duplicate_requirements_are_deduplicated_and_bounded() { + let item = requirement(pubkey(1)); + let request = crate::Token2022PreflightRequest { + query_role: "query".to_string(), + min_context_slot: std::option::Option::Some(7), + max_accounts: 1, + max_total_data_bytes: 512, + requirements: std::vec![item.clone(), item], + elgamal_registry: std::option::Option::None, + }; + let result = super::validate_and_deduplicate_requirements(&request); + assert_eq!(result.as_ref().map(std::vec::Vec::len), std::result::Result::Ok(1)); + } + + #[test] + fn conflicting_duplicates_and_aggregate_budget_fail_closed() { + let first = requirement(pubkey(2)); + let mut conflicting = first.clone(); + conflicting.role = "destination".to_string(); + let conflict_request = crate::Token2022PreflightRequest { + query_role: "query".to_string(), + min_context_slot: std::option::Option::None, + max_accounts: 2, + max_total_data_bytes: 1024, + requirements: std::vec![first, conflicting], + elgamal_registry: std::option::Option::None, + }; + assert!(super::validate_and_deduplicate_requirements(&conflict_request).is_err()); + let budget_request = crate::Token2022PreflightRequest { + query_role: "query".to_string(), + min_context_slot: std::option::Option::None, + max_accounts: 2, + max_total_data_bytes: 700, + requirements: std::vec![requirement(pubkey(3)), requirement(pubkey(4))], + elgamal_registry: std::option::Option::None, + }; + assert!(super::validate_and_deduplicate_requirements(&budget_request).is_err()); + } + + #[test] + fn requirement_validation_checks_mint_owner_decimals_and_extensions() { + let account = pubkey(5); + let mint = pubkey(6); + let owner = pubkey(7); + let requirement = crate::Token2022PreflightRequirement { + role: "source".to_string(), + account: account.clone(), + kind: kb_lib::DcToken2022StateKind::Account, + max_data_bytes: 512, + expected_mint: std::option::Option::Some(mint.clone()), + expected_owner: std::option::Option::Some(owner.clone()), + expected_decimals: std::option::Option::None, + required_extensions: std::vec!["memo_transfer".to_string()], + context: crate::Token2022StatefulContext::default(), + }; + let result = crate::Token2022StatefulReadResult { + commitment: "confirmed".to_string(), + context_slot: 42, + snapshot: crate::Token2022StatefulSnapshotBundle { + account_key: account.0.clone(), + slot: 42, + state_kind: "account".to_string(), + extension_names: std::vec!["memo_transfer".to_string()], + outputs: std::vec![kb_lib::MtApiMaterializedOutput { + output_key: "state".to_string(), + family: kb_lib::MdMaterializedEventFamily::TokenAccount, + payload_json: serde_json::json!({ + "domain":"spl_token2022_account_state", + "baseFields":{"mint":mint.0,"owner":owner.0} + }), + }], + }, + }; + let report = super::validate_snapshot_requirement(&requirement, &result); + assert_eq!( + report.as_ref().map(|value| return value.checks.len()), + std::result::Result::Ok(6) + ); + } +} diff --git a/kb-pipeline/src/solana_token2022_stateful.rs b/kb-pipeline/src/solana_token2022_stateful.rs new file mode 100644 index 0000000..09c301c --- /dev/null +++ b/kb-pipeline/src/solana_token2022_stateful.rs @@ -0,0 +1,742 @@ +// file: kb-pipeline/src/solana_token2022_stateful.rs +// version: 5 + +//! Contextual Token-2022 account-state validation and materialization routing. + +/// Optional external identities required to validate cross-account Token-2022 state. +#[derive(Clone, Debug, Default, PartialEq, serde::Deserialize, serde::Serialize)] +pub struct Token2022StatefulContext { + /// Expected group account for one TokenGroupMember extension. + pub expected_group_address: std::option::Option, +} + +/// Maximum complete Token-2022 account data accepted by one bounded RPC read. +pub const MAX_TOKEN2022_STATEFUL_ACCOUNT_BYTES: usize = 65_536; + +/// One bounded Token-2022 account read request. +#[derive(Clone, Debug, PartialEq)] +pub struct Token2022StatefulReadRequest { + /// Endpoint role used for the HTTP RPC request. + pub query_role: std::string::String, + /// Canonical account address. + pub account: kb_lib::MdPubkey, + /// Expected Token-2022 base-state category. + pub kind: kb_lib::DcToken2022StateKind, + /// Optional minimum RPC context slot. + pub min_context_slot: std::option::Option, + /// Maximum decoded account bytes accepted from the endpoint. + pub max_data_bytes: usize, + /// Optional external identities needed by cross-account extensions. + pub context: crate::Token2022StatefulContext, +} + +/// One bounded RPC read and its contextually validated projections. +#[derive(Clone, Debug, PartialEq, serde::Deserialize, serde::Serialize)] +pub struct Token2022StatefulReadResult { + /// Commitment used for the RPC read. + pub commitment: std::string::String, + /// Context slot returned by the endpoint. + pub context_slot: u64, + /// Complete validated snapshot bundle. + pub snapshot: crate::Token2022StatefulSnapshotBundle, +} + +/// Reads, validates, parses, and routes one bounded Token-2022 account snapshot. +pub async fn read_token2022_stateful_snapshot( + pool: &kb_onchain_transport::HttpEndpointPool, + request: &crate::Token2022StatefulReadRequest, +) -> kb_core::Result { + if request.query_role.trim().is_empty() { + return std::result::Result::Err(kb_core::Error::config( + "Token-2022 stateful read query_role must not be empty", + )); + } + if request.max_data_bytes == 0 + || request.max_data_bytes > crate::MAX_TOKEN2022_STATEFUL_ACCOUNT_BYTES + { + return std::result::Result::Err(kb_core::Error::config(format!( + "Token-2022 stateful read max_data_bytes must be between 1 and {}", + crate::MAX_TOKEN2022_STATEFUL_ACCOUNT_BYTES + ))); + } + let config = match kb_onchain_transport::GetAccountInfoConfig::new_with_data( + kb_onchain_transport::RpcCommitmentLevel::Confirmed, + request.min_context_slot, + request.max_data_bytes, + ) { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + let result = match pool + .get_account_info_for_role(request.query_role.as_str(), &request.account, &config) + .await + { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + return crate::materialize_token2022_account_info_result(request, &result); +} + +/// Validates one complete RPC account response before Token-2022 parsing and routing. +pub fn materialize_token2022_account_info_result( + request: &crate::Token2022StatefulReadRequest, + result: &kb_onchain_transport::AccountInfoResult, +) -> kb_core::Result { + if request.max_data_bytes == 0 + || request.max_data_bytes > crate::MAX_TOKEN2022_STATEFUL_ACCOUNT_BYTES + { + return std::result::Result::Err(kb_core::Error::config(format!( + "Token-2022 stateful read max_data_bytes must be between 1 and {}", + crate::MAX_TOKEN2022_STATEFUL_ACCOUNT_BYTES + ))); + } + if let std::option::Option::Some(min_context_slot) = request.min_context_slot { + if result.context.slot < min_context_slot { + return std::result::Result::Err(kb_core::Error::new( + "token2022_stateful_context_slot_too_old", + format!( + "Token-2022 account context slot {} is below requested minimum {min_context_slot}", + result.context.slot + ), + )); + } + } + let account = match result.account.as_ref() { + std::option::Option::Some(value) => value, + std::option::Option::None => { + return std::result::Result::Err(kb_core::Error::new( + "token2022_stateful_account_missing", + format!("Token-2022 account {} does not exist", request.account.0), + )); + }, + }; + if account.executable { + return std::result::Result::Err(kb_core::Error::new( + "token2022_stateful_account_executable", + format!("Token-2022 state account {} must not be executable", request.account.0), + )); + } + if account.owner.0 != kb_program_ids::SPL_TOKEN2022_PROGRAM_ID { + return std::result::Result::Err(kb_core::Error::new( + "token2022_stateful_owner_mismatch", + format!( + "Token-2022 state account {} owner must be {}, got {}", + request.account.0, + kb_program_ids::SPL_TOKEN2022_PROGRAM_ID, + account.owner.0 + ), + )); + } + if account.space > request.max_data_bytes as u64 || account.data.len() > request.max_data_bytes + { + return std::result::Result::Err(kb_core::Error::new( + "token2022_stateful_account_too_large", + format!( + "Token-2022 account {} reports {} bytes and returned {} bytes above limit {}", + request.account.0, + account.space, + account.data.len(), + request.max_data_bytes + ), + )); + } + if account.space != account.data.len() as u64 { + return std::result::Result::Err(kb_core::Error::new( + "token2022_stateful_account_data_incomplete", + format!( + "Token-2022 account {} reports {} bytes but returned {} decoded bytes", + request.account.0, + account.space, + account.data.len() + ), + )); + } + let state = match kb_lib::decoder_spl_token2022_parse_token2022_state( + request.kind, + account.data.as_slice(), + ) { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => { + return std::result::Result::Err(kb_core::Error::new( + "token2022_stateful_parse_failed", + error, + )); + }, + }; + let snapshot = match crate::materialize_parsed_token2022_stateful_snapshot_with_context( + request.account.0.as_str(), + result.context.slot, + &state, + &request.context, + ) { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => { + return std::result::Result::Err(kb_core::Error::new( + "token2022_stateful_projection_failed", + error, + )); + }, + }; + return std::result::Result::Ok(crate::Token2022StatefulReadResult { + commitment: "confirmed".to_string(), + context_slot: result.context.slot, + snapshot, + }); +} + +/// One contextually validated Token-2022 state snapshot and its owned projections. +#[derive(Clone, Debug, PartialEq, serde::Deserialize, serde::Serialize)] +pub struct Token2022StatefulSnapshotBundle { + /// Canonical account identity used for contextual checks and stable output keys. + pub account_key: std::string::String, + /// Context slot associated with the account read. + pub slot: u64, + /// Parsed base-state category. + pub state_kind: std::string::String, + /// Ordered published or future extension names retained by the parser. + pub extension_names: std::vec::Vec, + /// Processor-owned projections routed without duplicate ownership. + pub outputs: std::vec::Vec, +} + +/// Parse, contextually validate, and materialize one bounded Token-2022 account snapshot. +pub fn materialize_token2022_stateful_snapshot( + account_key: &str, + owner_program_id: &str, + slot: u64, + kind: kb_lib::DcToken2022StateKind, + data: &[u8], +) -> std::result::Result { + if owner_program_id != kb_program_ids::SPL_TOKEN2022_PROGRAM_ID { + return std::result::Result::Err(format!( + "Token-2022 state snapshot owner must be {}, got {owner_program_id}", + kb_program_ids::SPL_TOKEN2022_PROGRAM_ID + )); + } + let state = match kb_lib::decoder_spl_token2022_parse_token2022_state(kind, data) { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + return crate::materialize_parsed_token2022_stateful_snapshot_with_context( + account_key, + slot, + &state, + &crate::Token2022StatefulContext::default(), + ); +} + +/// Contextually validate and materialize one already parsed Token-2022 account snapshot. +pub fn materialize_parsed_token2022_stateful_snapshot( + account_key: &str, + slot: u64, + state: &kb_lib::DcToken2022State, +) -> std::result::Result { + return crate::materialize_parsed_token2022_stateful_snapshot_with_context( + account_key, + slot, + state, + &crate::Token2022StatefulContext::default(), + ); +} + +/// Contextually validate one parsed Token-2022 snapshot with external cross-account identities. +pub fn materialize_parsed_token2022_stateful_snapshot_with_context( + account_key: &str, + slot: u64, + state: &kb_lib::DcToken2022State, + context: &crate::Token2022StatefulContext, +) -> std::result::Result { + if account_key.trim().is_empty() { + return std::result::Result::Err( + "Token-2022 stateful snapshot requires a non-empty account key".to_string(), + ); + } + let decoded = match bs58::decode(account_key).into_vec() { + std::result::Result::Ok(value) => value, + std::result::Result::Err(_) => { + return std::result::Result::Err( + "Token-2022 stateful snapshot account key must be valid base58".to_string(), + ); + }, + }; + if decoded.len() != 32 { + return std::result::Result::Err(format!( + "Token-2022 stateful snapshot account key must decode to 32 bytes, got {}", + decoded.len() + )); + } + match validate_embedded_mint_identity(account_key, state) { + std::result::Result::Ok(()) => {}, + std::result::Result::Err(error) => return std::result::Result::Err(error), + } + match validate_group_member_identity(state, context) { + std::result::Result::Ok(()) => {}, + std::result::Result::Err(error) => return std::result::Result::Err(error), + } + let account_output = match kb_lib::materializer_token_materialize_token2022_state_snapshot( + account_key, + slot, + state, + ) { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + let mut outputs = std::vec![account_output]; + if state.kind == kb_lib::DcToken2022StateKind::Mint { + let metadata_outputs = match kb_lib::materializer_metadata_materialize_token2022_snapshot( + account_key, + slot, + state, + ) { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + outputs.extend(metadata_outputs); + } + let fee_outputs = match kb_lib::materializer_fees_materialize_token2022_state_snapshots( + account_key, + slot, + state, + ) { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + outputs.extend(fee_outputs); + let admin_outputs = match kb_lib::materializer_admin_materialize_token2022_state_snapshots( + account_key, + slot, + state, + ) { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + outputs.extend(admin_outputs); + let state_kind = match state.kind { + kb_lib::DcToken2022StateKind::Mint => "mint", + kb_lib::DcToken2022StateKind::Account => "account", + kb_lib::DcToken2022StateKind::Multisig => "multisig", + }; + return std::result::Result::Ok(crate::Token2022StatefulSnapshotBundle { + account_key: account_key.to_string(), + slot, + state_kind: state_kind.to_string(), + extension_names: state + .extensions + .iter() + .map(|entry| return entry.extension_name.to_string()) + .collect(), + outputs, + }); +} + +fn validate_embedded_mint_identity( + account_key: &str, + state: &kb_lib::DcToken2022State, +) -> std::result::Result<(), String> { + for entry in &state.extensions { + if entry.extension_name != "token_metadata" + && entry.extension_name != "token_group" + && entry.extension_name != "token_group_member" + { + continue; + } + let embedded_mint = entry.value_fields.get("mint").and_then(serde_json::Value::as_str); + let embedded_mint = match embedded_mint { + std::option::Option::Some(value) => value, + std::option::Option::None => { + return std::result::Result::Err(format!( + "Token-2022 {} extension requires a structured mint field", + entry.extension_name + )); + }, + }; + if embedded_mint != account_key { + return std::result::Result::Err(format!( + "Token-2022 {} mint {} does not match account {account_key}", + entry.extension_name, embedded_mint + )); + } + } + return std::result::Result::Ok(()); +} + +fn validate_group_member_identity( + state: &kb_lib::DcToken2022State, + context: &crate::Token2022StatefulContext, +) -> std::result::Result<(), String> { + for entry in &state.extensions { + if entry.extension_name != "token_group_member" { + continue; + } + let group = entry.value_fields.get("group").and_then(serde_json::Value::as_str); + let group = match group { + std::option::Option::Some(value) => value, + std::option::Option::None => { + return std::result::Result::Err( + "Token-2022 token_group_member extension requires a structured group field" + .to_string(), + ); + }, + }; + let expected = match context.expected_group_address.as_deref() { + std::option::Option::Some(value) => value, + std::option::Option::None => { + return std::result::Result::Err( + "Token-2022 token_group_member validation requires an expected group address" + .to_string(), + ); + }, + }; + if group != expected { + return std::result::Result::Err(format!( + "Token-2022 token_group_member group {group} does not match expected group {expected}" + )); + } + } + return std::result::Result::Ok(()); +} + +#[cfg(test)] +mod tests { + fn account_key(byte: u8) -> String { + return bs58::encode([byte; 32]).into_string(); + } + + fn mint_state(account_key: &str) -> kb_lib::DcToken2022State { + return kb_lib::DcToken2022State { + kind: kb_lib::DcToken2022StateKind::Mint, + base_fields: serde_json::json!({"supply":"1","decimals":0,"initialized":true}), + base_hex: "00".repeat(82), + account_type: std::option::Option::Some(1), + extensions: std::vec![ + kb_lib::DcToken2022TlvEntry { + extension_type: 19, + extension_name: "token_metadata", + value_hex: "01".to_string(), + value_fields: serde_json::json!({ + "mint": account_key, + "name": "Token", + "symbol": "TOK", + "uri": "https://example.invalid/token.json" + }), + }, + kb_lib::DcToken2022TlvEntry { + extension_type: 20, + extension_name: "token_group", + value_hex: "02".to_string(), + value_fields: serde_json::json!({ + "mint": account_key, + "size": "1", + "maxSize": "10" + }), + }, + ], + }; + } + + #[test] + fn mint_snapshot_routes_account_and_metadata_outputs_once() { + let account_key = account_key(7); + let state = mint_state(account_key.as_str()); + let bundle = + crate::materialize_parsed_token2022_stateful_snapshot(account_key.as_str(), 42, &state); + assert_eq!( + bundle.as_ref().map(|value| return value.outputs.len()), + std::result::Result::Ok(3) + ); + assert_eq!( + bundle.as_ref().map(|value| return value.state_kind.clone()), + std::result::Result::Ok("mint".to_string()) + ); + assert_eq!( + bundle.as_ref().map(|value| return value.extension_names.clone()), + std::result::Result::Ok(std::vec![ + "token_metadata".to_string(), + "token_group".to_string() + ]) + ); + } + + #[test] + fn embedded_metadata_mint_must_match_the_account_identity() { + let account_address = account_key(8); + let mut state = mint_state(account_address.as_str()); + state.extensions[0].value_fields["mint"] = serde_json::json!(account_key(9)); + let result = crate::materialize_parsed_token2022_stateful_snapshot( + account_address.as_str(), + 42, + &state, + ); + assert!(result.is_err()); + } + + #[test] + fn owner_and_account_identity_fail_closed_before_projection() { + let account_key = account_key(10); + let data = [0u8; 82]; + let wrong_owner = crate::materialize_token2022_stateful_snapshot( + account_key.as_str(), + kb_program_ids::SPL_TOKEN_PROGRAM_ID, + 1, + kb_lib::DcToken2022StateKind::Mint, + &data, + ); + assert!(wrong_owner.is_err()); + let state = mint_state(account_key.as_str()); + let malformed_key = + crate::materialize_parsed_token2022_stateful_snapshot("not-base58-0", 1, &state); + assert!(malformed_key.is_err()); + } + + #[test] + fn account_snapshot_has_no_metadata_projection() { + let account_key = account_key(11); + let state = kb_lib::DcToken2022State { + kind: kb_lib::DcToken2022StateKind::Account, + base_fields: serde_json::json!({"amount":"0","state":"initialized"}), + base_hex: "00".repeat(165), + account_type: std::option::Option::None, + extensions: std::vec::Vec::new(), + }; + let bundle = + crate::materialize_parsed_token2022_stateful_snapshot(account_key.as_str(), 9, &state); + assert_eq!( + bundle.as_ref().map(|value| return value.outputs.len()), + std::result::Result::Ok(1) + ); + } + #[test] + fn group_member_requires_and_matches_external_group_identity() { + let account_address = account_key(12); + let group_address = account_key(13); + let state = kb_lib::DcToken2022State { + kind: kb_lib::DcToken2022StateKind::Mint, + base_fields: serde_json::json!({"supply":"1","decimals":0,"initialized":true}), + base_hex: "00".repeat(82), + account_type: std::option::Option::Some(1), + extensions: std::vec![kb_lib::DcToken2022TlvEntry { + extension_type: 23, + extension_name: "token_group_member", + value_hex: "03".to_string(), + value_fields: serde_json::json!({ + "mint": account_address, + "group": group_address, + "memberNumber": "1" + }), + }], + }; + let missing = crate::materialize_parsed_token2022_stateful_snapshot( + account_address.as_str(), + 44, + &state, + ); + assert!(missing.is_err()); + let wrong_context = crate::Token2022StatefulContext { + expected_group_address: std::option::Option::Some(account_key(14)), + }; + let wrong = crate::materialize_parsed_token2022_stateful_snapshot_with_context( + account_address.as_str(), + 44, + &state, + &wrong_context, + ); + assert!(wrong.is_err()); + let context = crate::Token2022StatefulContext { + expected_group_address: std::option::Option::Some(group_address), + }; + let valid = crate::materialize_parsed_token2022_stateful_snapshot_with_context( + account_address.as_str(), + 44, + &state, + &context, + ); + assert_eq!( + valid.as_ref().map(|bundle| return bundle.outputs.len()), + std::result::Result::Ok(2) + ); + } + #[test] + fn mint_and_account_fee_extensions_route_to_the_fee_owner_once() { + let mint_address = account_key(15); + let mint = kb_lib::DcToken2022State { + kind: kb_lib::DcToken2022StateKind::Mint, + base_fields: serde_json::json!({"supply":"1","decimals":0,"initialized":true}), + base_hex: "00".repeat(82), + account_type: std::option::Option::Some(1), + extensions: std::vec![kb_lib::DcToken2022TlvEntry { + extension_type: 1, + extension_name: "transfer_fee_config", + value_hex: "01".to_string(), + value_fields: serde_json::json!({"withheldAmount":"7"}), + }], + }; + let mint_bundle = + crate::materialize_parsed_token2022_stateful_snapshot(mint_address.as_str(), 50, &mint); + assert_eq!( + mint_bundle.as_ref().map(|bundle| return bundle.outputs.len()), + std::result::Result::Ok(2) + ); + assert_eq!( + mint_bundle + .as_ref() + .map(|bundle| return bundle.outputs[1].payload_json["provenance"]["processorName"] + .clone()), + std::result::Result::Ok(serde_json::json!("fees")) + ); + let account_address = account_key(16); + let account = kb_lib::DcToken2022State { + kind: kb_lib::DcToken2022StateKind::Account, + base_fields: serde_json::json!({"amount":"0","state":"initialized"}), + base_hex: "00".repeat(165), + account_type: std::option::Option::Some(2), + extensions: std::vec![kb_lib::DcToken2022TlvEntry { + extension_type: 17, + extension_name: "confidential_transfer_fee_amount", + value_hex: "02".to_string(), + value_fields: serde_json::json!({"withheldAmount":"ciphertext"}), + }], + }; + let account_bundle = crate::materialize_parsed_token2022_stateful_snapshot( + account_address.as_str(), + 51, + &account, + ); + assert_eq!( + account_bundle.as_ref().map(|bundle| return bundle.outputs.len()), + std::result::Result::Ok(2) + ); + assert_eq!( + account_bundle + .as_ref() + .map(|bundle| return bundle.outputs[1].payload_json["confidentialValuesDecrypted"] + .clone()), + std::result::Result::Ok(serde_json::json!(false)) + ); + } + + #[test] + fn mint_admin_extensions_route_to_the_admin_owner_once() { + let account_key = account_key(12); + let mut state = mint_state(account_key.as_str()); + state.extensions.push(kb_lib::DcToken2022TlvEntry { + extension_type: 6, + extension_name: "default_account_state", + value_hex: "02".to_string(), + value_fields: serde_json::json!({"state": 2}), + }); + let bundle = + crate::materialize_parsed_token2022_stateful_snapshot(account_key.as_str(), 17, &state); + assert_eq!( + bundle.as_ref().map(|value| return value.outputs.len()), + std::result::Result::Ok(4) + ); + assert_eq!( + bundle.as_ref().map(|value| { + return value + .outputs + .iter() + .filter(|output| { + return output.payload_json["domain"] + == serde_json::json!("token2022_extension_admin_state"); + }) + .count(); + }), + std::result::Result::Ok(1) + ); + } + + #[test] + fn complete_rpc_account_routes_only_after_owner_size_and_context_validation() { + let account = account_key(21); + let request = crate::Token2022StatefulReadRequest { + query_role: "execution".to_string(), + account: kb_lib::MdPubkey(account.clone()), + kind: kb_lib::DcToken2022StateKind::Mint, + min_context_slot: std::option::Option::Some(40), + max_data_bytes: 82, + context: crate::Token2022StatefulContext::default(), + }; + let result = kb_onchain_transport::AccountInfoResult { + context: kb_onchain_transport::RpcResponseContext { + slot: 41, + api_version: std::option::Option::None, + }, + account: std::option::Option::Some(kb_onchain_transport::AccountInfoValue { + lamports: 1, + owner: kb_lib::MdProgramId(kb_program_ids::SPL_TOKEN2022_PROGRAM_ID.to_string()), + executable: false, + rent_epoch: 0, + space: 82, + data: std::vec![0; 82], + }), + }; + let materialized = crate::materialize_token2022_account_info_result(&request, &result); + assert_eq!( + materialized.as_ref().map(|value| return value.context_slot), + std::result::Result::Ok(41) + ); + assert_eq!( + materialized.as_ref().map(|value| return value.snapshot.outputs.len()), + std::result::Result::Ok(1) + ); + } + + #[test] + fn incomplete_foreign_executable_and_stale_rpc_accounts_fail_closed() { + let account = account_key(22); + let request = crate::Token2022StatefulReadRequest { + query_role: "execution".to_string(), + account: kb_lib::MdPubkey(account), + kind: kb_lib::DcToken2022StateKind::Mint, + min_context_slot: std::option::Option::Some(50), + max_data_bytes: 82, + context: crate::Token2022StatefulContext::default(), + }; + let base = kb_onchain_transport::AccountInfoValue { + lamports: 1, + owner: kb_lib::MdProgramId(kb_program_ids::SPL_TOKEN2022_PROGRAM_ID.to_string()), + executable: false, + rent_epoch: 0, + space: 82, + data: std::vec![0; 82], + }; + let stale = kb_onchain_transport::AccountInfoResult { + context: kb_onchain_transport::RpcResponseContext { + slot: 49, + api_version: std::option::Option::None, + }, + account: std::option::Option::Some(base.clone()), + }; + assert!(crate::materialize_token2022_account_info_result(&request, &stale).is_err()); + let mut foreign = base.clone(); + foreign.owner = kb_lib::MdProgramId(kb_program_ids::SPL_TOKEN_PROGRAM_ID.to_string()); + let foreign = kb_onchain_transport::AccountInfoResult { + context: kb_onchain_transport::RpcResponseContext { + slot: 50, + api_version: std::option::Option::None, + }, + account: std::option::Option::Some(foreign), + }; + assert!(crate::materialize_token2022_account_info_result(&request, &foreign).is_err()); + let mut executable = base.clone(); + executable.executable = true; + let executable = kb_onchain_transport::AccountInfoResult { + context: kb_onchain_transport::RpcResponseContext { + slot: 50, + api_version: std::option::Option::None, + }, + account: std::option::Option::Some(executable), + }; + assert!(crate::materialize_token2022_account_info_result(&request, &executable).is_err()); + let mut incomplete = base; + incomplete.data.pop(); + let incomplete = kb_onchain_transport::AccountInfoResult { + context: kb_onchain_transport::RpcResponseContext { + slot: 50, + api_version: std::option::Option::None, + }, + account: std::option::Option::Some(incomplete), + }; + assert!(crate::materialize_token2022_account_info_result(&request, &incomplete).is_err()); + } +}