From 62bc0ffb442f7cef74cd03c19a6f3769813b3c9f Mon Sep 17 00:00:00 2001 From: SinuS Von SifriduS Date: Thu, 23 Jul 2026 21:41:09 +0200 Subject: [PATCH] v0.1.0-pre.010 --- README.md | 13 +- ROADMAP.md | 22 +- RULES.md | 3 +- config/README.md | 12 +- config/example.config.json | 240 ++-- docs/LOGGING.md | 12 +- docs/MATERIALIZATIONS.md | 10 +- docs/NATIVE_SOLANA_MATERIALIZATION_AUDIT.md | 28 +- docs/SURFACE_CRATE_MATRIX.md | 53 +- docs/TRACING_CONTRACT.md | 6 +- kb-lib/README.md | 8 +- kb-lib/src/lib.rs | 24 +- kb-lib/src/materializer/admin.rs | 13 +- kb-lib/src/materializer/admin/constants.rs | 10 + kb-lib/src/materializer/admin/core.rs | 1126 ++++++++++++++++- kb-lib/src/materializer/compliance.rs | 8 +- kb-lib/src/materializer/compliance/audit.rs | 579 ++++++++- .../src/materializer/compliance/constants.rs | 7 + kb-lib/src/materializer/lifecycle.rs | 8 +- .../src/materializer/lifecycle/constants.rs | 7 + kb-lib/src/materializer/lifecycle/core.rs | 962 +++++++++++++- kb-lib/src/materializer/staking.rs | 8 +- kb-lib/src/materializer/staking/constants.rs | 7 + kb-lib/src/materializer/staking/core.rs | 869 ++++++++++++- kb-lib/tests/external_materializer_api.rs | 42 + 25 files changed, 3863 insertions(+), 214 deletions(-) create mode 100644 kb-lib/src/materializer/admin/constants.rs create mode 100644 kb-lib/src/materializer/compliance/constants.rs create mode 100644 kb-lib/src/materializer/lifecycle/constants.rs create mode 100644 kb-lib/src/materializer/staking/constants.rs create mode 100644 kb-lib/tests/external_materializer_api.rs diff --git a/README.md b/README.md index 78885f3..12cb6da 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,5 @@ - + # Khadhroony Bot3 @@ -58,3 +58,14 @@ cargo clippy --workspace --all-targets `0.1.0-pre.008` porte le décodeur Token‑2022 maximal, son parseur d’états Mint/Account/Multisig et de TLV, ainsi que le programme indépendant du registre ElGamal. Les deux composants gardent des frontières et des targets de tracing distincts. Tous les décodeurs migrés utilisent les mêmes contrats contextualisés, la même façade publique et aucune dépendance inverse vers `kb-store`. `0.1.0-pre.009` porte le décodeur indépendant Metaplex Token Metadata. Il couvre les 58 discriminateurs d’instructions publiés `0..=57`, les 15 variantes de comptes `Key 0..=14`, les PDA officiels et les layouts courants, expérimentaux et historiques. Les metadata externes Metaplex restent séparées des metadata incorporées de Token‑2022, de Metaplex Core et de Bubblegum. + +## Matérialisateurs consolidés + +`0.1.0-pre.010` porte les quatre matérialisateurs Solana natifs dans `kb-lib` : + +- `LifecycleMaterializer` pour les comptes System, nonces, ALT, loaders, Feature Gate, contextes ZK et rapports Slashing ; +- `AdminMaterializer` pour les assignations, configurations et autorités, avec ses projections stateful Token‑2022 et registre ElGamal ; +- `ComplianceAuditMaterializer` pour les écritures et copies bornées de bytecode ; +- `StakingMaterializer` pour les transitions Stake et Vote commitées. + +Les identités historiques des processors sont conservées pour la compatibilité des replays, tandis que les targets de tracing suivent désormais la hiérarchie `kb-lib.materializer.*`. Le contrat public `EventMaterializer`, ses modèles d’entrée et ses résultats restent réexportés à la racine de `kb-lib`, afin qu’un futur crate externe puisse fournir un matérialiseur sans dépendre d’une implémentation interne. diff --git a/ROADMAP.md b/ROADMAP.md index 8f5468b..c3f5d49 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -1,5 +1,5 @@ - + # ROADMAP — khadhroony-bot3 @@ -14,10 +14,12 @@ - [x] Porter exactement les contrats publics des APIs d’exécution et de matérialisation. - [x] Porter les modèles de `kb_model` vers `kb-lib`. - [~] Porter les décodeurs Solana core, SPL et Metaplex. -- [ ] Porter leurs matérialisateurs et exécuteurs. +- [~] Porter leurs matérialisateurs. +- [ ] Porter leurs exécuteurs. - [x] Fusionner `kb_store_core` et `kb_store_pg` dans `kb-store`. - [ ] Adapter `kb-pipeline` aux nouveaux chemins publics. - [ ] Adapter `kb-app-demo` et rétablir les validations fonctionnelles. +- [ ] Créer ultérieurement une crate off-chain dédiée au fetch borné HTTP/IPFS/Arweave des URI de metadata, séparée des décodeurs, matérialisateurs et du replay canonique on-chain. - [ ] Réorganiser les chemins d’export TS‑RS de `kb-lib` dans une tranche dédiée, en conservant `../frontend/ts/bindings/` comme préfixe de base. - [ ] Auditer rétrospectivement la couverture historique des décodeurs et matérialisateurs. @@ -110,5 +112,17 @@ - [x] Ajouter le Program ID Metaplex Token Metadata à `kb-program-ids` sans le classer comme surface native. - [x] Porter le target de tracing vers `kb-lib.decoder.metadata.metaplex_token_metadata`. - [x] Conserver les 69 tests Metaplex et la matrice machine-readable exhaustive. -- [ ] Valider `kb-program-ids`, les 320 tests de `kb-lib`, Clippy et le workspace avec Cargo sur la machine de développement. -- [ ] Poursuivre avec les matérialisateurs et exécuteurs après validation du port des décodeurs prioritaires. +- [x] Valider `kb-program-ids`, les 320 tests de `kb-lib`, Clippy et le workspace avec Cargo sur la machine de développement. +- [x] Poursuivre avec les matérialisateurs après validation du port des décodeurs prioritaires. + +### 0.1.0-pre.010 — Matérialisateurs Solana natifs dans `kb-lib` + +- [x] Porter `LifecycleMaterializer`, `AdminMaterializer`, `ComplianceAuditMaterializer` et `StakingMaterializer` dans les modules consolidés de `kb-lib`. +- [x] Conserver leurs 50 tests de projections natives, de politique de transaction et d’idempotence. +- [x] Réexporter les types concrets et les fonctions stateful publiques depuis la façade de `kb-lib`. +- [x] Ajouter un test d’intégration aval prouvant qu’un futur crate externe peut implémenter `EventMaterializer` avec la seule API publique. +- [x] Porter les targets vers `kb-lib.materializer.admin`, `kb-lib.materializer.compliance`, `kb-lib.materializer.lifecycle` et `kb-lib.materializer.staking`. +- [x] Conserver les identités de processor historiques afin de préserver les clés de replay et d’idempotence existantes. +- [x] Ajouter la règle d’ordre et d’absence de ligne vide interne dans les blocs de réexports publics. +- [ ] Valider les 371 tests attendus de `kb-lib`, Clippy et le workspace avec Cargo sur la machine de développement. +- [ ] Poursuivre avec les matérialisateurs Memo, SPL Token, ATA, Token‑2022, registre ElGamal et Metaplex. diff --git a/RULES.md b/RULES.md index 0f480dc..005e90b 100644 --- a/RULES.md +++ b/RULES.md @@ -1,5 +1,5 @@ - + # Règles spécifiques à `khadhroony-bot3` @@ -17,6 +17,7 @@ Toute livraison doit exécuter l'audit `python3 scripts/audit_rust_workspace_rul - Les packages Rust utilisent le préfixe `kb-` ; leur identifiant Rust correspondant utilise automatiquement `kb_`. - Les décodeurs, matérialisateurs et exécuteurs sont des modules de `kb-lib`, pas des crates séparées. - Le crate de journalisation s'appelle `kb-logging`. +- Les blocs consécutifs de réexports `pub use` et `pub(crate) use` ne contiennent aucune ligne vide interne et restent ordonnés alphabétiquement lorsque `cargo fmt` ne les réordonne pas. ## Règles Tauri et TypeScript diff --git a/config/README.md b/config/README.md index 6e80344..b94f635 100644 --- a/config/README.md +++ b/config/README.md @@ -1,5 +1,5 @@ - + # Configuration locale @@ -50,13 +50,13 @@ Les sources futures devront déclarer séparément : L’exemple configure, pour chacun des trois profils, quatre fichiers globaux puis trois fichiers dédiés par crate opérationnelle utilisant `tracing` : `debug.log`, `info.log` et `error.jsonl`. -`pre.020` rend `kb_materializer_admin` et `kb_materializer_compliance_audit` opérationnelles. `pre.021` active ensuite `kb_materializer_staking`. Elles rejoignent donc la console et disposent de leurs routes propres sous : +Les matérialisateurs natifs consolidés rejoignent la console et disposent de leurs routes propres sous : ```text -logs//kb_materializer_admin/ -logs//kb_materializer_compliance_audit/ -logs//kb_materializer_staking/ +logs//kb-lib/materializer/admin/ +logs//kb-lib/materializer/compliance/ +logs//kb-lib/materializer/lifecycle/ +logs//kb-lib/materializer/staking/ ``` Le test de configuration découvre dynamiquement toutes les crates déclarant `tracing.workspace = true` et vérifie leurs trois routes dans chaque profil. Les logs ne doivent contenir ni secret, ni DSN non masqué, ni payload Config ou bytecode complet. - diff --git a/config/example.config.json b/config/example.config.json index 3255c04..6868e17 100644 --- a/config/example.config.json +++ b/config/example.config.json @@ -42,11 +42,11 @@ "kb_executor_spl_token", "kb_executor_spl_token_2022", "kb-logging", - "kb_materializer_admin", - "kb_materializer_compliance_audit", - "kb_materializer_lifecycle", + "kb-lib.materializer.admin", + "kb-lib.materializer.compliance", + "kb-lib.materializer.lifecycle", "kb_materializer_risk", - "kb_materializer_staking", + "kb-lib.materializer.staking", "kb_materializer_token_accounts", "kb_materializer_transaction_annotations", "kb_pipeline", @@ -693,120 +693,120 @@ ] }, { - "name": "file_kb_materializer_admin_debug", + "name": "file_kb_lib_materializer_admin_debug", "enabled": true, "sink": "file", "level": "debug", - "path": "logs/devnet/kb_materializer_admin/debug.log", + "path": "logs/devnet/kb-lib/materializer/admin/debug.log", "rotation": "daily", "format": "human", "ansi": false, "targets": [ - "kb_materializer_admin" + "kb-lib.materializer.admin" ] }, { - "name": "file_kb_materializer_admin_info", + "name": "file_kb_lib_materializer_admin_info", "enabled": true, "sink": "file", "level": "info", - "path": "logs/devnet/kb_materializer_admin/info.log", + "path": "logs/devnet/kb-lib/materializer/admin/info.log", "rotation": "daily", "format": "human", "ansi": false, "targets": [ - "kb_materializer_admin" + "kb-lib.materializer.admin" ] }, { - "name": "file_kb_materializer_admin_error", + "name": "file_kb_lib_materializer_admin_error", "enabled": true, "sink": "file", "level": "error", - "path": "logs/devnet/kb_materializer_admin/error.jsonl", + "path": "logs/devnet/kb-lib/materializer/admin/error.jsonl", "rotation": "daily", "format": "json", "ansi": false, "targets": [ - "kb_materializer_admin" + "kb-lib.materializer.admin" ] }, { - "name": "file_kb_materializer_compliance_audit_debug", + "name": "file_kb_lib_materializer_compliance_debug", "enabled": true, "sink": "file", "level": "debug", - "path": "logs/devnet/kb_materializer_compliance_audit/debug.log", + "path": "logs/devnet/kb-lib/materializer/compliance/debug.log", "rotation": "daily", "format": "human", "ansi": false, "targets": [ - "kb_materializer_compliance_audit" + "kb-lib.materializer.compliance" ] }, { - "name": "file_kb_materializer_compliance_audit_info", + "name": "file_kb_lib_materializer_compliance_info", "enabled": true, "sink": "file", "level": "info", - "path": "logs/devnet/kb_materializer_compliance_audit/info.log", + "path": "logs/devnet/kb-lib/materializer/compliance/info.log", "rotation": "daily", "format": "human", "ansi": false, "targets": [ - "kb_materializer_compliance_audit" + "kb-lib.materializer.compliance" ] }, { - "name": "file_kb_materializer_compliance_audit_error", + "name": "file_kb_lib_materializer_compliance_error", "enabled": true, "sink": "file", "level": "error", - "path": "logs/devnet/kb_materializer_compliance_audit/error.jsonl", + "path": "logs/devnet/kb-lib/materializer/compliance/error.jsonl", "rotation": "daily", "format": "json", "ansi": false, "targets": [ - "kb_materializer_compliance_audit" + "kb-lib.materializer.compliance" ] }, { - "name": "file_kb_materializer_lifecycle_debug", + "name": "file_kb_lib_materializer_lifecycle_debug", "enabled": true, "sink": "file", "level": "debug", - "path": "logs/devnet/kb_materializer_lifecycle/debug.log", + "path": "logs/devnet/kb-lib/materializer/lifecycle/debug.log", "rotation": "daily", "format": "human", "ansi": false, "targets": [ - "kb_materializer_lifecycle" + "kb-lib.materializer.lifecycle" ] }, { - "name": "file_kb_materializer_lifecycle_info", + "name": "file_kb_lib_materializer_lifecycle_info", "enabled": true, "sink": "file", "level": "info", - "path": "logs/devnet/kb_materializer_lifecycle/info.log", + "path": "logs/devnet/kb-lib/materializer/lifecycle/info.log", "rotation": "daily", "format": "human", "ansi": false, "targets": [ - "kb_materializer_lifecycle" + "kb-lib.materializer.lifecycle" ] }, { - "name": "file_kb_materializer_lifecycle_error", + "name": "file_kb_lib_materializer_lifecycle_error", "enabled": true, "sink": "file", "level": "error", - "path": "logs/devnet/kb_materializer_lifecycle/error.jsonl", + "path": "logs/devnet/kb-lib/materializer/lifecycle/error.jsonl", "rotation": "daily", "format": "json", "ansi": false, "targets": [ - "kb_materializer_lifecycle" + "kb-lib.materializer.lifecycle" ] }, { @@ -849,39 +849,39 @@ ] }, { - "name": "file_kb_materializer_staking_debug", + "name": "file_kb_lib_materializer_staking_debug", "enabled": true, "level": "debug", "format": "human", - "path": "logs/devnet/kb_materializer_staking/debug.log", + "path": "logs/devnet/kb-lib/materializer/staking/debug.log", "targets": [ - "kb_materializer_staking" + "kb-lib.materializer.staking" ], "sink": "file", "rotation": "daily", "ansi": false }, { - "name": "file_kb_materializer_staking_info", + "name": "file_kb_lib_materializer_staking_info", "enabled": true, "level": "info", "format": "human", - "path": "logs/devnet/kb_materializer_staking/info.log", + "path": "logs/devnet/kb-lib/materializer/staking/info.log", "targets": [ - "kb_materializer_staking" + "kb-lib.materializer.staking" ], "sink": "file", "rotation": "daily", "ansi": false }, { - "name": "file_kb_materializer_staking_error", + "name": "file_kb_lib_materializer_staking_error", "enabled": true, "level": "error", "format": "json", - "path": "logs/devnet/kb_materializer_staking/error.jsonl", + "path": "logs/devnet/kb-lib/materializer/staking/error.jsonl", "targets": [ - "kb_materializer_staking" + "kb-lib.materializer.staking" ], "sink": "file", "rotation": "daily", @@ -1644,11 +1644,11 @@ "kb_executor_spl_token", "kb_executor_spl_token_2022", "kb-logging", - "kb_materializer_admin", - "kb_materializer_compliance_audit", - "kb_materializer_lifecycle", + "kb-lib.materializer.admin", + "kb-lib.materializer.compliance", + "kb-lib.materializer.lifecycle", "kb_materializer_risk", - "kb_materializer_staking", + "kb-lib.materializer.staking", "kb_materializer_token_accounts", "kb_materializer_transaction_annotations", "kb_pipeline", @@ -2295,120 +2295,120 @@ ] }, { - "name": "file_kb_materializer_admin_debug", + "name": "file_kb_lib_materializer_admin_debug", "enabled": true, "sink": "file", "level": "debug", - "path": "logs/mainnet_research/kb_materializer_admin/debug.log", + "path": "logs/mainnet_research/kb-lib/materializer/admin/debug.log", "rotation": "daily", "format": "human", "ansi": false, "targets": [ - "kb_materializer_admin" + "kb-lib.materializer.admin" ] }, { - "name": "file_kb_materializer_admin_info", + "name": "file_kb_lib_materializer_admin_info", "enabled": true, "sink": "file", "level": "info", - "path": "logs/mainnet_research/kb_materializer_admin/info.log", + "path": "logs/mainnet_research/kb-lib/materializer/admin/info.log", "rotation": "daily", "format": "human", "ansi": false, "targets": [ - "kb_materializer_admin" + "kb-lib.materializer.admin" ] }, { - "name": "file_kb_materializer_admin_error", + "name": "file_kb_lib_materializer_admin_error", "enabled": true, "sink": "file", "level": "error", - "path": "logs/mainnet_research/kb_materializer_admin/error.jsonl", + "path": "logs/mainnet_research/kb-lib/materializer/admin/error.jsonl", "rotation": "daily", "format": "json", "ansi": false, "targets": [ - "kb_materializer_admin" + "kb-lib.materializer.admin" ] }, { - "name": "file_kb_materializer_compliance_audit_debug", + "name": "file_kb_lib_materializer_compliance_debug", "enabled": true, "sink": "file", "level": "debug", - "path": "logs/mainnet_research/kb_materializer_compliance_audit/debug.log", + "path": "logs/mainnet_research/kb-lib/materializer/compliance/debug.log", "rotation": "daily", "format": "human", "ansi": false, "targets": [ - "kb_materializer_compliance_audit" + "kb-lib.materializer.compliance" ] }, { - "name": "file_kb_materializer_compliance_audit_info", + "name": "file_kb_lib_materializer_compliance_info", "enabled": true, "sink": "file", "level": "info", - "path": "logs/mainnet_research/kb_materializer_compliance_audit/info.log", + "path": "logs/mainnet_research/kb-lib/materializer/compliance/info.log", "rotation": "daily", "format": "human", "ansi": false, "targets": [ - "kb_materializer_compliance_audit" + "kb-lib.materializer.compliance" ] }, { - "name": "file_kb_materializer_compliance_audit_error", + "name": "file_kb_lib_materializer_compliance_error", "enabled": true, "sink": "file", "level": "error", - "path": "logs/mainnet_research/kb_materializer_compliance_audit/error.jsonl", + "path": "logs/mainnet_research/kb-lib/materializer/compliance/error.jsonl", "rotation": "daily", "format": "json", "ansi": false, "targets": [ - "kb_materializer_compliance_audit" + "kb-lib.materializer.compliance" ] }, { - "name": "file_kb_materializer_lifecycle_debug", + "name": "file_kb_lib_materializer_lifecycle_debug", "enabled": true, "sink": "file", "level": "debug", - "path": "logs/mainnet_research/kb_materializer_lifecycle/debug.log", + "path": "logs/mainnet_research/kb-lib/materializer/lifecycle/debug.log", "rotation": "daily", "format": "human", "ansi": false, "targets": [ - "kb_materializer_lifecycle" + "kb-lib.materializer.lifecycle" ] }, { - "name": "file_kb_materializer_lifecycle_info", + "name": "file_kb_lib_materializer_lifecycle_info", "enabled": true, "sink": "file", "level": "info", - "path": "logs/mainnet_research/kb_materializer_lifecycle/info.log", + "path": "logs/mainnet_research/kb-lib/materializer/lifecycle/info.log", "rotation": "daily", "format": "human", "ansi": false, "targets": [ - "kb_materializer_lifecycle" + "kb-lib.materializer.lifecycle" ] }, { - "name": "file_kb_materializer_lifecycle_error", + "name": "file_kb_lib_materializer_lifecycle_error", "enabled": true, "sink": "file", "level": "error", - "path": "logs/mainnet_research/kb_materializer_lifecycle/error.jsonl", + "path": "logs/mainnet_research/kb-lib/materializer/lifecycle/error.jsonl", "rotation": "daily", "format": "json", "ansi": false, "targets": [ - "kb_materializer_lifecycle" + "kb-lib.materializer.lifecycle" ] }, { @@ -2451,39 +2451,39 @@ ] }, { - "name": "file_kb_materializer_staking_debug", + "name": "file_kb_lib_materializer_staking_debug", "enabled": true, "level": "debug", "format": "human", - "path": "logs/mainnet_research/kb_materializer_staking/debug.log", + "path": "logs/mainnet_research/kb-lib/materializer/staking/debug.log", "targets": [ - "kb_materializer_staking" + "kb-lib.materializer.staking" ], "sink": "file", "rotation": "daily", "ansi": false }, { - "name": "file_kb_materializer_staking_info", + "name": "file_kb_lib_materializer_staking_info", "enabled": true, "level": "info", "format": "human", - "path": "logs/mainnet_research/kb_materializer_staking/info.log", + "path": "logs/mainnet_research/kb-lib/materializer/staking/info.log", "targets": [ - "kb_materializer_staking" + "kb-lib.materializer.staking" ], "sink": "file", "rotation": "daily", "ansi": false }, { - "name": "file_kb_materializer_staking_error", + "name": "file_kb_lib_materializer_staking_error", "enabled": true, "level": "error", "format": "json", - "path": "logs/mainnet_research/kb_materializer_staking/error.jsonl", + "path": "logs/mainnet_research/kb-lib/materializer/staking/error.jsonl", "targets": [ - "kb_materializer_staking" + "kb-lib.materializer.staking" ], "sink": "file", "rotation": "daily", @@ -3446,11 +3446,11 @@ "kb_executor_spl_token", "kb_executor_spl_token_2022", "kb-logging", - "kb_materializer_admin", - "kb_materializer_compliance_audit", - "kb_materializer_lifecycle", + "kb-lib.materializer.admin", + "kb-lib.materializer.compliance", + "kb-lib.materializer.lifecycle", "kb_materializer_risk", - "kb_materializer_staking", + "kb-lib.materializer.staking", "kb_materializer_token_accounts", "kb_materializer_transaction_annotations", "kb_pipeline", @@ -4097,120 +4097,120 @@ ] }, { - "name": "file_kb_materializer_admin_debug", + "name": "file_kb_lib_materializer_admin_debug", "enabled": true, "sink": "file", "level": "debug", - "path": "logs/mainnet/kb_materializer_admin/debug.log", + "path": "logs/mainnet/kb-lib/materializer/admin/debug.log", "rotation": "daily", "format": "human", "ansi": false, "targets": [ - "kb_materializer_admin" + "kb-lib.materializer.admin" ] }, { - "name": "file_kb_materializer_admin_info", + "name": "file_kb_lib_materializer_admin_info", "enabled": true, "sink": "file", "level": "info", - "path": "logs/mainnet/kb_materializer_admin/info.log", + "path": "logs/mainnet/kb-lib/materializer/admin/info.log", "rotation": "daily", "format": "human", "ansi": false, "targets": [ - "kb_materializer_admin" + "kb-lib.materializer.admin" ] }, { - "name": "file_kb_materializer_admin_error", + "name": "file_kb_lib_materializer_admin_error", "enabled": true, "sink": "file", "level": "error", - "path": "logs/mainnet/kb_materializer_admin/error.jsonl", + "path": "logs/mainnet/kb-lib/materializer/admin/error.jsonl", "rotation": "daily", "format": "json", "ansi": false, "targets": [ - "kb_materializer_admin" + "kb-lib.materializer.admin" ] }, { - "name": "file_kb_materializer_compliance_audit_debug", + "name": "file_kb_lib_materializer_compliance_debug", "enabled": true, "sink": "file", "level": "debug", - "path": "logs/mainnet/kb_materializer_compliance_audit/debug.log", + "path": "logs/mainnet/kb-lib/materializer/compliance/debug.log", "rotation": "daily", "format": "human", "ansi": false, "targets": [ - "kb_materializer_compliance_audit" + "kb-lib.materializer.compliance" ] }, { - "name": "file_kb_materializer_compliance_audit_info", + "name": "file_kb_lib_materializer_compliance_info", "enabled": true, "sink": "file", "level": "info", - "path": "logs/mainnet/kb_materializer_compliance_audit/info.log", + "path": "logs/mainnet/kb-lib/materializer/compliance/info.log", "rotation": "daily", "format": "human", "ansi": false, "targets": [ - "kb_materializer_compliance_audit" + "kb-lib.materializer.compliance" ] }, { - "name": "file_kb_materializer_compliance_audit_error", + "name": "file_kb_lib_materializer_compliance_error", "enabled": true, "sink": "file", "level": "error", - "path": "logs/mainnet/kb_materializer_compliance_audit/error.jsonl", + "path": "logs/mainnet/kb-lib/materializer/compliance/error.jsonl", "rotation": "daily", "format": "json", "ansi": false, "targets": [ - "kb_materializer_compliance_audit" + "kb-lib.materializer.compliance" ] }, { - "name": "file_kb_materializer_lifecycle_debug", + "name": "file_kb_lib_materializer_lifecycle_debug", "enabled": true, "sink": "file", "level": "debug", - "path": "logs/mainnet/kb_materializer_lifecycle/debug.log", + "path": "logs/mainnet/kb-lib/materializer/lifecycle/debug.log", "rotation": "daily", "format": "human", "ansi": false, "targets": [ - "kb_materializer_lifecycle" + "kb-lib.materializer.lifecycle" ] }, { - "name": "file_kb_materializer_lifecycle_info", + "name": "file_kb_lib_materializer_lifecycle_info", "enabled": true, "sink": "file", "level": "info", - "path": "logs/mainnet/kb_materializer_lifecycle/info.log", + "path": "logs/mainnet/kb-lib/materializer/lifecycle/info.log", "rotation": "daily", "format": "human", "ansi": false, "targets": [ - "kb_materializer_lifecycle" + "kb-lib.materializer.lifecycle" ] }, { - "name": "file_kb_materializer_lifecycle_error", + "name": "file_kb_lib_materializer_lifecycle_error", "enabled": true, "sink": "file", "level": "error", - "path": "logs/mainnet/kb_materializer_lifecycle/error.jsonl", + "path": "logs/mainnet/kb-lib/materializer/lifecycle/error.jsonl", "rotation": "daily", "format": "json", "ansi": false, "targets": [ - "kb_materializer_lifecycle" + "kb-lib.materializer.lifecycle" ] }, { @@ -4253,39 +4253,39 @@ ] }, { - "name": "file_kb_materializer_staking_debug", + "name": "file_kb_lib_materializer_staking_debug", "enabled": true, "level": "debug", "format": "human", - "path": "logs/mainnet/kb_materializer_staking/debug.log", + "path": "logs/mainnet/kb-lib/materializer/staking/debug.log", "targets": [ - "kb_materializer_staking" + "kb-lib.materializer.staking" ], "sink": "file", "rotation": "daily", "ansi": false }, { - "name": "file_kb_materializer_staking_info", + "name": "file_kb_lib_materializer_staking_info", "enabled": true, "level": "info", "format": "human", - "path": "logs/mainnet/kb_materializer_staking/info.log", + "path": "logs/mainnet/kb-lib/materializer/staking/info.log", "targets": [ - "kb_materializer_staking" + "kb-lib.materializer.staking" ], "sink": "file", "rotation": "daily", "ansi": false }, { - "name": "file_kb_materializer_staking_error", + "name": "file_kb_lib_materializer_staking_error", "enabled": true, "level": "error", "format": "json", - "path": "logs/mainnet/kb_materializer_staking/error.jsonl", + "path": "logs/mainnet/kb-lib/materializer/staking/error.jsonl", "targets": [ - "kb_materializer_staking" + "kb-lib.materializer.staking" ], "sink": "file", "rotation": "daily", diff --git a/docs/LOGGING.md b/docs/LOGGING.md index d7f21b7..f210d8f 100644 --- a/docs/LOGGING.md +++ b/docs/LOGGING.md @@ -1,5 +1,5 @@ - + # Logging @@ -40,7 +40,7 @@ Les niveaux sont cumulatifs : ## Targets canoniques -Le target est exactement le nom de la crate et provient de `src/constants.rs` : +Le target est le nom exact de la crate hors `kb-lib`. Dans `kb-lib`, il suit la hiérarchie du composant : ```text kb_app_demo @@ -52,10 +52,10 @@ kb_executor_spl_memo kb_executor_spl_noop kb_executor_spl_single_pool kb_logging -kb_materializer_admin -kb_materializer_compliance_audit -kb_materializer_lifecycle -kb_materializer_staking +kb-lib.materializer.admin +kb-lib.materializer.compliance +kb-lib.materializer.lifecycle +kb-lib.materializer.staking kb_pipeline kb_rpc kb_store_pg diff --git a/docs/MATERIALIZATIONS.md b/docs/MATERIALIZATIONS.md index e8eb5c6..7914f97 100644 --- a/docs/MATERIALIZATIONS.md +++ b/docs/MATERIALIZATIONS.md @@ -1,5 +1,5 @@ - + # Catalogue des matérialisations @@ -18,9 +18,9 @@ Les matérialisations transforment des événements décodés en événements m - `reward` : émission, claim, distribution, farming, incentives. - `risk` : signaux dérivés à partir des événements admin, liquidité, metadata, authority ou anomalies. -## Projections natives actives dans `0.4.1` +## Projections natives portées dans `kb-lib` -`kb_materializer_lifecycle` produit des événements lifecycle idempotents à partir d'observations exactes, réussies et commitées : +`kb_lib::LifecycleMaterializer` produit des événements lifecycle idempotents à partir d'observations exactes, réussies et commitées : | Domaine | Sortie stable | Entrées couvertes | |----------------------|--------------------------------------------|--------------------------------------------------------------------------------------------------------------| @@ -34,7 +34,9 @@ Les matérialisations transforment des événements décodés en événements m La famille décodée n'impose pas à elle seule la famille matérialisée. `authorize_nonce_account` est un événement `Admin`, tandis qu'une vérification ZK ElGamal est un événement `Audit`; ces observations ne produisent une sortie `Lifecycle` que lorsque leur mutation durable exacte est reconnue. Une transaction échouée ou non commitée est toujours refusée. -`kb_materializer_admin` produit des sorties `Admin` pour les assignations System, les écritures Config opaques et les changements d’autorité Loader. `kb_materializer_compliance_audit` produit des sorties `ComplianceAudit` pour les écritures/copies de bytecode Loader. `kb_materializer_staking` produit les projections instructionnelles Stake/Vote commitées : comptes stake/vote, autorités, lockup, délégation, vote state, retraits et dépôt de rewards. Les données complètes ne sont jamais recopiées : seules les clés, autorités, offsets, tailles, hashes, préfixes et paramètres bornés disponibles sont conservés. +`kb_lib::AdminMaterializer` produit des sorties `Admin` pour les assignations System, les écritures Config opaques et les changements d’autorité Loader. `kb_lib::ComplianceAuditMaterializer` produit des sorties `ComplianceAudit` pour les écritures/copies de bytecode Loader. `kb_lib::StakingMaterializer` produit les projections instructionnelles Stake/Vote commitées : comptes stake/vote, autorités, lockup, délégation, vote state, retraits et dépôt de rewards. Les données complètes ne sont jamais recopiées : seules les clés, autorités, offsets, tailles, hashes, préfixes et paramètres bornés disponibles sont conservés. + +Les quatre implémentations résident respectivement dans `kb_lib::materializer::lifecycle`, `admin`, `compliance` et `staking`, mais sont réexportées à la racine de `kb-lib`. Les identités de processor historiques restent stables pour les replays ; les targets de tracing utilisent la hiérarchie consolidée `kb-lib.materializer.*`. Le retrait d'un nonce account conserve une sémantique conditionnelle : le runtime ferme le compte uniquement lorsque la totalité du solde est retirée. La projection décrit l'opération commitée et ne prétend pas reconstruire l'état final transactionnel du compte. Les deltas SOL restent la responsabilité du graphe core. diff --git a/docs/NATIVE_SOLANA_MATERIALIZATION_AUDIT.md b/docs/NATIVE_SOLANA_MATERIALIZATION_AUDIT.md index 5e161e6..c428a89 100644 --- a/docs/NATIVE_SOLANA_MATERIALIZATION_AUDIT.md +++ b/docs/NATIVE_SOLANA_MATERIALIZATION_AUDIT.md @@ -1,5 +1,5 @@ - + # Audit des matérialisations Solana natives @@ -13,19 +13,19 @@ Les sorties ne sont pas des snapshots finaux de comptes ou de runtime. Les état | Matérialiseur | Surfaces | Projection | |------------------------------------|----------------------|---------------------------------------------------------------------------------------------------------------------| -| `kb_materializer_lifecycle` | Address Lookup Table | lifecycle create/freeze/extend/deactivate/close | -| `kb_materializer_lifecycle` | Feature | révocation de feature gate | -| `kb_materializer_lifecycle` | Loaders | déploiement, finalisation et transitions lifecycle stables | -| `kb_materializer_lifecycle` | System | durable nonce, create account et allocate | -| `kb_materializer_lifecycle` | ZK ElGamal | création ou fermeture d’un compte de contexte | -| `kb_materializer_lifecycle` | Slashing | initialisation ou fermeture d’un rapport de violation | -| `kb_materializer_admin` | System | assign et assign with seed | -| `kb_materializer_admin` | Config | écriture opaque bornée avec clés, taille, SHA-256 et préfixe | -| `kb_materializer_admin` | Loaders | changements d’autorité | -| `kb_materializer_compliance_audit` | Loaders | écritures/copies de bytecode bornées par offsets, tailles, SHA-256 et préfixes | -| `kb_materializer_compliance_audit` | Compute Budget | profil transactionnel agrégé, last-write-wins, une seule sortie par transaction | -| `kb_materializer_staking` | Stake | compte, délégation, désactivation, split, merge, withdraw, move stake/lamports, autorités et lockup instructionnels | -| `kb_materializer_staking` | Vote | compte, autorités, identité, commission, vote state, retraits et dépôt de rewards instructionnels | +| `LifecycleMaterializer` | Address Lookup Table | lifecycle create/freeze/extend/deactivate/close | +| `LifecycleMaterializer` | Feature | révocation de feature gate | +| `LifecycleMaterializer` | Loaders | déploiement, finalisation et transitions lifecycle stables | +| `LifecycleMaterializer` | System | durable nonce, create account et allocate | +| `LifecycleMaterializer` | ZK ElGamal | création ou fermeture d’un compte de contexte | +| `LifecycleMaterializer` | Slashing | initialisation ou fermeture d’un rapport de violation | +| `AdminMaterializer` | System | assign et assign with seed | +| `AdminMaterializer` | Config | écriture opaque bornée avec clés, taille, SHA-256 et préfixe | +| `AdminMaterializer` | Loaders | changements d’autorité | +| `ComplianceAuditMaterializer` | Loaders | écritures/copies de bytecode bornées par offsets, tailles, SHA-256 et préfixes | +| `ComplianceAuditMaterializer` | Compute Budget | profil transactionnel agrégé, last-write-wins, une seule sortie par transaction | +| `StakingMaterializer` | Stake | compte, délégation, désactivation, split, merge, withdraw, move stake/lamports, autorités et lockup instructionnels | +| `StakingMaterializer` | Vote | compte, autorités, identité, commission, vote state, retraits et dépôt de rewards instructionnels | ## Politique des transactions échouées diff --git a/docs/SURFACE_CRATE_MATRIX.md b/docs/SURFACE_CRATE_MATRIX.md index b465fa3..eb20c49 100644 --- a/docs/SURFACE_CRATE_MATRIX.md +++ b/docs/SURFACE_CRATE_MATRIX.md @@ -1,7 +1,7 @@ - + -# Matrice des crates réservés +# Matrice des modules réservés Ce document liste les crates actuellement présents dans le workspace. Un crate réservé ne signifie pas que le décodage, la matérialisation ou l’exécution est validé. @@ -246,27 +246,28 @@ Ce document liste les crates actuellement présents dans le workspace. Un crate ## Matérialisateurs -| Crate | -|---------------------------------------| -| `kb_materializer_admin` | -| `kb_materializer_bridge` | -| `kb_materializer_compliance_audit` | -| `kb_materializer_fees` | -| `kb_materializer_governance` | -| `kb_materializer_lending` | -| `kb_materializer_lifecycle` | -| `kb_materializer_liquidity` | -| `kb_materializer_metadata` | -| `kb_materializer_nft` | -| `kb_materializer_oracle` | -| `kb_materializer_orderbook` | -| `kb_materializer_perpetuals` | -| `kb_materializer_pool_state` | -| `kb_materializer_rewards` | -| `kb_materializer_risk` | -| `kb_materializer_routing` | -| `kb_materializer_staking` | -| `kb_materializer_token_accounts` | -| `kb_materializer_token_metadata_risk` | -| `kb_materializer_trades` | -| `kb_materializer_vault` | +| Module `kb-lib` | +|-----------------------------------------------| +| `materializer::admin::core` | +| `materializer::bridge::core` | +| `materializer::compliance::audit` | +| `materializer::fees::core` | +| `materializer::governance::core` | +| `materializer::lending::core` | +| `materializer::lifecycle::core` | +| `materializer::liquidity::core` | +| `materializer::metadata::core` | +| `materializer::nft::core` | +| `materializer::oracle::core` | +| `materializer::orderbook::core` | +| `materializer::perpetuals::core` | +| `materializer::pool::state` | +| `materializer::rewards::core` | +| `materializer::risk::core` | +| `materializer::routing::core` | +| `materializer::staking::core` | +| `materializer::token::accounts` | +| `materializer::token::metadata_risk` | +| `materializer::trades::core` | +| `materializer::transaction::annotations` | +| `materializer::vault::core` | diff --git a/docs/TRACING_CONTRACT.md b/docs/TRACING_CONTRACT.md index 3c0d588..e0fae05 100644 --- a/docs/TRACING_CONTRACT.md +++ b/docs/TRACING_CONTRACT.md @@ -1,5 +1,5 @@ - + # Contrat de tracing par crate et composant @@ -63,6 +63,10 @@ kb-lib.decoder.spl.elgamal_registry kb-lib.decoder.spl.memo kb-lib.decoder.spl.token kb-lib.decoder.spl.token_2022 +kb-lib.materializer.admin +kb-lib.materializer.compliance +kb-lib.materializer.lifecycle +kb-lib.materializer.staking kb-logging kb-store ``` diff --git a/kb-lib/README.md b/kb-lib/README.md index 52c73c5..c34730b 100644 --- a/kb-lib/README.md +++ b/kb-lib/README.md @@ -1,5 +1,5 @@ - + # kb-lib @@ -100,6 +100,9 @@ politique d’exécution définitivement interdite. La matrice normative est ## API publique utile +- `EventMaterializer`, `MaterializerIdentity`, `MaterializationTransactionPolicy`, `MaterializerExecutionResult` et `MaterializedOutput` : contrat complet utilisable par un matérialiseur interne ou externe ; +- `LifecycleMaterializer`, `AdminMaterializer`, `ComplianceAuditMaterializer` et `StakingMaterializer` : matérialisateurs Solana natifs concrets ; +- `materialize_token_2022_admin_state_snapshots()` et `materialize_elgamal_registry_state_snapshot()` : projections stateful d’administration déjà exposées ; - `SolanaCoreDecoder` : décodeur concret natif ; - `SplMemoDecoder` : décodeur exact des trois générations SPL Memo ; - `SplTokenDecoder` : décodeur exact du programme SPL Token classique ; @@ -131,4 +134,5 @@ L’appel de décodage complet utilise `InstructionDecoder::decode` après une r - `kb-lib` ne dépend jamais de `kb-store`. - Les décodeurs ne lisent pas un état RPC courant pour reconstruire une transaction historique. - Une transaction échouée peut produire une intention structurée, jamais une mutation commitée. -- Les matérialisateurs et exécuteurs seront portés dans des tranches séparées. +- Un matérialiseur externe dépend uniquement des contrats et modèles publics de `kb-lib`, jamais d’un décodeur concret. +- Les autres matérialisateurs et les exécuteurs sont portés dans des tranches séparées. diff --git a/kb-lib/src/lib.rs b/kb-lib/src/lib.rs index 0f6b3eb..6bf06c3 100644 --- a/kb-lib/src/lib.rs +++ b/kb-lib/src/lib.rs @@ -1,5 +1,5 @@ // file: kb-lib/src/lib.rs -// version: 11 +// version: 12 //! Consolidated decoder, executor, materializer and shared model library. #![warn(missing_docs)] @@ -286,6 +286,16 @@ pub(crate) use crate::decoder::spl_token_decode; pub(crate) use crate::decoder::spl_token_entry_for_tag; /// Returns the first byte of one retained classic SPL Token payload. pub(crate) use crate::decoder::spl_token_payload_tag; +/// Canonical tracing target for the native and SPL administration materializer. +pub(crate) use crate::materializer::admin::MATERIALIZER_ADMIN_TRACING_TARGET; +/// Canonical processor name for the native and SPL administration materializer. +pub(crate) use crate::materializer::admin::PROCESSOR_NAME as MATERIALIZER_ADMIN_PROCESSOR_NAME; +/// Canonical tracing target for the native compliance audit materializer. +pub(crate) use crate::materializer::compliance::MATERIALIZER_COMPLIANCE_AUDIT_TRACING_TARGET; +/// Canonical tracing target for the native lifecycle materializer. +pub(crate) use crate::materializer::lifecycle::MATERIALIZER_LIFECYCLE_TRACING_TARGET; +/// Canonical tracing target for the native stake and vote materializer. +pub(crate) use crate::materializer::staking::MATERIALIZER_STAKING_TRACING_TARGET; /// Exact byte length of one SPL ElGamal registry account. pub use crate::decoder::ELGAMAL_REGISTRY_ACCOUNT_LEN; @@ -405,6 +415,12 @@ pub use crate::executor::api::executor::InstructionExecutor; pub use crate::executor::api::executor::serialize_payload_json; /// Exposes the pretty JSON payload serializer. pub use crate::executor::api::executor::serialize_payload_json_pretty; +/// Stable native and SPL administration materializer. +pub use crate::materializer::admin::AdminMaterializer; +/// Materializes one exact SPL ElGamal registry account snapshot. +pub use crate::materializer::admin::materialize_elgamal_registry_state_snapshot; +/// Materializes exact Token-2022 administration account snapshots. +pub use crate::materializer::admin::materialize_token_2022_admin_state_snapshots; /// Stable decoded observation materializer contract used by the common pipeline. pub use crate::materializer::api::contracts::EventMaterializer; /// Explicit policy applied to successful and failed source transactions. @@ -429,6 +445,12 @@ pub use crate::materializer::api::contracts::validate_materialization_policy; pub use crate::materializer::api::contracts::validate_materialized_output_policy; /// Exposes the legacy common business materializer trait. pub use crate::materializer::api::materializer::Materializer; +/// Stable native compliance audit materializer. +pub use crate::materializer::compliance::ComplianceAuditMaterializer; +/// Stable native lifecycle materializer. +pub use crate::materializer::lifecycle::LifecycleMaterializer; +/// Stable native stake and vote materializer. +pub use crate::materializer::staking::StakingMaterializer; /// Current canonical transaction document version. pub use crate::model::canonical_transaction::CANONICAL_TRANSACTION_FORMAT_VERSION; /// Canonical address lookup table reference. diff --git a/kb-lib/src/materializer/admin.rs b/kb-lib/src/materializer/admin.rs index a43e1d0..5557275 100644 --- a/kb-lib/src/materializer/admin.rs +++ b/kb-lib/src/materializer/admin.rs @@ -1,6 +1,17 @@ // file: kb-lib/src/materializer/admin.rs -// version: 1 +// version: 2 //! `admin` materializer family. +mod constants; pub mod core; + +pub(crate) use crate::materializer::admin::constants::PROCESSOR_NAME; +pub(crate) use crate::materializer::admin::constants::TRACING_TARGET as MATERIALIZER_ADMIN_TRACING_TARGET; + +/// Stable native and SPL administration materializer. +pub use crate::materializer::admin::core::AdminMaterializer; +/// Materializes one exact SPL ElGamal registry account snapshot. +pub use crate::materializer::admin::core::materialize_elgamal_registry_state_snapshot; +/// Materializes exact Token-2022 administration account snapshots. +pub use crate::materializer::admin::core::materialize_token_2022_admin_state_snapshots; diff --git a/kb-lib/src/materializer/admin/constants.rs b/kb-lib/src/materializer/admin/constants.rs new file mode 100644 index 0000000..b7441fb --- /dev/null +++ b/kb-lib/src/materializer/admin/constants.rs @@ -0,0 +1,10 @@ +// file: kb-lib/src/materializer/admin/constants.rs +// version: 1 + +//! Local constants for the native and SPL administration materializer. + +/// Canonical processor name for this component. +pub(crate) const PROCESSOR_NAME: &str = "solana_native_admin"; + +/// Canonical tracing target for this component. +pub(crate) const TRACING_TARGET: &str = "kb-lib.materializer.admin"; diff --git a/kb-lib/src/materializer/admin/core.rs b/kb-lib/src/materializer/admin/core.rs index 2eca785..cb0f8da 100644 --- a/kb-lib/src/materializer/admin/core.rs +++ b/kb-lib/src/materializer/admin/core.rs @@ -1,10 +1,1124 @@ // file: kb-lib/src/materializer/admin/core.rs -// version: 1 +// version: 15 -//! Migration boundary for legacy crate `kb_materializer_admin`. +//! Stable administrative projections derived from exact committed observations. -/// Legacy crate name retained for migration and compatibility tracking. -pub const LEGACY_CRATE: &str = "kb_materializer_admin"; +const ACCEPTED_FAMILIES: &[crate::EventFamily] = + &[crate::EventFamily::Admin, crate::EventFamily::TokenAccount]; +const SYSTEM_SURFACE: &str = "solana_native_system"; +const SYSTEM_ENTRIES: &[&str] = &["assign", "assign_with_seed"]; +const CONFIG_SURFACE: &str = "solana_native_config"; +const CONFIG_ENTRIES: &[&str] = &["store"]; +const BPF_LOADER_UPGRADEABLE_SURFACE: &str = "solana_native_bpf_loader_upgradeable"; +const BPF_LOADER_UPGRADEABLE_ENTRIES: &[&str] = &["set_authority", "set_authority_checked"]; +const LOADER_V4_SURFACE: &str = "solana_native_loader_v4"; +const LOADER_V4_ENTRIES: &[&str] = &["transfer_authority"]; +const SPL_TOKEN_SURFACE: &str = "spl_token"; +const SPL_TOKEN_2022_SURFACE: &str = "spl_token_2022"; +const SPL_ELGAMAL_REGISTRY_SURFACE: &str = "spl_elgamal_registry"; +const SPL_ELGAMAL_REGISTRY_ENTRIES: &[&str] = &["create_registry", "update_registry"]; +const SPL_TOKEN_AUTHORITY_ENTRIES: &[&str] = &[ + "set_authority", + "initialize_permanent_delegate", + "initialize_interest_bearing_mint", + "update_interest_bearing_rate", +]; +const SPL_TOKEN_MULTISIG_ENTRIES: &[&str] = &["initialize_multisig", "initialize_multisig2"]; +const SPL_TOKEN_2022_EXTENSION_ADMIN_ENTRIES: &[&str] = &[ + "initialize_transfer_hook", + "update_transfer_hook_program_id", + "initialize_metadata_pointer", + "update_metadata_pointer", + "initialize_group_pointer", + "update_group_pointer", + "initialize_group_member_pointer", + "update_group_member_pointer", + "initialize_scaled_ui_amount", + "update_scaled_ui_amount_multiplier", +]; -/// Current porting status. -pub const MIGRATION_STATUS: &str = "source-preserved-pending-port"; +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum ProjectionKind { + SystemAccountOwnership, + ConfigAccountUpdate, + ProgramLoaderAuthority(&'static str), + SplTokenAuthority, + SplTokenMultisig, + SplElgamalRegistry, + SplToken2022ExtensionAdmin, +} + +impl ProjectionKind { + fn domain(self) -> &'static str { + return match self { + Self::SystemAccountOwnership => "system_account_ownership", + Self::ConfigAccountUpdate => "config_account_update", + Self::ProgramLoaderAuthority(_) => "program_loader_authority", + Self::SplTokenAuthority => "spl_token_authority", + Self::SplTokenMultisig => "spl_token_multisig", + Self::SplElgamalRegistry => "spl_elgamal_registry", + Self::SplToken2022ExtensionAdmin => "spl_token_2022_extension_admin", + }; + } + + fn loader_surface(self) -> std::option::Option<&'static str> { + return match self { + Self::ProgramLoaderAuthority(surface) => std::option::Option::Some(surface), + _ => std::option::Option::None, + }; + } + + fn output_key(self, surface_code: &str, operation: &str) -> std::string::String { + return match self { + Self::SystemAccountOwnership => format!("system_account_ownership:{operation}:0"), + Self::ConfigAccountUpdate => format!("config_account_update:{operation}:0"), + Self::ProgramLoaderAuthority(_) => { + format!("program_loader_authority:{surface_code}:{operation}:0") + }, + Self::SplTokenAuthority => format!("spl_token_authority:{operation}:0"), + Self::SplTokenMultisig => format!("spl_token_multisig:{operation}:0"), + Self::SplElgamalRegistry => format!("spl_elgamal_registry:{operation}:0"), + Self::SplToken2022ExtensionAdmin => { + format!("spl_token_2022_extension_admin:{operation}:0") + }, + }; + } +} + +/// Stable administrative materializer for supported native mutations. +#[derive(Clone, Debug, Default)] +pub struct AdminMaterializer; + +impl crate::Materializer for crate::AdminMaterializer { + fn materializer_name(&self) -> &'static str { + return "kb_materializer_admin"; + } + + fn materializer_version(&self) -> &'static str { + return env!("CARGO_PKG_VERSION"); + } + + fn accepts_event(&self, event: &crate::DecodedProtocolEvent) -> bool { + return ACCEPTED_FAMILIES.contains(&event.event_family) + && program_matches_surface(event) + && projection(event.surface_code.0.as_str(), event.event_name.0.as_str()).is_some(); + } + + fn materialize_event( + &self, + event: &crate::DecodedProtocolEvent, + ) -> kb_core::Result> { + if !crate::Materializer::accepts_event(self, event) { + return std::result::Result::Ok(std::vec::Vec::new()); + } + return std::result::Result::Ok(std::vec::Vec::new()); + } +} + +impl crate::EventMaterializer for crate::AdminMaterializer { + fn identity(&self) -> crate::MaterializerIdentity { + return crate::MaterializerIdentity { + name: "solana_native_admin".to_string(), + version: env!("CARGO_PKG_VERSION").to_string(), + }; + } + + fn accepted_families(&self) -> &'static [crate::EventFamily] { + return ACCEPTED_FAMILIES; + } + + fn accepts_observation(&self, observation: &crate::DecodedObservation) -> bool { + return ACCEPTED_FAMILIES.contains(&observation.event.event_family) + && program_matches_surface(&observation.event) + && projection( + observation.event.surface_code.0.as_str(), + observation.event.event_name.0.as_str(), + ) + .is_some(); + } + + fn transaction_policy( + &self, + _family: crate::EventFamily, + ) -> crate::MaterializationTransactionPolicy { + return crate::MaterializationTransactionPolicy::SuccessfulCommittedOnly; + } + + fn materialize( + &self, + observation: &crate::DecodedObservation, + ) -> crate::MaterializerExecutionResult { + let projection = match projection( + observation.event.surface_code.0.as_str(), + observation.event.event_name.0.as_str(), + ) { + std::option::Option::Some(value) => value, + std::option::Option::None => { + tracing::debug!( + target: crate::MATERIALIZER_ADMIN_TRACING_TARGET, + action = "materialize_admin", + surface_code = %observation.event.surface_code.0.as_str(), + entry_code = %observation.event.event_name.0.as_str(), + accepted = false, + "ignore observation outside supported native administrative projections" + ); + return crate::MaterializerExecutionResult::ignored(); + }, + }; + if !ACCEPTED_FAMILIES.contains(&observation.event.event_family) { + return crate::MaterializerExecutionResult::ignored(); + } + if !program_matches_surface(&observation.event) { + return crate::MaterializerExecutionResult::ignored(); + } + if observation.transaction_failed || !observation.observation_committed { + tracing::debug!( + target: crate::MATERIALIZER_ADMIN_TRACING_TARGET, + action = "materialize_admin", + surface_code = %observation.event.surface_code.0.as_str(), + entry_code = %observation.event.event_name.0.as_str(), + committed = observation.observation_committed, + transaction_failed = observation.transaction_failed, + "refuse uncommitted native administrative observation" + ); + return crate::MaterializerExecutionResult::refused( + "failed_transaction_admin_refused", + "failed or uncommitted native observations cannot create administrative mutation outputs", + ); + } + let accounts = observation + .payload_json + .get("accounts") + .cloned() + .unwrap_or(serde_json::Value::Null); + let parameters = observation + .payload_json + .get("parameters") + .cloned() + .unwrap_or_else(|| return observation.payload_json.clone()); + let operation = observation.event.event_name.0.clone(); + let output = crate::MaterializedOutput { + output_key: projection + .output_key(observation.event.surface_code.0.as_str(), operation.as_str()), + family: crate::MaterializedEventFamily::Admin, + payload_json: serde_json::json!({ + "projectionVersion": 1, + "projectionSemantics": "committed_instruction_admin_event", + "domain": projection.domain(), + "loaderSurface": projection.loader_surface(), + "operation": operation, + "programId": observation.event.program_id.0.clone(), + "signature": observation.event.signature.0.clone(), + "slot": observation.event.slot.0, + "instructionPath": observation.event.instruction_path.0.clone(), + "transactionSucceeded": true, + "accounts": accounts, + "parameters": parameters, + "projection": projection_details( + projection, + &accounts, + ¶meters, + observation.event.event_name.0.as_str(), + ), + }), + }; + tracing::debug!( + target: crate::MATERIALIZER_ADMIN_TRACING_TARGET, + action = "materialize_admin", + surface_code = %observation.event.surface_code.0.as_str(), + entry_code = %observation.event.event_name.0.as_str(), + projection_domain = projection.domain(), + output_count = 1_usize, + "materialize committed native administrative observation" + ); + return crate::MaterializerExecutionResult { + status: crate::MaterializerOutcomeStatus::Inserted, + outputs: std::vec![output], + diagnostics: std::vec::Vec::new(), + }; + } +} + +fn projection(surface_code: &str, entry_code: &str) -> std::option::Option { + if surface_code == SYSTEM_SURFACE && contains(SYSTEM_ENTRIES, entry_code) { + return std::option::Option::Some(ProjectionKind::SystemAccountOwnership); + } + if surface_code == CONFIG_SURFACE && contains(CONFIG_ENTRIES, entry_code) { + return std::option::Option::Some(ProjectionKind::ConfigAccountUpdate); + } + if surface_code == BPF_LOADER_UPGRADEABLE_SURFACE + && contains(BPF_LOADER_UPGRADEABLE_ENTRIES, entry_code) + { + return std::option::Option::Some(ProjectionKind::ProgramLoaderAuthority( + BPF_LOADER_UPGRADEABLE_SURFACE, + )); + } + if surface_code == LOADER_V4_SURFACE && contains(LOADER_V4_ENTRIES, entry_code) { + return std::option::Option::Some(ProjectionKind::ProgramLoaderAuthority( + LOADER_V4_SURFACE, + )); + } + if matches!(surface_code, SPL_TOKEN_SURFACE | SPL_TOKEN_2022_SURFACE) + && contains(SPL_TOKEN_AUTHORITY_ENTRIES, entry_code) + { + return std::option::Option::Some(ProjectionKind::SplTokenAuthority); + } + if matches!(surface_code, SPL_TOKEN_SURFACE | SPL_TOKEN_2022_SURFACE) + && contains(SPL_TOKEN_MULTISIG_ENTRIES, entry_code) + { + return std::option::Option::Some(ProjectionKind::SplTokenMultisig); + } + if surface_code == SPL_TOKEN_2022_SURFACE + && contains(SPL_TOKEN_2022_EXTENSION_ADMIN_ENTRIES, entry_code) + { + return std::option::Option::Some(ProjectionKind::SplToken2022ExtensionAdmin); + } + if surface_code == SPL_ELGAMAL_REGISTRY_SURFACE + && contains(SPL_ELGAMAL_REGISTRY_ENTRIES, entry_code) + { + return std::option::Option::Some(ProjectionKind::SplElgamalRegistry); + } + return std::option::Option::None; +} + +fn program_matches_surface(event: &crate::DecodedProtocolEvent) -> bool { + if event.surface_code.0 == SPL_TOKEN_SURFACE { + return event.program_id.0 == kb_program_ids::SPL_TOKEN_PROGRAM_ID; + } + if event.surface_code.0 == SPL_TOKEN_2022_SURFACE { + return event.program_id.0 == kb_program_ids::SPL_TOKEN_2022_PROGRAM_ID; + } + if event.surface_code.0 == SPL_ELGAMAL_REGISTRY_SURFACE { + return event.program_id.0 == kb_program_ids::SPL_TOKEN_2022_ELGAMAL_REGISTRY_PROGRAM_ID; + } + return true; +} + +fn projection_details( + projection: ProjectionKind, + accounts: &serde_json::Value, + parameters: &serde_json::Value, + operation: &str, +) -> serde_json::Value { + return match projection { + ProjectionKind::SystemAccountOwnership => serde_json::json!({ + "targetAccount": first_account_key_for_roles( + accounts, + &["assigned_account", "derived_account"], + ), + "baseAccount": account_key_for_role(accounts, "base_account"), + "newOwner": parameters.get("owner").cloned().unwrap_or(serde_json::Value::Null), + "seed": parameters.get("seed").cloned().unwrap_or(serde_json::Value::Null), + "stateTransition": if operation == "assign_with_seed" { + "derived_account_owner_assigned" + } else { + "account_owner_assigned" + }, + "transactionFinalAccountStateCaptured": false, + }), + ProjectionKind::ConfigAccountUpdate => serde_json::json!({ + "targetAccount": account_key_for_role(accounts, "config_account"), + "configuredKeys": parameters.get("configuredKeys").cloned().unwrap_or(serde_json::Value::Null), + "configuredKeyCount": parameters.get("configuredKeyCount").cloned().unwrap_or(serde_json::Value::Null), + "configuredSignerCount": parameters.get("configuredSignerCount").cloned().unwrap_or(serde_json::Value::Null), + "configDataByteLength": parameters.get("configDataByteLength").cloned().unwrap_or(serde_json::Value::Null), + "configDataSha256": parameters.get("configDataSha256").cloned().unwrap_or(serde_json::Value::Null), + "configDataPrefixHex": parameters.get("configDataPrefixHex").cloned().unwrap_or(serde_json::Value::Null), + "configDataSemantics": "opaque_program_specific", + "stateTransition": "config_account_data_replaced", + "payloadMaterialized": false, + "transactionFinalAccountStateCaptured": false, + }), + ProjectionKind::ProgramLoaderAuthority(_) => serde_json::json!({ + "targetAccount": first_account_key_for_roles( + accounts, + &["authority_target", "program_account"], + ), + "currentAuthority": first_account_key_for_roles( + accounts, + &["current_authority", "authority"], + ), + "newAuthority": account_key_for_role(accounts, "new_authority"), + "stateTransition": match operation { + "set_authority" => "authority_changed_or_revoked", + "set_authority_checked" => "authority_changed_with_new_authority_signature", + "transfer_authority" => "authority_transferred_with_new_authority_signature", + _ => "authority_changed", + }, + "newAuthorityOptional": operation == "set_authority", + "transactionFinalAccountStateCaptured": false, + }), + ProjectionKind::SplTokenAuthority => serde_json::json!({ + "targetAccount": account_key_for_role(accounts, "authority_target"), + "currentAuthority": account_key_for_role(accounts, "authority"), + "authorityType": parameters.get("authorityType").cloned().unwrap_or(serde_json::Value::Null), + "newAuthority": parameters.get("newAuthority").cloned().unwrap_or(serde_json::Value::Null), + "stateTransition": if parameters.get("newAuthority").is_some_and(serde_json::Value::is_null) { + "token_authority_revoked" + } else { + "token_authority_changed" + }, + "transactionFinalAccountStateCaptured": false, + }), + ProjectionKind::SplTokenMultisig => serde_json::json!({ + "multisigAccount": account_key_for_role(accounts, "multisig_account"), + "thresholdM": parameters.get("m").cloned().unwrap_or(serde_json::Value::Null), + "members": account_keys_for_role(accounts, "multisig_member"), + "stateTransition": "token_multisig_initialized", + "transactionFinalAccountStateCaptured": false, + }), + ProjectionKind::SplElgamalRegistry => serde_json::json!({ + "registryAccount": account_key_at_position(accounts, 0), + "ownerAccount": account_key_at_position( + accounts, + if operation == "create_registry" { 1 } else { 2 }, + ), + "proofAccountOrInstructionsSysvar": account_key_at_position( + accounts, + if operation == "create_registry" { 3 } else { 1 }, + ), + "proofInstructionOffset": parameters + .get("proofInstructionOffset") + .cloned() + .unwrap_or(serde_json::Value::Null), + "proofLocationKind": parameters + .get("proofLocationKind") + .cloned() + .unwrap_or(serde_json::Value::Null), + "stateTransition": if operation == "create_registry" { + "elgamal_registry_created" + } else { + "elgamal_registry_key_updated" + }, + "registeredElgamalPubkeyCaptured": false, + "proofValidatedByRegistryProgram": true, + "transactionFinalAccountStateCaptured": false, + }), + ProjectionKind::SplToken2022ExtensionAdmin => serde_json::json!({ + "targetAccount": account_key_at_position(accounts, 0), + "authority": parameters.get("authority").cloned().unwrap_or(serde_json::Value::Null), + "programId": parameters.get("programId").cloned().unwrap_or(serde_json::Value::Null), + "metadataAddress": parameters.get("metadataAddress").cloned().unwrap_or(serde_json::Value::Null), + "groupAddress": parameters.get("groupAddress").cloned().unwrap_or(serde_json::Value::Null), + "memberAddress": parameters.get("memberAddress").cloned().unwrap_or(serde_json::Value::Null), + "multiplier": parameters.get("multiplier").cloned().unwrap_or(serde_json::Value::Null), + "effectiveTimestamp": parameters.get("effectiveTimestamp").cloned().unwrap_or(serde_json::Value::Null), + "stateTransition": operation, + "stateReadRequired": true, + "transactionFinalAccountStateCaptured": false, + }), + }; +} + +fn account_keys_for_role(accounts: &serde_json::Value, role: &str) -> serde_json::Value { + let values = accounts + .as_array() + .map(|rows| { + return rows + .iter() + .filter_map(|row| { + if row.get("role").and_then(serde_json::Value::as_str) + != std::option::Option::Some(role) + { + return std::option::Option::None; + } + return row + .get("accountKey") + .and_then(serde_json::Value::as_str) + .map(|value| return serde_json::Value::String(value.to_string())); + }) + .collect::>(); + }) + .unwrap_or_default(); + return serde_json::Value::Array(values); +} + +fn first_account_key_for_roles(accounts: &serde_json::Value, roles: &[&str]) -> serde_json::Value { + for role in roles { + let value = account_key_for_role(accounts, role); + if !value.is_null() { + return value; + } + } + return serde_json::Value::Null; +} + +fn account_key_for_role(accounts: &serde_json::Value, role: &str) -> serde_json::Value { + let account_key = accounts.as_array().and_then(|values| { + return values.iter().find_map(|value| { + if value.get("role").and_then(serde_json::Value::as_str) + != std::option::Option::Some(role) + { + return std::option::Option::None; + } + return value + .get("accountKey") + .and_then(serde_json::Value::as_str) + .map(|account_key| return account_key.to_string()); + }); + }); + return match account_key { + std::option::Option::Some(value) => serde_json::Value::String(value), + std::option::Option::None => serde_json::Value::Null, + }; +} + +fn account_key_at_position(accounts: &serde_json::Value, position: usize) -> serde_json::Value { + let account_key = accounts.as_array().and_then(|values| { + return values.iter().find_map(|value| { + let row_position = value + .get("position") + .and_then(serde_json::Value::as_u64) + .map(|value| return value as usize); + if row_position != std::option::Option::Some(position) { + return std::option::Option::None; + } + return value + .get("accountKey") + .and_then(serde_json::Value::as_str) + .map(|account_key| return account_key.to_string()); + }); + }); + return match account_key { + std::option::Option::Some(value) => serde_json::Value::String(value), + std::option::Option::None => serde_json::Value::Null, + }; +} + +fn contains(entries: &[&str], entry_code: &str) -> bool { + return entries.iter().any(|entry| return *entry == entry_code); +} + +#[cfg(test)] +mod tests { + fn observation( + surface_code: &str, + entry_code: &str, + transaction_failed: bool, + accounts: serde_json::Value, + parameters: serde_json::Value, + ) -> crate::DecodedObservation { + return crate::DecodedObservation { + event_key: format!("{entry_code}:0"), + event: crate::DecodedProtocolEvent { + signature: crate::Signature("signature".to_string()), + slot: crate::Slot(42), + instruction_path: crate::InstructionPath("0".to_string()), + program_id: crate::ProgramId("program-id".to_string()), + protocol_code: crate::ProtocolCode("solana_native".to_string()), + surface_code: crate::SurfaceCode(surface_code.to_string()), + event_code: crate::EventCode(format!("{surface_code}.{entry_code}")), + event_name: crate::EventName(entry_code.to_string()), + event_family: crate::EventFamily::Admin, + source_kind: crate::EventSourceKind::Instruction, + confidence: crate::DecoderConfidence::ManualExact, + }, + payload_json: serde_json::json!({"accounts": accounts, "parameters": parameters}), + transaction_failed, + transaction_error: if transaction_failed { + std::option::Option::Some(serde_json::json!({"InstructionError": [0, "Custom"]})) + } else { + std::option::Option::None + }, + observation_committed: !transaction_failed, + proof: crate::DecoderProof { + kind: crate::DecoderProofKind::Manual, + confidence: crate::DecoderConfidence::ManualExact, + evidence: std::vec!["fixture".to_string()], + }, + }; + } + + fn account(role: &str, account_key: &str) -> serde_json::Value { + return serde_json::json!({"role": role, "accountKey": account_key}); + } + + #[test] + fn system_assign_creates_owner_projection() { + let materializer = crate::AdminMaterializer; + let observation = observation( + super::SYSTEM_SURFACE, + "assign", + false, + serde_json::json!([account("assigned_account", "account111",)]), + serde_json::json!({"owner": "owner111"}), + ); + let result = crate::EventMaterializer::materialize(&materializer, &observation); + assert_eq!(result.status, crate::MaterializerOutcomeStatus::Inserted); + assert_eq!(result.outputs[0].family, crate::MaterializedEventFamily::Admin); + assert_eq!(result.outputs[0].output_key, "system_account_ownership:assign:0"); + assert_eq!(result.outputs[0].payload_json["projection"]["newOwner"], "owner111"); + } + + #[test] + fn seeded_assign_preserves_base_seed_and_owner() { + let materializer = crate::AdminMaterializer; + let observation = observation( + super::SYSTEM_SURFACE, + "assign_with_seed", + false, + serde_json::json!([ + account("derived_account", "derived111"), + account("base_account", "base111"), + ]), + serde_json::json!({"owner": "owner111", "seed": "seed"}), + ); + let result = crate::EventMaterializer::materialize(&materializer, &observation); + assert_eq!(result.outputs[0].payload_json["projection"]["targetAccount"], "derived111"); + assert_eq!(result.outputs[0].payload_json["projection"]["baseAccount"], "base111"); + assert_eq!(result.outputs[0].payload_json["projection"]["seed"], "seed"); + } + + #[test] + fn config_store_preserves_bounded_opaque_audit_fields() { + let materializer = crate::AdminMaterializer; + let observation = observation( + super::CONFIG_SURFACE, + "store", + false, + serde_json::json!([ + account("config_account", "config111"), + account("configured_signer", "signer111"), + ]), + serde_json::json!({ + "configuredKeys": [{"pubkey": "signer111", "signer": true}], + "configuredKeyCount": 1, + "configuredSignerCount": 1, + "configDataByteLength": 64, + "configDataSha256": "abc", + "configDataPrefixHex": "0102", + }), + ); + let result = crate::EventMaterializer::materialize(&materializer, &observation); + assert_eq!(result.outputs[0].output_key, "config_account_update:store:0"); + assert_eq!(result.outputs[0].payload_json["projection"]["targetAccount"], "config111"); + assert_eq!(result.outputs[0].payload_json["projection"]["configDataSha256"], "abc"); + assert_eq!(result.outputs[0].payload_json["projection"]["payloadMaterialized"], false); + } + + #[test] + fn loader_authority_variants_create_exact_admin_outputs() { + let materializer = crate::AdminMaterializer; + for (surface, entry, target_role) in [ + (super::BPF_LOADER_UPGRADEABLE_SURFACE, "set_authority", "authority_target"), + ( + super::BPF_LOADER_UPGRADEABLE_SURFACE, + "set_authority_checked", + "authority_target", + ), + (super::LOADER_V4_SURFACE, "transfer_authority", "program_account"), + ] { + let observation = observation( + surface, + entry, + false, + serde_json::json!([ + account(target_role, "target111"), + account("current_authority", "current111"), + account("new_authority", "new111"), + ]), + serde_json::json!({}), + ); + let result = crate::EventMaterializer::materialize(&materializer, &observation); + assert_eq!(result.status, crate::MaterializerOutcomeStatus::Inserted); + assert_eq!(result.outputs[0].payload_json["projection"]["targetAccount"], "target111"); + assert_eq!(result.outputs[0].payload_json["projection"]["newAuthority"], "new111"); + } + } + + #[test] + fn classic_token_authority_and_multisig_create_distinct_admin_outputs() { + let mut authority = observation( + super::SPL_TOKEN_SURFACE, + "set_authority", + false, + serde_json::json!([ + account("authority_target", "mint111"), + account("authority", "current111"), + ]), + serde_json::json!({"authorityType":"mint_tokens","newAuthority":null}), + ); + authority.event.program_id = + crate::ProgramId(kb_program_ids::SPL_TOKEN_PROGRAM_ID.to_string()); + let authority_result = + crate::EventMaterializer::materialize(&crate::AdminMaterializer, &authority); + assert_eq!(authority_result.outputs[0].output_key, "spl_token_authority:set_authority:0"); + assert_eq!( + authority_result.outputs[0].payload_json["projection"]["stateTransition"], + "token_authority_revoked" + ); + + let mut multisig = observation( + super::SPL_TOKEN_SURFACE, + "initialize_multisig2", + false, + serde_json::json!([ + account("multisig_account", "multisig111"), + account("multisig_member", "member1"), + account("multisig_member", "member2"), + ]), + serde_json::json!({"m":2}), + ); + multisig.event.program_id = + crate::ProgramId(kb_program_ids::SPL_TOKEN_PROGRAM_ID.to_string()); + let multisig_result = + crate::EventMaterializer::materialize(&crate::AdminMaterializer, &multisig); + assert_eq!( + multisig_result.outputs[0].output_key, + "spl_token_multisig:initialize_multisig2:0" + ); + assert_eq!( + multisig_result.outputs[0].payload_json["projection"]["members"] + .as_array() + .map(std::vec::Vec::len), + std::option::Option::Some(2) + ); + } + + #[test] + fn token_2022_authority_change_uses_the_admin_owner_once() { + let mut observation = observation( + super::SPL_TOKEN_2022_SURFACE, + "set_authority", + false, + serde_json::json!([ + account("owned_account", "mint111"), + account("current_authority", "authority111"), + ]), + serde_json::json!({"authorityType":"mint_tokens","newAuthority":null}), + ); + observation.event.program_id = + crate::ProgramId(kb_program_ids::SPL_TOKEN_2022_PROGRAM_ID.to_string()); + let result = crate::EventMaterializer::materialize(&crate::AdminMaterializer, &observation); + assert_eq!(result.status, crate::MaterializerOutcomeStatus::Inserted); + assert_eq!(result.outputs.len(), 1); + assert_eq!( + result.outputs[0].payload_json["programId"], + kb_program_ids::SPL_TOKEN_2022_PROGRAM_ID + ); + } + + #[test] + fn failed_admin_mutation_is_refused() { + let materializer = crate::AdminMaterializer; + let observation = observation( + super::SYSTEM_SURFACE, + "assign", + true, + serde_json::json!([account("assigned_account", "account111",)]), + serde_json::json!({"owner": "owner111"}), + ); + let result = crate::EventMaterializer::materialize(&materializer, &observation); + assert_eq!(result.status, crate::MaterializerOutcomeStatus::Refused); + assert!(result.outputs.is_empty()); + } + + #[test] + fn ata_instructions_do_not_create_admin_outputs() { + let mut observation = observation( + "spl_associated_token_account", + "create", + false, + serde_json::json!([]), + serde_json::json!({}), + ); + observation.event.program_id = + crate::ProgramId(kb_program_ids::ASSOCIATED_TOKEN_PROGRAM_ID.to_string()); + observation.event.event_family = crate::EventFamily::Lifecycle; + assert!(!crate::EventMaterializer::accepts_observation( + &crate::AdminMaterializer, + &observation, + )); + let result = crate::EventMaterializer::materialize(&crate::AdminMaterializer, &observation); + assert_eq!(result.status, crate::MaterializerOutcomeStatus::Ignored); + assert!(result.outputs.is_empty()); + } + + #[test] + fn unrelated_admin_observation_is_ignored() { + let materializer = crate::AdminMaterializer; + let observation = observation( + "other_surface", + "set_authority", + false, + serde_json::json!([]), + serde_json::json!({}), + ); + let result = crate::EventMaterializer::materialize(&materializer, &observation); + assert_eq!(result.status, crate::MaterializerOutcomeStatus::Ignored); + } + + #[test] + fn output_serialization_is_deterministic() { + let materializer = crate::AdminMaterializer; + let observation = observation( + super::CONFIG_SURFACE, + "store", + false, + serde_json::json!([account("config_account", "config111",)]), + serde_json::json!({ + "configuredKeys": [], + "configuredKeyCount": 0, + "configuredSignerCount": 0, + "configDataByteLength": 1, + "configDataSha256": "abc", + "configDataPrefixHex": "01", + }), + ); + let first = crate::EventMaterializer::materialize(&materializer, &observation); + let second = crate::EventMaterializer::materialize(&materializer, &observation); + let first_json = match serde_json::to_string(&first) { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => panic!("first admin serialization failed: {error}"), + }; + let second_json = match serde_json::to_string(&second) { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => panic!("second admin serialization failed: {error}"), + }; + assert_eq!(first_json, second_json); + } + + #[test] + fn elgamal_registry_create_and_update_have_one_admin_owner() { + let materializer = crate::AdminMaterializer; + for (operation, offset, owner_position, transition) in [ + ("create_registry", 1_i64, 1_u64, "elgamal_registry_created"), + ("update_registry", 0_i64, 2_u64, "elgamal_registry_key_updated"), + ] { + let mut observation = observation( + super::SPL_ELGAMAL_REGISTRY_SURFACE, + operation, + false, + serde_json::json!([ + {"position": 0, "accountKey": "registry111"}, + {"position": 1, "accountKey": "proof111"}, + {"position": 2, "accountKey": "owner111"}, + {"position": 3, "accountKey": "proofCreate111"} + ]), + serde_json::json!({}), + ); + observation.event.program_id = crate::ProgramId( + kb_program_ids::SPL_TOKEN_2022_ELGAMAL_REGISTRY_PROGRAM_ID.to_string(), + ); + observation.payload_json = serde_json::json!({ + "accounts": observation.payload_json["accounts"].clone(), + "proofInstructionOffset": offset, + "proofLocationKind": if offset == 0 { + "context_state_account" + } else { + "relative_instruction_offset" + }, + }); + let result = crate::EventMaterializer::materialize(&materializer, &observation); + assert_eq!(result.status, crate::MaterializerOutcomeStatus::Inserted); + assert_eq!(result.outputs.len(), 1); + assert_eq!( + result.outputs[0].payload_json["projection"]["registryAccount"], + "registry111" + ); + assert_eq!( + result.outputs[0].payload_json["projection"]["ownerAccount"], + if owner_position == 1 { "proof111" } else { "owner111" } + ); + assert_eq!(result.outputs[0].payload_json["projection"]["stateTransition"], transition); + } + } + + #[test] + fn elgamal_registry_foreign_program_and_failed_transaction_are_refused() { + let materializer = crate::AdminMaterializer; + let mut foreign = observation( + super::SPL_ELGAMAL_REGISTRY_SURFACE, + "create_registry", + false, + serde_json::json!([]), + serde_json::json!({}), + ); + foreign.event.program_id = + crate::ProgramId(kb_program_ids::SPL_TOKEN_2022_PROGRAM_ID.to_string()); + assert!(!crate::EventMaterializer::accepts_observation(&materializer, &foreign,)); + let mut failed = foreign.clone(); + failed.event.program_id = crate::ProgramId( + kb_program_ids::SPL_TOKEN_2022_ELGAMAL_REGISTRY_PROGRAM_ID.to_string(), + ); + failed.transaction_failed = true; + failed.observation_committed = false; + let result = crate::EventMaterializer::materialize(&materializer, &failed); + assert_eq!(result.status, crate::MaterializerOutcomeStatus::Refused); + } + + #[test] + fn token_2022_pointer_and_hook_admin_events_have_one_owner() { + for (operation, field, value) in [ + ("initialize_transfer_hook", "programId", "hook111"), + ("update_metadata_pointer", "metadataAddress", "metadata111"), + ("update_group_pointer", "groupAddress", "group111"), + ("update_group_member_pointer", "memberAddress", "member111"), + ] { + let mut observation = observation( + super::SPL_TOKEN_2022_SURFACE, + operation, + false, + serde_json::json!([{"position":0,"accountKey":"mint111"}]), + serde_json::json!({(field):value,"authority":"authority111"}), + ); + observation.event.event_family = crate::EventFamily::TokenAccount; + observation.event.program_id = + crate::ProgramId(kb_program_ids::SPL_TOKEN_2022_PROGRAM_ID.to_string()); + let result = + crate::EventMaterializer::materialize(&crate::AdminMaterializer, &observation); + assert_eq!(result.status, crate::MaterializerOutcomeStatus::Inserted); + assert_eq!(result.outputs.len(), 1); + assert_eq!(result.outputs[0].payload_json["projection"][field], value); + assert_eq!(result.outputs[0].payload_json["projection"]["stateReadRequired"], true); + } + } + + #[test] + fn token_2022_extension_admin_rejects_foreign_program_and_failed_transaction() { + let mut observation = observation( + super::SPL_TOKEN_2022_SURFACE, + "initialize_metadata_pointer", + false, + serde_json::json!([]), + serde_json::json!({"metadataAddress":"metadata111"}), + ); + observation.event.event_family = crate::EventFamily::TokenAccount; + observation.event.program_id = + crate::ProgramId(kb_program_ids::SPL_TOKEN_PROGRAM_ID.to_string()); + assert!(!crate::EventMaterializer::accepts_observation( + &crate::AdminMaterializer, + &observation, + )); + observation.event.program_id = + crate::ProgramId(kb_program_ids::SPL_TOKEN_2022_PROGRAM_ID.to_string()); + observation.transaction_failed = true; + observation.observation_committed = false; + let result = crate::EventMaterializer::materialize(&crate::AdminMaterializer, &observation); + assert_eq!(result.status, crate::MaterializerOutcomeStatus::Refused); + } +} + +/// Materialize authoritative Token-2022 administrative extension snapshots. +pub fn materialize_token_2022_admin_state_snapshots( + account_key: &str, + slot: u64, + state: &crate::Token2022State, +) -> std::result::Result, String> { + if account_key.is_empty() { + return std::result::Result::Err( + "Token-2022 admin state snapshot requires a non-empty account key".to_string(), + ); + } + if state.kind != crate::Token2022StateKind::Mint { + for entry in &state.extensions { + if is_token_2022_admin_mint_extension(entry.extension_name) { + return std::result::Result::Err(format!( + "Token-2022 {} extension requires mint state", + entry.extension_name + )); + } + } + return std::result::Result::Ok(std::vec::Vec::new()); + } + let mut outputs = std::vec::Vec::new(); + for entry in &state.extensions { + if !is_token_2022_admin_mint_extension(entry.extension_name) { + continue; + } + let output_key = + format!("token_2022_admin_state:{}:{account_key}:{slot}", entry.extension_name); + outputs.push(crate::MaterializedOutput { + output_key: output_key.clone(), + family: crate::MaterializedEventFamily::Admin, + payload_json: serde_json::json!({ + "projectionVersion": 1, + "projectionSemantics": "authoritative_bounded_admin_state_snapshot", + "domain": "token_2022_extension_admin_state", + "idempotenceKey": output_key, + "accountKey": account_key, + "slot": slot, + "programId": kb_program_ids::SPL_TOKEN_2022_PROGRAM_ID, + "stateKind": "mint", + "extensionType": entry.extension_type, + "extensionName": entry.extension_name, + "valueHex": entry.value_hex, + "valueFields": entry.value_fields, + "finalAccountStateCaptured": true, + "provenance": { + "processorName": crate::MATERIALIZER_ADMIN_PROCESSOR_NAME, + "processorVersion": env!("CARGO_PKG_VERSION"), + "source": "exact_token_2022_tlv_state_parser" + } + }), + }); + } + return std::result::Result::Ok(outputs); +} + +fn is_token_2022_admin_mint_extension(extension_name: &str) -> bool { + return matches!( + extension_name, + "mint_close_authority" + | "default_account_state" + | "transfer_hook" + | "permanent_delegate" + | "metadata_pointer" + | "group_pointer" + | "group_member_pointer" + | "interest_bearing_config" + | "scaled_ui_amount" + | "pausable" + | "permissioned_burn" + ); +} + +#[cfg(test)] +mod token_2022_admin_state_tests { + fn mint_state() -> crate::Token2022State { + return crate::Token2022State { + kind: crate::Token2022StateKind::Mint, + base_fields: serde_json::json!({}), + base_hex: String::new(), + account_type: std::option::Option::Some(1), + extensions: vec![ + crate::Token2022TlvEntry { + extension_type: 6, + extension_name: "default_account_state", + value_hex: "02".to_string(), + value_fields: serde_json::json!({"state": 2}), + }, + crate::Token2022TlvEntry { + extension_type: 26, + extension_name: "pausable", + value_hex: "00".repeat(33), + value_fields: serde_json::json!({"paused": false}), + }, + crate::Token2022TlvEntry { + extension_type: 1, + extension_name: "transfer_fee_config", + value_hex: "00".to_string(), + value_fields: serde_json::json!({}), + }, + ], + }; + } + + #[test] + fn admin_snapshots_are_owned_once_filtered_and_idempotent() { + let state = mint_state(); + let first = crate::materialize_token_2022_admin_state_snapshots("mint111", 44, &state); + let second = crate::materialize_token_2022_admin_state_snapshots("mint111", 44, &state); + assert_eq!(first, second); + assert_eq!(first.as_ref().map(std::vec::Vec::len), std::result::Result::Ok(2)); + assert_eq!( + first.as_ref().map(|outputs| { + return outputs + .iter() + .map(|output| return output.payload_json["extensionName"].clone()) + .collect::>(); + }), + std::result::Result::Ok(vec![ + serde_json::json!("default_account_state"), + serde_json::json!("pausable"), + ]) + ); + } + + #[test] + fn admin_snapshots_reject_empty_identity_and_wrong_state_kind() { + let state = mint_state(); + assert!(crate::materialize_token_2022_admin_state_snapshots("", 44, &state).is_err()); + let account_state = crate::Token2022State { + kind: crate::Token2022StateKind::Account, + base_fields: serde_json::json!({}), + base_hex: String::new(), + account_type: std::option::Option::Some(2), + extensions: state.extensions[..1].to_vec(), + }; + assert!( + crate::materialize_token_2022_admin_state_snapshots("account111", 44, &account_state,) + .is_err() + ); + } +} + +/// Materialize one authoritative SPL ElGamal registry account snapshot. +pub fn materialize_elgamal_registry_state_snapshot( + registry_account_key: &str, + slot: u64, + state: &crate::ElGamalRegistryState, +) -> std::result::Result { + if registry_account_key.is_empty() { + return std::result::Result::Err( + "SPL ElGamal registry snapshot requires a non-empty account key".to_string(), + ); + } + return std::result::Result::Ok(crate::MaterializedOutput { + output_key: format!("spl_elgamal_registry_state:{registry_account_key}:{slot}"), + family: crate::MaterializedEventFamily::Admin, + payload_json: serde_json::json!({ + "projectionVersion": 1, + "domain": "spl_elgamal_registry_account_state", + "projectionSemantics": "authoritative_exact_account_snapshot", + "idempotenceKey": format!("spl_elgamal_registry:{registry_account_key}:{slot}"), + "registryAccountKey": registry_account_key, + "slot": slot, + "programId": kb_program_ids::SPL_TOKEN_2022_ELGAMAL_REGISTRY_PROGRAM_ID, + "owner": state.owner, + "elgamalPubkeyBase64": state.elgamal_pubkey_base64, + "wireHex": state.wire_hex, + "elgamalKeyValidityProvenByStateParser": false, + "confidentialValuesDecrypted": false, + "finalAccountStateCaptured": true, + "provenance": { + "processorName": crate::MATERIALIZER_ADMIN_PROCESSOR_NAME, + "processorVersion": env!("CARGO_PKG_VERSION"), + "source": "exact_spl_elgamal_registry_state_parser" + } + }), + }); +} + +#[cfg(test)] +mod elgamal_registry_state_tests { + #[test] + fn exact_registry_snapshot_preserves_public_state_without_claiming_proof_validation() { + let state = crate::ElGamalRegistryState { + owner: "owner111".to_string(), + elgamal_pubkey_base64: "AQID".to_string(), + wire_hex: "00".repeat(64), + }; + let output = crate::materialize_elgamal_registry_state_snapshot("registry111", 11, &state); + assert_eq!( + output.as_ref().map(|value| return value.payload_json["owner"].clone()), + std::result::Result::Ok(serde_json::json!("owner111")) + ); + assert_eq!( + output + .as_ref() + .map(|value| return value.payload_json["elgamalKeyValidityProvenByStateParser"] + .clone()), + std::result::Result::Ok(serde_json::json!(false)) + ); + assert_eq!( + output + .as_ref() + .map(|value| return value.payload_json["confidentialValuesDecrypted"].clone()), + std::result::Result::Ok(serde_json::json!(false)) + ); + } + + #[test] + fn registry_snapshot_is_idempotent_and_requires_account_identity() { + let state = crate::ElGamalRegistryState { + owner: "owner111".to_string(), + elgamal_pubkey_base64: "AQID".to_string(), + wire_hex: "00".repeat(64), + }; + let first = crate::materialize_elgamal_registry_state_snapshot("registry111", 11, &state); + let second = crate::materialize_elgamal_registry_state_snapshot("registry111", 11, &state); + assert_eq!(first, second); + assert!(crate::materialize_elgamal_registry_state_snapshot("", 11, &state).is_err()); + } +} diff --git a/kb-lib/src/materializer/compliance.rs b/kb-lib/src/materializer/compliance.rs index 2891fd4..c113c94 100644 --- a/kb-lib/src/materializer/compliance.rs +++ b/kb-lib/src/materializer/compliance.rs @@ -1,6 +1,12 @@ // file: kb-lib/src/materializer/compliance.rs -// version: 1 +// version: 2 //! `compliance` materializer family. pub mod audit; +mod constants; + +pub(crate) use crate::materializer::compliance::constants::TRACING_TARGET as MATERIALIZER_COMPLIANCE_AUDIT_TRACING_TARGET; + +/// Stable native compliance audit materializer. +pub use crate::materializer::compliance::audit::ComplianceAuditMaterializer; diff --git a/kb-lib/src/materializer/compliance/audit.rs b/kb-lib/src/materializer/compliance/audit.rs index 5cff13f..a5c6068 100644 --- a/kb-lib/src/materializer/compliance/audit.rs +++ b/kb-lib/src/materializer/compliance/audit.rs @@ -1,10 +1,577 @@ // file: kb-lib/src/materializer/compliance/audit.rs -// version: 1 +// version: 7 -//! Migration boundary for legacy crate `kb_materializer_compliance_audit`. +//! Stable compliance-audit projections for committed native bytecode mutations and execution profiles. -/// Legacy crate name retained for migration and compatibility tracking. -pub const LEGACY_CRATE: &str = "kb_materializer_compliance_audit"; +const ACCEPTED_FAMILIES: &[crate::EventFamily] = &[crate::EventFamily::Audit]; +const COMPUTE_BUDGET_SURFACE: &str = "solana_native_compute_budget"; +const COMPUTE_BUDGET_ENTRIES: &[&str] = &[ + "request_units_deprecated", + "request_heap_frame", + "set_compute_unit_limit", + "set_compute_unit_price", + "set_loaded_accounts_data_size_limit", +]; +const BPF_LOADER_DEPRECATED_SURFACE: &str = "solana_native_bpf_loader_deprecated"; +const BPF_LOADER_SURFACE: &str = "solana_native_bpf_loader"; +const BPF_LOADER_UPGRADEABLE_SURFACE: &str = "solana_native_bpf_loader_upgradeable"; +const LOADER_V4_SURFACE: &str = "solana_native_loader_v4"; +const WRITE_ENTRIES: &[&str] = &["write"]; +const LOADER_V4_COPY_ENTRIES: &[&str] = &["copy"]; -/// Current porting status. -pub const MIGRATION_STATUS: &str = "source-preserved-pending-port"; +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum ProjectionKind { + ProgramByteWrite(&'static str), + ProgramByteCopy, + ComputeBudgetProfile, +} + +impl ProjectionKind { + fn output_key(self, surface_code: &str, operation: &str) -> std::string::String { + return match self { + Self::ProgramByteWrite(_) | Self::ProgramByteCopy => { + format!("program_loader_code:{surface_code}:{operation}:0") + }, + Self::ComputeBudgetProfile => "transaction_compute_budget_profile:0".to_string(), + }; + } + + fn loader_surface(self) -> &'static str { + return match self { + Self::ProgramByteWrite(surface) => surface, + Self::ProgramByteCopy => LOADER_V4_SURFACE, + Self::ComputeBudgetProfile => COMPUTE_BUDGET_SURFACE, + }; + } +} + +/// Stable compliance-audit materializer for native bytecode mutations and execution profiles. +#[derive(Clone, Debug, Default)] +pub struct ComplianceAuditMaterializer; + +impl crate::Materializer for crate::ComplianceAuditMaterializer { + fn materializer_name(&self) -> &'static str { + return "kb_materializer_compliance_audit"; + } + + fn materializer_version(&self) -> &'static str { + return env!("CARGO_PKG_VERSION"); + } + + fn accepts_event(&self, event: &crate::DecodedProtocolEvent) -> bool { + return event.event_family == crate::EventFamily::Audit + && projection(event.surface_code.0.as_str(), event.event_name.0.as_str()).is_some(); + } + + fn materialize_event( + &self, + event: &crate::DecodedProtocolEvent, + ) -> kb_core::Result> { + if !crate::Materializer::accepts_event(self, event) { + return std::result::Result::Ok(std::vec::Vec::new()); + } + return std::result::Result::Ok(std::vec::Vec::new()); + } +} + +impl crate::EventMaterializer for crate::ComplianceAuditMaterializer { + fn identity(&self) -> crate::MaterializerIdentity { + return crate::MaterializerIdentity { + name: "solana_native_compliance_audit".to_string(), + version: env!("CARGO_PKG_VERSION").to_string(), + }; + } + + fn accepted_families(&self) -> &'static [crate::EventFamily] { + return ACCEPTED_FAMILIES; + } + + fn accepts_observation(&self, observation: &crate::DecodedObservation) -> bool { + return observation.event.event_family == crate::EventFamily::Audit + && projection( + observation.event.surface_code.0.as_str(), + observation.event.event_name.0.as_str(), + ) + .is_some(); + } + + fn transaction_policy( + &self, + _family: crate::EventFamily, + ) -> crate::MaterializationTransactionPolicy { + return crate::MaterializationTransactionPolicy::SuccessfulOrFailedAudit; + } + + fn materialize( + &self, + observation: &crate::DecodedObservation, + ) -> crate::MaterializerExecutionResult { + let projection = match projection( + observation.event.surface_code.0.as_str(), + observation.event.event_name.0.as_str(), + ) { + std::option::Option::Some(value) => value, + std::option::Option::None => { + tracing::debug!( + target: crate::MATERIALIZER_COMPLIANCE_AUDIT_TRACING_TARGET, + action = "materialize_compliance_audit", + surface_code = %observation.event.surface_code.0.as_str(), + entry_code = %observation.event.event_name.0.as_str(), + accepted = false, + "ignore observation outside supported native compliance-audit projections" + ); + return crate::MaterializerExecutionResult::ignored(); + }, + }; + if observation.event.event_family != crate::EventFamily::Audit { + return crate::MaterializerExecutionResult::ignored(); + } + if projection != ProjectionKind::ComputeBudgetProfile + && (observation.transaction_failed || !observation.observation_committed) + { + tracing::debug!( + target: crate::MATERIALIZER_COMPLIANCE_AUDIT_TRACING_TARGET, + action = "materialize_compliance_audit", + surface_code = %observation.event.surface_code.0.as_str(), + entry_code = %observation.event.event_name.0.as_str(), + committed = observation.observation_committed, + transaction_failed = observation.transaction_failed, + "refuse uncommitted native bytecode mutation observation" + ); + return crate::MaterializerExecutionResult::refused( + "failed_transaction_compliance_audit_refused", + "failed or uncommitted native observations cannot create committed bytecode mutation outputs", + ); + } + let accounts = observation + .payload_json + .get("accounts") + .cloned() + .unwrap_or(serde_json::Value::Null); + let parameters = observation + .payload_json + .get("parameters") + .cloned() + .unwrap_or(serde_json::Value::Null); + let operation = observation.event.event_name.0.clone(); + if projection == ProjectionKind::ComputeBudgetProfile { + return materialize_compute_budget_profile(observation, parameters, operation); + } + let output = crate::MaterializedOutput { + output_key: projection + .output_key(observation.event.surface_code.0.as_str(), operation.as_str()), + family: crate::MaterializedEventFamily::ComplianceAudit, + payload_json: serde_json::json!({ + "projectionVersion": 1, + "projectionSemantics": "committed_instruction_compliance_audit_event", + "domain": "program_loader_code_mutation", + "loaderSurface": projection.loader_surface(), + "operation": operation, + "programId": observation.event.program_id.0.clone(), + "signature": observation.event.signature.0.clone(), + "slot": observation.event.slot.0, + "instructionPath": observation.event.instruction_path.0.clone(), + "transactionSucceeded": true, + "accounts": accounts, + "parameters": parameters, + "projection": projection_details(projection, &accounts, ¶meters), + }), + }; + tracing::debug!( + target: crate::MATERIALIZER_COMPLIANCE_AUDIT_TRACING_TARGET, + action = "materialize_compliance_audit", + surface_code = %observation.event.surface_code.0.as_str(), + entry_code = %observation.event.event_name.0.as_str(), + loader_surface = projection.loader_surface(), + output_count = 1_usize, + "materialize committed native bytecode mutation observation" + ); + return crate::MaterializerExecutionResult { + status: crate::MaterializerOutcomeStatus::Inserted, + outputs: std::vec![output], + diagnostics: std::vec::Vec::new(), + }; + } +} + +fn materialize_compute_budget_profile( + observation: &crate::DecodedObservation, + parameters: serde_json::Value, + operation: std::string::String, +) -> crate::MaterializerExecutionResult { + let profile = parameters.get("transactionProfile").cloned().unwrap_or(serde_json::Value::Null); + if !compute_budget_profile_emits(&profile) { + return crate::MaterializerExecutionResult::ignored(); + } + let output = crate::MaterializedOutput { + output_key: "transaction_compute_budget_profile:0".to_string(), + family: crate::MaterializedEventFamily::ComplianceAudit, + payload_json: serde_json::json!({ + "projectionVersion": 1, + "projectionSemantics": "transaction_compute_budget_profile", + "domain": "transaction_compute_budget_profile", + "operation": operation, + "programId": observation.event.program_id.0.clone(), + "signature": observation.event.signature.0.clone(), + "slot": observation.event.slot.0, + "instructionPath": observation.event.instruction_path.0.clone(), + "transactionSucceeded": !observation.transaction_failed, + "profile": profile, + "profileSource": { + "surfaceCode": observation.event.surface_code.0.clone(), + "entryCode": observation.event.event_name.0.clone(), + "eventKey": observation.event_key.clone() + }, + }), + }; + tracing::debug!( + target: crate::MATERIALIZER_COMPLIANCE_AUDIT_TRACING_TARGET, + action = "materialize_compute_budget_profile", + surface_code = %observation.event.surface_code.0.as_str(), + entry_code = %observation.event.event_name.0.as_str(), + output_count = 1_usize, + transaction_failed = observation.transaction_failed, + "materialize native Compute Budget transaction profile" + ); + return crate::MaterializerExecutionResult { + status: crate::MaterializerOutcomeStatus::Inserted, + outputs: std::vec![output], + diagnostics: std::vec::Vec::new(), + }; +} + +fn compute_budget_profile_emits(profile: &serde_json::Value) -> bool { + return profile + .get("currentInstructionIsProfileEmitter") + .and_then(serde_json::Value::as_bool) + == std::option::Option::Some(true); +} + +fn projection(surface_code: &str, entry_code: &str) -> std::option::Option { + if surface_code == COMPUTE_BUDGET_SURFACE && contains(COMPUTE_BUDGET_ENTRIES, entry_code) { + return std::option::Option::Some(ProjectionKind::ComputeBudgetProfile); + } + if contains(WRITE_ENTRIES, entry_code) { + for supported in [ + BPF_LOADER_DEPRECATED_SURFACE, + BPF_LOADER_SURFACE, + BPF_LOADER_UPGRADEABLE_SURFACE, + LOADER_V4_SURFACE, + ] { + if surface_code == supported { + return std::option::Option::Some(ProjectionKind::ProgramByteWrite(supported)); + } + } + } + if surface_code == LOADER_V4_SURFACE && contains(LOADER_V4_COPY_ENTRIES, entry_code) { + return std::option::Option::Some(ProjectionKind::ProgramByteCopy); + } + return std::option::Option::None; +} + +fn projection_details( + projection: ProjectionKind, + accounts: &serde_json::Value, + parameters: &serde_json::Value, +) -> serde_json::Value { + return match projection { + ProjectionKind::ProgramByteWrite(_) => serde_json::json!({ + "targetAccount": first_account_key_for_roles( + accounts, + &["program_account", "buffer_account"], + ), + "authorityAccount": first_account_key_for_roles( + accounts, + &["authority", "buffer_authority"], + ), + "offset": parameters.get("offset").cloned().unwrap_or(serde_json::Value::Null), + "byteLength": parameters.get("byteLength").cloned().unwrap_or(serde_json::Value::Null), + "bytesSha256": parameters.get("bytesSha256").cloned().unwrap_or(serde_json::Value::Null), + "bytesPrefixHex": parameters.get("bytesPrefixHex").cloned().unwrap_or(serde_json::Value::Null), + "stateTransition": "program_bytes_written", + "codeBytesMaterialized": false, + "transactionFinalAccountStateCaptured": false, + }), + ProjectionKind::ProgramByteCopy => serde_json::json!({ + "targetAccount": account_key_for_role(accounts, "program_account"), + "authorityAccount": account_key_for_role(accounts, "authority"), + "sourceProgramAccount": account_key_for_role(accounts, "source_program_account"), + "destinationOffset": parameters.get("destinationOffset").cloned().unwrap_or(serde_json::Value::Null), + "sourceOffset": parameters.get("sourceOffset").cloned().unwrap_or(serde_json::Value::Null), + "byteLength": parameters.get("length").cloned().unwrap_or(serde_json::Value::Null), + "stateTransition": "program_bytes_copied", + "codeBytesMaterialized": false, + "transactionFinalAccountStateCaptured": false, + }), + ProjectionKind::ComputeBudgetProfile => serde_json::Value::Null, + }; +} + +fn first_account_key_for_roles(accounts: &serde_json::Value, roles: &[&str]) -> serde_json::Value { + for role in roles { + let value = account_key_for_role(accounts, role); + if !value.is_null() { + return value; + } + } + return serde_json::Value::Null; +} + +fn account_key_for_role(accounts: &serde_json::Value, role: &str) -> serde_json::Value { + let account_key = accounts.as_array().and_then(|values| { + return values.iter().find_map(|value| { + if value.get("role").and_then(serde_json::Value::as_str) + != std::option::Option::Some(role) + { + return std::option::Option::None; + } + return value + .get("accountKey") + .and_then(serde_json::Value::as_str) + .map(|account_key| return account_key.to_string()); + }); + }); + return match account_key { + std::option::Option::Some(value) => serde_json::Value::String(value), + std::option::Option::None => serde_json::Value::Null, + }; +} + +fn contains(entries: &[&str], entry_code: &str) -> bool { + return entries.iter().any(|entry| return *entry == entry_code); +} + +#[cfg(test)] +mod tests { + fn observation( + surface_code: &str, + entry_code: &str, + transaction_failed: bool, + accounts: serde_json::Value, + parameters: serde_json::Value, + ) -> crate::DecodedObservation { + return crate::DecodedObservation { + event_key: format!("{entry_code}:0"), + event: crate::DecodedProtocolEvent { + signature: crate::Signature("signature".to_string()), + slot: crate::Slot(42), + instruction_path: crate::InstructionPath("0".to_string()), + program_id: crate::ProgramId("program-id".to_string()), + protocol_code: crate::ProtocolCode("solana_native".to_string()), + surface_code: crate::SurfaceCode(surface_code.to_string()), + event_code: crate::EventCode(format!("{surface_code}.{entry_code}")), + event_name: crate::EventName(entry_code.to_string()), + event_family: crate::EventFamily::Audit, + source_kind: crate::EventSourceKind::Instruction, + confidence: crate::DecoderConfidence::ManualExact, + }, + payload_json: serde_json::json!({"accounts": accounts, "parameters": parameters}), + transaction_failed, + transaction_error: if transaction_failed { + std::option::Option::Some(serde_json::json!({"InstructionError": [0, "Custom"]})) + } else { + std::option::Option::None + }, + observation_committed: !transaction_failed, + proof: crate::DecoderProof { + kind: crate::DecoderProofKind::Manual, + confidence: crate::DecoderConfidence::ManualExact, + evidence: std::vec!["fixture".to_string()], + }, + }; + } + + fn account(role: &str, account_key: &str) -> serde_json::Value { + return serde_json::json!({"role": role, "accountKey": account_key}); + } + + #[test] + fn loader_write_variants_create_bounded_code_audits() { + let materializer = crate::ComplianceAuditMaterializer; + for (surface, target_role, authority_role) in [ + (super::BPF_LOADER_DEPRECATED_SURFACE, "program_account", "missing"), + (super::BPF_LOADER_SURFACE, "program_account", "missing"), + (super::BPF_LOADER_UPGRADEABLE_SURFACE, "buffer_account", "buffer_authority"), + (super::LOADER_V4_SURFACE, "program_account", "authority"), + ] { + let observation = observation( + surface, + "write", + false, + serde_json::json!([ + account(target_role, "target111"), + account(authority_role, "authority111"), + ]), + serde_json::json!({ + "offset": 7, + "byteLength": 32, + "bytesSha256": "abc", + "bytesPrefixHex": "0102", + }), + ); + let result = crate::EventMaterializer::materialize(&materializer, &observation); + assert_eq!(result.status, crate::MaterializerOutcomeStatus::Inserted); + assert_eq!(result.outputs[0].family, crate::MaterializedEventFamily::ComplianceAudit); + assert_eq!(result.outputs[0].payload_json["projection"]["targetAccount"], "target111"); + assert_eq!(result.outputs[0].payload_json["projection"]["bytesSha256"], "abc"); + assert_eq!( + result.outputs[0].payload_json["projection"]["codeBytesMaterialized"], + false + ); + } + } + + #[test] + fn loader_v4_copy_preserves_source_and_bounded_ranges() { + let materializer = crate::ComplianceAuditMaterializer; + let observation = observation( + super::LOADER_V4_SURFACE, + "copy", + false, + serde_json::json!([ + account("program_account", "target111"), + account("authority", "authority111"), + account("source_program_account", "source111"), + ]), + serde_json::json!({"destinationOffset": 8, "sourceOffset": 4, "length": 16}), + ); + let result = crate::EventMaterializer::materialize(&materializer, &observation); + assert_eq!( + result.outputs[0].output_key, + "program_loader_code:solana_native_loader_v4:copy:0" + ); + assert_eq!( + result.outputs[0].payload_json["projection"]["sourceProgramAccount"], + "source111" + ); + assert_eq!(result.outputs[0].payload_json["projection"]["byteLength"], 16); + } + + #[test] + fn compute_budget_profile_is_emitted_only_by_profile_emitter() { + let materializer = crate::ComplianceAuditMaterializer; + let emitter = observation( + super::COMPUTE_BUDGET_SURFACE, + "set_compute_unit_limit", + false, + serde_json::json!([]), + serde_json::json!({ + "transactionProfile": { + "currentInstructionIsProfileEmitter": true, + "profileEmitterInstructionPath": "0", + "computeBudgetInstructionCount": 2, + "effectiveValues": { + "set_compute_unit_limit": { + "value": 200000, + "sourceInstructionPath": "0" + } + } + } + }), + ); + let emitted = crate::EventMaterializer::materialize(&materializer, &emitter); + assert_eq!(emitted.status, crate::MaterializerOutcomeStatus::Inserted); + assert_eq!(emitted.outputs[0].output_key, "transaction_compute_budget_profile:0"); + assert_eq!(emitted.outputs[0].family, crate::MaterializedEventFamily::ComplianceAudit); + assert_eq!(emitted.outputs[0].payload_json["profile"]["computeBudgetInstructionCount"], 2); + let non_emitter = observation( + super::COMPUTE_BUDGET_SURFACE, + "set_compute_unit_price", + false, + serde_json::json!([]), + serde_json::json!({ + "transactionProfile": { + "currentInstructionIsProfileEmitter": false, + "profileEmitterInstructionPath": "0" + } + }), + ); + let ignored = crate::EventMaterializer::materialize(&materializer, &non_emitter); + assert_eq!(ignored.status, crate::MaterializerOutcomeStatus::Ignored); + } + + #[test] + fn failed_compute_budget_profile_remains_audit_materializable() { + let materializer = crate::ComplianceAuditMaterializer; + let observation = observation( + super::COMPUTE_BUDGET_SURFACE, + "set_compute_unit_price", + true, + serde_json::json!([]), + serde_json::json!({ + "transactionProfile": { + "currentInstructionIsProfileEmitter": true, + "profileEmitterInstructionPath": "0", + "computeBudgetInstructionCount": 1 + } + }), + ); + let result = crate::EventMaterializer::materialize(&materializer, &observation); + assert_eq!(result.status, crate::MaterializerOutcomeStatus::Inserted); + assert_eq!(result.outputs[0].payload_json["transactionSucceeded"], false); + } + + #[test] + fn failed_code_mutation_is_refused() { + let materializer = crate::ComplianceAuditMaterializer; + let observation = observation( + super::LOADER_V4_SURFACE, + "write", + true, + serde_json::json!([account("program_account", "target111")]), + serde_json::json!({"offset": 0, "byteLength": 1, "bytesSha256": "abc"}), + ); + let result = crate::EventMaterializer::materialize(&materializer, &observation); + assert_eq!(result.status, crate::MaterializerOutcomeStatus::Refused); + assert!(result.outputs.is_empty()); + } + + #[test] + fn unrelated_audit_observation_is_ignored() { + let materializer = crate::ComplianceAuditMaterializer; + let observation = observation( + "other_surface", + "write", + false, + serde_json::json!([]), + serde_json::json!({}), + ); + let result = crate::EventMaterializer::materialize(&materializer, &observation); + assert_eq!(result.status, crate::MaterializerOutcomeStatus::Ignored); + } + + #[test] + fn output_serialization_is_deterministic() { + let materializer = crate::ComplianceAuditMaterializer; + let observation = observation( + super::BPF_LOADER_UPGRADEABLE_SURFACE, + "write", + false, + serde_json::json!([ + account("buffer_account", "buffer111"), + account("buffer_authority", "authority111"), + ]), + serde_json::json!({ + "offset": 0, + "byteLength": 2, + "bytesSha256": "abc", + "bytesPrefixHex": "0102", + }), + ); + let first = crate::EventMaterializer::materialize(&materializer, &observation); + let second = crate::EventMaterializer::materialize(&materializer, &observation); + let first_json = match serde_json::to_string(&first) { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => { + panic!("first compliance serialization failed: {error}") + }, + }; + let second_json = match serde_json::to_string(&second) { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => { + panic!("second compliance serialization failed: {error}") + }, + }; + assert_eq!(first_json, second_json); + } +} diff --git a/kb-lib/src/materializer/compliance/constants.rs b/kb-lib/src/materializer/compliance/constants.rs new file mode 100644 index 0000000..e47bbc8 --- /dev/null +++ b/kb-lib/src/materializer/compliance/constants.rs @@ -0,0 +1,7 @@ +// file: kb-lib/src/materializer/compliance/constants.rs +// version: 1 + +//! Local constants for the native compliance audit materializer. + +/// Canonical tracing target for this component. +pub(crate) const TRACING_TARGET: &str = "kb-lib.materializer.compliance"; diff --git a/kb-lib/src/materializer/lifecycle.rs b/kb-lib/src/materializer/lifecycle.rs index 842fbe7..6158d65 100644 --- a/kb-lib/src/materializer/lifecycle.rs +++ b/kb-lib/src/materializer/lifecycle.rs @@ -1,6 +1,12 @@ // file: kb-lib/src/materializer/lifecycle.rs -// version: 1 +// version: 2 //! `lifecycle` materializer family. +mod constants; pub mod core; + +pub(crate) use crate::materializer::lifecycle::constants::TRACING_TARGET as MATERIALIZER_LIFECYCLE_TRACING_TARGET; + +/// Stable native lifecycle materializer. +pub use crate::materializer::lifecycle::core::LifecycleMaterializer; diff --git a/kb-lib/src/materializer/lifecycle/constants.rs b/kb-lib/src/materializer/lifecycle/constants.rs new file mode 100644 index 0000000..a5c1215 --- /dev/null +++ b/kb-lib/src/materializer/lifecycle/constants.rs @@ -0,0 +1,7 @@ +// file: kb-lib/src/materializer/lifecycle/constants.rs +// version: 1 + +//! Local constants for the native lifecycle materializer. + +/// Canonical tracing target for this component. +pub(crate) const TRACING_TARGET: &str = "kb-lib.materializer.lifecycle"; diff --git a/kb-lib/src/materializer/lifecycle/core.rs b/kb-lib/src/materializer/lifecycle/core.rs index f2b8036..aea7f85 100644 --- a/kb-lib/src/materializer/lifecycle/core.rs +++ b/kb-lib/src/materializer/lifecycle/core.rs @@ -1,10 +1,960 @@ // file: kb-lib/src/materializer/lifecycle/core.rs -// version: 1 +// version: 14 -//! Migration boundary for legacy crate `kb_materializer_lifecycle`. +//! Stable lifecycle projections derived from exact decoded observations. -/// Legacy crate name retained for migration and compatibility tracking. -pub const LEGACY_CRATE: &str = "kb_materializer_lifecycle"; +const ACCEPTED_FAMILIES: &[crate::EventFamily] = &[ + crate::EventFamily::Lifecycle, + crate::EventFamily::Admin, + crate::EventFamily::Audit, +]; +const ADDRESS_LOOKUP_TABLE_SURFACE: &str = "solana_native_address_lookup_table"; +const ADDRESS_LOOKUP_TABLE_ENTRIES: &[&str] = &[ + "create_lookup_table", + "freeze_lookup_table", + "extend_lookup_table", + "deactivate_lookup_table", + "close_lookup_table", +]; +const BPF_LOADER_DEPRECATED_SURFACE: &str = "solana_native_bpf_loader_deprecated"; +const BPF_LOADER_SURFACE: &str = "solana_native_bpf_loader"; +const BPF_LOADER_UPGRADEABLE_SURFACE: &str = "solana_native_bpf_loader_upgradeable"; +const LOADER_V4_SURFACE: &str = "solana_native_loader_v4"; +const FEATURE_SURFACE: &str = "solana_native_feature"; +const FEATURE_ENTRIES: &[&str] = &["revoke_pending_activation"]; +const IMMUTABLE_LOADER_ENTRIES: &[&str] = &["finalize"]; +const UPGRADEABLE_LOADER_ENTRIES: &[&str] = &[ + "initialize_buffer", + "deploy_with_max_data_len", + "upgrade", + "close", + "extend_program", +]; +const LOADER_V4_ENTRIES: &[&str] = &["set_program_length", "deploy", "retract", "finalize"]; +const SLASHING_SURFACE: &str = "solana_native_slashing"; +const SLASHING_ENTRIES: &[&str] = &["close_violation_report", "duplicate_block_proof"]; +const SYSTEM_SURFACE: &str = "solana_native_system"; +const SYSTEM_ACCOUNT_ENTRIES: &[&str] = &[ + "create_account", + "create_account_with_seed", + "allocate", + "allocate_with_seed", + "create_account_allow_prefund", +]; +const SYSTEM_NONCE_ENTRIES: &[&str] = &[ + "advance_nonce_account", + "withdraw_nonce_account", + "initialize_nonce_account", + "authorize_nonce_account", + "upgrade_nonce_account", +]; +const ZK_ELGAMAL_PROOF_SURFACE: &str = "solana_native_zk_elgamal_proof"; +const ZK_ELGAMAL_CLOSE_ENTRIES: &[&str] = &["close_context_state"]; +const ZK_ELGAMAL_VERIFY_ENTRIES: &[&str] = &[ + "verify_zero_ciphertext", + "verify_ciphertext_ciphertext_equality", + "verify_ciphertext_commitment_equality", + "verify_pubkey_validity", + "verify_percentage_with_cap", + "verify_batched_range_proof_u64", + "verify_batched_range_proof_u128", + "verify_batched_range_proof_u256", + "verify_grouped_ciphertext_2_handles_validity", + "verify_batched_grouped_ciphertext_2_handles_validity", + "verify_grouped_ciphertext_3_handles_validity", + "verify_batched_grouped_ciphertext_3_handles_validity", +]; -/// Current porting status. -pub const MIGRATION_STATUS: &str = "source-preserved-pending-port"; +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum ProjectionKind { + AddressLookupTable, + FeatureGate, + ProgramLoader(&'static str), + SlashingViolationReportInitialization, + SlashingViolationReportClosure, + SystemAccountLifecycle, + DurableNonceAccount, + ZkProofContextInitialization, + ZkProofContextClosure, +} + +impl ProjectionKind { + fn domain(self) -> &'static str { + return match self { + Self::AddressLookupTable => "address_lookup_table", + Self::FeatureGate => "feature_gate", + Self::ProgramLoader(_) => "program_loader", + Self::SlashingViolationReportInitialization | Self::SlashingViolationReportClosure => { + "slashing_violation_report" + }, + Self::SystemAccountLifecycle => "system_account", + Self::DurableNonceAccount => "durable_nonce_account", + Self::ZkProofContextInitialization | Self::ZkProofContextClosure => "zk_proof_context", + }; + } + + fn loader_surface(self) -> std::option::Option<&'static str> { + return match self { + Self::ProgramLoader(surface) => std::option::Option::Some(surface), + _ => std::option::Option::None, + }; + } + + fn output_key(self, surface_code: &str, operation: &str) -> std::string::String { + return match self { + Self::AddressLookupTable => format!("address_lookup_table:{operation}:0"), + Self::FeatureGate => format!("feature_gate:{operation}:0"), + Self::ProgramLoader(_) => { + format!("program_loader:{surface_code}:{operation}:0") + }, + Self::SlashingViolationReportInitialization | Self::SlashingViolationReportClosure => { + format!("slashing_violation_report:{operation}:0") + }, + Self::SystemAccountLifecycle => format!("system_account:{operation}:0"), + Self::DurableNonceAccount => format!("durable_nonce_account:{operation}:0"), + Self::ZkProofContextInitialization | Self::ZkProofContextClosure => { + format!("zk_proof_context:{operation}:0") + }, + }; + } +} + +/// Stable lifecycle materializer for supported native lifecycle projections. +#[derive(Clone, Debug, Default)] +pub struct LifecycleMaterializer; + +impl crate::Materializer for crate::LifecycleMaterializer { + fn materializer_name(&self) -> &'static str { + return "kb_materializer_lifecycle"; + } + + fn materializer_version(&self) -> &'static str { + return env!("CARGO_PKG_VERSION"); + } + + fn accepts_event(&self, event: &crate::DecodedProtocolEvent) -> bool { + return family_is_accepted(event.event_family) + && static_projection(event.surface_code.0.as_str(), event.event_name.0.as_str()) + .is_some(); + } + + fn materialize_event( + &self, + event: &crate::DecodedProtocolEvent, + ) -> kb_core::Result> { + if !crate::Materializer::accepts_event(self, event) { + return std::result::Result::Ok(std::vec::Vec::new()); + } + return std::result::Result::Ok(std::vec::Vec::new()); + } +} + +impl crate::EventMaterializer for crate::LifecycleMaterializer { + fn identity(&self) -> crate::MaterializerIdentity { + return crate::MaterializerIdentity { + name: "solana_native_lifecycle".to_string(), + version: env!("CARGO_PKG_VERSION").to_string(), + }; + } + + fn accepted_families(&self) -> &'static [crate::EventFamily] { + return ACCEPTED_FAMILIES; + } + + fn accepts_observation(&self, observation: &crate::DecodedObservation) -> bool { + return family_is_accepted(observation.event.event_family) + && observation_projection(observation).is_some(); + } + + fn transaction_policy( + &self, + _family: crate::EventFamily, + ) -> crate::MaterializationTransactionPolicy { + return crate::MaterializationTransactionPolicy::SuccessfulCommittedOnly; + } + + fn materialize( + &self, + observation: &crate::DecodedObservation, + ) -> crate::MaterializerExecutionResult { + let projection = match observation_projection(observation) { + std::option::Option::Some(value) => value, + std::option::Option::None => { + tracing::debug!( + target: crate::MATERIALIZER_LIFECYCLE_TRACING_TARGET, + action = "materialize_lifecycle", + surface_code = %observation.event.surface_code.0.as_str(), + entry_code = %observation.event.event_name.0.as_str(), + accepted = false, + "ignore observation outside supported native lifecycle projections" + ); + return crate::MaterializerExecutionResult::ignored(); + }, + }; + if observation.transaction_failed || !observation.observation_committed { + tracing::debug!( + target: crate::MATERIALIZER_LIFECYCLE_TRACING_TARGET, + action = "materialize_lifecycle", + surface_code = %observation.event.surface_code.0.as_str(), + entry_code = %observation.event.event_name.0.as_str(), + accepted = true, + committed = observation.observation_committed, + transaction_failed = observation.transaction_failed, + "refuse uncommitted native lifecycle observation" + ); + return crate::MaterializerExecutionResult::refused( + "failed_transaction_lifecycle_refused", + "failed or uncommitted native lifecycle observations cannot create lifecycle outputs", + ); + } + let accounts = observation + .payload_json + .get("accounts") + .cloned() + .unwrap_or(serde_json::Value::Null); + let parameters = observation + .payload_json + .get("parameters") + .cloned() + .unwrap_or(serde_json::Value::Null); + let operation = observation.event.event_name.0.clone(); + let output_key = + projection.output_key(observation.event.surface_code.0.as_str(), operation.as_str()); + let domain = projection.domain(); + let loader_surface = projection.loader_surface(); + let projection_details = + projection_details(projection, &accounts, ¶meters, operation.as_str()); + tracing::debug!( + target: crate::MATERIALIZER_LIFECYCLE_TRACING_TARGET, + action = "materialize_lifecycle", + surface_code = %observation.event.surface_code.0.as_str(), + entry_code = %observation.event.event_name.0.as_str(), + projection_domain = domain, + accepted = true, + committed = true, + output_count = 1_usize, + "materialize committed native lifecycle observation" + ); + let output = crate::MaterializedOutput { + output_key, + family: crate::MaterializedEventFamily::Lifecycle, + payload_json: serde_json::json!({ + "projectionVersion": 1, + "projectionSemantics": "committed_instruction_lifecycle_event", + "domain": domain, + "loaderSurface": loader_surface, + "operation": operation, + "programId": observation.event.program_id.0.clone(), + "signature": observation.event.signature.0.clone(), + "slot": observation.event.slot.0, + "instructionPath": observation.event.instruction_path.0.clone(), + "transactionSucceeded": true, + "accounts": accounts, + "parameters": parameters, + "projection": projection_details, + }), + }; + return crate::MaterializerExecutionResult { + status: crate::MaterializerOutcomeStatus::Inserted, + outputs: std::vec![output], + diagnostics: std::vec::Vec::new(), + }; + } +} + +fn family_is_accepted(family: crate::EventFamily) -> bool { + return ACCEPTED_FAMILIES.iter().any(|accepted| return *accepted == family); +} + +fn observation_projection( + observation: &crate::DecodedObservation, +) -> std::option::Option { + let surface_code = observation.event.surface_code.0.as_str(); + let entry_code = observation.event.event_name.0.as_str(); + let static_projection = static_projection(surface_code, entry_code); + if static_projection.is_some() { + return static_projection; + } + if surface_code == ZK_ELGAMAL_PROOF_SURFACE + && contains(ZK_ELGAMAL_VERIFY_ENTRIES, entry_code) + && observation + .payload_json + .get("parameters") + .and_then(|parameters| return parameters.get("contextStateRequested")) + .and_then(serde_json::Value::as_bool) + == std::option::Option::Some(true) + { + return std::option::Option::Some(ProjectionKind::ZkProofContextInitialization); + } + return std::option::Option::None; +} + +fn static_projection(surface_code: &str, entry_code: &str) -> std::option::Option { + if surface_code == FEATURE_SURFACE && contains(FEATURE_ENTRIES, entry_code) { + return std::option::Option::Some(ProjectionKind::FeatureGate); + } + if surface_code == ADDRESS_LOOKUP_TABLE_SURFACE + && contains(ADDRESS_LOOKUP_TABLE_ENTRIES, entry_code) + { + return std::option::Option::Some(ProjectionKind::AddressLookupTable); + } + if (surface_code == BPF_LOADER_DEPRECATED_SURFACE || surface_code == BPF_LOADER_SURFACE) + && contains(IMMUTABLE_LOADER_ENTRIES, entry_code) + { + let loader_surface = if surface_code == BPF_LOADER_DEPRECATED_SURFACE { + BPF_LOADER_DEPRECATED_SURFACE + } else { + BPF_LOADER_SURFACE + }; + return std::option::Option::Some(ProjectionKind::ProgramLoader(loader_surface)); + } + if surface_code == BPF_LOADER_UPGRADEABLE_SURFACE + && contains(UPGRADEABLE_LOADER_ENTRIES, entry_code) + { + return std::option::Option::Some(ProjectionKind::ProgramLoader( + BPF_LOADER_UPGRADEABLE_SURFACE, + )); + } + if surface_code == LOADER_V4_SURFACE && contains(LOADER_V4_ENTRIES, entry_code) { + return std::option::Option::Some(ProjectionKind::ProgramLoader(LOADER_V4_SURFACE)); + } + if surface_code == SLASHING_SURFACE && contains(SLASHING_ENTRIES, entry_code) { + return if entry_code == "duplicate_block_proof" { + std::option::Option::Some(ProjectionKind::SlashingViolationReportInitialization) + } else { + std::option::Option::Some(ProjectionKind::SlashingViolationReportClosure) + }; + } + if surface_code == SYSTEM_SURFACE && contains(SYSTEM_ACCOUNT_ENTRIES, entry_code) { + return std::option::Option::Some(ProjectionKind::SystemAccountLifecycle); + } + if surface_code == SYSTEM_SURFACE && contains(SYSTEM_NONCE_ENTRIES, entry_code) { + return std::option::Option::Some(ProjectionKind::DurableNonceAccount); + } + if surface_code == ZK_ELGAMAL_PROOF_SURFACE && contains(ZK_ELGAMAL_CLOSE_ENTRIES, entry_code) { + return std::option::Option::Some(ProjectionKind::ZkProofContextClosure); + } + return std::option::Option::None; +} + +fn projection_details( + projection: ProjectionKind, + accounts: &serde_json::Value, + parameters: &serde_json::Value, + operation: &str, +) -> serde_json::Value { + return match projection { + ProjectionKind::SlashingViolationReportInitialization => { + serde_json::json!({ + "targetAccount": account_key_for_role(accounts, "violation_report"), + "proofAccount": account_key_for_role(accounts, "proof_account"), + "violationSlot": parameters.get("violationSlot").cloned().unwrap_or(serde_json::Value::Null), + "nodePubkey": parameters.get("nodePubkey").cloned().unwrap_or(serde_json::Value::Null), + "reporter": parameters.get("reporter").cloned().unwrap_or(serde_json::Value::Null), + "lamportDestination": parameters.get("destination").cloned().unwrap_or(serde_json::Value::Null), + "stateTransition": "pda_assigned_allocated_and_data_stored_after_successful_duplicate_block_proof_acceptance", + "proofMaterialized": false, + "penaltyAppliedByProgram": false, + "enforcementSemantics": "violation_report_only_external_consensus_enforcement", + }) + }, + ProjectionKind::SlashingViolationReportClosure => serde_json::json!({ + "targetAccount": account_key_for_role(accounts, "violation_report"), + "lamportDestination": account_key_for_role(accounts, "destination"), + "stateTransition": "closed_after_minimum_retention_lamports_reclaimed_owner_reset_to_system_program", + "minimumRetentionEpochs": 3, + "transactionFinalAccountStateCaptured": false, + }), + ProjectionKind::SystemAccountLifecycle => serde_json::json!({ + "targetAccount": first_account_key_for_roles( + accounts, + &["new_account", "allocated_account", "derived_account"], + ), + "fundingAccount": account_key_for_role(accounts, "funding_account"), + "baseAccount": account_key_for_role(accounts, "base_account"), + "requestedLamports": parameters.get("lamports").cloned().unwrap_or(serde_json::Value::Null), + "requestedSpace": parameters.get("space").cloned().unwrap_or(serde_json::Value::Null), + "requestedOwner": parameters.get("owner").cloned().unwrap_or(serde_json::Value::Null), + "seed": parameters.get("seed").cloned().unwrap_or(serde_json::Value::Null), + "stateTransition": system_account_transition(operation), + "lamportTransferRepresentedByCoreBalanceChanges": operation == "create_account" + || operation == "create_account_with_seed" + || operation == "create_account_allow_prefund", + "transactionFinalAccountStateCaptured": false, + }), + ProjectionKind::DurableNonceAccount => serde_json::json!({ + "targetAccount": account_key_for_role(accounts, "nonce_account"), + "authorityAccount": account_key_for_role(accounts, "nonce_authority"), + "lamportDestination": account_key_for_role(accounts, "recipient_account"), + "stateTransition": nonce_transition(operation), + "newAuthority": parameters.get("authority").cloned().unwrap_or(serde_json::Value::Null), + "withdrawLamports": parameters.get("lamports").cloned().unwrap_or(serde_json::Value::Null), + "closureSemantics": if operation == "withdraw_nonce_account" { + serde_json::Value::String("closes_when_entire_balance_is_withdrawn".to_string()) + } else { + serde_json::Value::Null + }, + "transactionFinalAccountStateCaptured": false, + }), + ProjectionKind::ZkProofContextInitialization => serde_json::json!({ + "targetAccount": account_key_for_role(accounts, "proof_context_state"), + "contextStateAuthority": account_key_for_role(accounts, "context_state_authority"), + "proofType": parameters.get("proofType").cloned().unwrap_or(serde_json::Value::Null), + "stateTransition": "initialized_after_successful_runtime_verification", + "proofMaterialized": false, + }), + ProjectionKind::ZkProofContextClosure => serde_json::json!({ + "targetAccount": account_key_for_role(accounts, "proof_context_state"), + "lamportDestination": account_key_for_role(accounts, "lamport_destination"), + "contextStateAuthority": account_key_for_role(accounts, "context_state_authority"), + "stateTransition": "closed_lamports_reclaimed_owner_reset_to_system_program", + "proofMaterialized": false, + }), + ProjectionKind::AddressLookupTable + | ProjectionKind::FeatureGate + | ProjectionKind::ProgramLoader(_) => serde_json::json!({ + "stateTransition": operation, + }), + }; +} + +fn system_account_transition(operation: &str) -> &'static str { + return match operation { + "create_account" => "created_allocated_funded_and_assigned", + "create_account_with_seed" => "derived_account_created_allocated_funded_and_assigned", + "create_account_allow_prefund" => "prefunded_account_initialized_allocated_and_assigned", + "allocate" => "account_data_space_allocated", + "allocate_with_seed" => "derived_account_data_space_allocated", + _ => "unknown", + }; +} + +fn nonce_transition(operation: &str) -> &'static str { + return match operation { + "initialize_nonce_account" => "initialized", + "advance_nonce_account" => "nonce_advanced", + "authorize_nonce_account" => "authority_changed", + "upgrade_nonce_account" => "legacy_version_upgraded", + "withdraw_nonce_account" => "lamports_withdrawn_possible_closure", + _ => "unknown", + }; +} + +fn first_account_key_for_roles(accounts: &serde_json::Value, roles: &[&str]) -> serde_json::Value { + for role in roles { + let value = account_key_for_role(accounts, role); + if !value.is_null() { + return value; + } + } + return serde_json::Value::Null; +} + +fn account_key_for_role(accounts: &serde_json::Value, role: &str) -> serde_json::Value { + let account_key = accounts.as_array().and_then(|values| { + return values.iter().find_map(|value| { + if value.get("role").and_then(serde_json::Value::as_str) + != std::option::Option::Some(role) + { + return std::option::Option::None; + } + return value + .get("accountKey") + .and_then(serde_json::Value::as_str) + .map(|account_key| return account_key.to_string()); + }); + }); + return match account_key { + std::option::Option::Some(value) => serde_json::Value::String(value), + std::option::Option::None => serde_json::Value::Null, + }; +} + +fn contains(entries: &[&str], entry_code: &str) -> bool { + return entries.iter().any(|entry| return *entry == entry_code); +} + +#[cfg(test)] +mod tests { + fn observation_with_payload( + surface_code: &str, + entry_code: &str, + event_family: crate::EventFamily, + transaction_failed: bool, + accounts: serde_json::Value, + parameters: serde_json::Value, + ) -> crate::DecodedObservation { + return crate::DecodedObservation { + event_key: format!("{entry_code}:0"), + event: crate::DecodedProtocolEvent { + signature: crate::Signature("signature".to_string()), + slot: crate::Slot(42), + instruction_path: crate::InstructionPath("0".to_string()), + program_id: crate::ProgramId("program-id".to_string()), + protocol_code: crate::ProtocolCode("solana_native".to_string()), + surface_code: crate::SurfaceCode(surface_code.to_string()), + event_code: crate::EventCode(format!("{surface_code}.{entry_code}")), + event_name: crate::EventName(entry_code.to_string()), + event_family, + source_kind: crate::EventSourceKind::Instruction, + confidence: crate::DecoderConfidence::ManualExact, + }, + payload_json: serde_json::json!({ + "accounts": accounts, + "parameters": parameters, + }), + transaction_failed, + transaction_error: if transaction_failed { + std::option::Option::Some(serde_json::json!({"InstructionError": [0, "Custom"]})) + } else { + std::option::Option::None + }, + observation_committed: !transaction_failed, + proof: crate::DecoderProof { + kind: crate::DecoderProofKind::Manual, + confidence: crate::DecoderConfidence::ManualExact, + evidence: std::vec!["fixture".to_string()], + }, + }; + } + + fn observation( + surface_code: &str, + entry_code: &str, + transaction_failed: bool, + ) -> crate::DecodedObservation { + return observation_with_payload( + surface_code, + entry_code, + crate::EventFamily::Lifecycle, + transaction_failed, + serde_json::json!([{"role": "target", "accountKey": "account"}]), + serde_json::json!({"value": 41}), + ); + } + + fn nonce_observation( + entry_code: &str, + family: crate::EventFamily, + transaction_failed: bool, + ) -> crate::DecodedObservation { + return observation_with_payload( + super::SYSTEM_SURFACE, + entry_code, + family, + transaction_failed, + serde_json::json!([ + {"role": "nonce_account", "accountKey": "nonce111"}, + {"role": "recipient_account", "accountKey": "recipient111"}, + {"role": "nonce_authority", "accountKey": "authority111"} + ]), + serde_json::json!({ + "authority": "new-authority111", + "lamports": 55, + }), + ); + } + + fn slashing_observation( + entry_code: &str, + transaction_failed: bool, + ) -> crate::DecodedObservation { + let (family, accounts) = if entry_code == "duplicate_block_proof" { + ( + crate::EventFamily::Audit, + serde_json::json!([ + {"role": "proof_account", "accountKey": "proof111"}, + {"role": "violation_report", "accountKey": "report111"}, + {"role": "instructions_sysvar", "accountKey": kb_program_ids::SYSVAR_INSTRUCTIONS_PROGRAM_ID}, + {"role": "system_program", "accountKey": kb_program_ids::SYSTEM_PROGRAM_ID} + ]), + ) + } else { + ( + crate::EventFamily::Lifecycle, + serde_json::json!([ + {"role": "violation_report", "accountKey": "report111"}, + {"role": "destination", "accountKey": "destination111"} + ]), + ) + }; + return observation_with_payload( + super::SLASHING_SURFACE, + entry_code, + family, + transaction_failed, + accounts, + serde_json::json!({ + "proofType": "duplicate_block", + "violationSlot": 42, + "nodePubkey": "node111", + "reporter": "reporter111", + "destination": "destination111", + "penaltyAppliedByProgram": false, + }), + ); + } + + fn zk_observation( + entry_code: &str, + context_state_requested: bool, + transaction_failed: bool, + ) -> crate::DecodedObservation { + let accounts = if entry_code == "close_context_state" { + serde_json::json!([ + {"role": "proof_context_state", "accountKey": "context111"}, + {"role": "lamport_destination", "accountKey": "destination111"}, + {"role": "context_state_authority", "accountKey": "authority111"} + ]) + } else if context_state_requested { + serde_json::json!([ + {"role": "proof_context_state", "accountKey": "context111"}, + {"role": "context_state_authority", "accountKey": "authority111"} + ]) + } else { + serde_json::json!([]) + }; + let family = if entry_code == "close_context_state" { + crate::EventFamily::Lifecycle + } else { + crate::EventFamily::Audit + }; + return observation_with_payload( + super::ZK_ELGAMAL_PROOF_SURFACE, + entry_code, + family, + transaction_failed, + accounts, + serde_json::json!({ + "proofType": entry_code, + "contextStateRequested": context_state_requested, + }), + ); + } + + #[test] + fn successful_alt_lifecycle_creates_one_stable_output() { + let materializer = crate::LifecycleMaterializer; + let result = crate::EventMaterializer::materialize( + &materializer, + &observation(super::ADDRESS_LOOKUP_TABLE_SURFACE, "create_lookup_table", false), + ); + assert_eq!(result.status, crate::MaterializerOutcomeStatus::Inserted); + assert_eq!(result.outputs.len(), 1); + assert_eq!(result.outputs[0].output_key, "address_lookup_table:create_lookup_table:0"); + assert_eq!(result.outputs[0].payload_json["domain"], "address_lookup_table"); + assert_eq!(result.outputs[0].payload_json["loaderSurface"], serde_json::Value::Null); + } + + #[test] + fn successful_loader_lifecycle_creates_program_projection() { + let materializer = crate::LifecycleMaterializer; + let result = crate::EventMaterializer::materialize( + &materializer, + &observation(super::BPF_LOADER_UPGRADEABLE_SURFACE, "deploy_with_max_data_len", false), + ); + assert_eq!(result.status, crate::MaterializerOutcomeStatus::Inserted); + assert_eq!( + result.outputs[0].output_key, + "program_loader:solana_native_bpf_loader_upgradeable:deploy_with_max_data_len:0" + ); + assert_eq!(result.outputs[0].payload_json["domain"], "program_loader"); + assert_eq!( + result.outputs[0].payload_json["loaderSurface"], + super::BPF_LOADER_UPGRADEABLE_SURFACE + ); + } + + #[test] + fn successful_feature_revoke_creates_feature_gate_projection() { + let materializer = crate::LifecycleMaterializer; + let result = crate::EventMaterializer::materialize( + &materializer, + &observation(super::FEATURE_SURFACE, "revoke_pending_activation", false), + ); + assert_eq!(result.status, crate::MaterializerOutcomeStatus::Inserted); + assert_eq!(result.outputs[0].output_key, "feature_gate:revoke_pending_activation:0"); + assert_eq!(result.outputs[0].payload_json["domain"], "feature_gate"); + } + + #[test] + fn every_committed_system_account_operation_creates_a_lifecycle_projection() { + let materializer = crate::LifecycleMaterializer; + for (operation, role, transition) in [ + ("create_account", "new_account", "created_allocated_funded_and_assigned"), + ( + "create_account_with_seed", + "new_account", + "derived_account_created_allocated_funded_and_assigned", + ), + ("allocate", "allocated_account", "account_data_space_allocated"), + ("allocate_with_seed", "derived_account", "derived_account_data_space_allocated"), + ( + "create_account_allow_prefund", + "new_account", + "prefunded_account_initialized_allocated_and_assigned", + ), + ] { + let observation = observation_with_payload( + super::SYSTEM_SURFACE, + operation, + crate::EventFamily::Lifecycle, + false, + serde_json::json!([ + {"role": role, "accountKey": "target111"}, + {"role": "funding_account", "accountKey": "funding111"}, + {"role": "base_account", "accountKey": "base111"}, + ]), + serde_json::json!({ + "lamports": 55, + "space": 64, + "owner": "owner111", + "seed": "seed", + }), + ); + let result = crate::EventMaterializer::materialize(&materializer, &observation); + assert_eq!(result.status, crate::MaterializerOutcomeStatus::Inserted); + assert_eq!(result.outputs[0].output_key, format!("system_account:{operation}:0")); + assert_eq!(result.outputs[0].payload_json["projection"]["targetAccount"], "target111"); + assert_eq!(result.outputs[0].payload_json["projection"]["stateTransition"], transition); + } + } + + #[test] + fn system_account_creation_does_not_duplicate_balance_changes() { + let materializer = crate::LifecycleMaterializer; + let observation = observation_with_payload( + super::SYSTEM_SURFACE, + "create_account", + crate::EventFamily::Lifecycle, + false, + serde_json::json!([ + {"role": "funding_account", "accountKey": "funding111"}, + {"role": "new_account", "accountKey": "target111"}, + ]), + serde_json::json!({"lamports": 55, "space": 64, "owner": "owner111"}), + ); + let result = crate::EventMaterializer::materialize(&materializer, &observation); + assert_eq!(result.outputs[0].payload_json["projection"]["requestedLamports"], 55); + assert_eq!( + result.outputs[0].payload_json["projection"]["lamportTransferRepresentedByCoreBalanceChanges"], + true + ); + assert!(result.outputs[0].payload_json["projection"].get("balanceChanges").is_none()); + } + + #[test] + fn every_committed_system_nonce_operation_creates_a_lifecycle_projection() { + let materializer = crate::LifecycleMaterializer; + for (entry_code, family, transition) in [ + ("initialize_nonce_account", crate::EventFamily::Lifecycle, "initialized"), + ("advance_nonce_account", crate::EventFamily::Lifecycle, "nonce_advanced"), + ("authorize_nonce_account", crate::EventFamily::Admin, "authority_changed"), + ( + "upgrade_nonce_account", + crate::EventFamily::Lifecycle, + "legacy_version_upgraded", + ), + ( + "withdraw_nonce_account", + crate::EventFamily::Lifecycle, + "lamports_withdrawn_possible_closure", + ), + ] { + let observation = nonce_observation(entry_code, family, false); + assert!(crate::EventMaterializer::accepts_observation(&materializer, &observation,)); + let result = crate::EventMaterializer::materialize(&materializer, &observation); + assert_eq!(result.status, crate::MaterializerOutcomeStatus::Inserted); + assert_eq!(result.outputs[0].payload_json["domain"], "durable_nonce_account"); + assert_eq!(result.outputs[0].payload_json["projection"]["targetAccount"], "nonce111"); + assert_eq!( + result.outputs[0].payload_json["projection"]["authorityAccount"], + "authority111" + ); + assert_eq!(result.outputs[0].payload_json["projection"]["stateTransition"], transition); + } + } + + #[test] + fn nonce_withdrawal_preserves_conditional_closure_semantics() { + let materializer = crate::LifecycleMaterializer; + let observation = + nonce_observation("withdraw_nonce_account", crate::EventFamily::Lifecycle, false); + let result = crate::EventMaterializer::materialize(&materializer, &observation); + assert_eq!(result.outputs[0].payload_json["projection"]["withdrawLamports"], 55); + assert_eq!( + result.outputs[0].payload_json["projection"]["lamportDestination"], + "recipient111" + ); + assert_eq!( + result.outputs[0].payload_json["projection"]["closureSemantics"], + "closes_when_entire_balance_is_withdrawn" + ); + assert_eq!( + result.outputs[0].payload_json["projection"]["transactionFinalAccountStateCaptured"] + .as_bool(), + std::option::Option::Some(false) + ); + } + + #[test] + fn zk_context_is_materialized_only_when_requested() { + let materializer = crate::LifecycleMaterializer; + let without_context = zk_observation("verify_zero_ciphertext", false, false); + assert!(!crate::EventMaterializer::accepts_observation(&materializer, &without_context,)); + let with_context = zk_observation("verify_zero_ciphertext", true, false); + assert!(crate::EventMaterializer::accepts_observation(&materializer, &with_context,)); + let result = crate::EventMaterializer::materialize(&materializer, &with_context); + assert_eq!(result.status, crate::MaterializerOutcomeStatus::Inserted); + assert_eq!(result.outputs[0].payload_json["domain"], "zk_proof_context"); + assert_eq!(result.outputs[0].payload_json["projection"]["targetAccount"], "context111"); + assert_eq!( + result.outputs[0].payload_json["projection"]["stateTransition"], + "initialized_after_successful_runtime_verification" + ); + assert_eq!( + result.outputs[0].payload_json["projection"]["proofMaterialized"].as_bool(), + std::option::Option::Some(false) + ); + } + + #[test] + fn zk_context_close_creates_a_stable_lifecycle_projection() { + let materializer = crate::LifecycleMaterializer; + let observation = zk_observation("close_context_state", false, false); + let result = crate::EventMaterializer::materialize(&materializer, &observation); + assert_eq!(result.status, crate::MaterializerOutcomeStatus::Inserted); + assert_eq!(result.outputs[0].output_key, "zk_proof_context:close_context_state:0"); + assert_eq!( + result.outputs[0].payload_json["projection"]["lamportDestination"], + "destination111" + ); + assert_eq!( + result.outputs[0].payload_json["projection"]["stateTransition"], + "closed_lamports_reclaimed_owner_reset_to_system_program" + ); + } + + #[test] + fn successful_slashing_proof_initializes_violation_report_projection() { + let materializer = crate::LifecycleMaterializer; + let observation = slashing_observation("duplicate_block_proof", false); + assert!(crate::EventMaterializer::accepts_observation(&materializer, &observation,)); + let result = crate::EventMaterializer::materialize(&materializer, &observation); + assert_eq!(result.status, crate::MaterializerOutcomeStatus::Inserted); + assert_eq!( + result.outputs[0].output_key, + "slashing_violation_report:duplicate_block_proof:0" + ); + assert_eq!(result.outputs[0].payload_json["domain"], "slashing_violation_report"); + assert_eq!(result.outputs[0].payload_json["projection"]["targetAccount"], "report111"); + assert_eq!(result.outputs[0].payload_json["projection"]["proofMaterialized"], false); + assert_eq!(result.outputs[0].payload_json["projection"]["penaltyAppliedByProgram"], false); + } + + #[test] + fn successful_slashing_close_creates_stable_closure_projection() { + let materializer = crate::LifecycleMaterializer; + let observation = slashing_observation("close_violation_report", false); + let result = crate::EventMaterializer::materialize(&materializer, &observation); + assert_eq!(result.status, crate::MaterializerOutcomeStatus::Inserted); + assert_eq!( + result.outputs[0].payload_json["projection"]["stateTransition"], + "closed_after_minimum_retention_lamports_reclaimed_owner_reset_to_system_program" + ); + assert_eq!(result.outputs[0].payload_json["projection"]["minimumRetentionEpochs"], 3); + assert_eq!( + result.outputs[0].payload_json["projection"]["lamportDestination"], + "destination111" + ); + } + + #[test] + fn historical_zk_token_proof_is_not_materialized() { + let materializer = crate::LifecycleMaterializer; + let observation = observation_with_payload( + "solana_native_zk_token_proof", + "close_context_state", + crate::EventFamily::Audit, + false, + serde_json::json!([]), + serde_json::json!({}), + ); + assert!(!crate::EventMaterializer::accepts_observation(&materializer, &observation,)); + let result = crate::EventMaterializer::materialize(&materializer, &observation); + assert_eq!(result.status, crate::MaterializerOutcomeStatus::Ignored); + } + + #[test] + fn loader_write_and_admin_events_are_not_lifecycle_projections() { + let materializer = crate::LifecycleMaterializer; + for entry_code in ["write", "set_authority", "transfer_authority"] { + let observation = observation(super::BPF_LOADER_UPGRADEABLE_SURFACE, entry_code, false); + assert!(!crate::EventMaterializer::accepts_observation(&materializer, &observation,)); + } + } + + #[test] + fn unrelated_lifecycle_is_ignored() { + let materializer = crate::LifecycleMaterializer; + let result = crate::EventMaterializer::materialize( + &materializer, + &observation(super::SYSTEM_SURFACE, "transfer", false), + ); + assert_eq!(result.status, crate::MaterializerOutcomeStatus::Ignored); + } + + #[test] + fn ata_lifecycle_is_owned_by_token_accounts_materializer_only() { + let mut observation = observation("spl_associated_token_account", "create", false); + observation.event.program_id = + crate::ProgramId(kb_program_ids::ASSOCIATED_TOKEN_PROGRAM_ID.to_string()); + assert!(!crate::EventMaterializer::accepts_observation( + &crate::LifecycleMaterializer, + &observation, + )); + let result = + crate::EventMaterializer::materialize(&crate::LifecycleMaterializer, &observation); + assert_eq!(result.status, crate::MaterializerOutcomeStatus::Ignored); + assert!(result.outputs.is_empty()); + } + + #[test] + fn failed_native_lifecycle_is_refused() { + let materializer = crate::LifecycleMaterializer; + let observations = [ + observation(super::ADDRESS_LOOKUP_TABLE_SURFACE, "close_lookup_table", true), + observation(super::LOADER_V4_SURFACE, "deploy", true), + nonce_observation("authorize_nonce_account", crate::EventFamily::Admin, true), + zk_observation("verify_zero_ciphertext", true, true), + slashing_observation("duplicate_block_proof", true), + ]; + for observation in observations { + let policy = crate::validate_materialization_policy(&materializer, &observation); + assert!(policy.is_err()); + let direct = crate::EventMaterializer::materialize(&materializer, &observation); + assert_eq!(direct.status, crate::MaterializerOutcomeStatus::Refused); + } + } + + #[test] + fn output_serialization_is_deterministic() { + let materializer = crate::LifecycleMaterializer; + let observation = zk_observation("verify_batched_range_proof_u64", true, false); + let first = crate::EventMaterializer::materialize(&materializer, &observation); + let second = crate::EventMaterializer::materialize(&materializer, &observation); + let first_json = match serde_json::to_string(&first) { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => { + panic!("first lifecycle serialization failed: {error}") + }, + }; + let second_json = match serde_json::to_string(&second) { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => { + panic!("second lifecycle serialization failed: {error}") + }, + }; + assert_eq!(first_json, second_json); + } +} diff --git a/kb-lib/src/materializer/staking.rs b/kb-lib/src/materializer/staking.rs index 67e9381..05bf404 100644 --- a/kb-lib/src/materializer/staking.rs +++ b/kb-lib/src/materializer/staking.rs @@ -1,6 +1,12 @@ // file: kb-lib/src/materializer/staking.rs -// version: 1 +// version: 2 //! `staking` materializer family. +mod constants; pub mod core; + +pub(crate) use crate::materializer::staking::constants::TRACING_TARGET as MATERIALIZER_STAKING_TRACING_TARGET; + +/// Stable native stake and vote materializer. +pub use crate::materializer::staking::core::StakingMaterializer; diff --git a/kb-lib/src/materializer/staking/constants.rs b/kb-lib/src/materializer/staking/constants.rs new file mode 100644 index 0000000..a75272e --- /dev/null +++ b/kb-lib/src/materializer/staking/constants.rs @@ -0,0 +1,7 @@ +// file: kb-lib/src/materializer/staking/constants.rs +// version: 1 + +//! Local constants for the native stake and vote materializer. + +/// Canonical tracing target for this component. +pub(crate) const TRACING_TARGET: &str = "kb-lib.materializer.staking"; diff --git a/kb-lib/src/materializer/staking/core.rs b/kb-lib/src/materializer/staking/core.rs index 880d855..e7438f8 100644 --- a/kb-lib/src/materializer/staking/core.rs +++ b/kb-lib/src/materializer/staking/core.rs @@ -1,10 +1,867 @@ // file: kb-lib/src/materializer/staking/core.rs -// version: 1 +// version: 7 -//! Migration boundary for legacy crate `kb_materializer_staking`. +//! Instruction-level staking projections for native Stake and Vote observations. -/// Legacy crate name retained for migration and compatibility tracking. -pub const LEGACY_CRATE: &str = "kb_materializer_staking"; +const ACCEPTED_FAMILIES: &[crate::EventFamily] = &[ + crate::EventFamily::Lifecycle, + crate::EventFamily::Admin, + crate::EventFamily::Staking, + crate::EventFamily::Reward, +]; +const STAKE_SURFACE: &str = "solana_native_stake"; +const VOTE_SURFACE: &str = "solana_native_vote"; +const STAKE_ACCOUNT_ENTRIES: &[&str] = &[ + "initialize", + "initialize_checked", + "delegate_stake", + "deactivate", + "deactivate_delinquent", +]; +const STAKE_VALUE_ENTRIES: &[&str] = &["split", "withdraw", "merge", "move_stake", "move_lamports"]; +const STAKE_AUTHORITY_ENTRIES: &[&str] = &[ + "authorize", + "authorize_with_seed", + "authorize_checked", + "authorize_checked_with_seed", +]; +const STAKE_LOCKUP_ENTRIES: &[&str] = &["set_lockup", "set_lockup_checked"]; +const VOTE_ACCOUNT_ENTRIES: &[&str] = &["initialize_account", "initialize_account_v2"]; +const VOTE_AUTHORITY_ENTRIES: &[&str] = &[ + "authorize", + "authorize_checked", + "authorize_with_seed", + "authorize_checked_with_seed", +]; +const VOTE_ADMIN_ENTRIES: &[&str] = &[ + "update_validator_identity", + "update_commission", + "update_commission_collector", + "update_commission_bps", +]; +const VOTE_STATE_ENTRIES: &[&str] = &[ + "vote", + "vote_switch", + "update_vote_state", + "update_vote_state_switch", + "compact_update_vote_state", + "compact_update_vote_state_switch", + "tower_sync", + "tower_sync_switch", +]; +const VOTE_VALUE_ENTRIES: &[&str] = &["withdraw"]; +const VOTE_REWARD_ENTRIES: &[&str] = &["deposit_delegator_rewards"]; -/// Current porting status. -pub const MIGRATION_STATUS: &str = "source-preserved-pending-port"; +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum ProjectionKind { + StakeAccount, + StakeValue, + StakeAuthority, + StakeLockup, + VoteAccount, + VoteAuthority, + VoteAdmin, + VoteState, + VoteValue, + VoteReward, +} + +impl ProjectionKind { + fn domain(self) -> &'static str { + return match self { + Self::StakeAccount => "stake_account", + Self::StakeValue => "stake_value", + Self::StakeAuthority => "stake_authority", + Self::StakeLockup => "stake_lockup", + Self::VoteAccount => "vote_account", + Self::VoteAuthority => "vote_authority", + Self::VoteAdmin => "vote_admin", + Self::VoteState => "vote_state", + Self::VoteValue => "vote_value", + Self::VoteReward => "vote_reward", + }; + } + + fn family(self) -> crate::MaterializedEventFamily { + return match self { + Self::VoteReward => crate::MaterializedEventFamily::Reward, + _ => crate::MaterializedEventFamily::Staking, + }; + } + + fn output_key(self, operation: &str) -> std::string::String { + return format!("{}:{operation}:0", self.domain()); + } +} + +/// Stable staking materializer for committed native Stake and Vote observations. +#[derive(Clone, Debug, Default)] +pub struct StakingMaterializer; + +impl crate::Materializer for crate::StakingMaterializer { + fn materializer_name(&self) -> &'static str { + return "kb_materializer_staking"; + } + + fn materializer_version(&self) -> &'static str { + return env!("CARGO_PKG_VERSION"); + } + + fn accepts_event(&self, event: &crate::DecodedProtocolEvent) -> bool { + return family_is_accepted(event.event_family) + && projection(event.surface_code.0.as_str(), event.event_name.0.as_str()).is_some(); + } + + fn materialize_event( + &self, + event: &crate::DecodedProtocolEvent, + ) -> kb_core::Result> { + if !crate::Materializer::accepts_event(self, event) { + return std::result::Result::Ok(std::vec::Vec::new()); + } + return std::result::Result::Ok(std::vec::Vec::new()); + } +} + +impl crate::EventMaterializer for crate::StakingMaterializer { + fn identity(&self) -> crate::MaterializerIdentity { + return crate::MaterializerIdentity { + name: "solana_native_staking".to_string(), + version: env!("CARGO_PKG_VERSION").to_string(), + }; + } + + fn accepted_families(&self) -> &'static [crate::EventFamily] { + return ACCEPTED_FAMILIES; + } + + fn accepts_observation(&self, observation: &crate::DecodedObservation) -> bool { + return family_is_accepted(observation.event.event_family) + && projection( + observation.event.surface_code.0.as_str(), + observation.event.event_name.0.as_str(), + ) + .is_some(); + } + + fn transaction_policy( + &self, + _family: crate::EventFamily, + ) -> crate::MaterializationTransactionPolicy { + return crate::MaterializationTransactionPolicy::SuccessfulCommittedOnly; + } + + fn materialize( + &self, + observation: &crate::DecodedObservation, + ) -> crate::MaterializerExecutionResult { + let projection = match projection( + observation.event.surface_code.0.as_str(), + observation.event.event_name.0.as_str(), + ) { + std::option::Option::Some(value) => value, + std::option::Option::None => { + tracing::debug!( + target: crate::MATERIALIZER_STAKING_TRACING_TARGET, + action = "materialize_staking", + surface_code = %observation.event.surface_code.0.as_str(), + entry_code = %observation.event.event_name.0.as_str(), + accepted = false, + "ignore observation outside supported native staking projections" + ); + return crate::MaterializerExecutionResult::ignored(); + }, + }; + if !family_is_accepted(observation.event.event_family) { + return crate::MaterializerExecutionResult::ignored(); + } + if observation.transaction_failed || !observation.observation_committed { + tracing::debug!( + target: crate::MATERIALIZER_STAKING_TRACING_TARGET, + action = "materialize_staking", + surface_code = %observation.event.surface_code.0.as_str(), + entry_code = %observation.event.event_name.0.as_str(), + committed = observation.observation_committed, + transaction_failed = observation.transaction_failed, + "refuse uncommitted native staking observation" + ); + return crate::MaterializerExecutionResult::refused( + "failed_transaction_staking_refused", + "failed or uncommitted native Stake/Vote observations cannot create staking outputs", + ); + } + let accounts = observation + .payload_json + .get("accounts") + .cloned() + .unwrap_or(serde_json::Value::Null); + let parameters = observation + .payload_json + .get("parameters") + .cloned() + .unwrap_or(serde_json::Value::Null); + let operation = observation.event.event_name.0.clone(); + let output = crate::MaterializedOutput { + output_key: projection.output_key(operation.as_str()), + family: projection.family(), + payload_json: serde_json::json!({ + "projectionVersion": 1, + "projectionSemantics": "committed_instruction_staking_event", + "domain": projection.domain(), + "operation": operation, + "programId": observation.event.program_id.0.clone(), + "signature": observation.event.signature.0.clone(), + "slot": observation.event.slot.0, + "instructionPath": observation.event.instruction_path.0.clone(), + "transactionSucceeded": true, + "accounts": accounts, + "parameters": parameters, + "projection": projection_details( + projection, + &accounts, + ¶meters, + observation.event.event_name.0.as_str(), + ), + }), + }; + tracing::debug!( + target: crate::MATERIALIZER_STAKING_TRACING_TARGET, + action = "materialize_staking", + surface_code = %observation.event.surface_code.0.as_str(), + entry_code = %observation.event.event_name.0.as_str(), + projection_domain = projection.domain(), + output_count = 1_usize, + "materialize committed native staking observation" + ); + return crate::MaterializerExecutionResult { + status: crate::MaterializerOutcomeStatus::Inserted, + outputs: std::vec![output], + diagnostics: std::vec::Vec::new(), + }; + } +} + +fn family_is_accepted(family: crate::EventFamily) -> bool { + return ACCEPTED_FAMILIES.iter().any(|accepted| return *accepted == family); +} + +fn projection(surface_code: &str, entry_code: &str) -> std::option::Option { + if surface_code == STAKE_SURFACE { + return stake_projection(entry_code); + } + if surface_code == VOTE_SURFACE { + return vote_projection(entry_code); + } + return std::option::Option::None; +} + +fn stake_projection(entry_code: &str) -> std::option::Option { + if contains(STAKE_ACCOUNT_ENTRIES, entry_code) { + return std::option::Option::Some(ProjectionKind::StakeAccount); + } + if contains(STAKE_VALUE_ENTRIES, entry_code) { + return std::option::Option::Some(ProjectionKind::StakeValue); + } + if contains(STAKE_AUTHORITY_ENTRIES, entry_code) { + return std::option::Option::Some(ProjectionKind::StakeAuthority); + } + if contains(STAKE_LOCKUP_ENTRIES, entry_code) { + return std::option::Option::Some(ProjectionKind::StakeLockup); + } + return std::option::Option::None; +} + +fn vote_projection(entry_code: &str) -> std::option::Option { + if contains(VOTE_ACCOUNT_ENTRIES, entry_code) { + return std::option::Option::Some(ProjectionKind::VoteAccount); + } + if contains(VOTE_AUTHORITY_ENTRIES, entry_code) { + return std::option::Option::Some(ProjectionKind::VoteAuthority); + } + if contains(VOTE_ADMIN_ENTRIES, entry_code) { + return std::option::Option::Some(ProjectionKind::VoteAdmin); + } + if contains(VOTE_STATE_ENTRIES, entry_code) { + return std::option::Option::Some(ProjectionKind::VoteState); + } + if contains(VOTE_VALUE_ENTRIES, entry_code) { + return std::option::Option::Some(ProjectionKind::VoteValue); + } + if contains(VOTE_REWARD_ENTRIES, entry_code) { + return std::option::Option::Some(ProjectionKind::VoteReward); + } + return std::option::Option::None; +} + +fn projection_details( + projection: ProjectionKind, + accounts: &serde_json::Value, + parameters: &serde_json::Value, + operation: &str, +) -> serde_json::Value { + return match projection { + ProjectionKind::StakeAccount => stake_account_details(accounts, parameters, operation), + ProjectionKind::StakeValue => stake_value_details(accounts, parameters, operation), + ProjectionKind::StakeAuthority => stake_authority_details(accounts, parameters, operation), + ProjectionKind::StakeLockup => stake_lockup_details(accounts, parameters, operation), + ProjectionKind::VoteAccount => vote_account_details(accounts, parameters, operation), + ProjectionKind::VoteAuthority => vote_authority_details(accounts, parameters, operation), + ProjectionKind::VoteAdmin => vote_admin_details(accounts, parameters, operation), + ProjectionKind::VoteState => vote_state_details(accounts, parameters, operation), + ProjectionKind::VoteValue => vote_value_details(accounts, parameters, operation), + ProjectionKind::VoteReward => vote_reward_details(accounts, parameters, operation), + }; +} + +fn stake_account_details( + accounts: &serde_json::Value, + parameters: &serde_json::Value, + operation: &str, +) -> serde_json::Value { + return serde_json::json!({ + "stakeAccount": account_key_for_role(accounts, "stake_account"), + "voteAccount": account_key_for_role(accounts, "vote_account"), + "authorized": parameters.get("authorized").cloned().unwrap_or(serde_json::Value::Null), + "lockup": parameters.get("lockup").cloned().unwrap_or(serde_json::Value::Null), + "activationStateComputed": false, + "transactionFinalAccountStateCaptured": false, + "stateTransition": match operation { + "initialize" | "initialize_checked" => "stake_account_initialized", + "delegate_stake" => "stake_delegation_requested", + "deactivate" => "stake_deactivation_requested", + "deactivate_delinquent" => "stake_deactivation_requested_for_delinquent_vote_account", + _ => "stake_account_state_transition_requested", + }, + }); +} + +fn stake_value_details( + accounts: &serde_json::Value, + parameters: &serde_json::Value, + operation: &str, +) -> serde_json::Value { + return serde_json::json!({ + "stakeAccount": account_key_for_role(accounts, "stake_account"), + "splitAccount": account_key_for_role(accounts, "split_account"), + "destinationAccount": first_account_key_for_roles(accounts, &["destination", "recipient_account", "destination_stake_account"]), + "sourceStakeAccount": account_key_for_role(accounts, "source_stake_account"), + "lamports": parameters.get("lamports").cloned().unwrap_or(serde_json::Value::Null), + "lamportBalanceChangesDuplicated": false, + "activationStateComputed": false, + "transactionFinalAccountStateCaptured": false, + "stateTransition": match operation { + "split" => "stake_split_requested", + "withdraw" => "stake_lamports_withdrawn_or_account_closed_conditionally", + "merge" => "stake_accounts_merged", + "move_stake" => "active_stake_moved", + "move_lamports" => "inactive_lamports_moved", + _ => "stake_value_transition_requested", + }, + }); +} + +fn stake_authority_details( + accounts: &serde_json::Value, + parameters: &serde_json::Value, + operation: &str, +) -> serde_json::Value { + return serde_json::json!({ + "stakeAccount": account_key_for_role(accounts, "stake_account"), + "currentAuthority": first_account_key_for_roles(accounts, &["stake_or_withdraw_authority", "current_authority", "stake_authority", "withdraw_authority", "authority_base"]), + "newAuthority": first_parameter_or_account( + parameters, + &["newAuthority", "newAuthorizedPubkey"], + accounts, + "new_authority", + ), + "authorizationType": parameters.get("authorizationType").cloned().unwrap_or(serde_json::Value::Null), + "authoritySeed": parameters.get("authoritySeed").cloned().unwrap_or(serde_json::Value::Null), + "authorityOwner": parameters.get("authorityOwner").cloned().unwrap_or(serde_json::Value::Null), + "stateTransition": match operation { + "authorize_with_seed" | "authorize_checked_with_seed" => "stake_authority_changed_with_seed", + "authorize_checked" => "stake_authority_changed_with_new_authority_signature", + _ => "stake_authority_changed", + }, + "transactionFinalAccountStateCaptured": false, + }); +} + +fn stake_lockup_details( + accounts: &serde_json::Value, + parameters: &serde_json::Value, + operation: &str, +) -> serde_json::Value { + return serde_json::json!({ + "stakeAccount": account_key_for_role(accounts, "stake_account"), + "custodian": first_account_key_for_roles(accounts, &["lockup_custodian", "lockup_or_withdraw_authority", "new_lockup_authority"]), + "lockup": parameters.clone(), + "checkedCustodianFromAccount": operation == "set_lockup_checked", + "stateTransition": "stake_lockup_updated", + "transactionFinalAccountStateCaptured": false, + }); +} + +fn vote_account_details( + accounts: &serde_json::Value, + parameters: &serde_json::Value, + operation: &str, +) -> serde_json::Value { + return serde_json::json!({ + "voteAccount": account_key_for_role(accounts, "vote_account"), + "validatorIdentity": account_key_for_role(accounts, "validator_identity"), + "authorizedVoter": parameters.get("authorizedVoter").cloned().unwrap_or(serde_json::Value::Null), + "authorizedWithdrawer": parameters.get("authorizedWithdrawer").cloned().unwrap_or(serde_json::Value::Null), + "commissionPercent": parameters.get("commissionPercent").cloned().unwrap_or(serde_json::Value::Null), + "inflationRewardsCommissionBasisPoints": parameters.get("inflationRewardsCommissionBasisPoints").cloned().unwrap_or(serde_json::Value::Null), + "blockRevenueCommissionBasisPoints": parameters.get("blockRevenueCommissionBasisPoints").cloned().unwrap_or(serde_json::Value::Null), + "inflationRewardsCollector": account_key_for_role(accounts, "inflation_rewards_collector"), + "blockRevenueCollector": account_key_for_role(accounts, "block_revenue_collector"), + "stateTransition": if operation == "initialize_account_v2" { + "vote_account_initialized_v2" + } else { + "vote_account_initialized" + }, + "transactionFinalAccountStateCaptured": false, + }); +} + +fn vote_authority_details( + accounts: &serde_json::Value, + parameters: &serde_json::Value, + operation: &str, +) -> serde_json::Value { + return serde_json::json!({ + "voteAccount": account_key_for_role(accounts, "vote_account"), + "currentAuthority": first_account_key_for_roles(accounts, &["current_authority", "current_authority_base"]), + "newAuthority": first_parameter_or_account( + parameters, + &["newAuthority"], + accounts, + "new_authority", + ), + "authorizationType": parameters.get("authorizationType").cloned().unwrap_or(serde_json::Value::Null), + "currentAuthorityDerivedKeyOwner": parameters.get("currentAuthorityDerivedKeyOwner").cloned().unwrap_or(serde_json::Value::Null), + "currentAuthorityDerivedKeySeed": parameters.get("currentAuthorityDerivedKeySeed").cloned().unwrap_or(serde_json::Value::Null), + "newAuthorityFromAccount": parameters.get("newAuthorityFromAccount").cloned().unwrap_or(serde_json::Value::Bool(false)), + "stateTransition": match operation { + "authorize_with_seed" | "authorize_checked_with_seed" => "vote_authority_changed_with_seed", + "authorize_checked" => "vote_authority_changed_with_new_authority_signature", + _ => "vote_authority_changed", + }, + "transactionFinalAccountStateCaptured": false, + }); +} + +fn vote_admin_details( + accounts: &serde_json::Value, + parameters: &serde_json::Value, + operation: &str, +) -> serde_json::Value { + return serde_json::json!({ + "voteAccount": account_key_for_role(accounts, "vote_account"), + "withdrawAuthority": account_key_for_role(accounts, "withdraw_authority"), + "newValidatorIdentity": account_key_for_role(accounts, "new_validator_identity"), + "newCommissionCollector": account_key_for_role(accounts, "new_commission_collector"), + "commissionPercent": parameters.get("commissionPercent").cloned().unwrap_or(serde_json::Value::Null), + "commissionBasisPoints": parameters.get("commissionBasisPoints").cloned().unwrap_or(serde_json::Value::Null), + "commissionKind": parameters.get("commissionKind").cloned().unwrap_or(serde_json::Value::Null), + "stateTransition": match operation { + "update_validator_identity" => "vote_validator_identity_updated", + "update_commission" | "update_commission_bps" => "vote_commission_updated", + "update_commission_collector" => "vote_commission_collector_updated", + _ => "vote_admin_updated", + }, + "transactionFinalAccountStateCaptured": false, + }); +} + +fn vote_state_details( + accounts: &serde_json::Value, + parameters: &serde_json::Value, + operation: &str, +) -> serde_json::Value { + return serde_json::json!({ + "voteAccount": account_key_for_role(accounts, "vote_account"), + "voteAuthority": account_key_for_role(accounts, "vote_authority"), + "lastVotedSlot": parameters.get("lastVotedSlot").cloned().unwrap_or(serde_json::Value::Null), + "slots": parameters.get("slots").cloned().unwrap_or(serde_json::Value::Null), + "root": parameters.get("root").cloned().unwrap_or(serde_json::Value::Null), + "hash": first_parameter_value(parameters, &["hash", "bankHash"]), + "timestamp": parameters.get("timestamp").cloned().unwrap_or(serde_json::Value::Null), + "switchProofHash": parameters.get("switchProofHash").cloned().unwrap_or(serde_json::Value::Null), + "stateTransition": if operation.contains("switch") { + "vote_state_updated_with_switch_proof" + } else if operation.starts_with("tower_sync") { + "vote_tower_synchronized" + } else { + "vote_state_updated" + }, + "voteCreditsComputed": false, + "transactionFinalAccountStateCaptured": false, + }); +} + +fn vote_value_details( + accounts: &serde_json::Value, + parameters: &serde_json::Value, + _operation: &str, +) -> serde_json::Value { + return serde_json::json!({ + "voteAccount": account_key_for_role(accounts, "vote_account"), + "recipientAccount": account_key_for_role(accounts, "recipient_account"), + "withdrawAuthority": account_key_for_role(accounts, "withdraw_authority"), + "lamports": parameters.get("lamports").cloned().unwrap_or(serde_json::Value::Null), + "lamportBalanceChangesDuplicated": false, + "stateTransition": "vote_lamports_withdrawn_or_account_closed_conditionally", + "transactionFinalAccountStateCaptured": false, + }); +} + +fn vote_reward_details( + accounts: &serde_json::Value, + parameters: &serde_json::Value, + _operation: &str, +) -> serde_json::Value { + return serde_json::json!({ + "voteAccount": account_key_for_role(accounts, "vote_account"), + "depositSource": account_key_for_role(accounts, "deposit_source"), + "depositLamports": parameters.get("depositLamports").cloned().unwrap_or(serde_json::Value::Null), + "lamportBalanceChangesDuplicated": false, + "stateTransition": "delegator_rewards_deposited_into_vote_account", + "transactionFinalAccountStateCaptured": false, + }); +} + +fn first_parameter_or_account( + parameters: &serde_json::Value, + keys: &[&str], + accounts: &serde_json::Value, + fallback_role: &str, +) -> serde_json::Value { + let value = first_parameter_value(parameters, keys); + if !value.is_null() { + return value; + } + return account_key_for_role(accounts, fallback_role); +} + +fn first_parameter_value(parameters: &serde_json::Value, keys: &[&str]) -> serde_json::Value { + for key in keys { + if let std::option::Option::Some(value) = parameters.get(*key) { + return value.clone(); + } + } + return serde_json::Value::Null; +} + +fn first_account_key_for_roles(accounts: &serde_json::Value, roles: &[&str]) -> serde_json::Value { + for role in roles { + let value = account_key_for_role(accounts, role); + if !value.is_null() { + return value; + } + } + return serde_json::Value::Null; +} + +fn account_key_for_role(accounts: &serde_json::Value, role: &str) -> serde_json::Value { + let account_key = accounts.as_array().and_then(|values| { + return values.iter().find_map(|value| { + if value.get("role").and_then(serde_json::Value::as_str) + != std::option::Option::Some(role) + { + return std::option::Option::None; + } + return value + .get("accountKey") + .and_then(serde_json::Value::as_str) + .map(|account_key| return account_key.to_string()); + }); + }); + return match account_key { + std::option::Option::Some(value) => serde_json::Value::String(value), + std::option::Option::None => serde_json::Value::Null, + }; +} + +fn contains(entries: &[&str], entry_code: &str) -> bool { + return entries.iter().any(|entry| return *entry == entry_code); +} + +#[cfg(test)] +mod tests { + fn observation( + surface_code: &str, + entry_code: &str, + family: crate::EventFamily, + transaction_failed: bool, + accounts: serde_json::Value, + parameters: serde_json::Value, + ) -> crate::DecodedObservation { + return crate::DecodedObservation { + event_key: format!("{entry_code}:0"), + event: crate::DecodedProtocolEvent { + signature: crate::Signature("signature".to_string()), + slot: crate::Slot(42), + instruction_path: crate::InstructionPath("0".to_string()), + program_id: crate::ProgramId("program-id".to_string()), + protocol_code: crate::ProtocolCode("solana_native".to_string()), + surface_code: crate::SurfaceCode(surface_code.to_string()), + event_code: crate::EventCode(format!("{surface_code}.{entry_code}")), + event_name: crate::EventName(entry_code.to_string()), + event_family: family, + source_kind: crate::EventSourceKind::Instruction, + confidence: crate::DecoderConfidence::ManualExact, + }, + payload_json: serde_json::json!({"accounts": accounts, "parameters": parameters}), + transaction_failed, + transaction_error: if transaction_failed { + std::option::Option::Some(serde_json::json!({"InstructionError": [0, "Custom"]})) + } else { + std::option::Option::None + }, + observation_committed: !transaction_failed, + proof: crate::DecoderProof { + kind: crate::DecoderProofKind::Manual, + confidence: crate::DecoderConfidence::ManualExact, + evidence: std::vec!["fixture".to_string()], + }, + }; + } + + fn account(role: &str, account_key: &str) -> serde_json::Value { + return serde_json::json!({"role": role, "accountKey": account_key}); + } + + #[test] + fn stake_initialize_creates_instructional_staking_projection() { + let materializer = crate::StakingMaterializer; + let observation = observation( + super::STAKE_SURFACE, + "initialize", + crate::EventFamily::Lifecycle, + false, + serde_json::json!([account("stake_account", "stake111")]), + serde_json::json!({"authorized": {"staker": "staker111"}}), + ); + let result = crate::EventMaterializer::materialize(&materializer, &observation); + assert_eq!(result.status, crate::MaterializerOutcomeStatus::Inserted); + assert_eq!(result.outputs[0].family, crate::MaterializedEventFamily::Staking); + assert_eq!(result.outputs[0].output_key, "stake_account:initialize:0"); + assert_eq!(result.outputs[0].payload_json["projection"]["stakeAccount"], "stake111"); + assert_eq!( + result.outputs[0].payload_json["projection"]["transactionFinalAccountStateCaptured"], + false + ); + } + + #[test] + fn stake_authority_and_lockup_events_are_projected() { + let materializer = crate::StakingMaterializer; + let authority = observation( + super::STAKE_SURFACE, + "authorize_with_seed", + crate::EventFamily::Admin, + false, + serde_json::json!([account("stake_account", "stake111")]), + serde_json::json!({ + "newAuthorizedPubkey": "new111", + "authorizationType": "staker", + "authoritySeed": "seed", + "authorityOwner": "owner111", + }), + ); + let lockup = observation( + super::STAKE_SURFACE, + "set_lockup_checked", + crate::EventFamily::Admin, + false, + serde_json::json!([ + account("stake_account", "stake111"), + account("lockup_custodian", "custodian111"), + ]), + serde_json::json!({"epoch": 42}), + ); + let authority_result = crate::EventMaterializer::materialize(&materializer, &authority); + let lockup_result = crate::EventMaterializer::materialize(&materializer, &lockup); + assert_eq!(authority_result.outputs[0].output_key, "stake_authority:authorize_with_seed:0"); + assert_eq!( + authority_result.outputs[0].payload_json["projection"]["newAuthority"], + "new111" + ); + assert_eq!(lockup_result.outputs[0].output_key, "stake_lockup:set_lockup_checked:0"); + assert_eq!( + lockup_result.outputs[0].payload_json["projection"]["checkedCustodianFromAccount"], + true + ); + } + + #[test] + fn stake_value_projection_does_not_duplicate_lamport_changes() { + let materializer = crate::StakingMaterializer; + let observation = observation( + super::STAKE_SURFACE, + "withdraw", + crate::EventFamily::Staking, + false, + serde_json::json!([ + account("stake_account", "stake111"), + account("destination", "dest111"), + ]), + serde_json::json!({"lamports": 99}), + ); + let result = crate::EventMaterializer::materialize(&materializer, &observation); + assert_eq!(result.outputs[0].output_key, "stake_value:withdraw:0"); + assert_eq!(result.outputs[0].payload_json["projection"]["lamports"], 99); + assert_eq!( + result.outputs[0].payload_json["projection"]["lamportBalanceChangesDuplicated"], + false + ); + } + + #[test] + fn vote_account_and_authority_events_are_projected() { + let materializer = crate::StakingMaterializer; + let initialize = observation( + super::VOTE_SURFACE, + "initialize_account_v2", + crate::EventFamily::Lifecycle, + false, + serde_json::json!([ + account("vote_account", "vote111"), + account("validator_identity", "validator111"), + ]), + serde_json::json!({ + "authorizedVoter": "voter111", + "authorizedWithdrawer": "withdrawer111", + "inflationRewardsCommissionBasisPoints": 500, + }), + ); + let authorize = observation( + super::VOTE_SURFACE, + "authorize_checked", + crate::EventFamily::Admin, + false, + serde_json::json!([ + account("vote_account", "vote111"), + account("new_authority", "new111"), + ]), + serde_json::json!({"authorizationType": {"kind": "voter"}, "newAuthorityFromAccount": true}), + ); + let initialize_result = crate::EventMaterializer::materialize(&materializer, &initialize); + let authorize_result = crate::EventMaterializer::materialize(&materializer, &authorize); + assert_eq!(initialize_result.outputs[0].output_key, "vote_account:initialize_account_v2:0"); + assert_eq!( + initialize_result.outputs[0].payload_json["projection"]["validatorIdentity"], + "validator111" + ); + assert_eq!(authorize_result.outputs[0].output_key, "vote_authority:authorize_checked:0"); + assert_eq!( + authorize_result.outputs[0].payload_json["projection"]["newAuthority"], + "new111" + ); + } + + #[test] + fn vote_state_and_reward_outputs_are_distinguished() { + let materializer = crate::StakingMaterializer; + let vote_state = observation( + super::VOTE_SURFACE, + "tower_sync_switch", + crate::EventFamily::Staking, + false, + serde_json::json!([ + account("vote_account", "vote111"), + account("vote_authority", "authority111"), + ]), + serde_json::json!({"root": 10, "hash": "hash111", "switchProofHash": "switch111"}), + ); + let reward = observation( + super::VOTE_SURFACE, + "deposit_delegator_rewards", + crate::EventFamily::Reward, + false, + serde_json::json!([ + account("vote_account", "vote111"), + account("deposit_source", "source111"), + ]), + serde_json::json!({"depositLamports": 123}), + ); + let vote_result = crate::EventMaterializer::materialize(&materializer, &vote_state); + let reward_result = crate::EventMaterializer::materialize(&materializer, &reward); + assert_eq!(vote_result.outputs[0].output_key, "vote_state:tower_sync_switch:0"); + assert_eq!( + vote_result.outputs[0].payload_json["projection"]["switchProofHash"], + "switch111" + ); + assert_eq!(reward_result.outputs[0].family, crate::MaterializedEventFamily::Reward); + assert_eq!(reward_result.outputs[0].output_key, "vote_reward:deposit_delegator_rewards:0"); + } + + #[test] + fn failed_staking_mutation_is_refused() { + let materializer = crate::StakingMaterializer; + let observation = observation( + super::STAKE_SURFACE, + "delegate_stake", + crate::EventFamily::Staking, + true, + serde_json::json!([account("stake_account", "stake111")]), + serde_json::json!({}), + ); + let result = crate::EventMaterializer::materialize(&materializer, &observation); + assert_eq!(result.status, crate::MaterializerOutcomeStatus::Refused); + assert!(result.outputs.is_empty()); + } + + #[test] + fn unrelated_or_disabled_observations_are_ignored() { + let materializer = crate::StakingMaterializer; + let unrelated = observation( + "solana_native_system", + "create_account", + crate::EventFamily::Lifecycle, + false, + serde_json::json!([]), + serde_json::json!({}), + ); + let redelegate = observation( + super::STAKE_SURFACE, + "redelegate", + crate::EventFamily::Staking, + false, + serde_json::json!([]), + serde_json::json!({"officiallyDisabled": true}), + ); + let unrelated_result = crate::EventMaterializer::materialize(&materializer, &unrelated); + let redelegate_result = crate::EventMaterializer::materialize(&materializer, &redelegate); + assert_eq!(unrelated_result.status, crate::MaterializerOutcomeStatus::Ignored); + assert_eq!(redelegate_result.status, crate::MaterializerOutcomeStatus::Ignored); + } + + #[test] + fn output_serialization_is_deterministic() { + let materializer = crate::StakingMaterializer; + let observation = observation( + super::VOTE_SURFACE, + "update_commission_bps", + crate::EventFamily::Admin, + false, + serde_json::json!([account("vote_account", "vote111")]), + serde_json::json!({"commissionBasisPoints": 250, "commissionKind": "inflation_rewards"}), + ); + let first = crate::EventMaterializer::materialize(&materializer, &observation); + let second = crate::EventMaterializer::materialize(&materializer, &observation); + let first_json_result = serde_json::to_string(&first.outputs[0].payload_json); + let first_json = match first_json_result { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => panic!("unexpected serialization error: {error}"), + }; + let second_json_result = serde_json::to_string(&second.outputs[0].payload_json); + let second_json = match second_json_result { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => panic!("unexpected serialization error: {error}"), + }; + assert_eq!(first_json, second_json); + } +} diff --git a/kb-lib/tests/external_materializer_api.rs b/kb-lib/tests/external_materializer_api.rs new file mode 100644 index 0000000..cd9c823 --- /dev/null +++ b/kb-lib/tests/external_materializer_api.rs @@ -0,0 +1,42 @@ +// file: kb-lib/tests/external_materializer_api.rs +// version: 1 + +//! Downstream-style compilation contract for externally implemented materializers. + +const ACCEPTED_FAMILIES: &[kb_lib::EventFamily] = &[kb_lib::EventFamily::Audit]; + +struct ExternalAuditMaterializer; + +impl kb_lib::EventMaterializer for ExternalAuditMaterializer { + fn identity(&self) -> kb_lib::MaterializerIdentity { + return kb_lib::MaterializerIdentity { + name: "external_audit_materializer".to_string(), + version: "1".to_string(), + }; + } + + fn accepted_families(&self) -> &'static [kb_lib::EventFamily] { + return ACCEPTED_FAMILIES; + } + + fn transaction_policy( + &self, + _family: kb_lib::EventFamily, + ) -> kb_lib::MaterializationTransactionPolicy { + return kb_lib::MaterializationTransactionPolicy::SuccessfulOrFailedAudit; + } + + fn materialize( + &self, + _observation: &kb_lib::DecodedObservation, + ) -> kb_lib::MaterializerExecutionResult { + return kb_lib::MaterializerExecutionResult::ignored(); + } +} + +#[test] +fn external_materializer_uses_only_the_public_kb_lib_contract() { + let materializer: &dyn kb_lib::EventMaterializer = &ExternalAuditMaterializer; + assert_eq!(materializer.identity().name, "external_audit_materializer"); + assert_eq!(materializer.accepted_families(), ACCEPTED_FAMILIES); +}