diff --git a/CHANGELOG.md b/CHANGELOG.md index fb45a18..e57b347 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,7 +3,7 @@ - Migration complète de l’orchestration HTTP `backfill` dans `kb-pipeline`. - Conservation des sources explicites et historiques par adresse, des directions `before`/`after`, de la pagination bornée, des retries et de la reprise déterministe. - Adaptation aux façades `kb-onchain-transport`, `kb-store` et `kb-lib`. -- Conservation des 15 tests bot2, dont les deux tests async d’annulation coopérative. +- Conservation des 14 tests bot2, dont les deux tests async d’annulation coopérative. ## 0.1.0-pre.030 @@ -111,3 +111,8 @@ - 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 + +- 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`. diff --git a/README.md b/README.md index d211152..233fe55 100644 --- a/README.md +++ b/README.md @@ -103,3 +103,5 @@ La tranche `0.1.0-pre.029` migre la planification et l’extraction core de `kb- ### Backfill HTTP 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. diff --git a/ROADMAP.md b/ROADMAP.md index 89e5959..f1d8c61 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -294,6 +294,8 @@ - [x] Conserver les directions `before` et `after`, les ancres et la pagination RPC bornée. - [x] Conserver la reprise au dernier candidat contigu, les retries, le pacing et l’annulation coopérative. - [x] Adapter la persistance à `kb-store` et l’acquisition à `kb-onchain-transport`. -- [x] Conserver les 15 tests bot2. +- [x] Conserver les 14 tests bot2. - [ ] 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. diff --git a/kb-onchain-transport/src/get_transaction.rs b/kb-onchain-transport/src/get_transaction.rs index ecee400..deb837e 100644 --- a/kb-onchain-transport/src/get_transaction.rs +++ b/kb-onchain-transport/src/get_transaction.rs @@ -1,5 +1,5 @@ // file: kb-onchain-transport/src/get_transaction.rs -// version: 8 +// version: 9 //! Standard Solana `getTransaction` adapter for the canonical transaction contract. @@ -593,7 +593,7 @@ mod tests { include_str!("../tests/fixtures/get_transaction_legacy_failure.json") }, "v0_alt" => { - include_str!("../tests/fixtures/get_transaction_v0_alt_cpi_token_2022.json") + include_str!("../tests/fixtures/get_transaction_v0_alt_cpi_token2022.json") }, "optional_absent" => { include_str!("../tests/fixtures/get_transaction_optional_absent.json") diff --git a/kb-onchain-transport/tests/fixtures/get_transaction_v0_alt_cpi_token_2022.json b/kb-onchain-transport/tests/fixtures/get_transaction_v0_alt_cpi_token2022.json similarity index 100% rename from kb-onchain-transport/tests/fixtures/get_transaction_v0_alt_cpi_token_2022.json rename to kb-onchain-transport/tests/fixtures/get_transaction_v0_alt_cpi_token2022.json diff --git a/kb-pipeline/Cargo.toml b/kb-pipeline/Cargo.toml index da72994..d85d337 100644 --- a/kb-pipeline/Cargo.toml +++ b/kb-pipeline/Cargo.toml @@ -1,5 +1,5 @@ # file: kb-pipeline/Cargo.toml -# version: 7 +# version: 8 [package] name = "kb-pipeline" @@ -18,6 +18,7 @@ kb-onchain-transport = { path = "../kb-onchain-transport" } kb-program-ids = { path = "../kb-program-ids" } kb-store = { path = "../kb-store" } kb-wallet = { path = "../kb-wallet" } +serde.workspace = true serde_json.workspace = true sha2.workspace = true tokio.workspace = true diff --git a/kb-pipeline/src/lib.rs b/kb-pipeline/src/lib.rs index 5d3e62a..cfe656f 100644 --- a/kb-pipeline/src/lib.rs +++ b/kb-pipeline/src/lib.rs @@ -1,5 +1,5 @@ // file: kb-pipeline/src/lib.rs -// version: 5 +// version: 7 #![forbid(unsafe_code)] #![deny(unreachable_pub)] @@ -12,6 +12,8 @@ mod constants; mod core_extraction; mod decode_replay; mod plan; +mod solana_token2022_devnet_scenarios; +mod solana_token2022_validation; /// Address category used by one targeted backfill campaign. pub use self::backfill::BackfillAddressKind; @@ -31,8 +33,6 @@ pub use self::backfill::BackfillSource; pub use self::backfill::BackfillSummary; /// Executes one bounded HTTP transaction backfill campaign. pub use self::backfill::execute_http_backfill; -/// Canonical tracing target for pipeline orchestration. -pub(crate) use self::constants::TRACING_TARGET; /// Stable core extraction processor name. pub use self::core_extraction::CORE_EXTRACTION_PROCESSOR_NAME; /// Stable core extraction processor version. @@ -83,3 +83,36 @@ pub use self::decode_replay::new_decode_campaign_id; pub use self::plan::PipelineStage; /// Replay selection scope. pub use self::plan::ReplayScope; +/// Stable category of one independent Devnet validation scenario. +pub use self::solana_token2022_devnet_scenarios::DevnetSplValidationFamily; +/// Current implementation status of one Devnet validation scenario. +pub use self::solana_token2022_devnet_scenarios::DevnetSplValidationImplementationStatus; +/// One independent Devnet validation scenario exposed to applications. +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; +/// 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. +pub use self::solana_token2022_validation::Token2022ValidationEnvironment; +/// One bounded proof attached to a Token-2022 validation scenario. +pub use self::solana_token2022_validation::Token2022ValidationEvidence; +/// Machine-readable Token-2022 validation matrix. +pub use self::solana_token2022_validation::Token2022ValidationMatrix; +/// One scenario declared by the canonical validation matrix. +pub use self::solana_token2022_validation::Token2022ValidationMatrixScenario; +/// Complete bounded Token-2022 validation report. +pub use self::solana_token2022_validation::Token2022ValidationReport; +/// One declared Token-2022 validation scenario and its observed evidence. +pub use self::solana_token2022_validation::Token2022ValidationScenario; +/// Exact status of one Token-2022 validation scenario. +pub use self::solana_token2022_validation::Token2022ValidationStatus; +/// Loads and validates the canonical Token-2022 validation matrix. +pub use self::solana_token2022_validation::load_token2022_validation_matrix; +/// Validates one Token-2022 validation matrix. +pub use self::solana_token2022_validation::validate_token2022_validation_matrix; +/// Validates one bounded Token-2022 milestone report. +pub use self::solana_token2022_validation::validate_token2022_validation_report; + +/// Canonical tracing target for pipeline orchestration. +pub(crate) use self::constants::TRACING_TARGET; diff --git a/kb-pipeline/src/solana_token2022_devnet_scenarios.rs b/kb-pipeline/src/solana_token2022_devnet_scenarios.rs new file mode 100644 index 0000000..a0a805e --- /dev/null +++ b/kb-pipeline/src/solana_token2022_devnet_scenarios.rs @@ -0,0 +1,180 @@ +// file: kb-pipeline/src/solana_token2022_devnet_scenarios.rs +// version: 4 + +//! Stable Devnet validation scenarios required to close milestone 0.4.6. + +/// Stable category of one independent Devnet validation scenario. +#[derive(Clone, Copy, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)] +#[serde(rename_all = "snake_case")] +pub enum DevnetSplValidationFamily { + /// Public Token-2022 state mutations without cryptographic proofs. + Token2022Public, + /// ElGamal Registry lifecycle kept technically separate from Token-2022. + ElGamalRegistry, + /// Token-2022 confidential operations requiring proof material. + Token2022Confidential, +} + +/// Stable status of one scenario in the application validation workflow. +#[derive(Clone, Copy, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)] +#[serde(rename_all = "snake_case")] +pub enum DevnetSplValidationImplementationStatus { + /// The scenario can be simulated and submitted through the application. + Executable, + /// The typed backend exists but the application execution path remains to be connected. + BackendReady, + /// The scenario requires externally prepared proof material before execution. + ProofFixtureRequired, +} + +/// One independent Devnet validation scenario exposed to applications. +#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)] +pub struct DevnetSplValidationScenario { + /// Stable identifier used by scripts, tests and the frontend. + pub id: std::string::String, + /// Operator-visible label. + pub label: std::string::String, + /// Scenario family. + pub family: crate::DevnetSplValidationFamily, + /// Stable executor operation code. + pub operation_code: std::string::String, + /// Current implementation status. + pub implementation_status: crate::DevnetSplValidationImplementationStatus, + /// Whether this scenario mutates on-chain state. + pub destructive: bool, + /// Whether explicit operator confirmation is mandatory before submission. + pub operator_confirmation_required: bool, + /// Whether cryptographic proof material is required. + pub proof_required: bool, + /// Ordered fixture variables required by the scenario. + pub required_fixture_variables: std::vec::Vec, +} + +/// Returns the complete ordered Devnet scenario inventory for milestone 0.4.6. +pub fn devnet_spl_validation_scenarios() -> std::vec::Vec { + return vec![ + public_scenario("token2022_mint_to_checked", "Token-2022 MintToChecked", kb_lib::EX_SPL_TOKEN2022_MINT_TO_CHECKED_OPERATION, &["TOKEN2022_MINT", "TOKEN2022_SOURCE", "TOKEN2022_AUTHORITY", "TOKEN2022_DECIMALS", "TOKEN2022_MINT_AMOUNT_RAW"]), + public_scenario("token2022_transfer_checked", "Token-2022 TransferChecked", kb_lib::EX_SPL_TOKEN2022_TRANSFER_CHECKED_OPERATION, &["TOKEN2022_SOURCE", "TOKEN2022_MINT", "TOKEN2022_DESTINATION", "TOKEN2022_AUTHORITY", "TOKEN2022_DECIMALS", "TOKEN2022_TRANSFER_AMOUNT_RAW"]), + public_scenario("token2022_approve_checked", "Token-2022 ApproveChecked", kb_lib::EX_SPL_TOKEN2022_APPROVE_CHECKED_OPERATION, &["TOKEN2022_SOURCE", "TOKEN2022_MINT", "TOKEN2022_DELEGATE", "TOKEN2022_AUTHORITY", "TOKEN2022_DECIMALS", "TOKEN2022_APPROVE_AMOUNT_RAW"]), + public_scenario("token2022_revoke", "Token-2022 Revoke", kb_lib::EX_SPL_TOKEN2022_REVOKE_OPERATION, &["TOKEN2022_SOURCE", "TOKEN2022_AUTHORITY"]), + public_scenario("token2022_burn_checked", "Token-2022 BurnChecked", kb_lib::EX_SPL_TOKEN2022_BURN_CHECKED_OPERATION, &["TOKEN2022_SOURCE", "TOKEN2022_MINT", "TOKEN2022_AUTHORITY", "TOKEN2022_DECIMALS", "TOKEN2022_BURN_AMOUNT_RAW"]), + public_scenario("token2022_freeze_account", "Token-2022 FreezeAccount", kb_lib::EX_SPL_TOKEN2022_FREEZE_ACCOUNT_OPERATION, &["TOKEN2022_SOURCE", "TOKEN2022_MINT", "TOKEN2022_FREEZE_AUTHORITY"]), + public_scenario("token2022_thaw_account", "Token-2022 ThawAccount", kb_lib::EX_SPL_TOKEN2022_THAW_ACCOUNT_OPERATION, &["TOKEN2022_SOURCE", "TOKEN2022_MINT", "TOKEN2022_FREEZE_AUTHORITY"]), + public_scenario("token2022_close_destination", "Token-2022 CloseAccount destination", kb_lib::EX_SPL_TOKEN2022_CLOSE_ACCOUNT_OPERATION, &["TOKEN2022_DESTINATION", "KB_DEVNET_WALLET_ADDRESS", "TOKEN2022_AUTHORITY"]), + registry_scenario("elgamal_registry_create", "ElGamal Registry CreateRegistry", "spl_elgamal_registry.create_registry", &["ELGAMAL_REGISTRY_ADDRESS", "ELGAMAL_PUBKEY_BASE64", "PUBKEY_VALIDITY_PROOF_CONTEXT"]), + registry_scenario("elgamal_registry_update", "ElGamal Registry UpdateRegistry", "spl_elgamal_registry.update_registry", &["ELGAMAL_REGISTRY_ADDRESS", "ELGAMAL_PUBKEY_BASE64", "PUBKEY_VALIDITY_PROOF_CONTEXT"]), + confidential_scenario("token2022_configure_confidential_account", "Token-2022 ConfigureAccountWithRegistry", kb_lib::EX_SPL_TOKEN2022_CONFIGURE_CONFIDENTIAL_TRANSFER_ACCOUNT_WITH_REGISTRY_OPERATION, &["TOKEN2022_SOURCE", "TOKEN2022_MINT", "ELGAMAL_REGISTRY_ADDRESS", "PUBKEY_VALIDITY_PROOF_CONTEXT"]), + ]; +} + +fn public_scenario( + id: &str, + label: &str, + operation_code: &str, + variables: &[&str], +) -> crate::DevnetSplValidationScenario { + return scenario( + id, + label, + crate::DevnetSplValidationFamily::Token2022Public, + operation_code, + crate::DevnetSplValidationImplementationStatus::Executable, + true, + false, + variables, + ); +} + +fn registry_scenario( + id: &str, + label: &str, + operation_code: &str, + variables: &[&str], +) -> crate::DevnetSplValidationScenario { + return scenario( + id, + label, + crate::DevnetSplValidationFamily::ElGamalRegistry, + operation_code, + crate::DevnetSplValidationImplementationStatus::ProofFixtureRequired, + true, + true, + variables, + ); +} + +fn confidential_scenario( + id: &str, + label: &str, + operation_code: &str, + variables: &[&str], +) -> crate::DevnetSplValidationScenario { + return scenario( + id, + label, + crate::DevnetSplValidationFamily::Token2022Confidential, + operation_code, + crate::DevnetSplValidationImplementationStatus::ProofFixtureRequired, + true, + true, + variables, + ); +} + +fn scenario( + id: &str, + label: &str, + family: crate::DevnetSplValidationFamily, + operation_code: &str, + implementation_status: crate::DevnetSplValidationImplementationStatus, + destructive: bool, + proof_required: bool, + variables: &[&str], +) -> crate::DevnetSplValidationScenario { + return crate::DevnetSplValidationScenario { + id: id.to_string(), + label: label.to_string(), + family, + operation_code: operation_code.to_string(), + implementation_status, + destructive, + operator_confirmation_required: destructive, + proof_required, + required_fixture_variables: variables + .iter() + .map(|value| return (*value).to_string()) + .collect(), + }; +} + +#[cfg(test)] +mod tests { + #[test] + fn milestone_inventory_is_stable_unique_and_keeps_registry_separate() { + let scenarios = crate::devnet_spl_validation_scenarios(); + assert_eq!(scenarios.len(), 11); + let ids = scenarios + .iter() + .map(|scenario| return scenario.id.as_str()) + .collect::>(); + assert_eq!(ids.len(), scenarios.len()); + assert!( + scenarios.iter().any(|scenario| return scenario.family + == crate::DevnetSplValidationFamily::ElGamalRegistry) + ); + assert!( + scenarios + .iter() + .filter(|scenario| return scenario.family + == crate::DevnetSplValidationFamily::Token2022Public) + .all(|scenario| return !scenario.proof_required) + ); + } + + #[test] + fn every_mutating_scenario_requires_operator_confirmation() { + assert!(crate::devnet_spl_validation_scenarios().iter().all(|scenario| { + return !scenario.destructive || scenario.operator_confirmation_required; + })); + } +} diff --git a/kb-pipeline/src/solana_token2022_validation.rs b/kb-pipeline/src/solana_token2022_validation.rs new file mode 100644 index 0000000..c92872a --- /dev/null +++ b/kb-pipeline/src/solana_token2022_validation.rs @@ -0,0 +1,440 @@ +// file: kb-pipeline/src/solana_token2022_validation.rs +// version: 4 + +//! Machine-readable validation evidence contract for the Token-2022 milestone. + +/// Maximum number of evidence records accepted in one validation report. +pub const MAX_TOKEN2022_VALIDATION_EVIDENCE: usize = 64; + +/// Required validation environment for one scenario. +#[derive(Clone, Copy, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)] +#[serde(rename_all = "snake_case")] +pub enum Token2022ValidationEnvironment { + /// Deterministic offline fixtures and builder comparisons. + Offline, + /// A local validator under operator control. + Localnet, + /// Solana Devnet. + Devnet, + /// Scenario may run on Localnet or Devnet according to deployment availability. + LocalnetOrDevnet, + /// Mainnet observations without mutable execution. + MainnetObservation, + /// PostgreSQL persistence and replay validation. + Postgres, + /// Tauri application smoke validation. + Tauri, +} + +/// Exact status of one validation scenario. +#[derive(Clone, Copy, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)] +#[serde(rename_all = "snake_case")] +pub enum Token2022ValidationStatus { + /// Scenario is declared but has not been run. + NotRun, + /// Scenario was simulated without submission. + Simulated, + /// Scenario was submitted but confirmation evidence is incomplete. + Submitted, + /// Scenario was confirmed and its required postconditions passed. + Confirmed, + /// Scenario is not available in the selected environment. + Unavailable, + /// Scenario ran and failed. + Failed, +} + +/// One bounded proof attached to a validation scenario. +#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)] +pub struct Token2022ValidationEvidence { + /// Stable evidence kind such as `signature`, `test_suite`, or `replay`. + pub kind: std::string::String, + /// Bounded evidence value. + pub value: std::string::String, +} + +/// One declared Token-2022 validation scenario and its observed evidence. +#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)] +pub struct Token2022ValidationScenario { + /// Stable scenario identifier. + pub id: std::string::String, + /// Validation environment. + pub environment: crate::Token2022ValidationEnvironment, + /// Exact observed status. + pub status: crate::Token2022ValidationStatus, + /// Whether canonical hydration is required. + pub requires_canonical_hydration: bool, + /// Whether core extraction is required. + pub requires_core_extraction: bool, + /// Whether decode replay is required. + pub requires_decode_replay: bool, + /// Whether materialization is required. + pub requires_materialization: bool, + /// Whether a second idempotent replay is required. + pub requires_second_replay: bool, + /// Ordered evidence records. + pub evidence: std::vec::Vec, +} + +/// Machine-readable validation matrix loaded from the canonical JSON document. +#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)] +#[serde(rename_all = "camelCase")] +pub struct Token2022ValidationMatrix { + /// Matrix schema version. + pub matrix_version: u32, + /// Milestone owning this matrix. + pub milestone: std::string::String, + /// Aggregate matrix status. + pub status: std::string::String, + /// Exact accepted status vocabulary. + pub status_vocabulary: std::vec::Vec, + /// Scenarios in stable roadmap order. + pub scenarios: std::vec::Vec, +} + +/// One scenario declared by the canonical validation matrix. +#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)] +#[serde(rename_all = "camelCase")] +pub struct Token2022ValidationMatrixScenario { + /// Stable scenario identifier. + pub id: std::string::String, + /// Required execution environment. + pub environment: crate::Token2022ValidationEnvironment, + /// Exact observed status. + pub status: crate::Token2022ValidationStatus, + /// Evidence kinds required before this scenario may be confirmed. + pub required_evidence: std::vec::Vec, + /// Observed bounded evidence. + #[serde(default)] + pub evidence: std::vec::Vec, +} + +/// Loads and validates the canonical Token-2022 validation matrix. +pub fn load_token2022_validation_matrix() -> kb_core::Result { + let parsed = match serde_json::from_str::(include_str!( + "../../docs/SPL_TOKEN2022_VALIDATION_MATRIX.json" + )) { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => { + return std::result::Result::Err(kb_core::Error::new( + "token2022_validation_matrix_invalid_json", + format!("Token-2022 validation matrix JSON is invalid: {error}"), + )); + }, + }; + if let std::result::Result::Err(error) = validate_token2022_validation_matrix(&parsed) { + return std::result::Result::Err(error); + } + return std::result::Result::Ok(parsed); +} + +/// Validates schema, scenario inventory, statuses, and observed evidence. +pub fn validate_token2022_validation_matrix( + matrix: &crate::Token2022ValidationMatrix, +) -> kb_core::Result<()> { + if matrix.matrix_version != 2 || matrix.milestone != "0.4.6" { + return std::result::Result::Err(kb_core::Error::new( + "token2022_validation_matrix_contract_mismatch", + "Token-2022 validation matrix must use version 2 for milestone 0.4.6", + )); + } + let expected_statuses = + ["not_run", "simulated", "submitted", "confirmed", "unavailable", "failed"]; + let actual_statuses = matrix + .status_vocabulary + .iter() + .map(|status| return status.as_str()) + .collect::>(); + if actual_statuses.as_slice() != expected_statuses { + return std::result::Result::Err(kb_core::Error::new( + "token2022_validation_matrix_status_vocabulary_mismatch", + "Token-2022 validation status vocabulary differs from the compiled contract", + )); + } + let expected_ids = [ + "offline_full_regression", + "token2022_public_devnet", + "elgamal_registry_devnet", + "confidential_transfer_localnet_or_devnet", + "confidential_mint_burn_localnet_or_devnet", + "permissioned_confidential_burn_localnet_or_devnet", + "mainnet_observation_corpus", + "postgres_double_replay", + "tauri_smoke", + ]; + let actual_ids = matrix + .scenarios + .iter() + .map(|scenario| return scenario.id.as_str()) + .collect::>(); + if actual_ids.as_slice() != expected_ids { + return std::result::Result::Err(kb_core::Error::new( + "token2022_validation_matrix_scenario_inventory_mismatch", + "Token-2022 validation scenario inventory or order differs from the compiled contract", + )); + } + for scenario in &matrix.scenarios { + if scenario.required_evidence.is_empty() + || scenario.required_evidence.len() > crate::MAX_TOKEN2022_VALIDATION_EVIDENCE + { + return std::result::Result::Err(kb_core::Error::new( + "token2022_validation_matrix_required_evidence_invalid", + "Every Token-2022 validation scenario must declare bounded required evidence", + )); + } + let mut required = std::collections::BTreeSet::<&str>::new(); + for evidence_kind in &scenario.required_evidence { + if evidence_kind.trim().is_empty() || !required.insert(evidence_kind.as_str()) { + return std::result::Result::Err(kb_core::Error::new( + "token2022_validation_matrix_required_evidence_invalid", + "Required Token-2022 validation evidence must be non-empty and unique", + )); + } + } + if scenario.status == crate::Token2022ValidationStatus::Confirmed { + let observed = scenario + .evidence + .iter() + .map(|evidence| return evidence.kind.as_str()) + .collect::>(); + if !required.iter().all(|kind| return observed.contains(kind)) { + return std::result::Result::Err(kb_core::Error::new( + "token2022_validation_matrix_confirmed_without_required_evidence", + format!( + "Confirmed Token-2022 validation scenario {} lacks required evidence", + scenario.id + ), + )); + } + } else if matches!( + scenario.status, + crate::Token2022ValidationStatus::NotRun + | crate::Token2022ValidationStatus::Unavailable + ) && !scenario.evidence.is_empty() + { + return std::result::Result::Err(kb_core::Error::new( + "token2022_validation_matrix_unobserved_with_evidence", + "Not-run or unavailable Token-2022 scenarios must not retain observed evidence", + )); + } + } + return std::result::Result::Ok(()); +} + +/// Complete validation report checked before milestone closure. +#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)] +pub struct Token2022ValidationReport { + /// Scenarios in stable roadmap order. + pub scenarios: std::vec::Vec, +} + +/// Validates that a milestone report is bounded, unique, and does not overclaim evidence. +pub fn validate_token2022_validation_report( + report: &crate::Token2022ValidationReport, +) -> kb_core::Result<()> { + if report.scenarios.is_empty() { + return std::result::Result::Err(kb_core::Error::config( + "Token-2022 validation report must contain at least one scenario", + )); + } + let mut ids = std::collections::BTreeSet::::new(); + for scenario in &report.scenarios { + if scenario.id.trim().is_empty() || !ids.insert(scenario.id.clone()) { + return std::result::Result::Err(kb_core::Error::new( + "token2022_validation_scenario_identity_invalid", + "Token-2022 validation scenario ids must be non-empty and unique", + )); + } + if scenario.evidence.len() > crate::MAX_TOKEN2022_VALIDATION_EVIDENCE { + return std::result::Result::Err(kb_core::Error::new( + "token2022_validation_evidence_limit_exceeded", + format!( + "Token-2022 validation accepts at most {} evidence records per scenario", + crate::MAX_TOKEN2022_VALIDATION_EVIDENCE + ), + )); + } + for evidence in &scenario.evidence { + if evidence.kind.trim().is_empty() || evidence.value.trim().is_empty() { + return std::result::Result::Err(kb_core::Error::new( + "token2022_validation_evidence_invalid", + "Token-2022 validation evidence kind and value must be non-empty", + )); + } + } + let completed = matches!( + scenario.status, + crate::Token2022ValidationStatus::Simulated + | crate::Token2022ValidationStatus::Submitted + | crate::Token2022ValidationStatus::Confirmed + | crate::Token2022ValidationStatus::Failed + ); + if completed && scenario.evidence.is_empty() { + return std::result::Result::Err(kb_core::Error::new( + "token2022_validation_completed_without_evidence", + "A completed Token-2022 validation scenario must retain evidence", + )); + } + if scenario.status == crate::Token2022ValidationStatus::Confirmed + && (scenario.requires_canonical_hydration + || scenario.requires_core_extraction + || scenario.requires_decode_replay + || scenario.requires_materialization + || scenario.requires_second_replay) + && !has_pipeline_evidence(scenario) + { + return std::result::Result::Err(kb_core::Error::new( + "token2022_validation_confirmed_without_pipeline_evidence", + "A confirmed end-to-end Token-2022 scenario must retain hydration, extraction, replay, materialization, and idempotence evidence", + )); + } + } + return std::result::Result::Ok(()); +} + +fn has_pipeline_evidence(scenario: &crate::Token2022ValidationScenario) -> bool { + let kinds = scenario + .evidence + .iter() + .map(|evidence| return evidence.kind.as_str()) + .collect::>(); + if scenario.requires_canonical_hydration && !kinds.contains("canonical_hydration") { + return false; + } + if scenario.requires_core_extraction && !kinds.contains("core_extraction") { + return false; + } + if scenario.requires_decode_replay && !kinds.contains("decode_replay") { + return false; + } + if scenario.requires_materialization && !kinds.contains("materialization") { + return false; + } + if scenario.requires_second_replay && !kinds.contains("second_replay") { + return false; + } + return true; +} + +#[cfg(test)] +mod tests { + fn scenario(status: crate::Token2022ValidationStatus) -> crate::Token2022ValidationScenario { + return crate::Token2022ValidationScenario { + id: "confidential_transfer_devnet".to_string(), + environment: crate::Token2022ValidationEnvironment::Devnet, + status, + requires_canonical_hydration: true, + requires_core_extraction: true, + requires_decode_replay: true, + requires_materialization: true, + requires_second_replay: true, + evidence: vec![ + crate::Token2022ValidationEvidence { + kind: "signature".to_string(), + value: "signature".to_string(), + }, + crate::Token2022ValidationEvidence { + kind: "canonical_hydration".to_string(), + value: "1".to_string(), + }, + crate::Token2022ValidationEvidence { + kind: "core_extraction".to_string(), + value: "1".to_string(), + }, + crate::Token2022ValidationEvidence { + kind: "decode_replay".to_string(), + value: "1".to_string(), + }, + crate::Token2022ValidationEvidence { + kind: "materialization".to_string(), + value: "1".to_string(), + }, + crate::Token2022ValidationEvidence { + kind: "second_replay".to_string(), + value: "0_new_outputs".to_string(), + }, + ], + }; + } + + #[test] + fn confirmed_end_to_end_scenario_requires_complete_pipeline_evidence() { + let report = crate::Token2022ValidationReport { + scenarios: vec![scenario(crate::Token2022ValidationStatus::Confirmed)], + }; + assert!(crate::validate_token2022_validation_report(&report).is_ok()); + let mut incomplete = scenario(crate::Token2022ValidationStatus::Confirmed); + incomplete.evidence.retain(|evidence| return evidence.kind != "second_replay"); + assert!( + crate::validate_token2022_validation_report(&crate::Token2022ValidationReport { + scenarios: vec![incomplete] + }) + .is_err() + ); + } + + #[test] + fn not_run_and_unavailable_scenarios_do_not_invent_evidence() { + let mut not_run = scenario(crate::Token2022ValidationStatus::NotRun); + not_run.evidence.clear(); + let mut unavailable = scenario(crate::Token2022ValidationStatus::Unavailable); + unavailable.id = "permissioned_burn_devnet".to_string(); + unavailable.evidence.clear(); + assert!( + crate::validate_token2022_validation_report(&crate::Token2022ValidationReport { + scenarios: vec![not_run, unavailable] + }) + .is_ok() + ); + } + + #[test] + fn duplicate_ids_empty_evidence_and_completed_without_evidence_fail_closed() { + let first = scenario(crate::Token2022ValidationStatus::Confirmed); + let duplicate = first.clone(); + assert!( + crate::validate_token2022_validation_report(&crate::Token2022ValidationReport { + scenarios: vec![first, duplicate] + }) + .is_err() + ); + let mut failed = scenario(crate::Token2022ValidationStatus::Failed); + failed.evidence.clear(); + assert!( + crate::validate_token2022_validation_report(&crate::Token2022ValidationReport { + scenarios: vec![failed] + }) + .is_err() + ); + } + + #[test] + fn canonical_matrix_matches_compiled_inventory_and_observed_offline_evidence() { + let matrix = crate::load_token2022_validation_matrix(); + assert!(matrix.is_ok()); + let matrix = match matrix { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => panic!("unexpected matrix error: {error}"), + }; + assert_eq!(matrix.scenarios.len(), 9); + assert_eq!(matrix.scenarios[0].status, crate::Token2022ValidationStatus::Confirmed); + assert_eq!(matrix.scenarios[0].evidence.len(), 4); + assert!( + matrix.scenarios[1..] + .iter() + .all(|scenario| return scenario.status == crate::Token2022ValidationStatus::NotRun) + ); + } + + #[test] + fn confirmed_matrix_scenario_requires_every_declared_evidence_kind() { + let matrix = crate::load_token2022_validation_matrix(); + assert!(matrix.is_ok()); + let mut matrix = match matrix { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => panic!("unexpected matrix error: {error}"), + }; + matrix.scenarios[0].evidence.retain(|evidence| return evidence.kind != "clippy"); + assert!(crate::validate_token2022_validation_matrix(&matrix).is_err()); + } +}