From 252fe51e34f45fa27ddd3fe1e262ea618a34126a Mon Sep 17 00:00:00 2001 From: SinuS Von SifriduS Date: Fri, 24 Jul 2026 18:19:27 +0200 Subject: [PATCH] v0.1.0-pre.016 --- CHANGELOG.md | 11 +- README.md | 4 +- ROADMAP.md | 17 +- kb-lib/Cargo.toml | 4 +- kb-lib/README.md | 8 +- kb-lib/src/executor.rs | 34 +- kb-lib/src/executor/safety.rs | 15 + kb-lib/src/executor/safety/evaluation.rs | 694 ++++++++++++++++++++ kb-lib/src/executor/spl.rs | 23 +- kb-lib/src/executor/spl/memo.rs | 74 +-- kb-lib/src/executor/spl/memo/builder.rs | 499 ++++++++++++++ kb-lib/src/executor/spl/memo/constants.rs | 7 + kb-lib/src/executor/spl/memo/executor.rs | 314 +++++++++ kb-lib/src/executor/spl/memo/intent.rs | 112 ++++ kb-lib/src/lib.rs | 30 +- kb-lib/tests/external_executor_api.rs | 47 +- scripts/audit_khadhroony_workspace_rules.py | 4 +- 17 files changed, 1830 insertions(+), 67 deletions(-) create mode 100644 kb-lib/src/executor/safety.rs create mode 100644 kb-lib/src/executor/safety/evaluation.rs create mode 100644 kb-lib/src/executor/spl/memo/builder.rs create mode 100644 kb-lib/src/executor/spl/memo/constants.rs create mode 100644 kb-lib/src/executor/spl/memo/executor.rs create mode 100644 kb-lib/src/executor/spl/memo/intent.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index e14f8a0..9048b37 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,8 +1,17 @@ - + # CHANGELOG +## 0.1.0-pre.016 + +- Portage fonctionnel complet de `kb_execution_safety` dans `kb-lib`. +- Exposition des décisions, violations, évaluations et du checker sous les noms `ExSafety*`. +- Portage fonctionnel de l’exécuteur SPL Memo et de ses contrats `ExSplMemo*`. +- Conservation des générations v1/v3 en decode-only et de l’exécution v4 par le builder officiel. +- Conservation des limites de payload, de signers, des politiques de simulation et des validations post-exécution. +- Réduction de l’inventaire réservé de 103 à 102 exécuteurs. + ## 0.1.0-pre.015 - Portage fonctionnel complet de l’exécuteur Solana Core dans `kb-lib`. diff --git a/README.md b/README.md index 734ba81..211a45b 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,5 @@ - + # Khadhroony Bot3 @@ -87,3 +87,5 @@ Les modules de matérialisation sont privés. Tous les contrats, types concrets `0.1.0-pre.014` confirme la parité de l’API publique `ExApi*` avec bot2 et remplace les 104 frontières temporaires d’exécution par des squelettes `Ex*Executor`. Chaque squelette référence uniquement ses Program IDs enregistrés, annonce `Maybe` sur sa surface et construit exclusivement un plan réservé à zéro instruction. Les exécuteurs opérationnels seront portés séparément, en commençant par Solana Core puis SPL. `0.1.0-pre.015` rend `ExSolanaCoreExecutor` fonctionnel. Il construit des plans déterministes pour 109 opérations natives réparties sur 18 surfaces, conserve les refus explicites des programmes historiques non exécutables et expose ses intents sous les préfixes bot3 `ExSolanaCore*` et `EX_SOLANA_CORE_*`. Les 103 autres exécuteurs restent réservés jusqu’à leur port fonctionnel. + +`0.1.0-pre.016` intègre le moteur transversal `ExSafetyChecker` et rend `ExSplMemoExecutor` fonctionnel. Memo v4 produit une instruction officielle bornée et soumise à simulation ; les générations historiques v1/v3 restent decode-only. Les 102 autres exécuteurs restent réservés jusqu’à leur port fonctionnel. diff --git a/ROADMAP.md b/ROADMAP.md index 175f441..bac860a 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -1,5 +1,5 @@ - + # ROADMAP — khadhroony-bot3 @@ -189,5 +189,18 @@ - [x] Étendre le test aval à une construction Compute Budget réelle. - [x] Réduire les frontières réservées à 103 et adapter l’audit. - [x] Imposer les blocs `pub use` puis `pub(crate) use`, séparés par une ligne vide. +- [x] Valider les 507 tests unitaires, les trois tests d’intégration, Clippy et le workspace avec Cargo sur la machine de développement. +- [x] Porter ensuite les exécuteurs SPL déjà validés dans bot2. + +### 0.1.0-pre.016 — Safety d’exécution et exécuteur SPL Memo + +- [x] Porter `kb_execution_safety` dans `kb-lib` sous les contrats `ExSafety*`. +- [x] Conserver les décisions `Deny`, `RequireConfirmation` et `Allow`, avec violations stables. +- [x] Conserver les contrôles de simulation, blockhash/nonce, cluster, signers autorisés et plafonds de coût. +- [x] Remplacer le squelette Memo par `ExSplMemoExecutor` et ses intents typés. +- [x] Maintenir Memo v1/v3 en decode-only et Memo v4 exécutable. +- [x] Construire l’instruction avec `spl-memo-interface` en conservant ordre et doublons des signers. +- [x] Étendre le test aval à un plan Memo réel et à son évaluation safety. +- [x] Réduire les frontières réservées à 102 et adapter l’audit. - [ ] Valider les tests portés, Clippy et le workspace avec Cargo sur la machine de développement. -- [ ] Porter ensuite les exécuteurs SPL déjà validés dans bot2. +- [ ] Porter ensuite SPL Token classique et SPL Associated Token Account. diff --git a/kb-lib/Cargo.toml b/kb-lib/Cargo.toml index fda9eeb..3cdb438 100644 --- a/kb-lib/Cargo.toml +++ b/kb-lib/Cargo.toml @@ -1,5 +1,5 @@ # file: kb-lib/Cargo.toml -# version: 9 +# version: 10 [package] name = "kb-lib" @@ -32,6 +32,7 @@ solana-system-interface.workspace = true solana-vote-interface.workspace = true solana-zk-elgamal-proof-interface.workspace = true spl-associated-token-account-interface.workspace = true +spl-memo-interface.workspace = true tracing.workspace = true ts-rs.workspace = true wincode.workspace = true @@ -41,7 +42,6 @@ solana-ed25519-program.workspace = true solana-secp256k1-program.workspace = true solana-secp256r1-program.workspace = true spl-elgamal-registry-interface.workspace = true -spl-memo-interface.workspace = true spl-token-2022-interface.workspace = true spl-token-interface.workspace = true diff --git a/kb-lib/README.md b/kb-lib/README.md index 4745d8f..d407075 100644 --- a/kb-lib/README.md +++ b/kb-lib/README.md @@ -1,5 +1,5 @@ - + # kb-lib @@ -116,6 +116,8 @@ politique d’exécution définitivement interdite. La matrice normative est - `DcApiProtocolDecoder` : contrat de compatibilité avec les observations historiques ; - `ExSolanaCoreExecutor`, `ExSolanaCoreExecutionIntent` et `ExSolanaCoreOperation` : exécuteur natif fonctionnel et contrat typé couvrant 109 opérations ; - les constantes `EX_SOLANA_CORE_*_OPERATION` : codes stables des opérations System, Compute Budget, ALT, précompiles, Config, Feature, Slashing, ZK ElGamal, Stake, Vote et Loaders ; +- `ExSafetyChecker`, `ExSafetyDecision`, `ExSafetyEvaluation` et `ExSafetyViolation` : garde-fous communs avant simulation, signature et envoi ; +- `ExSplMemoExecutor`, `ExSplMemoExecutionIntent` et `ExSplMemoOperation` : exécuteur SPL Memo v4 fonctionnel, avec v1/v3 explicitement decode-only ; - `ExApiTypedInstructionExecutor` et `ExApiInstructionExecutor` : contrat typé actuel et pont JSON historique de l’exécution ; - `MdCoreInstructionReplayInput` : input source-neutral produit par l’extraction core ; - `DcApiDecoderExecutionResult`, `DcApiDecoderRecognition` et `DcApiDecoderCoverageDeclaration` : résultats typés du pipeline ; @@ -139,6 +141,8 @@ L’appel de décodage complet utilise `DcApiInstructionDecoder::decode` après - Une transaction échouée peut produire une intention structurée, jamais une mutation commitée. - Un matérialiseur externe dépend uniquement des contrats et modèles publics de `kb-lib`, jamais d’un décodeur concret. - Les squelettes réservés répondent uniquement `Maybe` pour leur Program ID et retournent une liste vide ; ils ne prétendent pas décoder une surface avant son port fonctionnel. -- Les 103 squelettes d’exécuteurs encore réservés annoncent uniquement `Maybe` pour leurs Program IDs enregistrés et construisent un plan réservé à zéro instruction. +- Les 102 squelettes d’exécuteurs encore réservés annoncent uniquement `Maybe` pour leurs Program IDs enregistrés et construisent un plan réservé à zéro instruction. - `ExSolanaCoreExecutor` annonce uniquement des capacités exactes `Supported` ou `Unsupported` et ne signe, n’envoie ni ne simule aucune transaction. +- `ExSafetyChecker` évalue les plans et résultats de simulation sans effectuer lui-même d’appel RPC, de signature ou d’envoi. +- `ExSplMemoExecutor` construit uniquement des plans ; il ne signe, n’envoie ni ne simule aucune transaction. - Les exécuteurs fonctionnels sont portés dans des tranches séparées, sans dépendre des décodeurs. diff --git a/kb-lib/src/executor.rs b/kb-lib/src/executor.rs index 6d1af26..d9556e0 100644 --- a/kb-lib/src/executor.rs +++ b/kb-lib/src/executor.rs @@ -1,5 +1,5 @@ // file: kb-lib/src/executor.rs -// version: 3 +// version: 4 //! Consolidated executor modules. @@ -22,6 +22,7 @@ mod orderbook; mod perpetuals; mod router; mod rwa; +mod safety; mod solana; mod spl; mod stable; @@ -236,6 +237,14 @@ pub use self::router::ExRouterOkxLabsV1Executor; pub use self::router::ExRouterOkxLabsV2Executor; /// Exposes the reserved `rwa/ondo_global_markets` executor. pub use self::rwa::ExRwaOndoGlobalMarketsExecutor; +/// Exposes the stateless execution safety checker. +pub use self::safety::ExSafetyChecker; +/// Exposes an execution safety decision. +pub use self::safety::ExSafetyDecision; +/// Exposes a complete execution safety evaluation. +pub use self::safety::ExSafetyEvaluation; +/// Exposes one execution safety violation. +pub use self::safety::ExSafetyViolation; /// Exposes the Address Lookup Table close operation code. pub use self::solana::EX_SOLANA_CORE_ADDRESS_LOOKUP_TABLE_CLOSE_OPERATION; /// Exposes the Address Lookup Table create operation code. @@ -484,14 +493,28 @@ pub use self::solana::ExSolanaCoreVoteLockout; pub use self::solana::ExSolanaCoreZkElGamalContextState; /// Exposes the official ZK ElGamal proof kinds. pub use self::solana::ExSolanaCoreZkElGamalProofType; +/// Exposes the stable SPL Memo add-memo operation code. +pub use self::spl::EX_SPL_MEMO_ADD_MEMO_OPERATION; +/// Exposes the SPL Memo payload bound. +pub use self::spl::EX_SPL_MEMO_MAX_MESSAGE_BYTES; +/// Exposes the SPL Memo signer bound. +pub use self::spl::EX_SPL_MEMO_MAX_SIGNERS; /// Exposes the reserved `spl/account_compression` executor. pub use self::spl::ExSplAccountCompressionExecutor; /// Exposes the reserved `spl/associated_token_account` executor. pub use self::spl::ExSplAssociatedTokenAccountExecutor; /// Exposes the reserved `spl/elgamal_registry` executor. pub use self::spl::ExSplElgamalRegistryExecutor; -/// Exposes the reserved `spl/memo` executor. +/// Exposes the typed SPL Memo execution intent. +pub use self::spl::ExSplMemoExecutionIntent; +/// Exposes the SPL Memo executor. pub use self::spl::ExSplMemoExecutor; +/// Exposes the exact SPL Memo generation. +pub use self::spl::ExSplMemoGeneration; +/// Exposes the typed SPL Memo operation. +pub use self::spl::ExSplMemoOperation; +/// Exposes one ordered SPL Memo signer. +pub use self::spl::ExSplMemoSigner; /// Exposes the reserved `spl/noop` executor. pub use self::spl::ExSplNoopExecutor; /// Exposes the reserved `spl/single_pool` executor. @@ -575,12 +598,16 @@ pub(crate) use self::solana::executor_solana_core_validate_seeded_address; pub(crate) use self::solana::executor_solana_core_vote_build_prepared_plan; /// Crate-root access to `build_prepared_plan` from `zk_elgamal`. pub(crate) use self::solana::executor_solana_core_zk_elgamal_build_prepared_plan; +/// Canonical SPL Memo tracing target. +pub(crate) use self::spl::EX_SPL_MEMO_TRACING_TARGET; +/// Internal SPL Memo plan builder. +pub(crate) use self::spl::executor_spl_memo_build_prepared_plan; #[cfg(test)] mod reserved_executor_tests { #[test] fn every_reserved_executor_is_registered_and_builds_only_an_empty_plan() { - let executors: [&dyn crate::ExApiInstructionExecutor; 103] = [ + let executors: [&dyn crate::ExApiInstructionExecutor; 102] = [ &crate::ExAdapterSaberDecimalWrapperExecutor, &crate::ExAdminJupiterLockExecutor, &crate::ExAdminPumpFeesExecutor, @@ -661,7 +688,6 @@ mod reserved_executor_tests { &crate::ExSplAccountCompressionExecutor, &crate::ExSplAssociatedTokenAccountExecutor, &crate::ExSplElgamalRegistryExecutor, - &crate::ExSplMemoExecutor, &crate::ExSplNoopExecutor, &crate::ExSplSinglePoolExecutor, &crate::ExSplStakePoolExecutor, diff --git a/kb-lib/src/executor/safety.rs b/kb-lib/src/executor/safety.rs new file mode 100644 index 0000000..7f1a1ff --- /dev/null +++ b/kb-lib/src/executor/safety.rs @@ -0,0 +1,15 @@ +// file: kb-lib/src/executor/safety.rs +// version: 1 + +//! Shared execution safety policy. + +mod evaluation; + +/// Exposes the stateless execution safety checker. +pub use self::evaluation::ExSafetyChecker; +/// Exposes a safety decision. +pub use self::evaluation::ExSafetyDecision; +/// Exposes a complete safety evaluation. +pub use self::evaluation::ExSafetyEvaluation; +/// Exposes one safety policy violation. +pub use self::evaluation::ExSafetyViolation; diff --git a/kb-lib/src/executor/safety/evaluation.rs b/kb-lib/src/executor/safety/evaluation.rs new file mode 100644 index 0000000..0851d08 --- /dev/null +++ b/kb-lib/src/executor/safety/evaluation.rs @@ -0,0 +1,694 @@ +// file: kb-lib/src/executor/safety/evaluation.rs +// version: 1 + +//! Safety checks applied before simulation, signing or sending. + +use ts_rs::TS; // rust-rules: derive-import + +/// Safety decision returned before continuing an execution stage. +#[derive(Clone, Copy, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize, TS)] +#[serde(rename_all = "snake_case")] +#[ts( + export, + export_to = "../frontend/ts/bindings/kb_lib/executor/safety/ExSafetyDecision.ts" +)] +pub enum ExSafetyDecision { + /// The plan is not allowed to continue. + Deny, + /// The plan requires explicit operator confirmation. + RequireConfirmation, + /// The plan may continue to the requested stage. + Allow, +} + +/// One stable safety policy violation. +#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize, TS)] +#[ts( + export, + export_to = "../frontend/ts/bindings/kb_lib/executor/safety/ExSafetyViolation.ts" +)] +pub struct ExSafetyViolation { + /// Stable violation code. + pub code: std::string::String, + /// Human-readable violation message. + pub message: std::string::String, +} + +/// Complete safety evaluation for one execution stage. +#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize, TS)] +#[ts( + export, + export_to = "../frontend/ts/bindings/kb_lib/executor/safety/ExSafetyEvaluation.ts" +)] +pub struct ExSafetyEvaluation { + /// Aggregate decision. + pub decision: crate::ExSafetyDecision, + /// Violations that produced a denial or confirmation requirement. + pub violations: std::vec::Vec, +} + +/// Stateless execution safety checker. +#[derive(Clone, Debug, Default)] +pub struct ExSafetyChecker; + +impl crate::ExSafetyChecker { + /// Evaluates a legacy reserved plan conservatively. + pub fn evaluate_plan( + &self, + _plan: &crate::ExApiExecutionPlan, + ) -> kb_core::Result { + return std::result::Result::Ok(crate::ExSafetyDecision::RequireConfirmation); + } + + /// Evaluates whether a typed plan may proceed to RPC simulation. + pub fn evaluate_prepared_plan( + &self, + plan: &crate::ExApiPreparedExecutionPlan, + ) -> kb_core::Result { + let mut violations = std::vec::Vec::new(); + if plan.instructions.is_empty() { + push_violation( + &mut violations, + "execution_plan_has_no_instructions", + "the prepared execution plan contains no instruction", + ); + } + if plan.required_signers.is_empty() { + push_violation( + &mut violations, + "execution_plan_has_no_signers", + "the prepared execution plan declares no required signer", + ); + } + if plan.policy.simulation != crate::ExApiExecutionSimulationPolicy::Required { + push_violation( + &mut violations, + "execution_simulation_not_required", + "simulation must be required before signing or sending", + ); + } + validate_blockhash_policy(plan, &mut violations); + validate_signer_contract(plan, &mut violations); + validate_authorized_signers(plan, &mut violations); + validate_cost_limits(plan, &mut violations); + if !violations.is_empty() { + return std::result::Result::Ok(crate::ExSafetyEvaluation { + decision: crate::ExSafetyDecision::Deny, + violations, + }); + } + if plan.policy.cluster.expected_cluster == crate::ExApiExecutionCluster::Mainnet { + if !plan.policy.cluster.allow_mainnet { + push_violation( + &mut violations, + "execution_mainnet_disabled", + "mainnet execution is disabled by the cluster policy", + ); + return std::result::Result::Ok(crate::ExSafetyEvaluation { + decision: crate::ExSafetyDecision::Deny, + violations, + }); + } + if !plan.policy.cluster.mainnet_confirmation { + push_violation( + &mut violations, + "execution_mainnet_confirmation_required", + "mainnet execution requires explicit operator confirmation", + ); + return std::result::Result::Ok(crate::ExSafetyEvaluation { + decision: crate::ExSafetyDecision::RequireConfirmation, + violations, + }); + } + } + return std::result::Result::Ok(crate::ExSafetyEvaluation { + decision: crate::ExSafetyDecision::Allow, + violations, + }); + } + + /// Evaluates whether a simulated plan may proceed to signing and sending. + pub fn evaluate_send( + &self, + plan: &crate::ExApiPreparedExecutionPlan, + simulation: &crate::ExApiExecutionSimulationResult, + ) -> kb_core::Result { + let plan_evaluation = match self.evaluate_prepared_plan(plan) { + std::result::Result::Ok(evaluation) => evaluation, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + if plan_evaluation.decision == crate::ExSafetyDecision::Deny { + return std::result::Result::Ok(plan_evaluation); + } + let mut violations = plan_evaluation.violations; + if plan.policy.dry_run { + push_violation( + &mut violations, + "execution_dry_run_enabled", + "dry-run mode forbids transaction signing and sending", + ); + } + if !simulation.simulated { + push_violation( + &mut violations, + "execution_simulation_missing", + "an RPC simulation result is required before sending", + ); + } else { + if !simulation.success { + push_violation( + &mut violations, + "execution_simulation_failed", + "the simulated transaction did not complete successfully", + ); + } + if simulation.replacement_blockhash.is_some() + || simulation.replacement_last_valid_block_height.is_some() + { + push_violation( + &mut violations, + "execution_simulation_replaced_blockhash", + "a simulation with a replacement blockhash cannot authorize signing or sending the original message", + ); + } + validate_simulation_context(plan, simulation, &mut violations); + } + validate_simulated_fee(plan, simulation, &mut violations); + if !violations.is_empty() { + let decision = if violations.iter().all(|violation| { + return violation.code == "execution_mainnet_confirmation_required"; + }) { + crate::ExSafetyDecision::RequireConfirmation + } else { + crate::ExSafetyDecision::Deny + }; + return std::result::Result::Ok(crate::ExSafetyEvaluation { decision, violations }); + } + return std::result::Result::Ok(crate::ExSafetyEvaluation { + decision: crate::ExSafetyDecision::Allow, + violations, + }); + } +} + +fn validate_blockhash_policy( + plan: &crate::ExApiPreparedExecutionPlan, + violations: &mut std::vec::Vec, +) { + match plan.policy.blockhash.kind { + crate::ExApiExecutionBlockhashKind::Latest => { + if plan.policy.blockhash.max_age_slots == std::option::Option::Some(0) + || plan.policy.blockhash.max_age_slots.is_none() + { + push_violation( + violations, + "execution_blockhash_age_missing", + "a positive maximum blockhash age is required", + ); + } + if plan.policy.blockhash.nonce_account.is_some() + || plan.policy.blockhash.nonce_authority.is_some() + { + push_violation( + violations, + "execution_latest_blockhash_has_nonce_fields", + "latest blockhash policy must not carry durable nonce fields", + ); + } + }, + crate::ExApiExecutionBlockhashKind::DurableNonce => { + if plan.policy.blockhash.nonce_account.is_none() + || plan.policy.blockhash.nonce_authority.is_none() + { + push_violation( + violations, + "execution_durable_nonce_context_missing", + "durable nonce policy requires both account and authority", + ); + } + if plan.policy.blockhash.max_age_slots.is_some() { + push_violation( + violations, + "execution_durable_nonce_has_blockhash_age", + "durable nonce policy must not carry a recent blockhash age", + ); + } + }, + } +} + +fn validate_signer_contract( + plan: &crate::ExApiPreparedExecutionPlan, + violations: &mut std::vec::Vec, +) { + let mut unique_signers = std::collections::BTreeSet::new(); + for required in &plan.required_signers { + if !unique_signers.insert(required.pubkey.0.as_str()) { + push_violation( + violations, + "execution_required_signer_duplicate", + format!("required signer {} is declared more than once", required.pubkey.0), + ); + } + } + if !plan + .required_signers + .iter() + .any(|required| return required.pubkey == plan.fee_payer) + { + push_violation( + violations, + "execution_fee_payer_not_declared", + "the transaction fee payer is missing from required signers", + ); + } + for instruction in &plan.instructions { + for account in &instruction.accounts { + if account.is_signer + && !plan + .required_signers + .iter() + .any(|required| return required.pubkey == account.pubkey) + { + push_violation( + violations, + "execution_instruction_signer_not_declared", + format!( + "instruction signer {} is missing from required signers", + account.pubkey.0 + ), + ); + } + } + } +} + +fn validate_authorized_signers( + plan: &crate::ExApiPreparedExecutionPlan, + violations: &mut std::vec::Vec, +) { + for required in &plan.required_signers { + if !plan + .policy + .authorized_signers + .iter() + .any(|authorized| return authorized == &required.pubkey) + { + push_violation( + violations, + "execution_signer_not_authorized", + format!( + "required signer {} with role {} is not authorized", + required.pubkey.0, required.role + ), + ); + } + } +} + +fn validate_cost_limits( + plan: &crate::ExApiPreparedExecutionPlan, + violations: &mut std::vec::Vec, +) { + match plan.policy.cost_limit.max_fee_lamports { + std::option::Option::Some(max_fee_lamports) if max_fee_lamports > 0 => {}, + std::option::Option::Some(_) | std::option::Option::None => { + push_violation( + violations, + "execution_fee_limit_missing", + "a positive transaction fee ceiling is required", + ); + }, + } + if plan.requested_spend_lamports > 0 { + match plan.policy.cost_limit.max_spend_lamports { + std::option::Option::Some(limit) if plan.requested_spend_lamports <= limit => {}, + std::option::Option::Some(_) => push_violation( + violations, + "execution_spend_limit_exceeded", + "the requested lamport spend exceeds the configured ceiling", + ), + std::option::Option::None => push_violation( + violations, + "execution_spend_limit_missing", + "a lamport spend ceiling is required for this operation", + ), + } + } + if let std::option::Option::Some(requested_price) = + plan.requested_compute_unit_price_micro_lamports + { + match plan.policy.cost_limit.max_compute_unit_price_micro_lamports { + std::option::Option::Some(limit) if requested_price <= limit => {}, + std::option::Option::Some(_) => push_violation( + violations, + "execution_compute_unit_price_limit_exceeded", + "the requested compute-unit price exceeds the configured ceiling", + ), + std::option::Option::None => push_violation( + violations, + "execution_compute_unit_price_limit_missing", + "a compute-unit price ceiling is required for this operation", + ), + } + } +} + +fn validate_simulation_context( + plan: &crate::ExApiPreparedExecutionPlan, + simulation: &crate::ExApiExecutionSimulationResult, + violations: &mut std::vec::Vec, +) { + if simulation.cluster != plan.policy.cluster.expected_cluster { + push_violation( + violations, + "execution_simulation_cluster_mismatch", + "the simulation cluster does not match the execution policy", + ); + } + if simulation.blockhash_kind != plan.policy.blockhash.kind { + push_violation( + violations, + "execution_simulation_blockhash_kind_mismatch", + "the simulated blockhash source does not match the execution policy", + ); + return; + } + match plan.policy.blockhash.kind { + crate::ExApiExecutionBlockhashKind::Latest => { + let max_age_slots = match plan.policy.blockhash.max_age_slots { + std::option::Option::Some(max_age_slots) => max_age_slots, + std::option::Option::None => return, + }; + match simulation.blockhash_age_slots { + std::option::Option::Some(age_slots) if age_slots <= max_age_slots => {}, + std::option::Option::Some(_) => push_violation( + violations, + "execution_simulation_blockhash_too_old", + "the simulated recent blockhash exceeds the configured maximum age", + ), + std::option::Option::None => push_violation( + violations, + "execution_simulation_blockhash_age_missing", + "the simulation adapter did not report the recent blockhash age", + ), + } + if simulation.nonce_account.is_some() || simulation.nonce_authority.is_some() { + push_violation( + violations, + "execution_simulation_latest_has_nonce_fields", + "a latest-blockhash simulation must not report durable nonce fields", + ); + } + }, + crate::ExApiExecutionBlockhashKind::DurableNonce => { + if simulation.blockhash_age_slots.is_some() { + push_violation( + violations, + "execution_simulation_nonce_has_blockhash_age", + "a durable nonce simulation must not report recent blockhash age", + ); + } + if simulation.nonce_account != plan.policy.blockhash.nonce_account { + push_violation( + violations, + "execution_simulation_nonce_account_mismatch", + "the simulated durable nonce account does not match the execution policy", + ); + } + if simulation.nonce_authority != plan.policy.blockhash.nonce_authority { + push_violation( + violations, + "execution_simulation_nonce_authority_mismatch", + "the simulated durable nonce authority does not match the execution policy", + ); + } + }, + } +} + +fn validate_simulated_fee( + plan: &crate::ExApiPreparedExecutionPlan, + simulation: &crate::ExApiExecutionSimulationResult, + violations: &mut std::vec::Vec, +) { + let limit = match plan.policy.cost_limit.max_fee_lamports { + std::option::Option::Some(limit) => limit, + std::option::Option::None => return, + }; + match simulation.estimated_fee_lamports { + std::option::Option::Some(fee) if fee <= limit => {}, + std::option::Option::Some(_) => push_violation( + violations, + "execution_simulated_fee_limit_exceeded", + "the simulated transaction fee exceeds the configured ceiling", + ), + std::option::Option::None => push_violation( + violations, + "execution_simulated_fee_missing", + "the simulation did not report an estimated transaction fee", + ), + } +} + +fn push_violation( + violations: &mut std::vec::Vec, + code: impl std::convert::Into, + message: impl std::convert::Into, +) { + violations.push(crate::ExSafetyViolation { + code: code.into(), + message: message.into(), + }); +} + +#[cfg(test)] +mod tests { + fn sample_plan() -> crate::ExApiPreparedExecutionPlan { + let signer = crate::MdPubkey(std::string::String::from("11111111111111111111111111111111")); + return crate::ExApiPreparedExecutionPlan { + executor_name: std::string::String::from("sample_executor"), + executor_version: std::string::String::from("0.4.2"), + intent_id: std::string::String::from("intent-1"), + operation_code: std::string::String::from("sample.operation"), + fee_payer: signer.clone(), + instructions: vec![crate::ExApiPlannedInstruction { + program_id: crate::MdProgramId(std::string::String::from( + "11111111111111111111111111111111", + )), + operation_code: std::string::String::from("sample.operation"), + accounts: vec![crate::ExApiPlannedAccount { + pubkey: signer.clone(), + is_signer: true, + is_writable: true, + }], + data: vec![1], + }], + required_signers: vec![crate::ExApiRequiredSigner { + pubkey: signer.clone(), + role: std::string::String::from("fee_payer"), + }], + policy: crate::ExApiExecutionPolicy { + cost_limit: crate::ExApiExecutionCostLimit { + max_spend_lamports: std::option::Option::Some(100), + max_fee_lamports: std::option::Option::Some(10_000), + max_compute_unit_price_micro_lamports: std::option::Option::Some(5), + }, + authorized_signers: vec![signer], + ..crate::ExApiExecutionPolicy::default() + }, + requested_spend_lamports: 100, + requested_compute_unit_price_micro_lamports: std::option::Option::None, + }; + } + + fn successful_simulation() -> crate::ExApiExecutionSimulationResult { + return crate::ExApiExecutionSimulationResult { + simulated: true, + success: true, + cluster: crate::ExApiExecutionCluster::Devnet, + blockhash_kind: crate::ExApiExecutionBlockhashKind::Latest, + blockhash_age_slots: std::option::Option::Some(3), + replacement_blockhash: std::option::Option::None, + replacement_last_valid_block_height: std::option::Option::None, + nonce_account: std::option::Option::None, + nonce_authority: std::option::Option::None, + units_consumed: std::option::Option::Some(500), + estimated_fee_lamports: std::option::Option::Some(5_000), + logs: std::vec::Vec::new(), + error: std::option::Option::None, + }; + } + + #[test] + fn conservative_plan_is_allowed_for_simulation() { + let plan = sample_plan(); + let evaluation = crate::ExSafetyChecker + .evaluate_prepared_plan(&plan) + .unwrap_or_else(|error| panic!("unexpected error: {error}")); + assert_eq!(evaluation.decision, crate::ExSafetyDecision::Allow); + assert!(evaluation.violations.is_empty()); + } + + #[test] + fn dry_run_blocks_send_after_successful_simulation() { + let plan = sample_plan(); + let simulation = successful_simulation(); + let evaluation = crate::ExSafetyChecker + .evaluate_send(&plan, &simulation) + .unwrap_or_else(|error| panic!("unexpected error: {error}")); + assert_eq!(evaluation.decision, crate::ExSafetyDecision::Deny); + assert!(evaluation.violations.iter().any(|violation| { + return violation.code == "execution_dry_run_enabled"; + })); + } + + #[test] + fn successful_simulation_allows_devnet_send_when_dry_run_is_disabled() { + let mut plan = sample_plan(); + plan.policy.dry_run = false; + let simulation = successful_simulation(); + let evaluation = crate::ExSafetyChecker + .evaluate_send(&plan, &simulation) + .unwrap_or_else(|error| panic!("unexpected error: {error}")); + assert_eq!(evaluation.decision, crate::ExSafetyDecision::Allow); + } + + #[test] + fn undeclared_fee_payer_and_instruction_signer_are_denied() { + let mut plan = sample_plan(); + plan.required_signers.clear(); + let evaluation = crate::ExSafetyChecker + .evaluate_prepared_plan(&plan) + .unwrap_or_else(|error| panic!("unexpected error: {error}")); + assert_eq!(evaluation.decision, crate::ExSafetyDecision::Deny); + assert!(evaluation.violations.iter().any(|violation| { + return violation.code == "execution_fee_payer_not_declared"; + })); + assert!(evaluation.violations.iter().any(|violation| { + return violation.code == "execution_instruction_signer_not_declared"; + })); + } + + #[test] + fn duplicate_required_signer_is_denied() { + let mut plan = sample_plan(); + let duplicate = plan.required_signers[0].clone(); + plan.required_signers.push(duplicate); + let evaluation = crate::ExSafetyChecker + .evaluate_prepared_plan(&plan) + .unwrap_or_else(|error| panic!("unexpected error: {error}")); + assert_eq!(evaluation.decision, crate::ExSafetyDecision::Deny); + assert!(evaluation.violations.iter().any(|violation| { + return violation.code == "execution_required_signer_duplicate"; + })); + } + + #[test] + fn unauthorized_signer_is_denied() { + let mut plan = sample_plan(); + plan.policy.authorized_signers.clear(); + let evaluation = crate::ExSafetyChecker + .evaluate_prepared_plan(&plan) + .unwrap_or_else(|error| panic!("unexpected error: {error}")); + assert_eq!(evaluation.decision, crate::ExSafetyDecision::Deny); + assert!(evaluation.violations.iter().any(|violation| { + return violation.code == "execution_signer_not_authorized"; + })); + } + + #[test] + fn spend_above_ceiling_is_denied() { + let mut plan = sample_plan(); + plan.policy.cost_limit.max_spend_lamports = std::option::Option::Some(99); + let evaluation = crate::ExSafetyChecker + .evaluate_prepared_plan(&plan) + .unwrap_or_else(|error| panic!("unexpected error: {error}")); + assert_eq!(evaluation.decision, crate::ExSafetyDecision::Deny); + assert!(evaluation.violations.iter().any(|violation| { + return violation.code == "execution_spend_limit_exceeded"; + })); + } + + #[test] + fn mainnet_requires_enablement_and_confirmation() { + let mut plan = sample_plan(); + plan.policy.cluster.expected_cluster = crate::ExApiExecutionCluster::Mainnet; + let disabled = crate::ExSafetyChecker + .evaluate_prepared_plan(&plan) + .unwrap_or_else(|error| panic!("unexpected error: {error}")); + assert_eq!(disabled.decision, crate::ExSafetyDecision::Deny); + plan.policy.cluster.allow_mainnet = true; + let unconfirmed = crate::ExSafetyChecker + .evaluate_prepared_plan(&plan) + .unwrap_or_else(|error| panic!("unexpected error: {error}")); + assert_eq!(unconfirmed.decision, crate::ExSafetyDecision::RequireConfirmation); + plan.policy.cluster.mainnet_confirmation = true; + let confirmed = crate::ExSafetyChecker + .evaluate_prepared_plan(&plan) + .unwrap_or_else(|error| panic!("unexpected error: {error}")); + assert_eq!(confirmed.decision, crate::ExSafetyDecision::Allow); + } + + #[test] + fn simulation_cluster_mismatch_is_denied() { + let mut plan = sample_plan(); + plan.policy.dry_run = false; + let mut simulation = successful_simulation(); + simulation.cluster = crate::ExApiExecutionCluster::Testnet; + let evaluation = crate::ExSafetyChecker + .evaluate_send(&plan, &simulation) + .unwrap_or_else(|error| panic!("unexpected error: {error}")); + assert_eq!(evaluation.decision, crate::ExSafetyDecision::Deny); + assert!(evaluation.violations.iter().any(|violation| { + return violation.code == "execution_simulation_cluster_mismatch"; + })); + } + + #[test] + fn stale_simulation_blockhash_is_denied() { + let mut plan = sample_plan(); + plan.policy.dry_run = false; + let mut simulation = successful_simulation(); + simulation.blockhash_age_slots = std::option::Option::Some(151); + let evaluation = crate::ExSafetyChecker + .evaluate_send(&plan, &simulation) + .unwrap_or_else(|error| panic!("unexpected error: {error}")); + assert_eq!(evaluation.decision, crate::ExSafetyDecision::Deny); + assert!(evaluation.violations.iter().any(|violation| { + return violation.code == "execution_simulation_blockhash_too_old"; + })); + } + + #[test] + fn replacement_blockhash_simulation_cannot_authorize_send() { + let mut plan = sample_plan(); + plan.policy.dry_run = false; + let mut simulation = successful_simulation(); + simulation.replacement_blockhash = + std::option::Option::Some(std::string::String::from("replacement-blockhash")); + simulation.replacement_last_valid_block_height = std::option::Option::Some(200); + let evaluation = crate::ExSafetyChecker + .evaluate_send(&plan, &simulation) + .unwrap_or_else(|error| panic!("unexpected error: {error}")); + assert_eq!(evaluation.decision, crate::ExSafetyDecision::Deny); + assert!(evaluation.violations.iter().any(|violation| { + return violation.code == "execution_simulation_replaced_blockhash"; + })); + } + + #[test] + fn simulated_fee_above_ceiling_is_denied() { + let mut plan = sample_plan(); + plan.policy.dry_run = false; + let mut simulation = successful_simulation(); + simulation.estimated_fee_lamports = std::option::Option::Some(10_001); + let evaluation = crate::ExSafetyChecker + .evaluate_send(&plan, &simulation) + .unwrap_or_else(|error| panic!("unexpected error: {error}")); + assert_eq!(evaluation.decision, crate::ExSafetyDecision::Deny); + assert!(evaluation.violations.iter().any(|violation| { + return violation.code == "execution_simulated_fee_limit_exceeded"; + })); + } +} diff --git a/kb-lib/src/executor/spl.rs b/kb-lib/src/executor/spl.rs index 75a5c20..1ef3d0b 100644 --- a/kb-lib/src/executor/spl.rs +++ b/kb-lib/src/executor/spl.rs @@ -1,5 +1,5 @@ // file: kb-lib/src/executor/spl.rs -// version: 2 +// version: 3 //! `spl` executor family. @@ -18,8 +18,22 @@ pub use self::account_compression::ExSplAccountCompressionExecutor; pub use self::associated_token_account::ExSplAssociatedTokenAccountExecutor; /// Exposes the reserved `spl/elgamal_registry` executor. pub use self::elgamal_registry::ExSplElgamalRegistryExecutor; -/// Exposes the reserved `spl/memo` executor. +/// Exposes the stable SPL Memo add-memo operation code. +pub use self::memo::EX_SPL_MEMO_ADD_MEMO_OPERATION; +/// Exposes the SPL Memo payload bound. +pub use self::memo::EX_SPL_MEMO_MAX_MESSAGE_BYTES; +/// Exposes the SPL Memo signer bound. +pub use self::memo::EX_SPL_MEMO_MAX_SIGNERS; +/// Exposes the typed SPL Memo execution intent. +pub use self::memo::ExSplMemoExecutionIntent; +/// Exposes the SPL Memo executor. pub use self::memo::ExSplMemoExecutor; +/// Exposes the exact SPL Memo generation. +pub use self::memo::ExSplMemoGeneration; +/// Exposes the typed SPL Memo operation. +pub use self::memo::ExSplMemoOperation; +/// Exposes one ordered SPL Memo signer. +pub use self::memo::ExSplMemoSigner; /// Exposes the reserved `spl/noop` executor. pub use self::noop::ExSplNoopExecutor; /// Exposes the reserved `spl/single_pool` executor. @@ -30,3 +44,8 @@ pub use self::stake_pool::ExSplStakePoolExecutor; pub use self::token::ExSplTokenExecutor; /// Exposes the reserved `spl/token2022` executor. pub use self::token2022::ExSplToken2022Executor; + +/// Canonical SPL Memo tracing target. +pub(crate) use self::memo::EX_SPL_MEMO_TRACING_TARGET; +/// Internal SPL Memo plan builder. +pub(crate) use self::memo::executor_spl_memo_build_prepared_plan; diff --git a/kb-lib/src/executor/spl/memo.rs b/kb-lib/src/executor/spl/memo.rs index 95d2436..a3602f3 100644 --- a/kb-lib/src/executor/spl/memo.rs +++ b/kb-lib/src/executor/spl/memo.rs @@ -1,53 +1,31 @@ // file: kb-lib/src/executor/spl/memo.rs -// version: 2 +// version: 4 -//! Reserved executor for `spl_memo`. +//! Safe typed executor for `spl_memo`. -/// Reserved executor for the `spl_memo` program surface. -#[derive(Clone, Debug, Default)] -pub struct ExSplMemoExecutor; +mod builder; +mod constants; +mod executor; +mod intent; -impl crate::ExApiInstructionExecutor for crate::ExSplMemoExecutor { - fn executor_name(&self) -> &'static str { - return "kb_executor_spl_memo"; - } +/// Exposes the SPL Memo executor. +pub use self::executor::ExSplMemoExecutor; +/// Exposes the stable add-memo operation code. +pub use self::intent::EX_SPL_MEMO_ADD_MEMO_OPERATION; +/// Exposes the conservative UTF-8 payload bound. +pub use self::intent::EX_SPL_MEMO_MAX_MESSAGE_BYTES; +/// Exposes the conservative ordered-signer bound. +pub use self::intent::EX_SPL_MEMO_MAX_SIGNERS; +/// Exposes the typed SPL Memo execution intent. +pub use self::intent::ExSplMemoExecutionIntent; +/// Exposes the exact SPL Memo program generation. +pub use self::intent::ExSplMemoGeneration; +/// Exposes the typed SPL Memo operation. +pub use self::intent::ExSplMemoOperation; +/// Exposes one ordered SPL Memo signer. +pub use self::intent::ExSplMemoSigner; - fn executor_version(&self) -> &'static str { - return env!("CARGO_PKG_VERSION"); - } - - fn program_ids(&self) -> &'static [&'static str] { - return &[ - kb_program_ids::SPL_MEMO_V1_PROGRAM_ID, - kb_program_ids::SPL_MEMO_V3_PROGRAM_ID, - kb_program_ids::SPL_MEMO_V4_PROGRAM_ID, - ]; - } - - fn supports_request( - &self, - request: &crate::ExApiExecutionRequest, - ) -> crate::ExApiExecutionSupport { - if crate::ExApiInstructionExecutor::handles_program_id(self, &request.program_id) { - return crate::ExApiExecutionSupport::Maybe; - } - return crate::ExApiExecutionSupport::No; - } - - fn build_plan( - &self, - _request: &crate::ExApiExecutionRequest, - ) -> kb_core::Result { - let payload_json = match crate::executor_api_serialize_payload_json( - &serde_json::json!({"status":"reserved_executor","surface":"spl_memo"}), - ) { - std::result::Result::Ok(serialized) => serialized, - std::result::Result::Err(error) => return std::result::Result::Err(error), - }; - return std::result::Result::Ok(crate::ExApiExecutionPlan { - executor_name: "kb_executor_spl_memo".to_string(), - instruction_count: 0, - payload_json, - }); - } -} +/// Internal SPL Memo plan builder. +pub(crate) use self::builder::executor_spl_memo_build_prepared_plan; +/// Canonical tracing target for SPL Memo execution. +pub(crate) use self::constants::EX_SPL_MEMO_TRACING_TARGET; diff --git a/kb-lib/src/executor/spl/memo/builder.rs b/kb-lib/src/executor/spl/memo/builder.rs new file mode 100644 index 0000000..d458f5b --- /dev/null +++ b/kb-lib/src/executor/spl/memo/builder.rs @@ -0,0 +1,499 @@ +// file: kb-lib/src/executor/spl/memo/builder.rs +// version: 1 + +//! Official SPL Memo instruction builder and conservative plan validation. + +use std::str::FromStr; // rust-rules: trait-import + +pub(crate) fn executor_spl_memo_build_prepared_plan( + intent: &crate::ExSplMemoExecutionIntent, +) -> kb_core::Result { + if intent.intent_id.trim().is_empty() { + return std::result::Result::Err(kb_core::Error::new( + "execution_spl_memo_intent_id_empty", + "SPL Memo execution intent id must not be empty", + )); + } + let fee_payer = match parse_pubkey(&intent.fee_payer, "fee_payer") { + std::result::Result::Ok(pubkey) => pubkey, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + match validate_policy(intent) { + std::result::Result::Ok(()) => {}, + std::result::Result::Err(error) => return std::result::Result::Err(error), + } + let (generation, message, signers) = match &intent.operation { + crate::ExSplMemoOperation::AddMemo { generation, message, signers } => { + (*generation, message, signers) + }, + }; + if message.len() > crate::EX_SPL_MEMO_MAX_MESSAGE_BYTES { + return std::result::Result::Err(kb_core::Error::new( + "execution_spl_memo_payload_too_large", + format!( + "SPL Memo payload length {} exceeds the executor limit {}", + message.len(), + crate::EX_SPL_MEMO_MAX_MESSAGE_BYTES + ), + )); + } + if signers.len() > crate::EX_SPL_MEMO_MAX_SIGNERS { + return std::result::Result::Err(kb_core::Error::new( + "execution_spl_memo_signer_limit_exceeded", + format!( + "SPL Memo signer occurrence count {} exceeds the executor limit {}", + signers.len(), + crate::EX_SPL_MEMO_MAX_SIGNERS + ), + )); + } + let program_id = match solana_pubkey::Pubkey::from_str(generation.program_id()) { + std::result::Result::Ok(pubkey) => pubkey, + std::result::Result::Err(error) => { + return std::result::Result::Err(kb_core::Error::new( + "execution_spl_memo_program_id_invalid", + error.to_string(), + )); + }, + }; + let mut signer_pubkeys = std::vec::Vec::with_capacity(signers.len()); + for signer in signers { + let pubkey = match parse_pubkey(&signer.pubkey, "memo_signer") { + std::result::Result::Ok(pubkey) => pubkey, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + signer_pubkeys.push(pubkey); + } + let signer_refs = signer_pubkeys.iter().collect::>(); + let instruction = spl_memo_interface::instruction::build_memo( + &program_id, + message.as_bytes(), + signer_refs.as_slice(), + ); + let planned_instruction = + planned_instruction(crate::EX_SPL_MEMO_ADD_MEMO_OPERATION, &instruction); + let required_signers = required_signers(&intent.fee_payer, signers.as_slice()); + tracing::debug!( + target: crate::EX_SPL_MEMO_TRACING_TARGET, + action = "build_prepared_plan", + intent_id = %intent.intent_id, + operation_code = crate::EX_SPL_MEMO_ADD_MEMO_OPERATION, + program_id = generation.program_id(), + payload_length = message.len(), + signer_occurrence_count = signers.len(), + required_signer_count = required_signers.len(), + dry_run = intent.policy.dry_run, + "built SPL Memo execution plan" + ); + return std::result::Result::Ok(crate::ExApiPreparedExecutionPlan { + executor_name: std::string::String::from("kb_executor_spl_memo"), + executor_version: std::string::String::from(env!("CARGO_PKG_VERSION")), + intent_id: intent.intent_id.clone(), + operation_code: std::string::String::from(crate::EX_SPL_MEMO_ADD_MEMO_OPERATION), + fee_payer: crate::MdPubkey(fee_payer.to_string()), + instructions: vec![planned_instruction], + required_signers, + policy: intent.policy.clone(), + requested_spend_lamports: 0, + requested_compute_unit_price_micro_lamports: std::option::Option::None, + }); +} + +fn validate_policy(intent: &crate::ExSplMemoExecutionIntent) -> kb_core::Result<()> { + if intent.policy.simulation != crate::ExApiExecutionSimulationPolicy::Required { + return std::result::Result::Err(kb_core::Error::new( + "execution_spl_memo_simulation_required", + "SPL Memo execution requires simulation before signing or sending", + )); + } + match intent.policy.cost_limit.max_fee_lamports { + std::option::Option::Some(limit) if limit > 0 => {}, + std::option::Option::Some(_) | std::option::Option::None => { + return std::result::Result::Err(kb_core::Error::new( + "execution_spl_memo_fee_limit_missing", + "SPL Memo execution requires a positive transaction fee ceiling", + )); + }, + } + let validation = &intent.policy.post_execution_validation; + if !validation.canonical_insert_required + || !validation.core_extraction_required + || !validation.decode_replay_required + || !validation.materialization_required + { + return std::result::Result::Err(kb_core::Error::new( + "execution_spl_memo_post_validation_required", + "SPL Memo execution requires canonical insertion, core extraction, decode replay and materialization validation", + )); + } + return std::result::Result::Ok(()); +} + +fn parse_pubkey(pubkey: &crate::MdPubkey, field: &str) -> kb_core::Result { + if pubkey.0.trim().is_empty() { + return std::result::Result::Err(kb_core::Error::new( + "execution_spl_memo_pubkey_empty", + format!("SPL Memo {field} public key must not be empty"), + )); + } + return match solana_pubkey::Pubkey::from_str(pubkey.0.as_str()) { + std::result::Result::Ok(parsed) => std::result::Result::Ok(parsed), + std::result::Result::Err(error) => std::result::Result::Err(kb_core::Error::new( + "execution_spl_memo_pubkey_invalid", + format!("invalid SPL Memo {field} public key: {error}"), + )), + }; +} + +fn planned_instruction( + operation_code: &str, + instruction: &solana_instruction::Instruction, +) -> crate::ExApiPlannedInstruction { + return crate::ExApiPlannedInstruction { + program_id: crate::MdProgramId(instruction.program_id.to_string()), + operation_code: std::string::String::from(operation_code), + accounts: instruction + .accounts + .iter() + .map(|account| { + return crate::ExApiPlannedAccount { + pubkey: crate::MdPubkey(account.pubkey.to_string()), + is_signer: account.is_signer, + is_writable: account.is_writable, + }; + }) + .collect(), + data: instruction.data.clone(), + }; +} + +fn required_signers( + fee_payer: &crate::MdPubkey, + signers: &[crate::ExSplMemoSigner], +) -> std::vec::Vec { + let mut required = vec![crate::ExApiRequiredSigner { + pubkey: fee_payer.clone(), + role: std::string::String::from("fee_payer"), + }]; + for signer in signers { + if required.iter().any(|candidate| return candidate.pubkey == signer.pubkey) { + continue; + } + required.push(crate::ExApiRequiredSigner { + pubkey: signer.pubkey.clone(), + role: std::string::String::from("memo_signer"), + }); + } + return required; +} + +#[cfg(test)] +pub(crate) mod tests { + use std::str::FromStr; // rust-rules: trait-import + + fn pubkey(value: &str) -> crate::MdPubkey { + return crate::MdPubkey(std::string::String::from(value)); + } + + fn intent( + generation: crate::ExSplMemoGeneration, + message: &str, + signers: &[&str], + dry_run: bool, + ) -> crate::ExSplMemoExecutionIntent { + let fee_payer = kb_program_ids::SYSTEM_PROGRAM_ID; + let mut authorized_signers = vec![pubkey(fee_payer)]; + for signer in signers { + let candidate = pubkey(signer); + if !authorized_signers.contains(&candidate) { + authorized_signers.push(candidate); + } + } + return crate::ExSplMemoExecutionIntent { + intent_id: std::string::String::from("memo-intent-1"), + fee_payer: pubkey(fee_payer), + policy: crate::ExApiExecutionPolicy { + cost_limit: crate::ExApiExecutionCostLimit { + max_spend_lamports: std::option::Option::Some(0), + max_fee_lamports: std::option::Option::Some(10_000), + max_compute_unit_price_micro_lamports: std::option::Option::None, + }, + authorized_signers, + dry_run, + post_execution_validation: crate::ExApiPostExecutionValidationPolicy { + canonical_insert_required: true, + core_extraction_required: true, + decode_replay_required: true, + materialization_required: true, + }, + ..crate::ExApiExecutionPolicy::default() + }, + operation: crate::ExSplMemoOperation::AddMemo { + generation, + message: std::string::String::from(message), + signers: signers + .iter() + .map(|value| { + return crate::ExSplMemoSigner { pubkey: pubkey(value) }; + }) + .collect(), + }, + }; + } + + fn assert_matches_official( + plan: &crate::ExApiPreparedExecutionPlan, + generation: crate::ExSplMemoGeneration, + message: &str, + signers: &[&str], + ) { + let program_id = solana_pubkey::Pubkey::from_str(generation.program_id()) + .unwrap_or_else(|error| panic!("invalid program fixture: {error}")); + let signer_pubkeys = signers + .iter() + .map(|value| { + return solana_pubkey::Pubkey::from_str(value) + .unwrap_or_else(|error| panic!("invalid signer fixture: {error}")); + }) + .collect::>(); + let signer_refs = signer_pubkeys.iter().collect::>(); + let official = spl_memo_interface::instruction::build_memo( + &program_id, + message.as_bytes(), + signer_refs.as_slice(), + ); + assert_eq!(plan.instructions.len(), 1); + let actual = &plan.instructions[0]; + assert_eq!(actual.program_id.0, official.program_id.to_string()); + assert_eq!(actual.data, official.data); + assert_eq!(actual.accounts.len(), official.accounts.len()); + for (actual_account, official_account) in + actual.accounts.iter().zip(official.accounts.iter()) + { + assert_eq!(actual_account.pubkey.0, official_account.pubkey.to_string()); + assert_eq!(actual_account.is_signer, official_account.is_signer); + assert_eq!(actual_account.is_writable, official_account.is_writable); + } + } + + #[test] + fn current_generation_matches_the_official_explicit_program_builder() { + let generation = crate::ExSplMemoGeneration::V4; + let intent = intent(generation, "memo 🐆", &[kb_program_ids::VOTE_PROGRAM_ID], true); + let plan = crate::ExApiTypedInstructionExecutor::build_prepared_plan( + &crate::ExSplMemoExecutor, + &intent, + ) + .unwrap_or_else(|error| panic!("Memo plan failed: {error}")); + assert_matches_official(&plan, generation, "memo 🐆", &[kb_program_ids::VOTE_PROGRAM_ID]); + } + + #[test] + fn empty_payload_and_zero_signers_are_constructible() { + let intent = intent(crate::ExSplMemoGeneration::V4, "", &[], false); + let plan = crate::ExApiTypedInstructionExecutor::build_prepared_plan( + &crate::ExSplMemoExecutor, + &intent, + ) + .unwrap_or_else(|error| panic!("empty Memo plan failed: {error}")); + assert!(plan.instructions[0].data.is_empty()); + assert!(plan.instructions[0].accounts.is_empty()); + assert_eq!(plan.required_signers.len(), 1); + assert_eq!(plan.requested_spend_lamports, 0); + } + + #[test] + fn duplicate_signers_remain_ordered_readonly_accounts_but_required_signers_are_unique() { + let first = kb_program_ids::VOTE_PROGRAM_ID; + let second = kb_program_ids::STAKE_PROGRAM_ID; + let intent = + intent(crate::ExSplMemoGeneration::V4, "ordered", &[first, second, first], false); + let plan = crate::ExApiTypedInstructionExecutor::build_prepared_plan( + &crate::ExSplMemoExecutor, + &intent, + ) + .unwrap_or_else(|error| panic!("ordered Memo plan failed: {error}")); + let accounts = &plan.instructions[0].accounts; + assert_eq!(accounts.len(), 3); + assert_eq!(accounts[0].pubkey.0, first); + assert_eq!(accounts[1].pubkey.0, second); + assert_eq!(accounts[2].pubkey.0, first); + assert!(accounts.iter().all(|account| return account.is_signer)); + assert!(accounts.iter().all(|account| return !account.is_writable)); + assert_eq!(plan.required_signers.len(), 3); + } + + #[test] + fn prepared_plan_passes_common_pre_simulation_safety() { + let intent = intent( + crate::ExSplMemoGeneration::V4, + "safe plan", + &[kb_program_ids::VOTE_PROGRAM_ID], + true, + ); + let plan = crate::ExApiTypedInstructionExecutor::build_prepared_plan( + &crate::ExSplMemoExecutor, + &intent, + ) + .unwrap_or_else(|error| panic!("safe Memo plan failed: {error}")); + let evaluation = crate::ExSafetyChecker + .evaluate_prepared_plan(&plan) + .unwrap_or_else(|error| panic!("safety evaluation failed: {error}")); + assert_eq!(evaluation.decision, crate::ExSafetyDecision::Allow); + assert!(evaluation.violations.is_empty()); + } + + #[test] + fn current_generation_accepts_non_dry_run_plans() { + let generation = crate::ExSplMemoGeneration::V4; + let intent = intent(generation, "dry", &[], false); + let plan = crate::ExApiTypedInstructionExecutor::build_prepared_plan( + &crate::ExSplMemoExecutor, + &intent, + ) + .unwrap_or_else(|error| panic!("non-dry-run Memo plan failed: {error}")); + assert!(!plan.policy.dry_run); + assert_eq!(plan.instructions[0].program_id.0, generation.program_id()); + } + + #[test] + fn rejects_oversize_payload_and_optional_simulation() { + let mut oversize = intent( + crate::ExSplMemoGeneration::V4, + std::string::String::from_utf8(vec![b'a'; crate::EX_SPL_MEMO_MAX_MESSAGE_BYTES + 1]) + .unwrap_or_else(|error| panic!("fixture creation failed: {error}")) + .as_str(), + &[], + true, + ); + let error = crate::ExApiTypedInstructionExecutor::build_prepared_plan( + &crate::ExSplMemoExecutor, + &oversize, + ) + .expect_err("oversize payload must fail"); + assert_eq!(error.code(), "execution_spl_memo_payload_too_large"); + + oversize.operation = crate::ExSplMemoOperation::AddMemo { + generation: crate::ExSplMemoGeneration::V4, + message: std::string::String::from("valid"), + signers: std::vec::Vec::new(), + }; + oversize.policy.simulation = crate::ExApiExecutionSimulationPolicy::Optional; + let error = crate::ExApiTypedInstructionExecutor::build_prepared_plan( + &crate::ExSplMemoExecutor, + &oversize, + ) + .expect_err("optional simulation must fail"); + assert_eq!(error.code(), "execution_spl_memo_simulation_required"); + } + + #[test] + fn every_cluster_is_constructible_and_mainnet_remains_governed_by_common_safety() { + for cluster in [ + crate::ExApiExecutionCluster::Localnet, + crate::ExApiExecutionCluster::Devnet, + crate::ExApiExecutionCluster::Testnet, + crate::ExApiExecutionCluster::Mainnet, + ] { + let mut intent = intent(crate::ExSplMemoGeneration::V4, "universal", &[], false); + intent.policy.cluster.expected_cluster = cluster; + let plan = crate::ExApiTypedInstructionExecutor::build_prepared_plan( + &crate::ExSplMemoExecutor, + &intent, + ) + .unwrap_or_else(|error| panic!("cluster plan failed: {error}")); + assert_eq!(plan.policy.cluster.expected_cluster, cluster); + if cluster == crate::ExApiExecutionCluster::Mainnet { + let evaluation = crate::ExSafetyChecker + .evaluate_prepared_plan(&plan) + .unwrap_or_else(|error| panic!("Mainnet safety evaluation failed: {error}")); + assert_eq!(evaluation.decision, crate::ExSafetyDecision::Deny); + assert!(evaluation.violations.iter().any(|violation| { + return violation.code == "execution_mainnet_disabled"; + })); + let mut explicitly_enabled = plan.clone(); + explicitly_enabled.policy.cluster.allow_mainnet = true; + explicitly_enabled.policy.cluster.mainnet_confirmation = true; + let enabled_evaluation = crate::ExSafetyChecker + .evaluate_prepared_plan(&explicitly_enabled) + .unwrap_or_else(|error| { + panic!("enabled Mainnet safety evaluation failed: {error}") + }); + assert_eq!(enabled_evaluation.decision, crate::ExSafetyDecision::Allow); + } + } + } + + #[test] + fn rejects_missing_fee_cap_incomplete_post_validation_and_signer_overflow() { + let mut intent = intent(crate::ExSplMemoGeneration::V4, "policy", &[], true); + intent.policy.cost_limit.max_fee_lamports = std::option::Option::None; + let error = crate::ExApiTypedInstructionExecutor::build_prepared_plan( + &crate::ExSplMemoExecutor, + &intent, + ) + .expect_err("missing fee cap must fail"); + assert_eq!(error.code(), "execution_spl_memo_fee_limit_missing"); + + intent.policy.cost_limit.max_fee_lamports = std::option::Option::Some(10_000); + intent.policy.post_execution_validation.materialization_required = false; + let error = crate::ExApiTypedInstructionExecutor::build_prepared_plan( + &crate::ExSplMemoExecutor, + &intent, + ) + .expect_err("incomplete post validation must fail"); + assert_eq!(error.code(), "execution_spl_memo_post_validation_required"); + + intent.policy.post_execution_validation.materialization_required = true; + intent.operation = crate::ExSplMemoOperation::AddMemo { + generation: crate::ExSplMemoGeneration::V4, + message: std::string::String::from("bounded"), + signers: (0..=crate::EX_SPL_MEMO_MAX_SIGNERS) + .map(|_| { + return crate::ExSplMemoSigner { + pubkey: pubkey(kb_program_ids::VOTE_PROGRAM_ID), + }; + }) + .collect(), + }; + let error = crate::ExApiTypedInstructionExecutor::build_prepared_plan( + &crate::ExSplMemoExecutor, + &intent, + ) + .expect_err("signer overflow must fail"); + assert_eq!(error.code(), "execution_spl_memo_signer_limit_exceeded"); + } + + #[test] + fn machine_readable_matrix_matches_executor_policy() { + let parsed = serde_json::from_str::(include_str!( + "../../../../../docs/SPL_MEMO_MATRIX.json" + )) + .unwrap_or_else(|error| panic!("Memo matrix parsing failed: {error}")); + let contract = parsed + .get("executorContract") + .unwrap_or_else(|| panic!("Memo executor contract is missing")); + assert_eq!( + contract.get("operationCode").and_then(serde_json::Value::as_str), + std::option::Option::Some(crate::EX_SPL_MEMO_ADD_MEMO_OPERATION) + ); + assert_eq!( + contract.get("maximumMessageBytes").and_then(serde_json::Value::as_u64), + std::option::Option::Some(crate::EX_SPL_MEMO_MAX_MESSAGE_BYTES as u64) + ); + assert_eq!( + contract.get("maximumSignerOccurrences").and_then(serde_json::Value::as_u64), + std::option::Option::Some(crate::EX_SPL_MEMO_MAX_SIGNERS as u64) + ); + assert_eq!( + contract.get("allClustersConstructible").and_then(serde_json::Value::as_bool), + std::option::Option::Some(true) + ); + assert_eq!( + contract + .get("historicalGenerationSendEnabled") + .and_then(serde_json::Value::as_bool), + std::option::Option::Some(false) + ); + } +} diff --git a/kb-lib/src/executor/spl/memo/constants.rs b/kb-lib/src/executor/spl/memo/constants.rs new file mode 100644 index 0000000..2ee3ab0 --- /dev/null +++ b/kb-lib/src/executor/spl/memo/constants.rs @@ -0,0 +1,7 @@ +// file: kb-lib/src/executor/spl/memo/constants.rs +// version: 1 + +//! Local constants for the `kb_executor_spl_memo` crate. Program identifiers live in `kb_program_ids`. + +/// Canonical tracing target for this crate. +pub(crate) const EX_SPL_MEMO_TRACING_TARGET: &str = "kb-lib.executor.spl.memo"; diff --git a/kb-lib/src/executor/spl/memo/executor.rs b/kb-lib/src/executor/spl/memo/executor.rs new file mode 100644 index 0000000..eb9d22a --- /dev/null +++ b/kb-lib/src/executor/spl/memo/executor.rs @@ -0,0 +1,314 @@ +// file: kb-lib/src/executor/spl/memo/executor.rs +// version: 1 + +//! Exact capability dispatch and typed plan construction for SPL Memo. + +/// SPL Memo executor implementation. +#[derive(Clone, Debug, Default)] +pub struct ExSplMemoExecutor; + +impl crate::ExSplMemoExecutor { + fn exact_capability( + &self, + program_id: &crate::MdProgramId, + operation_code: &str, + ) -> crate::ExApiExecutionCapability { + let generation = match crate::ExSplMemoGeneration::from_program_id(program_id.0.as_str()) { + std::option::Option::Some(value) => value, + std::option::Option::None => { + return crate::ExApiExecutionCapability::unsupported( + "execution_spl_memo_program_not_owned", + format!("program {} is not owned by kb_executor_spl_memo", program_id.0), + ); + }, + }; + if operation_code != crate::EX_SPL_MEMO_ADD_MEMO_OPERATION { + return crate::ExApiExecutionCapability::unsupported( + "execution_spl_memo_operation_unsupported", + format!("SPL Memo operation {operation_code} is not implemented"), + ); + } + if generation != crate::ExSplMemoGeneration::V4 { + return crate::ExApiExecutionCapability::unsupported( + "execution_spl_memo_historical_generation_decode_only", + format!( + "SPL Memo generation {generation:?} is historical decode-only; only current or experimental generations are executable" + ), + ); + } + return crate::ExApiExecutionCapability::supported(operation_code); + } +} + +impl crate::ExApiTypedInstructionExecutor for crate::ExSplMemoExecutor { + type Intent = crate::ExSplMemoExecutionIntent; + + fn capability( + &self, + program_id: &crate::MdProgramId, + operation_code: &str, + ) -> crate::ExApiExecutionCapability { + return self.exact_capability(program_id, operation_code); + } + + fn build_prepared_plan( + &self, + intent: &Self::Intent, + ) -> kb_core::Result { + let program_id = crate::MdProgramId(std::string::String::from( + intent.operation.generation().program_id(), + )); + return match self.exact_capability(&program_id, intent.operation.operation_code()) { + crate::ExApiExecutionCapability::Supported { operation_code: _ } => { + crate::executor_spl_memo_build_prepared_plan(intent) + }, + crate::ExApiExecutionCapability::Unsupported { reason_code, reason } => { + std::result::Result::Err(kb_core::Error::new(reason_code, reason)) + }, + }; + } +} + +impl crate::ExApiInstructionExecutor for crate::ExSplMemoExecutor { + fn executor_name(&self) -> &'static str { + return "kb_executor_spl_memo"; + } + + fn executor_version(&self) -> &'static str { + return env!("CARGO_PKG_VERSION"); + } + + fn program_ids(&self) -> &'static [&'static str] { + return &[ + kb_program_ids::SPL_MEMO_V1_PROGRAM_ID, + kb_program_ids::SPL_MEMO_V3_PROGRAM_ID, + kb_program_ids::SPL_MEMO_V4_PROGRAM_ID, + ]; + } + + fn supports_request( + &self, + request: &crate::ExApiExecutionRequest, + ) -> crate::ExApiExecutionSupport { + return match self.exact_capability(&request.program_id, &request.operation_code) { + crate::ExApiExecutionCapability::Supported { operation_code: _ } => { + crate::ExApiExecutionSupport::Yes + }, + crate::ExApiExecutionCapability::Unsupported { reason_code: _, reason: _ } => { + crate::ExApiExecutionSupport::No + }, + }; + } + + fn build_plan( + &self, + request: &crate::ExApiExecutionRequest, + ) -> kb_core::Result { + match self.exact_capability(&request.program_id, &request.operation_code) { + crate::ExApiExecutionCapability::Supported { operation_code: _ } => {}, + crate::ExApiExecutionCapability::Unsupported { reason_code, reason } => { + return std::result::Result::Err(kb_core::Error::new(reason_code, reason)); + }, + } + let intent = + match serde_json::from_str::(&request.payload_json) { + std::result::Result::Ok(intent) => intent, + std::result::Result::Err(error) => { + return std::result::Result::Err(kb_core::Error::new( + "execution_spl_memo_intent_deserialize_failed", + error.to_string(), + )); + }, + }; + if intent.operation.operation_code() != request.operation_code.as_str() { + return std::result::Result::Err(kb_core::Error::new( + "execution_operation_code_mismatch", + format!( + "request operation {} does not match typed intent operation {}", + request.operation_code, + intent.operation.operation_code() + ), + )); + } + if intent.operation.generation().program_id() != request.program_id.0.as_str() { + return std::result::Result::Err(kb_core::Error::new( + "execution_program_id_mismatch", + format!( + "request program {} does not match typed intent program {}", + request.program_id.0, + intent.operation.generation().program_id() + ), + )); + } + let prepared = + match crate::ExApiTypedInstructionExecutor::build_prepared_plan(self, &intent) { + std::result::Result::Ok(plan) => plan, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + let payload_value = match serde_json::to_value(&prepared) { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => { + return std::result::Result::Err(kb_core::Error::new( + "execution_spl_memo_plan_serialize_failed", + error.to_string(), + )); + }, + }; + let payload_json = match crate::executor_api_serialize_payload_json(&payload_value) { + std::result::Result::Ok(serialized) => serialized, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + return std::result::Result::Ok(crate::ExApiExecutionPlan { + executor_name: std::string::String::from("kb_executor_spl_memo"), + instruction_count: prepared.instructions.len(), + payload_json, + }); + } +} + +#[cfg(test)] +mod tests { + fn request( + program_id: &str, + operation_code: &str, + payload_json: std::string::String, + ) -> crate::ExApiExecutionRequest { + return crate::ExApiExecutionRequest { + program_id: crate::MdProgramId(program_id.to_string()), + operation_code: operation_code.to_string(), + payload_json, + }; + } + + fn pubkey(value: &str) -> crate::MdPubkey { + return crate::MdPubkey(std::string::String::from(value)); + } + + fn intent( + generation: crate::ExSplMemoGeneration, + message: &str, + signers: &[&str], + dry_run: bool, + ) -> crate::ExSplMemoExecutionIntent { + let fee_payer = kb_program_ids::SYSTEM_PROGRAM_ID; + let mut authorized_signers = vec![pubkey(fee_payer)]; + for signer in signers { + let candidate = pubkey(signer); + if !authorized_signers.contains(&candidate) { + authorized_signers.push(candidate); + } + } + return crate::ExSplMemoExecutionIntent { + intent_id: std::string::String::from("memo-intent-1"), + fee_payer: pubkey(fee_payer), + policy: crate::ExApiExecutionPolicy { + cost_limit: crate::ExApiExecutionCostLimit { + max_spend_lamports: std::option::Option::Some(0), + max_fee_lamports: std::option::Option::Some(10_000), + max_compute_unit_price_micro_lamports: std::option::Option::None, + }, + authorized_signers, + dry_run, + post_execution_validation: crate::ExApiPostExecutionValidationPolicy { + canonical_insert_required: true, + core_extraction_required: true, + decode_replay_required: true, + materialization_required: true, + }, + ..crate::ExApiExecutionPolicy::default() + }, + operation: crate::ExSplMemoOperation::AddMemo { + generation, + message: std::string::String::from(message), + signers: signers + .iter() + .map(|value| { + return crate::ExSplMemoSigner { pubkey: pubkey(value) }; + }) + .collect(), + }, + }; + } + + #[test] + fn exact_capabilities_support_only_current_v4_add_memo() { + let executor = crate::ExSplMemoExecutor; + for program_id in + [kb_program_ids::SPL_MEMO_V1_PROGRAM_ID, kb_program_ids::SPL_MEMO_V3_PROGRAM_ID] + { + let request = request( + program_id, + crate::EX_SPL_MEMO_ADD_MEMO_OPERATION, + std::string::String::from("{}"), + ); + assert_eq!( + crate::ExApiInstructionExecutor::supports_request(&executor, &request), + crate::ExApiExecutionSupport::No + ); + } + let current = request( + kb_program_ids::SPL_MEMO_V4_PROGRAM_ID, + crate::EX_SPL_MEMO_ADD_MEMO_OPERATION, + std::string::String::from("{}"), + ); + assert_eq!( + crate::ExApiInstructionExecutor::supports_request(&executor, ¤t), + crate::ExApiExecutionSupport::Yes + ); + let unsupported_operation = request( + kb_program_ids::SPL_MEMO_V4_PROGRAM_ID, + "spl_memo.remove_memo", + std::string::String::from("{}"), + ); + assert_eq!( + crate::ExApiInstructionExecutor::supports_request(&executor, &unsupported_operation,), + crate::ExApiExecutionSupport::No + ); + let foreign = request( + kb_program_ids::SYSTEM_PROGRAM_ID, + crate::EX_SPL_MEMO_ADD_MEMO_OPERATION, + std::string::String::from("{}"), + ); + assert_eq!( + crate::ExApiInstructionExecutor::supports_request(&executor, &foreign), + crate::ExApiExecutionSupport::No + ); + } + + #[test] + fn generic_request_roundtrips_the_typed_plan() { + let intent = intent( + crate::ExSplMemoGeneration::V4, + "generic", + &[kb_program_ids::VOTE_PROGRAM_ID], + false, + ); + let payload_json = serde_json::to_string(&intent) + .unwrap_or_else(|error| panic!("intent serialization failed: {error}")); + let request = request( + kb_program_ids::SPL_MEMO_V4_PROGRAM_ID, + crate::EX_SPL_MEMO_ADD_MEMO_OPERATION, + payload_json, + ); + let plan = crate::ExApiInstructionExecutor::build_plan(&crate::ExSplMemoExecutor, &request) + .unwrap_or_else(|error| panic!("generic plan failed: {error}")); + assert_eq!(plan.instruction_count, 1); + let prepared = + serde_json::from_str::(&plan.payload_json) + .unwrap_or_else(|error| panic!("prepared plan parsing failed: {error}")); + assert_eq!(prepared.instructions[0].data, b"generic"); + } + + #[test] + fn historical_generations_are_decode_only_with_stable_reason() { + for generation in [crate::ExSplMemoGeneration::V1, crate::ExSplMemoGeneration::V3] { + let intent = intent(generation, "historical", &[], true); + let error = crate::ExApiTypedInstructionExecutor::build_prepared_plan( + &crate::ExSplMemoExecutor, + &intent, + ) + .expect_err("historical Memo generation must not build a plan"); + assert_eq!(error.code(), "execution_spl_memo_historical_generation_decode_only"); + } + } +} diff --git a/kb-lib/src/executor/spl/memo/intent.rs b/kb-lib/src/executor/spl/memo/intent.rs new file mode 100644 index 0000000..01ca697 --- /dev/null +++ b/kb-lib/src/executor/spl/memo/intent.rs @@ -0,0 +1,112 @@ +// file: kb-lib/src/executor/spl/memo/intent.rs +// version: 1 + +//! Typed SPL Memo execution intents. + +use ts_rs::TS; // rust-rules: derive-import + +/// Stable operation code for adding one SPL Memo annotation. +pub const EX_SPL_MEMO_ADD_MEMO_OPERATION: &str = "spl_memo.add_memo"; +/// Conservative UTF-8 payload bound used before transaction assembly. +pub const EX_SPL_MEMO_MAX_MESSAGE_BYTES: usize = 566; +/// Conservative bound on ordered signer occurrences accepted by one intent. +pub const EX_SPL_MEMO_MAX_SIGNERS: usize = 32; + +/// Exact SPL Memo program generation. +#[derive(Clone, Copy, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize, TS)] +#[serde(rename_all = "snake_case")] +#[ts( + export, + export_to = "../frontend/ts/bindings/kb_lib/executor/spl/memo/intent/ExSplMemoGeneration.ts" +)] +pub enum ExSplMemoGeneration { + /// Historical v1 program, whose runtime ignores supplied accounts. + V1, + /// Historical v3 program, whose runtime requires every supplied account to sign. + V3, + /// Current v4 program, whose runtime requires every supplied account to sign. + V4, +} + +impl crate::ExSplMemoGeneration { + /// Returns the exact Program ID for this generation. + pub fn program_id(&self) -> &'static str { + return match self { + Self::V1 => kb_program_ids::SPL_MEMO_V1_PROGRAM_ID, + Self::V3 => kb_program_ids::SPL_MEMO_V3_PROGRAM_ID, + Self::V4 => kb_program_ids::SPL_MEMO_V4_PROGRAM_ID, + }; + } + + pub(crate) fn from_program_id(program_id: &str) -> std::option::Option { + return match program_id { + kb_program_ids::SPL_MEMO_V1_PROGRAM_ID => std::option::Option::Some(Self::V1), + kb_program_ids::SPL_MEMO_V3_PROGRAM_ID => std::option::Option::Some(Self::V3), + kb_program_ids::SPL_MEMO_V4_PROGRAM_ID => std::option::Option::Some(Self::V4), + _ => std::option::Option::None, + }; + } +} + +/// One ordered signer account supplied to the Memo instruction. +#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize, TS)] +#[ts( + export, + export_to = "../frontend/ts/bindings/kb_lib/executor/spl/memo/intent/ExSplMemoSigner.ts" +)] +pub struct ExSplMemoSigner { + /// Signer public key; duplicates are preserved in instruction order. + pub pubkey: crate::MdPubkey, +} + +/// Typed SPL Memo operation arguments. +#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize, TS)] +#[serde(tag = "operation", rename_all = "snake_case")] +#[ts( + export, + export_to = "../frontend/ts/bindings/kb_lib/executor/spl/memo/intent/ExSplMemoOperation.ts" +)] +pub enum ExSplMemoOperation { + /// Add one exact UTF-8 Memo payload with ordered readonly signer accounts. + AddMemo { + /// Exact Memo program generation. + generation: crate::ExSplMemoGeneration, + /// UTF-8 text whose bytes become the complete instruction payload. + message: std::string::String, + /// Ordered signer accounts. Duplicates are preserved. + signers: std::vec::Vec, + }, +} + +impl crate::ExSplMemoOperation { + /// Returns the stable operation code. + pub fn operation_code(&self) -> &'static str { + return match self { + Self::AddMemo { .. } => crate::EX_SPL_MEMO_ADD_MEMO_OPERATION, + }; + } + + /// Returns the exact requested program generation. + pub fn generation(&self) -> crate::ExSplMemoGeneration { + return match self { + Self::AddMemo { generation, .. } => *generation, + }; + } +} + +/// Complete typed intent accepted by the SPL Memo executor. +#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize, TS)] +#[ts( + export, + export_to = "../frontend/ts/bindings/kb_lib/executor/spl/memo/intent/ExSplMemoExecutionIntent.ts" +)] +pub struct ExSplMemoExecutionIntent { + /// Stable caller-provided identifier used for logs and replay correlation. + pub intent_id: std::string::String, + /// Transaction fee payer. + pub fee_payer: crate::MdPubkey, + /// Conservative execution policy. + pub policy: crate::ExApiExecutionPolicy, + /// Typed Memo operation and arguments. + pub operation: crate::ExSplMemoOperation, +} diff --git a/kb-lib/src/lib.rs b/kb-lib/src/lib.rs index ef12016..7807e24 100644 --- a/kb-lib/src/lib.rs +++ b/kb-lib/src/lib.rs @@ -1,5 +1,5 @@ // file: kb-lib/src/lib.rs -// version: 18 +// version: 19 //! Consolidated decoder, executor, materializer and shared model library. #![warn(missing_docs)] @@ -563,6 +563,12 @@ pub use self::executor::EX_SOLANA_CORE_ZK_ELGAMAL_CLOSE_CONTEXT_STATE_OPERATION; pub use self::executor::EX_SOLANA_CORE_ZK_ELGAMAL_VERIFY_FROM_ACCOUNT_OPERATION; /// Exposes the ZK ElGamal inline proof verification operation code. pub use self::executor::EX_SOLANA_CORE_ZK_ELGAMAL_VERIFY_INLINE_OPERATION; +/// Exposes the stable SPL Memo add-memo operation code. +pub use self::executor::EX_SPL_MEMO_ADD_MEMO_OPERATION; +/// Exposes the SPL Memo payload bound. +pub use self::executor::EX_SPL_MEMO_MAX_MESSAGE_BYTES; +/// Exposes the SPL Memo signer bound. +pub use self::executor::EX_SPL_MEMO_MAX_SIGNERS; /// Exposes the reserved `ExAdapterSaberDecimalWrapperExecutor` implementation. pub use self::executor::ExAdapterSaberDecimalWrapperExecutor; /// Exposes the reserved `ExAdminJupiterLockExecutor` implementation. @@ -763,6 +769,14 @@ pub use self::executor::ExRouterOkxLabsV1Executor; pub use self::executor::ExRouterOkxLabsV2Executor; /// Exposes the reserved `ExRwaOndoGlobalMarketsExecutor` implementation. pub use self::executor::ExRwaOndoGlobalMarketsExecutor; +/// Exposes the stateless execution safety checker. +pub use self::executor::ExSafetyChecker; +/// Exposes an execution safety decision. +pub use self::executor::ExSafetyDecision; +/// Exposes a complete execution safety evaluation. +pub use self::executor::ExSafetyEvaluation; +/// Exposes one execution safety violation. +pub use self::executor::ExSafetyViolation; /// Exposes one Config Program key entry. pub use self::executor::ExSolanaCoreConfigKey; /// Exposes one Ed25519 verification offset record. @@ -797,8 +811,16 @@ pub use self::executor::ExSplAccountCompressionExecutor; pub use self::executor::ExSplAssociatedTokenAccountExecutor; /// Exposes the reserved `ExSplElgamalRegistryExecutor` implementation. pub use self::executor::ExSplElgamalRegistryExecutor; -/// Exposes the reserved `ExSplMemoExecutor` implementation. +/// Exposes the typed SPL Memo execution intent. +pub use self::executor::ExSplMemoExecutionIntent; +/// Exposes the SPL Memo executor. pub use self::executor::ExSplMemoExecutor; +/// Exposes the exact SPL Memo generation. +pub use self::executor::ExSplMemoGeneration; +/// Exposes the typed SPL Memo operation. +pub use self::executor::ExSplMemoOperation; +/// Exposes one ordered SPL Memo signer. +pub use self::executor::ExSplMemoSigner; /// Exposes the reserved `ExSplNoopExecutor` implementation. pub use self::executor::ExSplNoopExecutor; /// Exposes the reserved `ExSplSinglePoolExecutor` implementation. @@ -1412,6 +1434,8 @@ pub(crate) use self::decoder::decoder_spl_token2022_entry_for_tag; pub(crate) use self::decoder::decoder_spl_token2022_payload_tag; /// Canonical tracing target for this crate. pub(crate) use self::executor::EX_SOLANA_CORE_TRACING_TARGET; +/// Canonical SPL Memo tracing target. +pub(crate) use self::executor::EX_SPL_MEMO_TRACING_TARGET; /// Crate-root access to `build_prepared_plan` from `address_lookup_table`. pub(crate) use self::executor::executor_solana_core_address_lookup_table_build_prepared_plan; /// Crate-root access to `build_prepared_plan` from `builder`. @@ -1448,6 +1472,8 @@ pub(crate) use self::executor::executor_solana_core_validate_seeded_address; pub(crate) use self::executor::executor_solana_core_vote_build_prepared_plan; /// Crate-root access to `build_prepared_plan` from `zk_elgamal`. pub(crate) use self::executor::executor_solana_core_zk_elgamal_build_prepared_plan; +/// Internal SPL Memo plan builder. +pub(crate) use self::executor::executor_spl_memo_build_prepared_plan; /// Canonical processor name for the native and SPL administration materializer. pub(crate) use self::materializer::MT_ADMIN_PROCESSOR_NAME; /// Canonical tracing target for the native and SPL administration materializer. diff --git a/kb-lib/tests/external_executor_api.rs b/kb-lib/tests/external_executor_api.rs index 3f72d2f..d655ecb 100644 --- a/kb-lib/tests/external_executor_api.rs +++ b/kb-lib/tests/external_executor_api.rs @@ -1,5 +1,5 @@ // file: kb-lib/tests/external_executor_api.rs -// version: 3 +// version: 4 //! Downstream-style compilation contract for externally implemented executors. @@ -117,4 +117,49 @@ fn external_executor_uses_only_the_public_kb_lib_contract() { solana_core_plan.operation_code, kb_lib::EX_SOLANA_CORE_COMPUTE_BUDGET_SET_UNIT_LIMIT_OPERATION ); + let memo_executor = kb_lib::ExSplMemoExecutor; + let memo_program_id = kb_lib::MdProgramId(kb_program_ids::SPL_MEMO_V4_PROGRAM_ID.to_string()); + let memo_capability = kb_lib::ExApiTypedInstructionExecutor::capability( + &memo_executor, + &memo_program_id, + kb_lib::EX_SPL_MEMO_ADD_MEMO_OPERATION, + ); + assert!(memo_capability.is_supported()); + let memo_intent = kb_lib::ExSplMemoExecutionIntent { + intent_id: "external-spl-memo".to_string(), + fee_payer: kb_lib::MdPubkey(kb_program_ids::SYSTEM_PROGRAM_ID.to_string()), + policy: kb_lib::ExApiExecutionPolicy { + cost_limit: kb_lib::ExApiExecutionCostLimit { + max_spend_lamports: std::option::Option::Some(0), + max_fee_lamports: std::option::Option::Some(10_000), + max_compute_unit_price_micro_lamports: std::option::Option::None, + }, + authorized_signers: vec![kb_lib::MdPubkey( + kb_program_ids::SYSTEM_PROGRAM_ID.to_string(), + )], + post_execution_validation: kb_lib::ExApiPostExecutionValidationPolicy { + canonical_insert_required: true, + core_extraction_required: true, + decode_replay_required: true, + materialization_required: true, + }, + ..kb_lib::ExApiExecutionPolicy::default() + }, + operation: kb_lib::ExSplMemoOperation::AddMemo { + generation: kb_lib::ExSplMemoGeneration::V4, + message: "external memo".to_string(), + signers: std::vec::Vec::new(), + }, + }; + let memo_plan = + kb_lib::ExApiTypedInstructionExecutor::build_prepared_plan(&memo_executor, &memo_intent) + .unwrap_or_else(|error| panic!("SPL Memo executor failed: {error}")); + assert_eq!(memo_plan.instructions.len(), 1); + assert_eq!( + kb_lib::ExSafetyChecker + .evaluate_prepared_plan(&memo_plan) + .unwrap_or_else(|error| panic!("safety evaluation failed: {error}")) + .decision, + kb_lib::ExSafetyDecision::Allow + ); } diff --git a/scripts/audit_khadhroony_workspace_rules.py b/scripts/audit_khadhroony_workspace_rules.py index b977a68..3aa27a8 100755 --- a/scripts/audit_khadhroony_workspace_rules.py +++ b/scripts/audit_khadhroony_workspace_rules.py @@ -438,13 +438,13 @@ def audit_reserved_executor_scaffolds(root: pathlib.Path) -> list[Violation]: "reserved executor must build only an explicit zero-instruction plan", ) ) - if reserved_count != 103: + if reserved_count != 102: violations.append( Violation( "KH_EX007", "kb-lib/src/executor", 1, - f"expected 103 reserved executor boundaries, found {reserved_count}", + f"expected 102 reserved executor boundaries, found {reserved_count}", ) ) return violations