diff --git a/Cargo.toml b/Cargo.toml index 0663de4..acea3a1 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,5 +1,5 @@ # file: Cargo.toml -# version: 13 +# version: 14 [workspace] resolver = "3" @@ -18,7 +18,7 @@ members = [ ] [workspace.package] -version = "0.4.7-pre.6" +version = "0.4.7-pre.7" edition = "2024" license = "MIT" repository = "https://git.sasedev.com/Sasedev/khadhroony-bot3" diff --git a/docs/plans/V0_4_7_METAPLEX_TOKEN_METADATA_COMPLETION_PLAN.md b/docs/plans/V0_4_7_METAPLEX_TOKEN_METADATA_COMPLETION_PLAN.md index 7173fa0..01ea827 100644 --- a/docs/plans/V0_4_7_METAPLEX_TOKEN_METADATA_COMPLETION_PLAN.md +++ b/docs/plans/V0_4_7_METAPLEX_TOKEN_METADATA_COMPLETION_PLAN.md @@ -1,5 +1,5 @@ - + # Plan `0.4.7` — achèvement de Metaplex Token Metadata @@ -575,3 +575,11 @@ Le développement fonctionnel peut commencer après acceptation des recommandati 5. niveaux de preuve distincts pour les validations synthétiques, simulation réseau et envoi réel ; 6. séquence de prereleases `pre.002` à `pre.011` décrite ci-dessus. + +## État de `0.4.7-pre.007` + +- lectures stateful bornées dans `kb-pipeline` ; +- préflight généraliste lié aux plans `kb-lib` ; +- orchestration simulation-first, résolution des signers et postconditions explicites ; +- aucune dépendance vers `kb-pipeline-demo-scenarios` ; +- les fixtures et campagnes réseau restent réservées à `pre.008`. diff --git a/docs/validation/V0_4_7_PRE_007_METAPLEX_PIPELINE_GENERALISTE.md b/docs/validation/V0_4_7_PRE_007_METAPLEX_PIPELINE_GENERALISTE.md new file mode 100644 index 0000000..12bcdb8 --- /dev/null +++ b/docs/validation/V0_4_7_PRE_007_METAPLEX_PIPELINE_GENERALISTE.md @@ -0,0 +1,21 @@ + + + +# Validation `0.4.7-pre.007` — pipeline généraliste Metaplex + +## Contrats introduits + +- lectures RPC `confirmed` bornées pour metadata, edition et token record ; +- validation owner, taille, contexte, PDA et décodage délégué à `kb-lib` ; +- préflight des plans Metaplex, corrélation bornée et approbation des opérations dépréciées ; +- orchestration liée au hash exact de simulation ; +- résolution complète des signers et blocage du send en dry-run ; +- postconditions `confirmed`, `contradicted` et `not_applicable` sans succès inventé. + +## Frontière + +`kb-pipeline` reste généraliste et ne dépend pas de `kb-pipeline-demo-scenarios`. Les fixtures et campagnes Devnet/Testnet sont reportées à `pre.008`. + +## Validation de préparation + +Les audits statiques doivent être exécutés dans l’environnement de préparation. Les commandes Cargo doivent être exécutées sur le workspace local, car `cargo` n’est pas disponible ici. diff --git a/kb-pipeline/CHANGELOG.md b/kb-pipeline/CHANGELOG.md index 9785b6a..38eeb21 100644 --- a/kb-pipeline/CHANGELOG.md +++ b/kb-pipeline/CHANGELOG.md @@ -1,8 +1,19 @@ - + # CHANGELOG — kb-pipeline +## 0.4.7-pre.007-delta-fix-001 — correction et documentation de l’API Metaplex + +- ajout de la rustdoc manquante sur les paramètres de dérivation des comptes edition et token record ; +- correction du test de préflight utilisant le code stable de `PuffMetadata` ; +- ajout d’exemples d’utilisation des lectures stateful, du préflight, de l’orchestration simulation-first et des postconditions Metaplex. + +## 0.4.7-pre.007 + +- ajout des lectures stateful bornées Metaplex Token Metadata ; +- ajout du préflight, de l’orchestration simulation-first et des postconditions explicites. + ## 0.4.7-pre.003-delta-fix-002 — dette de compatibilité des exécuteurs - ajout d’un TODO de réaudit de l’orchestration après extension future des exécuteurs Solana Core/SPL aux opérations obsolètes encore constructibles ; diff --git a/kb-pipeline/Cargo.toml b/kb-pipeline/Cargo.toml index 3b54456..142ff29 100644 --- a/kb-pipeline/Cargo.toml +++ b/kb-pipeline/Cargo.toml @@ -1,5 +1,5 @@ # file: kb-pipeline/Cargo.toml -# version: 13 +# version: 14 [package] name = "kb-pipeline" diff --git a/kb-pipeline/README.md b/kb-pipeline/README.md index 3059bcd..4fc4e62 100644 --- a/kb-pipeline/README.md +++ b/kb-pipeline/README.md @@ -1,5 +1,5 @@ - + # kb-pipeline @@ -12,7 +12,7 @@ - replay contextualisé des décodeurs ; - matérialisation optionnelle et idempotente ; - préflights et inspections stateful ; -- orchestration Token-2022, preuves et postconditions ; +- orchestration Token-2022 et Metaplex Token Metadata, preuves et postconditions ; - corrélation entre instructions observées et états finaux. La crate coordonne `kb-onchain-transport`, `kb-store`, `kb-lib`, `kb-core` et `kb-program-ids`. Elle ne contient pas l’interface desktop ni les scénarios opérateur de démonstration. @@ -23,7 +23,7 @@ La crate coordonne `kb-onchain-transport`, `kb-store`, `kb-lib`, `kb-core` et `k - extraction Core ; - decode replay ; - inspections stateful Solana Core, SPL Token, ATA, Token-2022 et registre ElGamal ; -- préflight cryptographique et orchestration d’exécution Token-2022. +- préflights stateful et orchestrations d’exécution Token-2022 et Metaplex Token Metadata. Voir [USAGE.md](USAGE.md) pour les contrats publics. diff --git a/kb-pipeline/TODO.md b/kb-pipeline/TODO.md index 41ba2f1..d6b5b70 100644 --- a/kb-pipeline/TODO.md +++ b/kb-pipeline/TODO.md @@ -1,12 +1,13 @@ - + # TODO — kb-pipeline ## `0.4.7` -- [ ] Metaplex Token Metadata - intégrer l’exécution et les étapes restantes au replay bot3. -- [ ] Metaplex Token Metadata - ajouter l’orchestration nécessaire aux préflights, postconditions et validations. +- [x] Metaplex Token Metadata - intégrer les lectures stateful bornées, le préflight et l’orchestration simulation-first généraliste. +- [x] Metaplex Token Metadata - ajouter la résolution des signers, les diagnostics de dépréciation et les postconditions explicites. +- [ ] Metaplex Token Metadata - compléter en `pre.008` les fixtures et campagnes Devnet/Testnet dans `kb-pipeline-demo-scenarios`. ## Réaudit ultérieur des surfaces historiques diff --git a/kb-pipeline/USAGE.md b/kb-pipeline/USAGE.md index 3abefff..725c77a 100644 --- a/kb-pipeline/USAGE.md +++ b/kb-pipeline/USAGE.md @@ -1,5 +1,5 @@ - + # Utilisation de kb-pipeline @@ -178,3 +178,97 @@ Les campagnes longues exposent des traits d’observation distincts pour le back - la crate orchestre les traitements mais ne fournit pas d’interface opérateur ; - elle ne conserve pas de secret de wallet ; - elle ne remplace pas les scénarios Devnet et validations explicites de `kb-pipeline-demo-scenarios`. + +## Lire un compte Metaplex Token Metadata + +La lecture stateful exige un endpoint HTTP, une catégorie de compte explicite et une borne stricte sur les données décodées. + +```rust +async fn read_metaplex_metadata( + pool: &kb_onchain_transport::HttpEndpointPool, + metadata: kb_lib::MdPubkey, +) -> kb_core::Result { + let request = kb_pipeline::MetaplexTokenMetadataStatefulReadRequest { + query_role: "rpc".to_string(), + account: metadata, + kind: kb_pipeline::MetaplexTokenMetadataAccountKind::Metadata, + min_context_slot: std::option::Option::None, + max_data_bytes: kb_pipeline::MAX_METAPLEX_TOKEN_METADATA_ACCOUNT_BYTES, + }; + let result = kb_pipeline::read_metaplex_token_metadata_stateful_snapshot( + pool, + &request, + ) + .await; + + match result { + Ok(snapshot) => Ok(snapshot), + Err(error) => Err(error), + } +} +``` + +Pour une edition, fournir le mint dans `MetaplexTokenMetadataAccountKind::Edition`. Pour un token record programmable, fournir le mint et le token account dans `TokenRecord`. + +## Inspecter le préflight Metaplex + +Le préflight lie un plan préparé par `kb-lib` à des snapshots confirmés et applique la politique des opérations dépréciées. + +```rust +fn inspect_metaplex_preflight( + plan: kb_lib::ExApiPreparedExecutionPlan, + snapshots: Vec, + allow_deprecated_operation: bool, +) -> kb_core::Result { + let request = kb_pipeline::MetaplexTokenMetadataPreflightRequest { + plan, + snapshots, + allow_deprecated_operation, + }; + + kb_pipeline::inspect_metaplex_token_metadata_preflight(&request) +} +``` + +Une opération marquée dépréciée par `kb-lib` est refusée lorsque `allow_deprecated_operation` vaut `false`. + +## Valider l’enveloppe d’exécution Metaplex + +L’orchestration refuse la signature ou la soumission tant que la simulation exacte du message, les signers et la confirmation opérateur ne sont pas cohérents. + +```rust +fn validate_metaplex_execution( + plan: kb_lib::ExApiPreparedExecutionPlan, + preflight: kb_pipeline::MetaplexTokenMetadataPreflightReport, + message_hash: String, + resolved_signers: Vec, +) -> kb_core::Result { + let request = kb_pipeline::MetaplexTokenMetadataExecutionReadinessRequest { + plan, + preflight, + message_hash: message_hash.clone(), + simulated_message_hash: message_hash, + simulated: true, + simulation_succeeded: true, + resolved_signers, + submit: false, + operator_confirmed: false, + }; + + kb_pipeline::validate_metaplex_token_metadata_execution_readiness(&request) +} +``` + +Pour une soumission réelle, `submit` et `operator_confirmed` doivent être vrais et le plan ne doit plus être en `dry_run`. + +## Agréger les postconditions Metaplex + +```rust +fn summarize_metaplex_postconditions( + statuses: &[kb_pipeline::MetaplexTokenMetadataPostconditionStatus], +) -> kb_pipeline::MetaplexTokenMetadataPostconditionStatus { + kb_pipeline::summarize_metaplex_token_metadata_postconditions(statuses) +} +``` + +`Contradicted` est prioritaire sur `Confirmed`, et l’absence de postcondition applicable reste `NotApplicable`. diff --git a/kb-pipeline/src/lib.rs b/kb-pipeline/src/lib.rs index 4ee6e85..3532cb1 100644 --- a/kb-pipeline/src/lib.rs +++ b/kb-pipeline/src/lib.rs @@ -1,5 +1,5 @@ // file: kb-pipeline/src/lib.rs -// version: 17 +// version: 18 #![forbid(unsafe_code)] #![deny(unreachable_pub)] @@ -14,6 +14,9 @@ mod decode_replay; mod plan; mod solana_ata_stateful; mod solana_elgamal_registry_stateful; +mod solana_metaplex_token_metadata_execution_orchestration; +mod solana_metaplex_token_metadata_preflight; +mod solana_metaplex_token_metadata_stateful; mod solana_stateful; mod solana_token_2022_correlation; mod solana_token_2022_crypto_preflight; @@ -121,6 +124,40 @@ pub use self::solana_elgamal_registry_stateful::materialize_elgamal_registry_acc pub use self::solana_elgamal_registry_stateful::materialize_elgamal_registry_stateful_snapshot; /// Migrated read_elgamal_registry_stateful_snapshot contract. pub use self::solana_elgamal_registry_stateful::read_elgamal_registry_stateful_snapshot; +/// Maximum number of resolved signers accepted by one Metaplex execution envelope. +pub use self::solana_metaplex_token_metadata_execution_orchestration::MAX_METAPLEX_TOKEN_METADATA_EXECUTION_SIGNERS; +/// Deterministic Metaplex execution-readiness report. +pub use self::solana_metaplex_token_metadata_execution_orchestration::MetaplexTokenMetadataExecutionReadinessReport; +/// Complete Metaplex execution-readiness request. +pub use self::solana_metaplex_token_metadata_execution_orchestration::MetaplexTokenMetadataExecutionReadinessRequest; +/// Result of one Metaplex stateful postcondition. +pub use self::solana_metaplex_token_metadata_execution_orchestration::MetaplexTokenMetadataPostconditionStatus; +/// Aggregates Metaplex postconditions without inventing success. +pub use self::solana_metaplex_token_metadata_execution_orchestration::summarize_metaplex_token_metadata_postconditions; +/// Validates the complete Metaplex execution envelope before signing. +pub use self::solana_metaplex_token_metadata_execution_orchestration::validate_metaplex_token_metadata_execution_readiness; +/// Maximum number of correlated Metaplex snapshots accepted by one preflight. +pub use self::solana_metaplex_token_metadata_preflight::MAX_METAPLEX_TOKEN_METADATA_PREFLIGHT_ACCOUNTS; +/// Deterministic Metaplex preflight report. +pub use self::solana_metaplex_token_metadata_preflight::MetaplexTokenMetadataPreflightReport; +/// Complete Metaplex preflight request. +pub use self::solana_metaplex_token_metadata_preflight::MetaplexTokenMetadataPreflightRequest; +/// Inspects one prepared Metaplex plan against confirmed state snapshots. +pub use self::solana_metaplex_token_metadata_preflight::inspect_metaplex_token_metadata_preflight; +/// Maximum complete Metaplex account data accepted by one RPC read. +pub use self::solana_metaplex_token_metadata_stateful::MAX_METAPLEX_TOKEN_METADATA_ACCOUNT_BYTES; +/// Supported Metaplex account category for one stateful read. +pub use self::solana_metaplex_token_metadata_stateful::MetaplexTokenMetadataAccountKind; +/// One bounded Metaplex stateful read request. +pub use self::solana_metaplex_token_metadata_stateful::MetaplexTokenMetadataStatefulReadRequest; +/// One bounded Metaplex stateful read result. +pub use self::solana_metaplex_token_metadata_stateful::MetaplexTokenMetadataStatefulReadResult; +/// Canonical bounded Metaplex state snapshot. +pub use self::solana_metaplex_token_metadata_stateful::MetaplexTokenMetadataStatefulSnapshot; +/// Validates one RPC account response and delegates parsing to `kb-lib`. +pub use self::solana_metaplex_token_metadata_stateful::materialize_metaplex_token_metadata_account_info_result; +/// Reads one bounded Metaplex Token Metadata account. +pub use self::solana_metaplex_token_metadata_stateful::read_metaplex_token_metadata_stateful_snapshot; /// One machine-readable native Solana stateful readiness check. pub use self::solana_stateful::SolanaCoreStatefulCheck; /// One contextual fact measured during native Solana stateful readiness. diff --git a/kb-pipeline/src/solana_metaplex_token_metadata_execution_orchestration.rs b/kb-pipeline/src/solana_metaplex_token_metadata_execution_orchestration.rs new file mode 100644 index 0000000..35b3c4d --- /dev/null +++ b/kb-pipeline/src/solana_metaplex_token_metadata_execution_orchestration.rs @@ -0,0 +1,170 @@ +// file: kb-pipeline/src/solana_metaplex_token_metadata_execution_orchestration.rs +// version: 1 + +//! Simulation-first Metaplex Token Metadata execution orchestration. + +/// Maximum number of resolved signers accepted by one Metaplex execution envelope. +pub const MAX_METAPLEX_TOKEN_METADATA_EXECUTION_SIGNERS: usize = 32; + +/// Complete request checked before signing or submitting one Metaplex transaction. +#[derive(Clone, Debug, PartialEq)] +pub struct MetaplexTokenMetadataExecutionReadinessRequest { + /// Exact prepared execution plan. + pub plan: kb_lib::ExApiPreparedExecutionPlan, + /// Successful stateful preflight report. + pub preflight: crate::MetaplexTokenMetadataPreflightReport, + /// Exact compiled message hash. + pub message_hash: std::string::String, + /// Hash retained by simulation evidence. + pub simulated_message_hash: std::string::String, + /// Whether simulation was performed. + pub simulated: bool, + /// Whether simulation succeeded. + pub simulation_succeeded: bool, + /// Public keys available to sign. + pub resolved_signers: std::vec::Vec, + /// Whether submission was requested. + pub submit: bool, + /// Whether the operator confirmed submission. + pub operator_confirmed: bool, +} + +/// Deterministic readiness report emitted before signing. +#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)] +pub struct MetaplexTokenMetadataExecutionReadinessReport { + /// Stable operation code. + pub operation_code: std::string::String, + /// Exact message hash bound to simulation. + pub message_hash: std::string::String, + /// Confirmed preflight context slot. + pub context_slot: u64, + /// Deduplicated required signers. + pub required_signers: std::vec::Vec, + /// Whether signing and submission are authorized. + pub send_authorized: bool, + /// Ordered successful checks. + pub checks: std::vec::Vec, +} + +/// Validates simulation, preflight, signer and submission policies. +pub fn validate_metaplex_token_metadata_execution_readiness( + request: &crate::MetaplexTokenMetadataExecutionReadinessRequest, +) -> kb_core::Result { + if request.plan.operation_code != request.preflight.operation_code { + return std::result::Result::Err(kb_core::Error::new( + "metaplex_execution_operation_mismatch", + "Metaplex plan and preflight operation codes must match", + )); + } + if request.message_hash.trim().is_empty() + || request.message_hash != request.simulated_message_hash + || !request.simulated + || !request.simulation_succeeded + { + return std::result::Result::Err(kb_core::Error::new( + "metaplex_execution_simulation_required", + "Metaplex execution requires successful exact-message simulation", + )); + } + if request.resolved_signers.len() > crate::MAX_METAPLEX_TOKEN_METADATA_EXECUTION_SIGNERS { + return std::result::Result::Err(kb_core::Error::new( + "metaplex_execution_signer_limit_exceeded", + "Metaplex execution signer limit exceeded", + )); + } + let resolved = request + .resolved_signers + .iter() + .map(|item| return item.0.clone()) + .collect::>(); + let mut required = std::collections::BTreeMap::::new(); + for signer in &request.plan.required_signers { + if !resolved.contains(signer.pubkey.0.as_str()) { + return std::result::Result::Err(kb_core::Error::new( + "metaplex_execution_signer_unresolved", + format!("Metaplex signer {} is unresolved", signer.pubkey.0), + )); + } + required + .entry(signer.pubkey.0.clone()) + .or_insert_with(|| return signer.pubkey.clone()); + } + if request.submit && !request.operator_confirmed { + return std::result::Result::Err(kb_core::Error::new( + "metaplex_execution_confirmation_required", + "Metaplex submission requires explicit operator confirmation", + )); + } + if request.submit && request.plan.policy.dry_run { + return std::result::Result::Err(kb_core::Error::new( + "metaplex_execution_dry_run_blocks_submission", + "Metaplex dry-run plan cannot be submitted", + )); + } + return std::result::Result::Ok(crate::MetaplexTokenMetadataExecutionReadinessReport { + operation_code: request.plan.operation_code.clone(), + message_hash: request.message_hash.clone(), + context_slot: request.preflight.context_slot, + required_signers: required.into_values().collect(), + send_authorized: request.submit + && request.operator_confirmed + && !request.plan.policy.dry_run, + checks: vec![ + "preflight_bound_to_operation".to_string(), + "simulation_bound_to_exact_message".to_string(), + "all_required_signers_resolved".to_string(), + "submission_policy_consistent".to_string(), + ], + }); +} + +/// Result of one explicit postcondition. +#[derive(Clone, Copy, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)] +#[serde(rename_all = "snake_case")] +pub enum MetaplexTokenMetadataPostconditionStatus { + /// Final state confirms the expected effect. + Confirmed, + /// Final state contradicts the expected effect. + Contradicted, + /// No stateful assertion applies. + NotApplicable, +} + +/// Aggregates Metaplex postconditions without turning absence into success. +pub fn summarize_metaplex_token_metadata_postconditions( + statuses: &[crate::MetaplexTokenMetadataPostconditionStatus], +) -> crate::MetaplexTokenMetadataPostconditionStatus { + if statuses + .iter() + .any(|value| return *value == crate::MetaplexTokenMetadataPostconditionStatus::Contradicted) + { + return crate::MetaplexTokenMetadataPostconditionStatus::Contradicted; + } + if statuses + .iter() + .any(|value| return *value == crate::MetaplexTokenMetadataPostconditionStatus::Confirmed) + { + return crate::MetaplexTokenMetadataPostconditionStatus::Confirmed; + } + return crate::MetaplexTokenMetadataPostconditionStatus::NotApplicable; +} + +#[cfg(test)] +mod tests { + #[test] + fn postconditions_never_convert_not_applicable_into_success() { + assert_eq!( + crate::summarize_metaplex_token_metadata_postconditions(&[ + crate::MetaplexTokenMetadataPostconditionStatus::NotApplicable, + ]), + crate::MetaplexTokenMetadataPostconditionStatus::NotApplicable, + ); + assert_eq!( + crate::summarize_metaplex_token_metadata_postconditions(&[ + crate::MetaplexTokenMetadataPostconditionStatus::Confirmed, + crate::MetaplexTokenMetadataPostconditionStatus::Contradicted, + ]), + crate::MetaplexTokenMetadataPostconditionStatus::Contradicted, + ); + } +} diff --git a/kb-pipeline/src/solana_metaplex_token_metadata_preflight.rs b/kb-pipeline/src/solana_metaplex_token_metadata_preflight.rs new file mode 100644 index 0000000..ca43045 --- /dev/null +++ b/kb-pipeline/src/solana_metaplex_token_metadata_preflight.rs @@ -0,0 +1,136 @@ +// file: kb-pipeline/src/solana_metaplex_token_metadata_preflight.rs +// version: 2 + +//! Stateful Metaplex Token Metadata preflight contracts. + +/// Maximum number of correlated Metaplex snapshots accepted by one preflight. +pub const MAX_METAPLEX_TOKEN_METADATA_PREFLIGHT_ACCOUNTS: usize = 32; + +/// Complete preflight request for one prepared Metaplex execution plan. +#[derive(Clone, Debug, PartialEq)] +pub struct MetaplexTokenMetadataPreflightRequest { + /// Exact prepared plan produced by `kb-lib`. + pub plan: kb_lib::ExApiPreparedExecutionPlan, + /// Confirmed bounded state snapshots correlated with the plan. + pub snapshots: std::vec::Vec, + /// Whether deprecated execution was explicitly approved by the operator. + pub allow_deprecated_operation: bool, +} + +/// Deterministic Metaplex preflight report. +#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)] +pub struct MetaplexTokenMetadataPreflightReport { + /// Stable operation code. + pub operation_code: std::string::String, + /// Highest confirmed context slot across supplied snapshots. + pub context_slot: u64, + /// Canonical accounts inspected by the preflight. + pub inspected_accounts: std::vec::Vec, + /// Ordered successful checks. + pub checks: std::vec::Vec, +} + +/// Validates program ownership, snapshot bounds, account correlation and deprecation policy. +pub fn inspect_metaplex_token_metadata_preflight( + request: &crate::MetaplexTokenMetadataPreflightRequest, +) -> kb_core::Result { + if request.snapshots.len() > crate::MAX_METAPLEX_TOKEN_METADATA_PREFLIGHT_ACCOUNTS { + return std::result::Result::Err(kb_core::Error::new( + "metaplex_preflight_account_limit_exceeded", + "Metaplex preflight account limit exceeded", + )); + } + if request.plan.instructions.is_empty() { + return std::result::Result::Err(kb_core::Error::new( + "metaplex_preflight_empty_plan", + "Metaplex execution plan must contain at least one instruction", + )); + } + for instruction in &request.plan.instructions { + if instruction.program_id.0 != kb_program_ids::METADATA_METAPLEX_TOKEN_METADATA_PROGRAM_ID { + return std::result::Result::Err(kb_core::Error::new( + "metaplex_preflight_program_mismatch", + "Every Metaplex plan instruction must target Token Metadata", + )); + } + if instruction.operation_code != request.plan.operation_code { + return std::result::Result::Err(kb_core::Error::new( + "metaplex_preflight_operation_mismatch", + "Metaplex plan and instruction operation codes must match", + )); + } + } + let deprecated = kb_lib::EX_METAPLEX_TOKEN_METADATA_DEPRECATED_OPERATION_CODES + .contains(&request.plan.operation_code.as_str()); + if deprecated && !request.allow_deprecated_operation { + return std::result::Result::Err(kb_core::Error::new( + "metaplex_preflight_deprecated_operation_not_approved", + "Deprecated Metaplex execution requires explicit operator approval", + )); + } + let mut inspected = std::collections::BTreeMap::::new(); + let mut context_slot = 0_u64; + for snapshot in &request.snapshots { + if snapshot.commitment != "confirmed" { + return std::result::Result::Err(kb_core::Error::new( + "metaplex_preflight_commitment_mismatch", + "Metaplex stateful snapshots must use confirmed commitment", + )); + } + context_slot = context_slot.max(snapshot.context_slot); + inspected + .entry(snapshot.snapshot.account.0.clone()) + .or_insert_with(|| return snapshot.snapshot.account.clone()); + } + return std::result::Result::Ok(crate::MetaplexTokenMetadataPreflightReport { + operation_code: request.plan.operation_code.clone(), + context_slot, + inspected_accounts: inspected.into_values().collect(), + checks: vec![ + "program_id_exact".to_string(), + "operation_code_exact".to_string(), + "confirmed_state_snapshots".to_string(), + "deprecated_policy_explicit".to_string(), + "account_correlation_bounded".to_string(), + ], + }); +} + +#[cfg(test)] +mod tests { + #[test] + fn rejects_empty_plans_and_unapproved_deprecated_operations() { + let mut plan = kb_lib::ExApiPreparedExecutionPlan { + executor_name: "kb-lib".to_string(), + executor_version: "0".to_string(), + intent_id: "i".to_string(), + operation_code: kb_lib::EX_METAPLEX_TOKEN_METADATA_PUFF_METADATA_OPERATION.to_string(), + fee_payer: kb_lib::MdPubkey("11111111111111111111111111111111".to_string()), + instructions: vec![], + required_signers: vec![], + policy: std::default::Default::default(), + requested_spend_lamports: 0, + requested_compute_unit_price_micro_lamports: std::option::Option::None, + }; + let empty = crate::MetaplexTokenMetadataPreflightRequest { + plan: plan.clone(), + snapshots: vec![], + allow_deprecated_operation: false, + }; + assert!(crate::inspect_metaplex_token_metadata_preflight(&empty).is_err()); + plan.instructions.push(kb_lib::ExApiPlannedInstruction { + program_id: kb_lib::MdProgramId( + kb_program_ids::METADATA_METAPLEX_TOKEN_METADATA_PROGRAM_ID.to_string(), + ), + operation_code: plan.operation_code.clone(), + accounts: vec![], + data: vec![14], + }); + let deprecated = crate::MetaplexTokenMetadataPreflightRequest { + plan, + snapshots: vec![], + allow_deprecated_operation: false, + }; + assert!(crate::inspect_metaplex_token_metadata_preflight(&deprecated).is_err()); + } +} diff --git a/kb-pipeline/src/solana_metaplex_token_metadata_stateful.rs b/kb-pipeline/src/solana_metaplex_token_metadata_stateful.rs new file mode 100644 index 0000000..bfaa427 --- /dev/null +++ b/kb-pipeline/src/solana_metaplex_token_metadata_stateful.rs @@ -0,0 +1,256 @@ +// file: kb-pipeline/src/solana_metaplex_token_metadata_stateful.rs +// version: 2 + +//! Bounded Metaplex Token Metadata account reads and canonical state projections. + +/// Maximum complete Metaplex account data accepted by one RPC read. +pub const MAX_METAPLEX_TOKEN_METADATA_ACCOUNT_BYTES: usize = 1_048_576; + +/// Supported Metaplex account category for one bounded stateful read. +#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)] +#[serde(tag = "kind", rename_all = "snake_case")] +pub enum MetaplexTokenMetadataAccountKind { + /// Metadata PDA derived from the mint encoded in the account. + Metadata, + /// Master edition or printed edition PDA derived from the supplied mint. + Edition { + /// Mint used to derive the edition PDA. + mint: kb_lib::MdPubkey, + }, + /// Programmable token record PDA derived from mint and token account. + TokenRecord { + /// Mint used to derive the token-record PDA. + mint: kb_lib::MdPubkey, + /// Token account used to derive the token-record PDA. + token: kb_lib::MdPubkey, + }, +} + +/// One bounded Metaplex account read request. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct MetaplexTokenMetadataStatefulReadRequest { + /// Endpoint role used for the HTTP RPC request. + pub query_role: std::string::String, + /// Canonical account address. + pub account: kb_lib::MdPubkey, + /// Expected account category and derivation inputs. + pub kind: crate::MetaplexTokenMetadataAccountKind, + /// 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, +} + +/// Canonical bounded Metaplex state snapshot. +#[derive(Clone, Debug, PartialEq, serde::Deserialize, serde::Serialize)] +pub struct MetaplexTokenMetadataStatefulSnapshot { + /// Canonical account address. + pub account: kb_lib::MdPubkey, + /// Stable account category. + pub account_kind: std::string::String, + /// Mint correlated with this account when available. + pub mint: std::option::Option, + /// RPC context slot. + pub slot: u64, + /// Bounded decoder-owned projection. + pub payload_json: serde_json::Value, +} + +/// One bounded RPC read and its validated snapshot. +#[derive(Clone, Debug, PartialEq, serde::Deserialize, serde::Serialize)] +pub struct MetaplexTokenMetadataStatefulReadResult { + /// Commitment used by the read. + pub commitment: std::string::String, + /// Context slot returned by the endpoint. + pub context_slot: u64, + /// Validated canonical snapshot. + pub snapshot: crate::MetaplexTokenMetadataStatefulSnapshot, +} + +/// Reads and validates one bounded Metaplex Token Metadata account. +pub async fn read_metaplex_token_metadata_stateful_snapshot( + pool: &kb_onchain_transport::HttpEndpointPool, + request: &crate::MetaplexTokenMetadataStatefulReadRequest, +) -> kb_core::Result { + if request.query_role.trim().is_empty() { + return std::result::Result::Err(kb_core::Error::config( + "Metaplex 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, + 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_metaplex_token_metadata_account_info_result(request, &result); +} + +/// Validates one RPC account response and delegates parsing to `kb-lib`. +pub fn materialize_metaplex_token_metadata_account_info_result( + request: &crate::MetaplexTokenMetadataStatefulReadRequest, + result: &kb_onchain_transport::AccountInfoResult, +) -> kb_core::Result { + if request.max_data_bytes == 0 + || request.max_data_bytes > crate::MAX_METAPLEX_TOKEN_METADATA_ACCOUNT_BYTES + { + return std::result::Result::Err(kb_core::Error::config(format!( + "Metaplex stateful max_data_bytes must be between 1 and {}", + crate::MAX_METAPLEX_TOKEN_METADATA_ACCOUNT_BYTES + ))); + } + if let std::option::Option::Some(minimum) = request.min_context_slot { + if result.context.slot < minimum { + return std::result::Result::Err(kb_core::Error::new( + "metaplex_stateful_context_slot_too_old", + "Metaplex account context slot is below the requested minimum", + )); + } + } + 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( + "metaplex_stateful_account_missing", + format!("Metaplex account {} does not exist", request.account.0), + )); + }, + }; + if account.executable { + return std::result::Result::Err(kb_core::Error::new( + "metaplex_stateful_account_executable", + "Metaplex state accounts must not be executable", + )); + } + if account.owner.0 != kb_program_ids::METADATA_METAPLEX_TOKEN_METADATA_PROGRAM_ID { + return std::result::Result::Err(kb_core::Error::new( + "metaplex_stateful_owner_mismatch", + "Metaplex state account owner does not match Token Metadata", + )); + } + if account.space != account.data.len() as u64 || account.data.len() > request.max_data_bytes { + return std::result::Result::Err(kb_core::Error::new( + "metaplex_stateful_account_data_invalid", + "Metaplex account data is incomplete or above the configured bound", + )); + } + let snapshot = match decode_snapshot(request, result.context.slot, account.data.as_slice()) { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + return std::result::Result::Ok(crate::MetaplexTokenMetadataStatefulReadResult { + commitment: "confirmed".to_string(), + context_slot: result.context.slot, + snapshot, + }); +} + +fn decode_snapshot( + request: &crate::MetaplexTokenMetadataStatefulReadRequest, + slot: u64, + data: &[u8], +) -> kb_core::Result { + let owner = kb_program_ids::METADATA_METAPLEX_TOKEN_METADATA_PROGRAM_ID; + return match &request.kind { + crate::MetaplexTokenMetadataAccountKind::Metadata => { + match kb_lib::decoder_metadata_metaplex_token_metadata_decode_metadata_account( + request.account.0.as_str(), + owner, + data, + ) { + std::result::Result::Ok(value) => { + std::result::Result::Ok(crate::MetaplexTokenMetadataStatefulSnapshot { + account: request.account.clone(), + account_kind: "metadata".to_string(), + mint: std::option::Option::Some(kb_lib::MdPubkey(value.mint)), + slot, + payload_json: value.payload_json, + }) + }, + std::result::Result::Err(error) => std::result::Result::Err(kb_core::Error::new( + "metaplex_stateful_decode_failed", + error.to_string(), + )), + } + }, + crate::MetaplexTokenMetadataAccountKind::Edition { mint } => { + match kb_lib::decoder_metadata_metaplex_token_metadata_decode_edition_account( + request.account.0.as_str(), + owner, + mint.0.as_str(), + data, + ) { + std::result::Result::Ok(value) => { + std::result::Result::Ok(crate::MetaplexTokenMetadataStatefulSnapshot { + account: request.account.clone(), + account_kind: "edition".to_string(), + mint: std::option::Option::Some(mint.clone()), + slot, + payload_json: value.payload_json, + }) + }, + std::result::Result::Err(error) => std::result::Result::Err(kb_core::Error::new( + "metaplex_stateful_decode_failed", + error.to_string(), + )), + } + }, + crate::MetaplexTokenMetadataAccountKind::TokenRecord { mint, token } => { + match kb_lib::decoder_metadata_metaplex_token_metadata_decode_token_record_account( + request.account.0.as_str(), + owner, + mint.0.as_str(), + token.0.as_str(), + data, + ) { + std::result::Result::Ok(value) => { + std::result::Result::Ok(crate::MetaplexTokenMetadataStatefulSnapshot { + account: request.account.clone(), + account_kind: "token_record".to_string(), + mint: std::option::Option::Some(mint.clone()), + slot, + payload_json: value.payload_json, + }) + }, + std::result::Result::Err(error) => std::result::Result::Err(kb_core::Error::new( + "metaplex_stateful_decode_failed", + error.to_string(), + )), + } + }, + }; +} + +#[cfg(test)] +mod tests { + #[test] + fn rejects_zero_and_oversize_bounds_before_account_decoding() { + let request = crate::MetaplexTokenMetadataStatefulReadRequest { + query_role: "rpc".to_string(), + account: kb_lib::MdPubkey("11111111111111111111111111111111".to_string()), + kind: crate::MetaplexTokenMetadataAccountKind::Metadata, + min_context_slot: std::option::Option::None, + max_data_bytes: 0, + }; + let result = kb_onchain_transport::AccountInfoResult { + context: kb_onchain_transport::RpcResponseContext { + slot: 1, + api_version: std::option::Option::None, + }, + account: std::option::Option::None, + }; + assert!( + crate::materialize_metaplex_token_metadata_account_info_result(&request, &result) + .is_err() + ); + } +}