0.1.0-pre.004
This commit is contained in:
13
README.md
13
README.md
@@ -1,3 +1,6 @@
|
|||||||
|
<!-- file: README.md -->
|
||||||
|
<!-- version: 1 -->
|
||||||
|
|
||||||
# Khadhroony Bot3
|
# Khadhroony Bot3
|
||||||
|
|
||||||
Khadhroony Bot3 is the consolidated successor workspace to `khadhroony-bot2`.
|
Khadhroony Bot3 is the consolidated successor workspace to `khadhroony-bot2`.
|
||||||
@@ -37,3 +40,13 @@ cargo clippy --workspace --all-targets
|
|||||||
## Contrats consolidés
|
## Contrats consolidés
|
||||||
|
|
||||||
À partir de `0.1.0-pre.002`, `kb-lib` porte les modèles et contrats fondamentaux autrefois répartis entre `kb_model`, `kb_decoder_api`, `kb_materializer_api` et `kb_execution_api`. Les implémentations restent dans des modules dédiés ; `kb-lib/src/lib.rs` ne contient que les déclarations de modules et les réexports publics.
|
À partir de `0.1.0-pre.002`, `kb-lib` porte les modèles et contrats fondamentaux autrefois répartis entre `kb_model`, `kb_decoder_api`, `kb_materializer_api` et `kb_execution_api`. Les implémentations restent dans des modules dédiés ; `kb-lib/src/lib.rs` ne contient que les déclarations de modules et les réexports publics.
|
||||||
|
|
||||||
|
## Stockage consolidé
|
||||||
|
|
||||||
|
À partir de `0.1.0-pre.003`, `kb-store` remplace le scaffold initial par une implémentation complète :
|
||||||
|
|
||||||
|
- contrats store-neutral : DTO, entités, pagination, santé et traits de repository ;
|
||||||
|
- adaptateur PostgreSQL : connexion, initialisation idempotente, requêtes, diagnostics et replay ;
|
||||||
|
- façade unique dans `kb-store/src/lib.rs` ;
|
||||||
|
- modèle de replay partagé conservé dans `kb-lib`, sans duplication ;
|
||||||
|
- options PostgreSQL indépendantes de `kb-config`, adaptées par la frontière applicative.
|
||||||
|
|||||||
37
ROADMAP.md
37
ROADMAP.md
@@ -1,3 +1,6 @@
|
|||||||
|
<!-- file: ROADMAP.md -->
|
||||||
|
<!-- version: 2 -->
|
||||||
|
|
||||||
# ROADMAP — khadhroony-bot3
|
# ROADMAP — khadhroony-bot3
|
||||||
|
|
||||||
## 0.1.0 — Consolidation du workspace
|
## 0.1.0 — Consolidation du workspace
|
||||||
@@ -7,12 +10,12 @@
|
|||||||
- [x] Générer l’arborescence des modules à partir des anciennes crates.
|
- [x] Générer l’arborescence des modules à partir des anciennes crates.
|
||||||
- [x] Conserver une copie de référence complète de `khadhroony-bot2`.
|
- [x] Conserver une copie de référence complète de `khadhroony-bot2`.
|
||||||
- [x] Restaurer l’architecture modulaire de `kb-core` à partir de `kb_core`, avec `lib.rs` limité aux modules et réexports.
|
- [x] Restaurer l’architecture modulaire de `kb-core` à partir de `kb_core`, avec `lib.rs` limité aux modules et réexports.
|
||||||
- [ ] Porter exactement les contrats publics de `kb_decoder_api`.
|
- [x] Porter exactement les contrats publics de `kb_decoder_api`.
|
||||||
- [ ] Porter exactement les contrats publics des APIs d’exécution et de matérialisation.
|
- [x] Porter exactement les contrats publics des APIs d’exécution et de matérialisation.
|
||||||
- [ ] Porter les modèles de `kb_model` vers `kb-lib`.
|
- [x] Porter les modèles de `kb_model` vers `kb-lib`.
|
||||||
- [ ] Porter les décodeurs Solana core, SPL et Metaplex.
|
- [~] Porter les décodeurs Solana core, SPL et Metaplex.
|
||||||
- [ ] Porter leurs matérialisateurs et exécuteurs.
|
- [ ] Porter leurs matérialisateurs et exécuteurs.
|
||||||
- [ ] Fusionner `kb_store_core` et `kb_store_pg` dans `kb-store`.
|
- [x] Fusionner `kb_store_core` et `kb_store_pg` dans `kb-store`.
|
||||||
- [ ] Adapter `kb-pipeline` aux nouveaux chemins publics.
|
- [ ] Adapter `kb-pipeline` aux nouveaux chemins publics.
|
||||||
- [ ] Adapter `kb-app-demo` et rétablir les validations fonctionnelles.
|
- [ ] Adapter `kb-app-demo` et rétablir les validations fonctionnelles.
|
||||||
- [ ] Auditer rétrospectivement la couverture historique des décodeurs et matérialisateurs.
|
- [ ] Auditer rétrospectivement la couverture historique des décodeurs et matérialisateurs.
|
||||||
@@ -24,4 +27,26 @@
|
|||||||
- [x] Porter les contrats publics de matérialisation dans `kb-lib::materializer::api`.
|
- [x] Porter les contrats publics de matérialisation dans `kb-lib::materializer::api`.
|
||||||
- [x] Porter les contrats publics d'exécution dans `kb-lib::executor::api`.
|
- [x] Porter les contrats publics d'exécution dans `kb-lib::executor::api`.
|
||||||
- [x] Maintenir `lib.rs` comme façade sans implémentation métier.
|
- [x] Maintenir `lib.rs` comme façade sans implémentation métier.
|
||||||
- [ ] Valider le workspace avec Cargo sur la machine de développement.
|
- [x] Valider le workspace avec Cargo sur la machine de développement.
|
||||||
|
|
||||||
|
### 0.1.0-pre.003 — Réécriture de `kb-store`
|
||||||
|
|
||||||
|
- [x] Remplacer le scaffold par une architecture intégrée contrats/adaptateurs.
|
||||||
|
- [x] Porter les DTO, entités, traits et validations store-neutral.
|
||||||
|
- [x] Porter l’adaptateur PostgreSQL, ses requêtes, diagnostics et initialisations idempotentes.
|
||||||
|
- [x] Conserver `CoreInstructionReplayInput` dans `kb-lib` et le réexporter depuis `kb-store`.
|
||||||
|
- [x] Supprimer la dépendance directe de `kb-store` vers `kb-config`.
|
||||||
|
- [x] Adapter les règles bot2 devenues incompatibles avec l’architecture bot3.
|
||||||
|
- [x] Valider `cargo check`, les 84 tests et Clippy pour `kb-store`, puis `cargo check --workspace`.
|
||||||
|
- [ ] Valider `cargo test --workspace` et `cargo clippy --workspace --all-targets`.
|
||||||
|
- [ ] Valider les tests PostgreSQL réels avec `KB_POSTGRES_TEST_URL`.
|
||||||
|
|
||||||
|
### 0.1.0-pre.004 — Décodeur Solana Core dans `kb-lib`
|
||||||
|
|
||||||
|
- [x] Remplacer le scaffold `kb-lib::decoder::solana::core` par le décodeur maximal validé de bot2.
|
||||||
|
- [x] Conserver les 18 surfaces natives, leurs 121 déclarations de couverture et leurs tests.
|
||||||
|
- [x] Adapter les contrats vers les réexports de `kb-lib` sans dépendance vers `kb-store`.
|
||||||
|
- [x] Préfixer les symboles internes afin d’éviter les collisions avec les futurs décodeurs fusionnés.
|
||||||
|
- [x] Déclarer le target hiérarchique `kb-lib.decoder.solana.core`.
|
||||||
|
- [ ] Valider `kb-lib` et le workspace avec Cargo sur la machine de développement.
|
||||||
|
- [ ] Poursuivre avec SPL Memo après validation.
|
||||||
|
|||||||
102
RULES.md
102
RULES.md
@@ -1,9 +1,9 @@
|
|||||||
<!-- file: RULES.md -->
|
<!-- file: RULES.md -->
|
||||||
<!-- version: 16 -->
|
<!-- version: 17 -->
|
||||||
|
|
||||||
# Règles spécifiques à `khadhroony-bot2`
|
# Règles spécifiques à `khadhroony-bot3`
|
||||||
|
|
||||||
Ce fichier contient uniquement les règles propres au projet et au workspace `khadhroony-bot2`.
|
Ce fichier contient uniquement les règles propres au projet et au workspace `khadhroony-bot3`.
|
||||||
|
|
||||||
Les règles Rust générales et réutilisables dans tous les projets sont définies dans [`RUST_RULES.md`](RUST_RULES.md). Les deux fichiers sont normatifs et cumulatifs. En cas de conflit, la règle la plus stricte s'applique ; une règle spécifique au workspace ne peut jamais assouplir une règle générale sans exception explicitement documentée.
|
Les règles Rust générales et réutilisables dans tous les projets sont définies dans [`RUST_RULES.md`](RUST_RULES.md). Les deux fichiers sont normatifs et cumulatifs. En cas de conflit, la règle la plus stricte s'applique ; une règle spécifique au workspace ne peut jamais assouplir une règle générale sans exception explicitement documentée.
|
||||||
|
|
||||||
@@ -14,10 +14,9 @@ Toute livraison doit exécuter l'audit `python3 scripts/audit_rust_workspace_rul
|
|||||||
- Tous les noms de fichiers et de répertoires doivent être écrits en anglais.
|
- Tous les noms de fichiers et de répertoires doivent être écrits en anglais.
|
||||||
- Les noms de fichiers et de répertoires ne doivent contenir aucun accent, espace ou caractère spécial inutile.
|
- Les noms de fichiers et de répertoires ne doivent contenir aucun accent, espace ou caractère spécial inutile.
|
||||||
- Les noms internes doivent utiliser le format `snake_case` lorsque c'est applicable.
|
- Les noms internes doivent utiliser le format `snake_case` lorsque c'est applicable.
|
||||||
- Les crates Rust utilisent le préfixe `kb_`.
|
- Les packages Rust utilisent le préfixe `kb-` ; leur identifiant Rust correspondant utilise automatiquement `kb_`.
|
||||||
- Les crates de décodeur utilisent le format `kb_decoder_<protocol>_<surface>`.
|
- Les décodeurs, matérialisateurs et exécuteurs sont des modules de `kb-lib`, pas des crates séparées.
|
||||||
- Les crates de matérialisation utilisent le format `kb_materializer_<family>`.
|
- Le crate de journalisation s'appelle `kb-logging`.
|
||||||
- Le crate de journalisation s'appelle `kb_logging`.
|
|
||||||
|
|
||||||
## Règles Tauri et TypeScript
|
## Règles Tauri et TypeScript
|
||||||
|
|
||||||
@@ -40,13 +39,13 @@ Toute livraison doit exécuter l'audit `python3 scripts/audit_rust_workspace_rul
|
|||||||
- Les matérialisateurs doivent refuser les transactions non commitées pour les mutations d'état, tout en pouvant conserver séparément une intention non commitée lorsque le modèle métier le prévoit explicitement.
|
- Les matérialisateurs doivent refuser les transactions non commitées pour les mutations d'état, tout en pouvant conserver séparément une intention non commitée lorsque le modèle métier le prévoit explicitement.
|
||||||
- Toute absence volontaire de projection doit être documentée avec une justification technique précise. Un matérialisateur ne doit inventer ni état final, ni montant, ni autorité, ni frais, ni agrégation que l'observation ne prouve pas.
|
- Toute absence volontaire de projection doit être documentée avec une justification technique précise. Un matérialisateur ne doit inventer ni état final, ni montant, ni autorité, ni frais, ni agrégation que l'observation ne prouve pas.
|
||||||
- Pour chaque programme Solana, les exécuteurs doivent couvrir maximalement les opérations officiellement appelables et les opérations expérimentales publiées lorsque leurs contrats exacts et garde-fous sont prouvés. Les opérations historiques, dépréciées ou obsolètes doivent rester décodables et matérialisables, mais ne doivent jamais être exposées à l'exécution ; cette exclusion doit être explicite dans la matrice et le README.
|
- Pour chaque programme Solana, les exécuteurs doivent couvrir maximalement les opérations officiellement appelables et les opérations expérimentales publiées lorsque leurs contrats exacts et garde-fous sont prouvés. Les opérations historiques, dépréciées ou obsolètes doivent rester décodables et matérialisables, mais ne doivent jamais être exposées à l'exécution ; cette exclusion doit être explicite dans la matrice et le README.
|
||||||
- Les stores exposent leurs comportements via les traits de `kb_store_core`.
|
- `kb-store` possède les contrats de persistance neutres et les adaptateurs de base de données.
|
||||||
- `kb_store_pg` est le store de production.
|
- PostgreSQL est l'adaptateur de production de `kb-store`.
|
||||||
- `kb_store_sqlite` sert aux tests, imports et corpus locaux.
|
- Un futur adaptateur SQLite doit rester interne à `kb-store` et limité aux tests, imports et corpus locaux.
|
||||||
- `kb_wallet` reste isolé du reste du système.
|
- `kb-wallet` reste isolé du reste du système.
|
||||||
- Les transactions raw sont immuables et restent la source d'audit.
|
- Les transactions raw sont immuables et restent la source d'audit.
|
||||||
- Les replays doivent être ciblés par module, version, programme, surface, discriminator, slot ou signatures.
|
- Les replays doivent être ciblés par module, version, programme, surface, discriminator, slot ou signatures.
|
||||||
- La documentation du projet ne doit pas référencer le nom de l'ancien workspace.
|
- La documentation active du projet ne doit pas présenter l'ancien workspace comme architecture courante. Les documents de migration et copies de référence peuvent le nommer explicitement.
|
||||||
|
|
||||||
## Règles de documentation projet
|
## Règles de documentation projet
|
||||||
|
|
||||||
@@ -81,25 +80,25 @@ Les nouvelles surfaces de programmes doivent utiliser un nom canonique basé sur
|
|||||||
<function_code>_<family_code>_<identifier_code>[_vN]
|
<function_code>_<family_code>_<identifier_code>[_vN]
|
||||||
```
|
```
|
||||||
|
|
||||||
Les crates de décodage doivent utiliser :
|
Les modules de décodage doivent utiliser :
|
||||||
|
|
||||||
```text
|
```text
|
||||||
kb_decoder_<function_code>_<family_code>_<identifier_code>[_vN]
|
kb_lib::decoder::<function_code>::<family_code>_<identifier_code>[_vN]
|
||||||
```
|
```
|
||||||
|
|
||||||
Exemples :
|
Exemples :
|
||||||
|
|
||||||
- `kb_decoder_amm_raydium_cpmm` ;
|
- `kb_lib::decoder::amm::raydium_cpmm` ;
|
||||||
- `kb_decoder_clmm_raydium` ;
|
- `kb_lib::decoder::clmm::raydium` ;
|
||||||
- `kb_decoder_dlmm_meteora` ;
|
- `kb_lib::decoder::dlmm::meteora` ;
|
||||||
- `kb_decoder_router_jupiter_aggregator_v6` ;
|
- `kb_lib::decoder::router::jupiter_aggregator_v6` ;
|
||||||
- `kb_decoder_orderbook_openbook_v2` ;
|
- `kb_lib::decoder::orderbook::openbook_v2` ;
|
||||||
- `kb_decoder_metadata_metaplex_token_metadata` ;
|
- `kb_lib::decoder::metadata::metaplex_token_metadata` ;
|
||||||
- `kb_decoder_nft_metaplex_bubblegum`.
|
- `kb_lib::decoder::nft::metaplex_bubblegum`.
|
||||||
|
|
||||||
Les noms historiques ou issus d'IDL doivent être conservés dans le registre, mais ne doivent pas créer de nouvelles crates si une crate canonique existe déjà.
|
Les noms historiques ou issus d'IDL doivent être conservés dans le registre, mais ne doivent pas créer de nouveau module si un module canonique existe déjà.
|
||||||
|
|
||||||
Aucun renommage massif de crates n'est autorisé sans étape de contrôle dédiée et sans `cargo build` validé.
|
Aucun renommage massif de modules n'est autorisé sans étape de contrôle dédiée et sans validation Cargo.
|
||||||
|
|
||||||
## Règles de nommage des surfaces Solana
|
## Règles de nommage des surfaces Solana
|
||||||
|
|
||||||
@@ -118,13 +117,13 @@ Aucun renommage massif de crates n'est autorisé sans étape de contrôle dédi
|
|||||||
|
|
||||||
## Index court de programme
|
## Index court de programme
|
||||||
|
|
||||||
- Les noms de crates ne doivent pas commencer par un index hexadécimal.
|
- Les noms de modules ne doivent pas commencer par un index hexadécimal.
|
||||||
- Un champ `registry_code` optionnel peut être ajouté au registre pour l'UI, PostgreSQL ou les matrices.
|
- Un champ `registry_code` optionnel peut être ajouté au registre pour l'UI, PostgreSQL ou les matrices.
|
||||||
- Le nom canonique reste la source principale : fonction + famille + identifiant + version.
|
- Le nom canonique reste la source principale : fonction + famille + identifiant + version.
|
||||||
|
|
||||||
## Comptes non exécutables
|
## Comptes non exécutables
|
||||||
|
|
||||||
- Une adresse de compte, de pool, de PDA ou de vault ne doit pas générer une crate de décodeur.
|
- Une adresse de compte, de pool, de PDA ou de vault ne doit pas générer un module de décodeur.
|
||||||
- Le vrai `program_id` propriétaire doit être prouvé avant création d'une surface canonique.
|
- Le vrai `program_id` propriétaire doit être prouvé avant création d'une surface canonique.
|
||||||
|
|
||||||
## Constantes Rust
|
## Constantes Rust
|
||||||
@@ -136,13 +135,15 @@ Aucun renommage massif de crates n'est autorisé sans étape de contrôle dédi
|
|||||||
|
|
||||||
## Règles de tracing
|
## Règles de tracing
|
||||||
|
|
||||||
- Une crate est opérationnelle lorsqu’elle effectue des I/O, orchestre un pipeline, décode, matérialise, exécute, applique une politique runtime ou prend une décision mutable observable.
|
- Une crate ou un composant de `kb-lib` est opérationnel lorsqu’il effectue des I/O, orchestre un pipeline, décode, matérialise, exécute, applique une politique runtime ou prend une décision mutable observable.
|
||||||
- Toute crate opérationnelle ajoutée ou modifiée doit dépendre de `tracing` depuis le workspace et déclarer exactement un `pub(crate) const TRACING_TARGET` dans `src/constants.rs`.
|
- Toute crate opérationnelle ajoutée ou modifiée doit dépendre de `tracing` depuis le workspace et déclarer exactement un `pub(crate) const TRACING_TARGET` dans `src/constants.rs`.
|
||||||
- La valeur canonique de `TRACING_TARGET` est le nom exact du package Cargo. Les targets historiques `khbot.*`, les suffixes de module et les targets de fenêtre sont interdits dans les nouvelles modifications.
|
- Hors `kb-lib`, la valeur canonique de `TRACING_TARGET` est le nom exact du package Cargo.
|
||||||
|
- Dans `kb-lib`, chaque composant opérationnel possède son propre `constants.rs` et un target hiérarchique stable fondé sur son identifiant de décodeur, matérialiseur ou exécuteur ; un target unique `kb-lib` ne doit pas effacer l'identité du composant.
|
||||||
|
- Les targets historiques `khbot.*` et les targets de fenêtre sont interdits dans les nouvelles modifications.
|
||||||
- Les macros `tracing` doivent utiliser `target: crate::TRACING_TARGET` et des champs structurés stables. La granularité interne passe par `action`, `stage`, `window`, `campaign_id`, `signature`, `instruction_path`, `program_id`, `processor_name`, `processor_version`, `status` et `error_code`.
|
- Les macros `tracing` doivent utiliser `target: crate::TRACING_TARGET` et des champs structurés stables. La granularité interne passe par `action`, `stage`, `window`, `campaign_id`, `signature`, `instruction_path`, `program_id`, `processor_name`, `processor_version`, `status` et `error_code`.
|
||||||
- Les crates passives de types, contrats, DTO, API sans exécution, registres ou constantes restent sans dépendance `tracing`. `kb_config` reste une exception de bootstrap tant que sa validation précède l’installation du subscriber.
|
- Les crates passives de types, contrats, DTO, API sans exécution, registres ou constantes restent sans dépendance `tracing`. `kb-config` reste une exception de bootstrap tant que sa validation précède l’installation du subscriber.
|
||||||
- Il est interdit d’ajouter `tracing` sans événement réel ou de conserver un faux target uniquement consommé par `let _target`.
|
- Il est interdit d’ajouter `tracing` sans événement réel ou de conserver un faux target uniquement consommé par `let _target`.
|
||||||
- Une décision interne doit être journalisée par la crate responsable ; `kb_app_demo` ne journalise que les frontières Tauri/UI, les actions utilisateur et les résumés d’orchestration.
|
- Une décision interne doit être journalisée par la crate responsable ; `kb-app-demo` ne journalise que les frontières Tauri/UI, les actions utilisateur et les résumés d’orchestration.
|
||||||
- Tout input sélectionné sans décodeur compatible, tout résultat de décodage `failed` ou `unsupported`, tout résultat de matérialisation `failed`, toute validation de résultat invalide et toute erreur de persistance doivent émettre un événement `error` avant le retour ou la persistance terminale.
|
- Tout input sélectionné sans décodeur compatible, tout résultat de décodage `failed` ou `unsupported`, tout résultat de matérialisation `failed`, toute validation de résultat invalide et toute erreur de persistance doivent émettre un événement `error` avant le retour ou la persistance terminale.
|
||||||
- Une transaction Solana échouée mais correctement décodée n’est pas une erreur du logiciel. Une décision `ignored`, un refus de matérialisation conforme à la politique ou une annulation coopérative ne doivent pas être promus artificiellement au niveau `error`.
|
- Une transaction Solana échouée mais correctement décodée n’est pas une erreur du logiciel. Une décision `ignored`, un refus de matérialisation conforme à la politique ou une annulation coopérative ne doivent pas être promus artificiellement au niveau `error`.
|
||||||
- Les erreurs de décodage et de matérialisation doivent conserver au minimum, lorsque disponibles : `campaign_id`, `signature`, `slot`, `instruction_path`, `program_id`, processor ou materializer avec version, `input_key`, `input_hash` ou hash du payload, statut, code et diagnostic borné.
|
- Les erreurs de décodage et de matérialisation doivent conserver au minimum, lorsque disponibles : `campaign_id`, `signature`, `slot`, `instruction_path`, `program_id`, processor ou materializer avec version, `input_key`, `input_hash` ou hash du payload, statut, code et diagnostic borné.
|
||||||
@@ -169,11 +170,11 @@ Aucun renommage massif de crates n'est autorisé sans étape de contrôle dédi
|
|||||||
|
|
||||||
## Règles des exécuteurs
|
## Règles des exécuteurs
|
||||||
|
|
||||||
- Les crates d'exécution utilisent le préfixe `kb_executor_`.
|
- Les exécuteurs résident dans `kb_lib::executor`.
|
||||||
- Une surface classifiée peut avoir un décodeur `kb_decoder_<surface>` et un exécuteur `kb_executor_<surface>`.
|
- Une surface classifiée peut avoir un module décodeur et un module exécuteur distincts dans `kb-lib`.
|
||||||
- Les exécuteurs ne doivent pas dépendre des décodeurs.
|
- Les exécuteurs ne doivent pas dépendre des décodeurs.
|
||||||
- Les exécuteurs doivent passer par `kb_execution_api` pour leur contrat public.
|
- Les exécuteurs doivent passer par `kb_lib::executor::api` pour leur contrat public.
|
||||||
- Les garde-fous communs doivent être placés dans `kb_execution_safety`.
|
- Les garde-fous communs doivent être placés dans les modules de sécurité partagés de `kb-lib`.
|
||||||
- Aucun exécuteur ne doit envoyer de transaction sans simulation et validation explicite.
|
- Aucun exécuteur ne doit envoyer de transaction sans simulation et validation explicite.
|
||||||
- Les surfaces non classifiées ne doivent pas avoir de crate exécuteur.
|
- Les surfaces non classifiées ne doivent pas avoir de crate exécuteur.
|
||||||
- Pour chaque programme Solana et contrairement aux décodeurs, les exécuteurs ne construisent pas les opérations obsolètes ou purement historiques. Ils couvrent uniquement les opérations courantes et expérimentales officiellement constructibles, avec un statut exact `Supported` ou `Unsupported(reason)`.
|
- Pour chaque programme Solana et contrairement aux décodeurs, les exécuteurs ne construisent pas les opérations obsolètes ou purement historiques. Ils couvrent uniquement les opérations courantes et expérimentales officiellement constructibles, avec un statut exact `Supported` ou `Unsupported(reason)`.
|
||||||
@@ -185,7 +186,7 @@ Aucun renommage massif de crates n'est autorisé sans étape de contrôle dédi
|
|||||||
|
|
||||||
## Règles de configuration
|
## Règles de configuration
|
||||||
|
|
||||||
- La configuration applicative commune doit passer par `kb_config`.
|
- La configuration applicative commune doit passer par `kb-config`.
|
||||||
- Les fichiers JSON de configuration ne doivent pas contenir de commentaires.
|
- Les fichiers JSON de configuration ne doivent pas contenir de commentaires.
|
||||||
- Les secrets ne doivent pas être écrits en clair dans le dépôt.
|
- Les secrets ne doivent pas être écrits en clair dans le dépôt.
|
||||||
- Les valeurs sensibles doivent utiliser des variables d'environnement ou un stockage chiffré dédié.
|
- Les valeurs sensibles doivent utiliser des variables d'environnement ou un stockage chiffré dédié.
|
||||||
@@ -193,18 +194,18 @@ Aucun renommage massif de crates n'est autorisé sans étape de contrôle dédi
|
|||||||
|
|
||||||
## Ordre de développement cible
|
## Ordre de développement cible
|
||||||
|
|
||||||
- `kb_logging` doit être stabilisé avant les logs avancés des autres crates.
|
- `kb-logging` doit être stabilisé avant les logs avancés des autres crates.
|
||||||
- `kb_config` doit être stabilisé avant les stores, RPC, wallet, applications et workers.
|
- `kb-config` doit être stabilisé avant les stores, RPC, wallet et applications.
|
||||||
- Les contrats SQL et de matérialisation doivent être définis avant les gros décodeurs DEX.
|
- Les contrats SQL et de matérialisation doivent être définis avant les gros décodeurs DEX.
|
||||||
- Les implémentations détaillées des matérialisateurs doivent suivre les sorties réelles des décodeurs correspondants.
|
- Les implémentations détaillées des matérialisateurs doivent suivre les sorties réelles des décodeurs correspondants.
|
||||||
- `kb_app_demo` doit fournir des validations live après chaque capacité majeure, sans créer une application Tauri séparée par crate.
|
- `kb-app-demo` doit fournir des validations live après chaque capacité majeure, sans créer une application Tauri séparée par crate.
|
||||||
|
|
||||||
## Règles de livraison ChatGPT
|
## Règles de livraison ChatGPT
|
||||||
|
|
||||||
- Après le squelette initial, ChatGPT doit fournir uniquement des zips delta sauf demande explicite de zip complet.
|
- Après le squelette initial, ChatGPT doit fournir uniquement des zips delta sauf demande explicite de zip complet.
|
||||||
- Un delta qui modifie la racine du workspace ou plusieurs modules doit être nommé `khadhroony-bot2_vX.Y.Z-pre.abc-delta.zip`.
|
- Un delta qui modifie la racine du workspace ou plusieurs modules doit être nommé `khadhroony-bot3_vX.Y.Z-pre.abc-delta.zip`.
|
||||||
- Un delta qui ne modifie qu'un seul module Rust doit être nommé `kb_modulename_vX.Y.Z-pre.abc-delta.zip`.
|
- Un delta qui ne modifie qu'un seul module Rust doit être nommé `kb_modulename_vX.Y.Z-pre.abc-delta.zip`.
|
||||||
- Lorsqu'un delta `pre.abc` a déjà été livré et qu'un correctif est nécessaire, le numéro `abc` ne doit pas être incrémenté. Le correctif doit être nommé `khadhroony-bot2_vX.Y.Z-pre.abc-delta-fix-001.zip`, puis `-fix-002.zip`, etc. Pour un seul module, utiliser la même règle avec le préfixe `kb_modulename`.
|
- Lorsqu'un delta `pre.abc` a déjà été livré et qu'un correctif est nécessaire, le numéro `abc` ne doit pas être incrémenté. Le correctif doit être nommé `khadhroony-bot3_vX.Y.Z-pre.abc-delta-fix-001.zip`, puis `-fix-002.zip`, etc. Pour un seul module, utiliser la même règle avec le nom de package.
|
||||||
- Un correctif doit indiquer dans `delta.md` le delta de base et les correctifs antérieurs à appliquer. Il ne doit pas réutiliser silencieusement le nom ou l'empreinte d'une archive déjà livrée.
|
- Un correctif doit indiquer dans `delta.md` le delta de base et les correctifs antérieurs à appliquer. Il ne doit pas réutiliser silencieusement le nom ou l'empreinte d'une archive déjà livrée.
|
||||||
- Le numéro `pre.abc` suivant est réservé à une nouvelle tranche fonctionnelle, pas à la réparation d'une archive existante.
|
- Le numéro `pre.abc` suivant est réservé à une nouvelle tranche fonctionnelle, pas à la réparation d'une archive existante.
|
||||||
- Chaque zip delta ou correctif doit contenir un fichier `delta.md` non versionné à la racine du zip.
|
- Chaque zip delta ou correctif doit contenir un fichier `delta.md` non versionné à la racine du zip.
|
||||||
@@ -214,26 +215,31 @@ Aucun renommage massif de crates n'est autorisé sans étape de contrôle dédi
|
|||||||
- Les suppressions de fichiers ou dossiers doivent être indiquées dans `delta.md`, car l'extraction d'un zip ne supprime pas automatiquement les anciens fichiers.
|
- Les suppressions de fichiers ou dossiers doivent être indiquées dans `delta.md`, car l'extraction d'un zip ne supprime pas automatiquement les anciens fichiers.
|
||||||
- Les prompts de session doivent rappeler ce format de livraison et la règle `delta-fix-NNN`.
|
- Les prompts de session doivent rappeler ce format de livraison et la règle `delta-fix-NNN`.
|
||||||
|
|
||||||
## Constantes des décodeurs
|
## Constantes des composants de `kb-lib`
|
||||||
|
|
||||||
- Chaque crate `kb_decoder_*` classifié avec un `program_id` doit avoir un fichier `src/constants.rs`.
|
- Chaque composant classifié avec un `program_id` doit avoir son propre fichier `constants.rs`.
|
||||||
- `src/constants.rs` contient les `PROGRAM_ID`, puis les discriminators, sélecteurs, opcodes, constantes Borsh et constantes de décodage quand elles seront connues.
|
- `constants.rs` contient les discriminators, sélecteurs, opcodes, constantes Borsh et constantes de décodage quand elles sont connues.
|
||||||
- `src/lib.rs` doit réexporter les constantes nécessaires avec `pub use crate::constants::...`.
|
- Le module parent puis `kb-lib/src/lib.rs` réexportent les constantes publiques nécessaires.
|
||||||
- `src/decoder.rs` doit utiliser les constantes réexportées par la crate, par exemple `crate::AMM_PUMP_SWAP_PROGRAM_ID`, et non `crate::constants::AMM_PUMP_SWAP_PROGRAM_ID`.
|
- Les fichiers d'implémentation utilisent le chemin public le plus court réexporté par `kb-lib`.
|
||||||
- Les literals de `program_id` ne doivent pas rester dans `program_ids()`, sauf dans un fichier `constants.rs`.
|
- Les literals de `program_id` ne doivent pas rester dans `program_ids()`, sauf dans un fichier `constants.rs`.
|
||||||
|
|
||||||
## Identifiants de programmes
|
## Identifiants de programmes
|
||||||
|
|
||||||
Les `program_id` connus doivent être définis une seule fois dans `kb_program_ids`. Les crates de décodeur, d’exécuteur, de store, d’application ou d’outil doivent référencer directement `kb_program_ids::XXX_PROGRAM_ID`. Les fichiers `constants.rs` locaux ne doivent pas redéfinir ces chaînes ; ils restent réservés aux constantes internes du module, par exemple discriminators, opcodes, seeds, index de comptes ou layouts.
|
Les `program_id` connus doivent être définis une seule fois dans `kb-program-ids`. Les composants de décodeur, d’exécuteur, de store, d’application ou d’outil doivent référencer directement `kb_program_ids::XXX_PROGRAM_ID`. Les fichiers `constants.rs` locaux ne doivent pas redéfinir ces chaînes ; ils restent réservés aux constantes internes du module, par exemple discriminators, opcodes, seeds, index de comptes ou layouts.
|
||||||
|
|
||||||
## Validation frontend Tauri
|
## Validation frontend Tauri
|
||||||
|
|
||||||
- Pour `kb_app_demo`, ne pas lancer `npm --prefix kb_app_demo run build` séparément : la validation frontend de développement est réalisée par `cargo tauri dev -c kb_app_demo/tauri.conf.json`, qui démarre et pilote le serveur Vite.
|
- Pour `kb-app-demo`, ne pas lancer `npm --prefix kb-app-demo run build` séparément : la validation frontend de développement est réalisée par `cargo tauri dev -c kb-app-demo/tauri.conf.json`, qui démarre et pilote le serveur Vite.
|
||||||
|
|
||||||
## Architecture khadhroony-bot3
|
## Architecture `khadhroony-bot3`
|
||||||
|
|
||||||
- Les décodeurs, exécuteurs et matérialisateurs résident exclusivement dans `kb-lib`.
|
- Les décodeurs, exécuteurs et matérialisateurs résident exclusivement dans `kb-lib`.
|
||||||
- Les modules peuvent posséder leur propre fichier de constantes.
|
- Les modules peuvent posséder leur propre fichier de constantes.
|
||||||
- Toute API publique portée depuis une ancienne crate doit être réexportée au crate root de `kb-lib`.
|
- Toute API publique portée depuis une ancienne crate doit être réexportée au crate root de `kb-lib`.
|
||||||
- `kb-store` regroupe les contrats store-neutral et PostgreSQL.
|
- `kb-store` regroupe les contrats store-neutral et les adaptateurs de persistance, avec PostgreSQL comme adaptateur de production initial.
|
||||||
|
- `kb-store/src/lib.rs` reste une façade : les DTO, entités, traits, requêtes et implémentations résident dans des modules privés dédiés et toute API publique est réexportée au crate root.
|
||||||
|
- Les modèles source-neutral partagés avec les décodeurs, notamment `CoreInstructionReplayInput`, appartiennent à `kb-lib`; `kb-store` les consomme et les réexporte sans les dupliquer.
|
||||||
|
- `kb-lib` ne dépend jamais de `kb-store`. Cette direction de dépendance évite tout cycle entre modèles, décodage et persistance.
|
||||||
|
- `kb-store` ne dépend pas de `kb-config`. La frontière applicative transforme une configuration résolue en options de store explicitement validées.
|
||||||
|
- Les adaptateurs concrets implémentent les mêmes traits neutres et ne font pas fuiter leurs types de connexion dans les contrats.
|
||||||
- Seul `kb-app-demo` est conservé comme binaire pendant la migration initiale.
|
- Seul `kb-app-demo` est conservé comme binaire pendant la migration initiale.
|
||||||
|
|||||||
@@ -23,7 +23,7 @@
|
|||||||
"targets": [
|
"targets": [
|
||||||
"kb_app_demo",
|
"kb_app_demo",
|
||||||
"kb_decoder_metadata_metaplex_token_metadata",
|
"kb_decoder_metadata_metaplex_token_metadata",
|
||||||
"kb_decoder_solana_core",
|
"kb-lib.decoder.solana.core",
|
||||||
"kb_decoder_spl_associated_token_account",
|
"kb_decoder_spl_associated_token_account",
|
||||||
"kb_decoder_spl_elgamal_registry",
|
"kb_decoder_spl_elgamal_registry",
|
||||||
"kb_decoder_spl_memo",
|
"kb_decoder_spl_memo",
|
||||||
@@ -41,7 +41,7 @@
|
|||||||
"kb_executor_spl_single_pool",
|
"kb_executor_spl_single_pool",
|
||||||
"kb_executor_spl_token",
|
"kb_executor_spl_token",
|
||||||
"kb_executor_spl_token_2022",
|
"kb_executor_spl_token_2022",
|
||||||
"kb_logging",
|
"kb-logging",
|
||||||
"kb_materializer_admin",
|
"kb_materializer_admin",
|
||||||
"kb_materializer_compliance_audit",
|
"kb_materializer_compliance_audit",
|
||||||
"kb_materializer_lifecycle",
|
"kb_materializer_lifecycle",
|
||||||
@@ -51,7 +51,7 @@
|
|||||||
"kb_materializer_transaction_annotations",
|
"kb_materializer_transaction_annotations",
|
||||||
"kb_pipeline",
|
"kb_pipeline",
|
||||||
"kb_rpc",
|
"kb_rpc",
|
||||||
"kb_store_pg",
|
"kb-store",
|
||||||
"kb_wallet"
|
"kb_wallet"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
@@ -147,42 +147,42 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "file_kb_decoder_solana_core_debug",
|
"name": "file_kb_lib_decoder_solana_core_debug",
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
"sink": "file",
|
"sink": "file",
|
||||||
"level": "debug",
|
"level": "debug",
|
||||||
"path": "logs/devnet/kb_decoder_solana_core/debug.log",
|
"path": "logs/devnet/kb-lib/decoder/solana/core/debug.log",
|
||||||
"rotation": "daily",
|
"rotation": "daily",
|
||||||
"format": "human",
|
"format": "human",
|
||||||
"ansi": false,
|
"ansi": false,
|
||||||
"targets": [
|
"targets": [
|
||||||
"kb_decoder_solana_core"
|
"kb-lib.decoder.solana.core"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "file_kb_decoder_solana_core_info",
|
"name": "file_kb_lib_decoder_solana_core_info",
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
"sink": "file",
|
"sink": "file",
|
||||||
"level": "info",
|
"level": "info",
|
||||||
"path": "logs/devnet/kb_decoder_solana_core/info.log",
|
"path": "logs/devnet/kb-lib/decoder/solana/core/info.log",
|
||||||
"rotation": "daily",
|
"rotation": "daily",
|
||||||
"format": "human",
|
"format": "human",
|
||||||
"ansi": false,
|
"ansi": false,
|
||||||
"targets": [
|
"targets": [
|
||||||
"kb_decoder_solana_core"
|
"kb-lib.decoder.solana.core"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "file_kb_decoder_solana_core_error",
|
"name": "file_kb_lib_decoder_solana_core_error",
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
"sink": "file",
|
"sink": "file",
|
||||||
"level": "error",
|
"level": "error",
|
||||||
"path": "logs/devnet/kb_decoder_solana_core/error.jsonl",
|
"path": "logs/devnet/kb-lib/decoder/solana/core/error.jsonl",
|
||||||
"rotation": "daily",
|
"rotation": "daily",
|
||||||
"format": "json",
|
"format": "json",
|
||||||
"ansi": false,
|
"ansi": false,
|
||||||
"targets": [
|
"targets": [
|
||||||
"kb_decoder_solana_core"
|
"kb-lib.decoder.solana.core"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -658,12 +658,12 @@
|
|||||||
"enabled": true,
|
"enabled": true,
|
||||||
"sink": "file",
|
"sink": "file",
|
||||||
"level": "debug",
|
"level": "debug",
|
||||||
"path": "logs/devnet/kb_logging/debug.log",
|
"path": "logs/devnet/kb-logging/debug.log",
|
||||||
"rotation": "daily",
|
"rotation": "daily",
|
||||||
"format": "human",
|
"format": "human",
|
||||||
"ansi": false,
|
"ansi": false,
|
||||||
"targets": [
|
"targets": [
|
||||||
"kb_logging"
|
"kb-logging"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -671,12 +671,12 @@
|
|||||||
"enabled": true,
|
"enabled": true,
|
||||||
"sink": "file",
|
"sink": "file",
|
||||||
"level": "info",
|
"level": "info",
|
||||||
"path": "logs/devnet/kb_logging/info.log",
|
"path": "logs/devnet/kb-logging/info.log",
|
||||||
"rotation": "daily",
|
"rotation": "daily",
|
||||||
"format": "human",
|
"format": "human",
|
||||||
"ansi": false,
|
"ansi": false,
|
||||||
"targets": [
|
"targets": [
|
||||||
"kb_logging"
|
"kb-logging"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -684,12 +684,12 @@
|
|||||||
"enabled": true,
|
"enabled": true,
|
||||||
"sink": "file",
|
"sink": "file",
|
||||||
"level": "error",
|
"level": "error",
|
||||||
"path": "logs/devnet/kb_logging/error.jsonl",
|
"path": "logs/devnet/kb-logging/error.jsonl",
|
||||||
"rotation": "daily",
|
"rotation": "daily",
|
||||||
"format": "json",
|
"format": "json",
|
||||||
"ansi": false,
|
"ansi": false,
|
||||||
"targets": [
|
"targets": [
|
||||||
"kb_logging"
|
"kb-logging"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -970,12 +970,12 @@
|
|||||||
"enabled": true,
|
"enabled": true,
|
||||||
"sink": "file",
|
"sink": "file",
|
||||||
"level": "debug",
|
"level": "debug",
|
||||||
"path": "logs/devnet/kb_store_pg/debug.log",
|
"path": "logs/devnet/kb-store/debug.log",
|
||||||
"rotation": "daily",
|
"rotation": "daily",
|
||||||
"format": "human",
|
"format": "human",
|
||||||
"ansi": false,
|
"ansi": false,
|
||||||
"targets": [
|
"targets": [
|
||||||
"kb_store_pg"
|
"kb-store"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -983,12 +983,12 @@
|
|||||||
"enabled": true,
|
"enabled": true,
|
||||||
"sink": "file",
|
"sink": "file",
|
||||||
"level": "info",
|
"level": "info",
|
||||||
"path": "logs/devnet/kb_store_pg/info.log",
|
"path": "logs/devnet/kb-store/info.log",
|
||||||
"rotation": "daily",
|
"rotation": "daily",
|
||||||
"format": "human",
|
"format": "human",
|
||||||
"ansi": false,
|
"ansi": false,
|
||||||
"targets": [
|
"targets": [
|
||||||
"kb_store_pg"
|
"kb-store"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -996,12 +996,12 @@
|
|||||||
"enabled": true,
|
"enabled": true,
|
||||||
"sink": "file",
|
"sink": "file",
|
||||||
"level": "error",
|
"level": "error",
|
||||||
"path": "logs/devnet/kb_store_pg/error.jsonl",
|
"path": "logs/devnet/kb-store/error.jsonl",
|
||||||
"rotation": "daily",
|
"rotation": "daily",
|
||||||
"format": "json",
|
"format": "json",
|
||||||
"ansi": false,
|
"ansi": false,
|
||||||
"targets": [
|
"targets": [
|
||||||
"kb_store_pg"
|
"kb-store"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -1625,7 +1625,7 @@
|
|||||||
"targets": [
|
"targets": [
|
||||||
"kb_app_demo",
|
"kb_app_demo",
|
||||||
"kb_decoder_metadata_metaplex_token_metadata",
|
"kb_decoder_metadata_metaplex_token_metadata",
|
||||||
"kb_decoder_solana_core",
|
"kb-lib.decoder.solana.core",
|
||||||
"kb_decoder_spl_associated_token_account",
|
"kb_decoder_spl_associated_token_account",
|
||||||
"kb_decoder_spl_elgamal_registry",
|
"kb_decoder_spl_elgamal_registry",
|
||||||
"kb_decoder_spl_memo",
|
"kb_decoder_spl_memo",
|
||||||
@@ -1643,7 +1643,7 @@
|
|||||||
"kb_executor_spl_single_pool",
|
"kb_executor_spl_single_pool",
|
||||||
"kb_executor_spl_token",
|
"kb_executor_spl_token",
|
||||||
"kb_executor_spl_token_2022",
|
"kb_executor_spl_token_2022",
|
||||||
"kb_logging",
|
"kb-logging",
|
||||||
"kb_materializer_admin",
|
"kb_materializer_admin",
|
||||||
"kb_materializer_compliance_audit",
|
"kb_materializer_compliance_audit",
|
||||||
"kb_materializer_lifecycle",
|
"kb_materializer_lifecycle",
|
||||||
@@ -1653,7 +1653,7 @@
|
|||||||
"kb_materializer_transaction_annotations",
|
"kb_materializer_transaction_annotations",
|
||||||
"kb_pipeline",
|
"kb_pipeline",
|
||||||
"kb_rpc",
|
"kb_rpc",
|
||||||
"kb_store_pg",
|
"kb-store",
|
||||||
"kb_wallet"
|
"kb_wallet"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
@@ -1749,42 +1749,42 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "file_kb_decoder_solana_core_debug",
|
"name": "file_kb_lib_decoder_solana_core_debug",
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
"sink": "file",
|
"sink": "file",
|
||||||
"level": "debug",
|
"level": "debug",
|
||||||
"path": "logs/mainnet_research/kb_decoder_solana_core/debug.log",
|
"path": "logs/mainnet_research/kb-lib/decoder/solana/core/debug.log",
|
||||||
"rotation": "daily",
|
"rotation": "daily",
|
||||||
"format": "human",
|
"format": "human",
|
||||||
"ansi": false,
|
"ansi": false,
|
||||||
"targets": [
|
"targets": [
|
||||||
"kb_decoder_solana_core"
|
"kb-lib.decoder.solana.core"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "file_kb_decoder_solana_core_info",
|
"name": "file_kb_lib_decoder_solana_core_info",
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
"sink": "file",
|
"sink": "file",
|
||||||
"level": "info",
|
"level": "info",
|
||||||
"path": "logs/mainnet_research/kb_decoder_solana_core/info.log",
|
"path": "logs/mainnet_research/kb-lib/decoder/solana/core/info.log",
|
||||||
"rotation": "daily",
|
"rotation": "daily",
|
||||||
"format": "human",
|
"format": "human",
|
||||||
"ansi": false,
|
"ansi": false,
|
||||||
"targets": [
|
"targets": [
|
||||||
"kb_decoder_solana_core"
|
"kb-lib.decoder.solana.core"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "file_kb_decoder_solana_core_error",
|
"name": "file_kb_lib_decoder_solana_core_error",
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
"sink": "file",
|
"sink": "file",
|
||||||
"level": "error",
|
"level": "error",
|
||||||
"path": "logs/mainnet_research/kb_decoder_solana_core/error.jsonl",
|
"path": "logs/mainnet_research/kb-lib/decoder/solana/core/error.jsonl",
|
||||||
"rotation": "daily",
|
"rotation": "daily",
|
||||||
"format": "json",
|
"format": "json",
|
||||||
"ansi": false,
|
"ansi": false,
|
||||||
"targets": [
|
"targets": [
|
||||||
"kb_decoder_solana_core"
|
"kb-lib.decoder.solana.core"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -2260,12 +2260,12 @@
|
|||||||
"enabled": true,
|
"enabled": true,
|
||||||
"sink": "file",
|
"sink": "file",
|
||||||
"level": "debug",
|
"level": "debug",
|
||||||
"path": "logs/mainnet_research/kb_logging/debug.log",
|
"path": "logs/mainnet_research/kb-logging/debug.log",
|
||||||
"rotation": "daily",
|
"rotation": "daily",
|
||||||
"format": "human",
|
"format": "human",
|
||||||
"ansi": false,
|
"ansi": false,
|
||||||
"targets": [
|
"targets": [
|
||||||
"kb_logging"
|
"kb-logging"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -2273,12 +2273,12 @@
|
|||||||
"enabled": true,
|
"enabled": true,
|
||||||
"sink": "file",
|
"sink": "file",
|
||||||
"level": "info",
|
"level": "info",
|
||||||
"path": "logs/mainnet_research/kb_logging/info.log",
|
"path": "logs/mainnet_research/kb-logging/info.log",
|
||||||
"rotation": "daily",
|
"rotation": "daily",
|
||||||
"format": "human",
|
"format": "human",
|
||||||
"ansi": false,
|
"ansi": false,
|
||||||
"targets": [
|
"targets": [
|
||||||
"kb_logging"
|
"kb-logging"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -2286,12 +2286,12 @@
|
|||||||
"enabled": true,
|
"enabled": true,
|
||||||
"sink": "file",
|
"sink": "file",
|
||||||
"level": "error",
|
"level": "error",
|
||||||
"path": "logs/mainnet_research/kb_logging/error.jsonl",
|
"path": "logs/mainnet_research/kb-logging/error.jsonl",
|
||||||
"rotation": "daily",
|
"rotation": "daily",
|
||||||
"format": "json",
|
"format": "json",
|
||||||
"ansi": false,
|
"ansi": false,
|
||||||
"targets": [
|
"targets": [
|
||||||
"kb_logging"
|
"kb-logging"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -2572,12 +2572,12 @@
|
|||||||
"enabled": true,
|
"enabled": true,
|
||||||
"sink": "file",
|
"sink": "file",
|
||||||
"level": "debug",
|
"level": "debug",
|
||||||
"path": "logs/mainnet_research/kb_store_pg/debug.log",
|
"path": "logs/mainnet_research/kb-store/debug.log",
|
||||||
"rotation": "daily",
|
"rotation": "daily",
|
||||||
"format": "human",
|
"format": "human",
|
||||||
"ansi": false,
|
"ansi": false,
|
||||||
"targets": [
|
"targets": [
|
||||||
"kb_store_pg"
|
"kb-store"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -2585,12 +2585,12 @@
|
|||||||
"enabled": true,
|
"enabled": true,
|
||||||
"sink": "file",
|
"sink": "file",
|
||||||
"level": "info",
|
"level": "info",
|
||||||
"path": "logs/mainnet_research/kb_store_pg/info.log",
|
"path": "logs/mainnet_research/kb-store/info.log",
|
||||||
"rotation": "daily",
|
"rotation": "daily",
|
||||||
"format": "human",
|
"format": "human",
|
||||||
"ansi": false,
|
"ansi": false,
|
||||||
"targets": [
|
"targets": [
|
||||||
"kb_store_pg"
|
"kb-store"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -2598,12 +2598,12 @@
|
|||||||
"enabled": true,
|
"enabled": true,
|
||||||
"sink": "file",
|
"sink": "file",
|
||||||
"level": "error",
|
"level": "error",
|
||||||
"path": "logs/mainnet_research/kb_store_pg/error.jsonl",
|
"path": "logs/mainnet_research/kb-store/error.jsonl",
|
||||||
"rotation": "daily",
|
"rotation": "daily",
|
||||||
"format": "json",
|
"format": "json",
|
||||||
"ansi": false,
|
"ansi": false,
|
||||||
"targets": [
|
"targets": [
|
||||||
"kb_store_pg"
|
"kb-store"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -3427,7 +3427,7 @@
|
|||||||
"targets": [
|
"targets": [
|
||||||
"kb_app_demo",
|
"kb_app_demo",
|
||||||
"kb_decoder_metadata_metaplex_token_metadata",
|
"kb_decoder_metadata_metaplex_token_metadata",
|
||||||
"kb_decoder_solana_core",
|
"kb-lib.decoder.solana.core",
|
||||||
"kb_decoder_spl_associated_token_account",
|
"kb_decoder_spl_associated_token_account",
|
||||||
"kb_decoder_spl_elgamal_registry",
|
"kb_decoder_spl_elgamal_registry",
|
||||||
"kb_decoder_spl_memo",
|
"kb_decoder_spl_memo",
|
||||||
@@ -3445,7 +3445,7 @@
|
|||||||
"kb_executor_spl_single_pool",
|
"kb_executor_spl_single_pool",
|
||||||
"kb_executor_spl_token",
|
"kb_executor_spl_token",
|
||||||
"kb_executor_spl_token_2022",
|
"kb_executor_spl_token_2022",
|
||||||
"kb_logging",
|
"kb-logging",
|
||||||
"kb_materializer_admin",
|
"kb_materializer_admin",
|
||||||
"kb_materializer_compliance_audit",
|
"kb_materializer_compliance_audit",
|
||||||
"kb_materializer_lifecycle",
|
"kb_materializer_lifecycle",
|
||||||
@@ -3455,7 +3455,7 @@
|
|||||||
"kb_materializer_transaction_annotations",
|
"kb_materializer_transaction_annotations",
|
||||||
"kb_pipeline",
|
"kb_pipeline",
|
||||||
"kb_rpc",
|
"kb_rpc",
|
||||||
"kb_store_pg",
|
"kb-store",
|
||||||
"kb_wallet"
|
"kb_wallet"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
@@ -3551,42 +3551,42 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "file_kb_decoder_solana_core_debug",
|
"name": "file_kb_lib_decoder_solana_core_debug",
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
"sink": "file",
|
"sink": "file",
|
||||||
"level": "debug",
|
"level": "debug",
|
||||||
"path": "logs/mainnet/kb_decoder_solana_core/debug.log",
|
"path": "logs/mainnet/kb-lib/decoder/solana/core/debug.log",
|
||||||
"rotation": "daily",
|
"rotation": "daily",
|
||||||
"format": "human",
|
"format": "human",
|
||||||
"ansi": false,
|
"ansi": false,
|
||||||
"targets": [
|
"targets": [
|
||||||
"kb_decoder_solana_core"
|
"kb-lib.decoder.solana.core"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "file_kb_decoder_solana_core_info",
|
"name": "file_kb_lib_decoder_solana_core_info",
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
"sink": "file",
|
"sink": "file",
|
||||||
"level": "info",
|
"level": "info",
|
||||||
"path": "logs/mainnet/kb_decoder_solana_core/info.log",
|
"path": "logs/mainnet/kb-lib/decoder/solana/core/info.log",
|
||||||
"rotation": "daily",
|
"rotation": "daily",
|
||||||
"format": "human",
|
"format": "human",
|
||||||
"ansi": false,
|
"ansi": false,
|
||||||
"targets": [
|
"targets": [
|
||||||
"kb_decoder_solana_core"
|
"kb-lib.decoder.solana.core"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "file_kb_decoder_solana_core_error",
|
"name": "file_kb_lib_decoder_solana_core_error",
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
"sink": "file",
|
"sink": "file",
|
||||||
"level": "error",
|
"level": "error",
|
||||||
"path": "logs/mainnet/kb_decoder_solana_core/error.jsonl",
|
"path": "logs/mainnet/kb-lib/decoder/solana/core/error.jsonl",
|
||||||
"rotation": "daily",
|
"rotation": "daily",
|
||||||
"format": "json",
|
"format": "json",
|
||||||
"ansi": false,
|
"ansi": false,
|
||||||
"targets": [
|
"targets": [
|
||||||
"kb_decoder_solana_core"
|
"kb-lib.decoder.solana.core"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -4062,12 +4062,12 @@
|
|||||||
"enabled": true,
|
"enabled": true,
|
||||||
"sink": "file",
|
"sink": "file",
|
||||||
"level": "debug",
|
"level": "debug",
|
||||||
"path": "logs/mainnet/kb_logging/debug.log",
|
"path": "logs/mainnet/kb-logging/debug.log",
|
||||||
"rotation": "daily",
|
"rotation": "daily",
|
||||||
"format": "human",
|
"format": "human",
|
||||||
"ansi": false,
|
"ansi": false,
|
||||||
"targets": [
|
"targets": [
|
||||||
"kb_logging"
|
"kb-logging"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -4075,12 +4075,12 @@
|
|||||||
"enabled": true,
|
"enabled": true,
|
||||||
"sink": "file",
|
"sink": "file",
|
||||||
"level": "info",
|
"level": "info",
|
||||||
"path": "logs/mainnet/kb_logging/info.log",
|
"path": "logs/mainnet/kb-logging/info.log",
|
||||||
"rotation": "daily",
|
"rotation": "daily",
|
||||||
"format": "human",
|
"format": "human",
|
||||||
"ansi": false,
|
"ansi": false,
|
||||||
"targets": [
|
"targets": [
|
||||||
"kb_logging"
|
"kb-logging"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -4088,12 +4088,12 @@
|
|||||||
"enabled": true,
|
"enabled": true,
|
||||||
"sink": "file",
|
"sink": "file",
|
||||||
"level": "error",
|
"level": "error",
|
||||||
"path": "logs/mainnet/kb_logging/error.jsonl",
|
"path": "logs/mainnet/kb-logging/error.jsonl",
|
||||||
"rotation": "daily",
|
"rotation": "daily",
|
||||||
"format": "json",
|
"format": "json",
|
||||||
"ansi": false,
|
"ansi": false,
|
||||||
"targets": [
|
"targets": [
|
||||||
"kb_logging"
|
"kb-logging"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -4374,12 +4374,12 @@
|
|||||||
"enabled": true,
|
"enabled": true,
|
||||||
"sink": "file",
|
"sink": "file",
|
||||||
"level": "debug",
|
"level": "debug",
|
||||||
"path": "logs/mainnet/kb_store_pg/debug.log",
|
"path": "logs/mainnet/kb-store/debug.log",
|
||||||
"rotation": "daily",
|
"rotation": "daily",
|
||||||
"format": "human",
|
"format": "human",
|
||||||
"ansi": false,
|
"ansi": false,
|
||||||
"targets": [
|
"targets": [
|
||||||
"kb_store_pg"
|
"kb-store"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -4387,12 +4387,12 @@
|
|||||||
"enabled": true,
|
"enabled": true,
|
||||||
"sink": "file",
|
"sink": "file",
|
||||||
"level": "info",
|
"level": "info",
|
||||||
"path": "logs/mainnet/kb_store_pg/info.log",
|
"path": "logs/mainnet/kb-store/info.log",
|
||||||
"rotation": "daily",
|
"rotation": "daily",
|
||||||
"format": "human",
|
"format": "human",
|
||||||
"ansi": false,
|
"ansi": false,
|
||||||
"targets": [
|
"targets": [
|
||||||
"kb_store_pg"
|
"kb-store"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -4400,12 +4400,12 @@
|
|||||||
"enabled": true,
|
"enabled": true,
|
||||||
"sink": "file",
|
"sink": "file",
|
||||||
"level": "error",
|
"level": "error",
|
||||||
"path": "logs/mainnet/kb_store_pg/error.jsonl",
|
"path": "logs/mainnet/kb-store/error.jsonl",
|
||||||
"rotation": "daily",
|
"rotation": "daily",
|
||||||
"format": "json",
|
"format": "json",
|
||||||
"ansi": false,
|
"ansi": false,
|
||||||
"targets": [
|
"targets": [
|
||||||
"kb_store_pg"
|
"kb-store"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<!-- file: docs/TRACING_CONTRACT.md -->
|
<!-- file: docs/TRACING_CONTRACT.md -->
|
||||||
<!-- version: 12 -->
|
<!-- version: 13 -->
|
||||||
|
|
||||||
# Contrat de tracing par crate
|
# Contrat de tracing par crate et composant
|
||||||
|
|
||||||
## Objectif
|
## Objectif
|
||||||
|
|
||||||
@@ -21,19 +21,21 @@ Une crate passive contient uniquement des types, contrats, DTO, traits sans impl
|
|||||||
|
|
||||||
## Cible canonique
|
## Cible canonique
|
||||||
|
|
||||||
Toute crate qui déclare `tracing.workspace = true` doit :
|
Toute crate opérationnelle hors `kb-lib` qui déclare `tracing.workspace = true` doit :
|
||||||
|
|
||||||
- posséder `src/constants.rs` ;
|
- posséder `src/constants.rs` ;
|
||||||
- définir exactement une constante `pub(crate) const TRACING_TARGET` ;
|
- définir exactement une constante `pub(crate) const TRACING_TARGET` ;
|
||||||
- utiliser comme valeur le nom exact du package Cargo ;
|
- utiliser comme valeur le nom exact du package Cargo ;
|
||||||
- appeler les macros avec `target: crate::constants::TRACING_TARGET` ;
|
- réexporter la constante depuis sa façade puis appeler les macros avec `target: crate::TRACING_TARGET` ;
|
||||||
- déplacer la granularité interne dans des champs structurés.
|
- déplacer la granularité interne dans des champs structurés.
|
||||||
|
|
||||||
|
`kb-lib` constitue une exception architecturale volontaire : chaque décodeur, matérialiseur ou exécuteur opérationnel possède son propre `constants.rs` et un target hiérarchique `kb-lib.<famille>.<surface>`. La façade de `kb-lib` réexporte chaque target sous un alias interne préfixé afin que plusieurs composants puissent coexister sans collision.
|
||||||
|
|
||||||
Exemple :
|
Exemple :
|
||||||
|
|
||||||
```rust
|
```rust
|
||||||
tracing::error!(
|
tracing::error!(
|
||||||
target: crate::constants::TRACING_TARGET,
|
target: crate::SOLANA_CORE_TRACING_TARGET,
|
||||||
action = "decoder_outcome_failure",
|
action = "decoder_outcome_failure",
|
||||||
campaign_id = %campaign_id,
|
campaign_id = %campaign_id,
|
||||||
signature = %input.signature,
|
signature = %input.signature,
|
||||||
@@ -47,40 +49,19 @@ tracing::error!(
|
|||||||
);
|
);
|
||||||
```
|
```
|
||||||
|
|
||||||
Les targets `khbot.*`, les targets suffixés par module et les targets de fenêtre ne sont plus utilisés par les crates opérationnelles migrées. Les identifiants frontend historiques restent acceptés comme valeurs du champ `frontend_target`, mais l’événement Rust est émis sous `kb_app_demo`.
|
Les targets `khbot.*` et les targets de fenêtre ne sont plus utilisés par les composants opérationnels migrés. Les identifiants frontend historiques restent acceptés comme valeurs du champ `frontend_target`, mais l’événement Rust est émis sous le target canonique de l’application.
|
||||||
|
|
||||||
## Crates actuellement routées
|
## Crates actuellement routées
|
||||||
|
|
||||||
La configuration couvre toutes les crates qui déclarent actuellement `tracing.workspace = true` :
|
Les targets déjà migrés vers les noms canoniques bot3 sont :
|
||||||
|
|
||||||
```text
|
```text
|
||||||
kb_app_demo
|
kb-lib.decoder.solana.core
|
||||||
kb_decoder_solana_core
|
kb-logging
|
||||||
kb_decoder_spl_associated_token_account
|
kb-store
|
||||||
kb_decoder_spl_memo
|
|
||||||
kb_decoder_spl_token
|
|
||||||
kb_decoder_spl_token_2022
|
|
||||||
kb_executor_metadata_spl_name_service
|
|
||||||
kb_execution_solana
|
|
||||||
kb_executor_solana_core
|
|
||||||
kb_executor_spl_account_compression
|
|
||||||
kb_executor_spl_associated_token_account
|
|
||||||
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_materializer_transaction_annotations
|
|
||||||
kb_pipeline
|
|
||||||
kb_rpc
|
|
||||||
kb_store_pg
|
|
||||||
kb_wallet
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Le test `every_tracing_crate_has_one_canonical_target_constant` détecte automatiquement toute crate avec `tracing.workspace = true` et vérifie la présence d’une unique constante canonique. Le test de `kb_config` vérifie parallèlement la matrice de routes de chaque profil.
|
L’audit `audit_khadhroony_workspace_rules.py` vérifie les targets de crates et les targets hiérarchiques de `kb-lib`. Il interdit également les faux usages `let _target` et impose que chaque macro de `kb-lib` utilise un alias réexporté par la façade.
|
||||||
|
|
||||||
Les routes sont filtrées au niveau de leur writer plutôt que par un `Filtered` layer distinct. Un seul filtre d’admission agrégé empêche en amont le formatage des événements refusés par toutes les routes. Cette architecture préserve toutes les sorties globales et par crate au-delà de la limite interne de 64 identifiants de filtres de `tracing-subscriber`. Le test `more_than_64_routes_compose_without_filtered_layer_ids` protège explicitement ce contrat.
|
Les routes sont filtrées au niveau de leur writer plutôt que par un `Filtered` layer distinct. Un seul filtre d’admission agrégé empêche en amont le formatage des événements refusés par toutes les routes. Cette architecture préserve toutes les sorties globales et par crate au-delà de la limite interne de 64 identifiants de filtres de `tracing-subscriber`. Le test `more_than_64_routes_compose_without_filtered_layer_ids` protège explicitement ce contrat.
|
||||||
|
|
||||||
@@ -137,10 +118,10 @@ Ils sont journalisés en `debug`, `info` ou `warn` selon leur impact.
|
|||||||
|
|
||||||
## Responsabilité
|
## Responsabilité
|
||||||
|
|
||||||
- `kb_rpc` : sélection d’endpoint, requêtes, réponses bornées, retry, rate limit et transport.
|
- `kb-rpc` : sélection d’endpoint, requêtes, réponses bornées, retry, rate limit et transport.
|
||||||
- `kb_execution_solana` : assemblage du message, contrôle du contrat de signataires, liaison de simulation et signature transactionnelle.
|
- composants `kb-lib.executor` : assemblage du message, contrôle du contrat de signataires, liaison de simulation et signature transactionnelle.
|
||||||
- `kb_store_pg` : transactions SQL, commit/rollback, compteurs et erreurs de persistance.
|
- `kb-store` : transactions SQL, commit/rollback, compteurs et erreurs de persistance.
|
||||||
- `kb_pipeline` : sélection, dispatch, concurrence, annulation, backfill et agrégation.
|
- `kb-pipeline` : sélection, dispatch, concurrence, annulation, backfill et agrégation.
|
||||||
- décodeur : reconnaissance, validation du format, décision et diagnostic borné.
|
- décodeur : reconnaissance, validation du format, décision et diagnostic borné.
|
||||||
- matérialiseur : applicabilité exacte, politique de transaction et sorties produites.
|
- matérialiseur : applicabilité exacte, politique de transaction et sorties produites.
|
||||||
- exécuteur : support, construction, simulation et garde-fous.
|
- exécuteur : support, construction, simulation et garde-fous.
|
||||||
@@ -166,3 +147,5 @@ L’ajout ou la suppression de `tracing.workspace = true` impose dans le même d
|
|||||||
3. la mise à jour de `config/example.config.json` ;
|
3. la mise à jour de `config/example.config.json` ;
|
||||||
4. la mise à jour des tests de contrat ;
|
4. la mise à jour des tests de contrat ;
|
||||||
5. la mise à jour de la liste ci-dessus.
|
5. la mise à jour de la liste ci-dessus.
|
||||||
|
|
||||||
|
Pour un composant de `kb-lib`, les mêmes obligations s’appliquent au target hiérarchique, à son alias de façade et à ses routes dédiées.
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
# file: kb-lib/Cargo.toml
|
# file: kb-lib/Cargo.toml
|
||||||
# version: 2
|
# version: 3
|
||||||
|
|
||||||
[package]
|
[package]
|
||||||
name = "kb-lib"
|
name = "kb-lib"
|
||||||
@@ -9,14 +9,25 @@ license.workspace = true
|
|||||||
publish.workspace = true
|
publish.workspace = true
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
|
base64.workspace = true
|
||||||
|
borsh.workspace = true
|
||||||
|
bs58.workspace = true
|
||||||
kb-core = { path = "../kb-core" }
|
kb-core = { path = "../kb-core" }
|
||||||
kb-program-ids = { path = "../kb-program-ids" }
|
kb-program-ids = { path = "../kb-program-ids" }
|
||||||
base64 = "0.22"
|
serde.workspace = true
|
||||||
bs58 = "0.5"
|
serde_json.workspace = true
|
||||||
serde = { version = "1", features = ["derive"] }
|
sha2.workspace = true
|
||||||
serde_json = "1"
|
solana-address-lookup-table-interface.workspace = true
|
||||||
sha2 = "0.10"
|
solana-compute-budget-interface.workspace = true
|
||||||
ts-rs = { version = "11", features = ["serde-compat"] }
|
solana-loader-v3-interface.workspace = true
|
||||||
|
solana-sdk-ids.workspace = true
|
||||||
|
solana-stake-interface.workspace = true
|
||||||
|
solana-system-interface.workspace = true
|
||||||
|
solana-vote-interface.workspace = true
|
||||||
|
solana-zk-elgamal-proof-interface.workspace = true
|
||||||
|
tracing.workspace = true
|
||||||
|
ts-rs.workspace = true
|
||||||
|
wincode.workspace = true
|
||||||
|
|
||||||
[lints]
|
[lints]
|
||||||
workspace = true
|
workspace = true
|
||||||
|
|||||||
51
kb-lib/README.md
Normal file
51
kb-lib/README.md
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
<!-- file: kb-lib/README.md -->
|
||||||
|
<!-- version: 1 -->
|
||||||
|
|
||||||
|
# kb-lib
|
||||||
|
|
||||||
|
`kb-lib` regroupe les modèles partagés et les composants de décodage, matérialisation et exécution de `khadhroony-bot3`. Les composants restent séparés par modules privés et sont exposés par la façade unique `kb-lib/src/lib.rs`.
|
||||||
|
|
||||||
|
## Décodeur Solana Core
|
||||||
|
|
||||||
|
`SolanaCoreDecoder` est le premier décodeur concret porté depuis bot2. Il implémente `InstructionDecoder` et `ProtocolDecoder` sans dépendre de PostgreSQL, du RPC, de Tauri ou du wallet.
|
||||||
|
|
||||||
|
Il couvre les 18 surfaces natives suivantes :
|
||||||
|
|
||||||
|
- System Program ;
|
||||||
|
- Compute Budget ;
|
||||||
|
- Address Lookup Table ;
|
||||||
|
- Config et Feature Gate ;
|
||||||
|
- Vote et Stake ;
|
||||||
|
- loaders natifs, BPF historiques, upgradeable et Loader v4 ;
|
||||||
|
- précompiles Ed25519, secp256k1 et secp256r1 ;
|
||||||
|
- Slashing ;
|
||||||
|
- ZK ElGamal Proof et l’ancien ZK Token Proof.
|
||||||
|
|
||||||
|
La matrice `docs/NATIVE_SOLANA_DECODER_MATRIX.json` reste la source machine-readable de la couverture. Les instructions inconnues, tronquées, historiques ou issues d’une transaction échouée conservent les statuts et diagnostics explicites définis par les contrats communs.
|
||||||
|
|
||||||
|
## API publique utile
|
||||||
|
|
||||||
|
- `SolanaCoreDecoder` : décodeur concret natif ;
|
||||||
|
- `InstructionDecoder` : contrat de reconnaissance, couverture et décodage contextualisé ;
|
||||||
|
- `ProtocolDecoder` : contrat de compatibilité avec les observations historiques ;
|
||||||
|
- `CoreInstructionReplayInput` : input source-neutral produit par l’extraction core ;
|
||||||
|
- `DecoderExecutionResult`, `DecoderRecognition` et `DecoderCoverageDeclaration` : résultats typés du pipeline ;
|
||||||
|
- modèles canoniques et nomenclature réexportés depuis la façade.
|
||||||
|
|
||||||
|
Les helpers, constantes wire et types intermédiaires de chaque composant restent internes. Leurs noms sont préfixés au niveau de la façade interne afin d’éviter les collisions lors de la fusion des anciens crates.
|
||||||
|
|
||||||
|
## Exemple
|
||||||
|
|
||||||
|
```rust
|
||||||
|
let decoder = kb_lib::SolanaCoreDecoder;
|
||||||
|
let recognition = kb_lib::InstructionDecoder::recognize(&decoder, &input);
|
||||||
|
```
|
||||||
|
|
||||||
|
L’appel de décodage complet utilise `InstructionDecoder::decode` après une reconnaissance compatible. Le pipeline demeure responsable de la sélection du décodeur et de la persistance du résultat.
|
||||||
|
|
||||||
|
## Frontières
|
||||||
|
|
||||||
|
- `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.
|
||||||
@@ -1,13 +1,10 @@
|
|||||||
// file: kb-lib/src/decoder/api.rs
|
// file: kb-lib/src/decoder/api.rs
|
||||||
// version: 3
|
// version: 4
|
||||||
|
|
||||||
//! Decoder contracts consolidated from `kb_decoder_api`.
|
//! Decoder contracts consolidated from `kb_decoder_api`.
|
||||||
|
|
||||||
/// Decoder API constants.
|
|
||||||
pub mod constants;
|
|
||||||
pub mod contracts;
|
pub mod contracts;
|
||||||
pub mod decoder;
|
pub mod decoder;
|
||||||
/// Canonical tracing target for this crate.
|
|
||||||
/// Current contextual core instruction input contract version.
|
/// Current contextual core instruction input contract version.
|
||||||
pub use crate::decoder::api::contracts::CORE_INSTRUCTION_INPUT_CONTRACT_VERSION;
|
pub use crate::decoder::api::contracts::CORE_INSTRUCTION_INPUT_CONTRACT_VERSION;
|
||||||
/// Stable contextual decoded observation.
|
/// Stable contextual decoded observation.
|
||||||
|
|||||||
@@ -1,5 +0,0 @@
|
|||||||
// file: kb-lib/src/decoder/api/constants.rs
|
|
||||||
// version: 1
|
|
||||||
|
|
||||||
/// Canonical tracing target for this crate.
|
|
||||||
pub(crate) const TRACING_TARGET: &str = "kb_decoder_api";
|
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
// file: kb-lib/src/decoder/api/decoder.rs
|
// file: kb-lib/src/decoder/api/decoder.rs
|
||||||
// version: 4
|
// version: 5
|
||||||
|
|
||||||
//! Shared decoder contracts for protocol decoder crates.
|
//! Shared decoder contracts for protocol decoder crates.
|
||||||
|
|
||||||
@@ -68,7 +68,6 @@ impl ProtocolDecoder for InitialDecoder {
|
|||||||
&self,
|
&self,
|
||||||
_observation: &crate::ProgramObservation,
|
_observation: &crate::ProgramObservation,
|
||||||
) -> kb_core::Result<std::vec::Vec<crate::DecodedProtocolEvent>> {
|
) -> kb_core::Result<std::vec::Vec<crate::DecodedProtocolEvent>> {
|
||||||
let _target = crate::TRACING_TARGET;
|
|
||||||
return std::result::Result::Ok(std::vec::Vec::new());
|
return std::result::Result::Ok(std::vec::Vec::new());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,95 @@
|
|||||||
// file: kb-lib/src/decoder/solana.rs
|
// file: kb-lib/src/decoder/solana.rs
|
||||||
// version: 1
|
// version: 3
|
||||||
|
|
||||||
//! `solana` decoder family.
|
//! `solana` decoder family.
|
||||||
|
|
||||||
pub mod core;
|
mod core;
|
||||||
|
|
||||||
|
pub(crate) use self::core::SOLANA_CORE_ADDRESS_LOOKUP_TABLE_SURFACE_CODE;
|
||||||
|
pub(crate) use self::core::SOLANA_CORE_BPF_LOADER_DEPRECATED_SURFACE_CODE;
|
||||||
|
pub(crate) use self::core::SOLANA_CORE_BPF_LOADER_SURFACE_CODE;
|
||||||
|
pub(crate) use self::core::SOLANA_CORE_BPF_LOADER_UPGRADEABLE_SURFACE_CODE;
|
||||||
|
pub(crate) use self::core::SOLANA_CORE_COMPUTE_BUDGET_SURFACE_CODE;
|
||||||
|
pub(crate) use self::core::SOLANA_CORE_CONFIG_SURFACE_CODE;
|
||||||
|
pub(crate) use self::core::SOLANA_CORE_ED25519_PUBLIC_KEY_BYTES;
|
||||||
|
pub(crate) use self::core::SOLANA_CORE_ED25519_SURFACE_CODE;
|
||||||
|
pub(crate) use self::core::SOLANA_CORE_FEATURE_SURFACE_CODE;
|
||||||
|
pub(crate) use self::core::SOLANA_CORE_LOADER_V4_SURFACE_CODE;
|
||||||
|
pub(crate) use self::core::SOLANA_CORE_MAX_NATIVE_INSTRUCTION_PAYLOAD_BYTES;
|
||||||
|
pub(crate) use self::core::SOLANA_CORE_NATIVE_EVENT_VERSION;
|
||||||
|
pub(crate) use self::core::SOLANA_CORE_NATIVE_LOADER_SURFACE_CODE;
|
||||||
|
pub(crate) use self::core::SOLANA_CORE_PRECOMPILE_COMPONENT_PREFIX_BYTES;
|
||||||
|
pub(crate) use self::core::SOLANA_CORE_PROTOCOL_CODE;
|
||||||
|
pub(crate) use self::core::SOLANA_CORE_SECP256K1_ETHEREUM_ADDRESS_BYTES;
|
||||||
|
pub(crate) use self::core::SOLANA_CORE_SECP256K1_SURFACE_CODE;
|
||||||
|
pub(crate) use self::core::SOLANA_CORE_SECP256R1_MAX_SIGNATURES;
|
||||||
|
pub(crate) use self::core::SOLANA_CORE_SECP256R1_PUBLIC_KEY_BYTES;
|
||||||
|
pub(crate) use self::core::SOLANA_CORE_SECP256R1_SURFACE_CODE;
|
||||||
|
pub(crate) use self::core::SOLANA_CORE_SIGNATURE_BYTES;
|
||||||
|
pub(crate) use self::core::SOLANA_CORE_SLASHING_SURFACE_CODE;
|
||||||
|
pub(crate) use self::core::SOLANA_CORE_STAKE_SURFACE_CODE;
|
||||||
|
pub(crate) use self::core::SOLANA_CORE_SYSTEM_SURFACE_CODE;
|
||||||
|
pub(crate) use self::core::SOLANA_CORE_TRACING_TARGET;
|
||||||
|
pub(crate) use self::core::SOLANA_CORE_U8_OFFSETS_BYTES;
|
||||||
|
pub(crate) use self::core::SOLANA_CORE_U16_OFFSETS_BYTES;
|
||||||
|
pub(crate) use self::core::SOLANA_CORE_VOTE_SURFACE_CODE;
|
||||||
|
pub(crate) use self::core::SOLANA_CORE_ZK_ELGAMAL_PROOF_SURFACE_CODE;
|
||||||
|
pub(crate) use self::core::SOLANA_CORE_ZK_TOKEN_PROOF_SURFACE_CODE;
|
||||||
|
pub(crate) use self::core::SolanaCoreAccountRole;
|
||||||
|
pub(crate) use self::core::SolanaCoreResolvedInstructionPayload;
|
||||||
|
pub(crate) use self::core::solana_core_address_lookup_table_coverage;
|
||||||
|
pub(crate) use self::core::solana_core_address_lookup_table_decode;
|
||||||
|
pub(crate) use self::core::solana_core_address_lookup_table_recognize;
|
||||||
|
pub(crate) use self::core::solana_core_bounded_hexadecimal_prefix;
|
||||||
|
pub(crate) use self::core::solana_core_bounded_slice;
|
||||||
|
pub(crate) use self::core::solana_core_compute_budget_coverage;
|
||||||
|
pub(crate) use self::core::solana_core_compute_budget_decode;
|
||||||
|
pub(crate) use self::core::solana_core_compute_budget_recognize;
|
||||||
|
pub(crate) use self::core::solana_core_config_coverage;
|
||||||
|
pub(crate) use self::core::solana_core_config_decode;
|
||||||
|
pub(crate) use self::core::solana_core_config_recognize;
|
||||||
|
pub(crate) use self::core::solana_core_decode_instruction_data;
|
||||||
|
pub(crate) use self::core::solana_core_decoded_payload_length;
|
||||||
|
pub(crate) use self::core::solana_core_decoded_payload_sha256;
|
||||||
|
pub(crate) use self::core::solana_core_decoded_result;
|
||||||
|
pub(crate) use self::core::solana_core_failed_result;
|
||||||
|
pub(crate) use self::core::solana_core_feature_coverage;
|
||||||
|
pub(crate) use self::core::solana_core_feature_decode;
|
||||||
|
pub(crate) use self::core::solana_core_feature_recognize;
|
||||||
|
pub(crate) use self::core::solana_core_hash_bytes;
|
||||||
|
pub(crate) use self::core::solana_core_hexadecimal_prefix;
|
||||||
|
pub(crate) use self::core::solana_core_loaders_coverage;
|
||||||
|
pub(crate) use self::core::solana_core_loaders_decode;
|
||||||
|
pub(crate) use self::core::solana_core_loaders_recognize;
|
||||||
|
pub(crate) use self::core::solana_core_payload_hash;
|
||||||
|
pub(crate) use self::core::solana_core_precompiles_coverage;
|
||||||
|
pub(crate) use self::core::solana_core_precompiles_decode;
|
||||||
|
pub(crate) use self::core::solana_core_precompiles_recognize;
|
||||||
|
pub(crate) use self::core::solana_core_read_u32_le;
|
||||||
|
pub(crate) use self::core::solana_core_read_u64_le;
|
||||||
|
pub(crate) use self::core::solana_core_resolve_accounts;
|
||||||
|
pub(crate) use self::core::solana_core_resolve_u8_instruction_payload;
|
||||||
|
pub(crate) use self::core::solana_core_resolve_u16_instruction_payload;
|
||||||
|
pub(crate) use self::core::solana_core_slashing_coverage;
|
||||||
|
pub(crate) use self::core::solana_core_slashing_decode;
|
||||||
|
pub(crate) use self::core::solana_core_slashing_recognize;
|
||||||
|
pub(crate) use self::core::solana_core_stake_coverage;
|
||||||
|
pub(crate) use self::core::solana_core_stake_decode;
|
||||||
|
pub(crate) use self::core::solana_core_stake_recognize;
|
||||||
|
pub(crate) use self::core::solana_core_system_coverage;
|
||||||
|
pub(crate) use self::core::solana_core_system_decode;
|
||||||
|
pub(crate) use self::core::solana_core_system_recognize;
|
||||||
|
pub(crate) use self::core::solana_core_target_outer_instruction_index;
|
||||||
|
pub(crate) use self::core::solana_core_unsupported_result;
|
||||||
|
pub(crate) use self::core::solana_core_vote_coverage;
|
||||||
|
pub(crate) use self::core::solana_core_vote_decode;
|
||||||
|
pub(crate) use self::core::solana_core_vote_recognize;
|
||||||
|
pub(crate) use self::core::solana_core_zk_elgamal_coverage;
|
||||||
|
pub(crate) use self::core::solana_core_zk_elgamal_decode;
|
||||||
|
pub(crate) use self::core::solana_core_zk_elgamal_recognize;
|
||||||
|
pub(crate) use self::core::solana_core_zk_token_proof_coverage;
|
||||||
|
pub(crate) use self::core::solana_core_zk_token_proof_decode;
|
||||||
|
pub(crate) use self::core::solana_core_zk_token_proof_recognize;
|
||||||
|
|
||||||
|
/// Runtime-native Solana decoder with phased maximal native coverage.
|
||||||
|
pub use self::core::SolanaCoreDecoder;
|
||||||
|
|||||||
@@ -1,10 +1,197 @@
|
|||||||
// file: kb-lib/src/decoder/solana/core.rs
|
// file: kb-lib/src/decoder/solana/core.rs
|
||||||
// version: 1
|
// version: 17
|
||||||
|
|
||||||
//! Migration boundary for legacy crate `kb_decoder_solana_core`.
|
//! Solana Core decoder component.
|
||||||
|
|
||||||
/// Legacy crate name retained for migration and compatibility tracking.
|
mod accounts;
|
||||||
pub const LEGACY_CRATE: &str = "kb_decoder_solana_core";
|
mod address_lookup_table;
|
||||||
|
mod compute_budget;
|
||||||
|
mod config;
|
||||||
|
mod constants;
|
||||||
|
mod decoder;
|
||||||
|
mod event;
|
||||||
|
mod feature;
|
||||||
|
mod loaders;
|
||||||
|
mod payload;
|
||||||
|
mod precompiles;
|
||||||
|
mod slashing;
|
||||||
|
mod stake;
|
||||||
|
mod system;
|
||||||
|
mod vote;
|
||||||
|
mod zk_elgamal;
|
||||||
|
mod zk_token_proof;
|
||||||
|
|
||||||
/// Current porting status.
|
/// Expected role and privileges for one positional instruction account.
|
||||||
pub const MIGRATION_STATUS: &str = "source-preserved-pending-port";
|
pub(crate) use self::accounts::AccountRole as SolanaCoreAccountRole;
|
||||||
|
/// Resolves positional instruction accounts and validates their core indexes.
|
||||||
|
pub(crate) use self::accounts::resolve_accounts as solana_core_resolve_accounts;
|
||||||
|
/// Returns declared Address Lookup Table instruction coverage.
|
||||||
|
pub(crate) use self::address_lookup_table::address_lookup_table_coverage as solana_core_address_lookup_table_coverage;
|
||||||
|
/// Decodes one Address Lookup Table instruction.
|
||||||
|
pub(crate) use self::address_lookup_table::address_lookup_table_decode as solana_core_address_lookup_table_decode;
|
||||||
|
/// Recognizes one Address Lookup Table instruction without producing an event.
|
||||||
|
pub(crate) use self::address_lookup_table::address_lookup_table_recognize as solana_core_address_lookup_table_recognize;
|
||||||
|
/// Returns declared Compute Budget instruction coverage.
|
||||||
|
pub(crate) use self::compute_budget::compute_budget_coverage as solana_core_compute_budget_coverage;
|
||||||
|
/// Decodes one Compute Budget instruction.
|
||||||
|
pub(crate) use self::compute_budget::compute_budget_decode as solana_core_compute_budget_decode;
|
||||||
|
/// Recognizes one Compute Budget instruction without producing an event.
|
||||||
|
pub(crate) use self::compute_budget::compute_budget_recognize as solana_core_compute_budget_recognize;
|
||||||
|
/// Returns declared Config Program instruction coverage.
|
||||||
|
pub(crate) use self::config::config_coverage as solana_core_config_coverage;
|
||||||
|
/// Decodes one generic Config Program store instruction.
|
||||||
|
pub(crate) use self::config::config_decode as solana_core_config_decode;
|
||||||
|
/// Recognizes one generic Config Program store instruction.
|
||||||
|
pub(crate) use self::config::config_recognize as solana_core_config_recognize;
|
||||||
|
/// Stable Address Lookup Table surface code.
|
||||||
|
pub(crate) use self::constants::ADDRESS_LOOKUP_TABLE_SURFACE_CODE as SOLANA_CORE_ADDRESS_LOOKUP_TABLE_SURFACE_CODE;
|
||||||
|
/// Stable deprecated immutable BPF Loader surface code.
|
||||||
|
pub(crate) use self::constants::BPF_LOADER_DEPRECATED_SURFACE_CODE as SOLANA_CORE_BPF_LOADER_DEPRECATED_SURFACE_CODE;
|
||||||
|
/// Stable immutable BPF Loader v2 surface code.
|
||||||
|
pub(crate) use self::constants::BPF_LOADER_SURFACE_CODE as SOLANA_CORE_BPF_LOADER_SURFACE_CODE;
|
||||||
|
/// Stable upgradeable BPF Loader surface code.
|
||||||
|
pub(crate) use self::constants::BPF_LOADER_UPGRADEABLE_SURFACE_CODE as SOLANA_CORE_BPF_LOADER_UPGRADEABLE_SURFACE_CODE;
|
||||||
|
/// Stable Compute Budget surface code.
|
||||||
|
pub(crate) use self::constants::COMPUTE_BUDGET_SURFACE_CODE as SOLANA_CORE_COMPUTE_BUDGET_SURFACE_CODE;
|
||||||
|
/// Stable Config Program surface code.
|
||||||
|
pub(crate) use self::constants::CONFIG_SURFACE_CODE as SOLANA_CORE_CONFIG_SURFACE_CODE;
|
||||||
|
/// Byte length of an Ed25519 public key.
|
||||||
|
pub(crate) use self::constants::ED25519_PUBLIC_KEY_BYTES as SOLANA_CORE_ED25519_PUBLIC_KEY_BYTES;
|
||||||
|
/// Stable Ed25519 signature precompile surface code.
|
||||||
|
pub(crate) use self::constants::ED25519_SURFACE_CODE as SOLANA_CORE_ED25519_SURFACE_CODE;
|
||||||
|
/// Stable Feature Gate surface code.
|
||||||
|
pub(crate) use self::constants::FEATURE_SURFACE_CODE as SOLANA_CORE_FEATURE_SURFACE_CODE;
|
||||||
|
/// Stable Loader v4 surface code.
|
||||||
|
pub(crate) use self::constants::LOADER_V4_SURFACE_CODE as SOLANA_CORE_LOADER_V4_SURFACE_CODE;
|
||||||
|
/// Maximum retained native instruction payload accepted by the first decoder phase.
|
||||||
|
pub(crate) use self::constants::MAX_NATIVE_INSTRUCTION_PAYLOAD_BYTES as SOLANA_CORE_MAX_NATIVE_INSTRUCTION_PAYLOAD_BYTES;
|
||||||
|
/// Current native event payload contract version.
|
||||||
|
pub(crate) use self::constants::NATIVE_EVENT_VERSION as SOLANA_CORE_NATIVE_EVENT_VERSION;
|
||||||
|
/// Stable Native Loader surface code.
|
||||||
|
pub(crate) use self::constants::NATIVE_LOADER_SURFACE_CODE as SOLANA_CORE_NATIVE_LOADER_SURFACE_CODE;
|
||||||
|
/// Maximum number of component bytes retained as a hexadecimal event prefix.
|
||||||
|
pub(crate) use self::constants::PRECOMPILE_COMPONENT_PREFIX_BYTES as SOLANA_CORE_PRECOMPILE_COMPONENT_PREFIX_BYTES;
|
||||||
|
/// Stable protocol code shared by native Solana events.
|
||||||
|
pub(crate) use self::constants::PROTOCOL_CODE as SOLANA_CORE_PROTOCOL_CODE;
|
||||||
|
/// Byte length of a secp256k1 Ethereum address.
|
||||||
|
pub(crate) use self::constants::SECP256K1_ETHEREUM_ADDRESS_BYTES as SOLANA_CORE_SECP256K1_ETHEREUM_ADDRESS_BYTES;
|
||||||
|
/// Stable secp256k1 signature precompile surface code.
|
||||||
|
pub(crate) use self::constants::SECP256K1_SURFACE_CODE as SOLANA_CORE_SECP256K1_SURFACE_CODE;
|
||||||
|
/// Runtime maximum number of secp256r1 signatures in one precompile instruction.
|
||||||
|
pub(crate) use self::constants::SECP256R1_MAX_SIGNATURES as SOLANA_CORE_SECP256R1_MAX_SIGNATURES;
|
||||||
|
/// Byte length of a compressed secp256r1 public key.
|
||||||
|
pub(crate) use self::constants::SECP256R1_PUBLIC_KEY_BYTES as SOLANA_CORE_SECP256R1_PUBLIC_KEY_BYTES;
|
||||||
|
/// Stable secp256r1 signature precompile surface code.
|
||||||
|
pub(crate) use self::constants::SECP256R1_SURFACE_CODE as SOLANA_CORE_SECP256R1_SURFACE_CODE;
|
||||||
|
/// Byte length shared by compact Ed25519, secp256k1 and secp256r1 signatures.
|
||||||
|
pub(crate) use self::constants::SIGNATURE_BYTES as SOLANA_CORE_SIGNATURE_BYTES;
|
||||||
|
/// Stable Slashing Program decoder surface code.
|
||||||
|
pub(crate) use self::constants::SLASHING_SURFACE_CODE as SOLANA_CORE_SLASHING_SURFACE_CODE;
|
||||||
|
/// Stable Stake Program surface code.
|
||||||
|
pub(crate) use self::constants::STAKE_SURFACE_CODE as SOLANA_CORE_STAKE_SURFACE_CODE;
|
||||||
|
/// Stable System Program surface code.
|
||||||
|
pub(crate) use self::constants::SYSTEM_SURFACE_CODE as SOLANA_CORE_SYSTEM_SURFACE_CODE;
|
||||||
|
/// Canonical tracing target for the Solana Core decoder component.
|
||||||
|
pub(crate) use self::constants::TRACING_TARGET as SOLANA_CORE_TRACING_TARGET;
|
||||||
|
/// Byte length of one secp256k1 offsets entry.
|
||||||
|
pub(crate) use self::constants::U8_OFFSETS_BYTES as SOLANA_CORE_U8_OFFSETS_BYTES;
|
||||||
|
/// Byte length of one Ed25519 or secp256r1 offsets entry.
|
||||||
|
pub(crate) use self::constants::U16_OFFSETS_BYTES as SOLANA_CORE_U16_OFFSETS_BYTES;
|
||||||
|
/// Stable Vote Program surface code.
|
||||||
|
pub(crate) use self::constants::VOTE_SURFACE_CODE as SOLANA_CORE_VOTE_SURFACE_CODE;
|
||||||
|
/// Stable native ZK ElGamal Proof surface code.
|
||||||
|
pub(crate) use self::constants::ZK_ELGAMAL_PROOF_SURFACE_CODE as SOLANA_CORE_ZK_ELGAMAL_PROOF_SURFACE_CODE;
|
||||||
|
/// Stable historical ZK Token Proof surface code.
|
||||||
|
pub(crate) use self::constants::ZK_TOKEN_PROOF_SURFACE_CODE as SOLANA_CORE_ZK_TOKEN_PROOF_SURFACE_CODE;
|
||||||
|
/// Builds one exact decoded native observation.
|
||||||
|
pub(crate) use self::event::decoded_result as solana_core_decoded_result;
|
||||||
|
/// Builds one failed native decode result.
|
||||||
|
pub(crate) use self::event::failed_result as solana_core_failed_result;
|
||||||
|
/// Builds one unsupported native decode result with a bounded diagnostic.
|
||||||
|
pub(crate) use self::event::unsupported_result as solana_core_unsupported_result;
|
||||||
|
/// Returns declared Feature Gate instruction coverage.
|
||||||
|
pub(crate) use self::feature::feature_coverage as solana_core_feature_coverage;
|
||||||
|
/// Decodes one Feature Gate instruction.
|
||||||
|
pub(crate) use self::feature::feature_decode as solana_core_feature_decode;
|
||||||
|
/// Recognizes one Feature Gate instruction without producing an event.
|
||||||
|
pub(crate) use self::feature::feature_recognize as solana_core_feature_recognize;
|
||||||
|
/// Returns declared loader instruction coverage.
|
||||||
|
pub(crate) use self::loaders::loaders_coverage as solana_core_loaders_coverage;
|
||||||
|
/// Decodes one loader instruction.
|
||||||
|
pub(crate) use self::loaders::loaders_decode as solana_core_loaders_decode;
|
||||||
|
/// Recognizes one loader instruction without producing an event.
|
||||||
|
pub(crate) use self::loaders::loaders_recognize as solana_core_loaders_recognize;
|
||||||
|
/// One resolved outer instruction payload and its provenance relative to the target instruction.
|
||||||
|
pub(crate) use self::payload::ResolvedInstructionPayload as SolanaCoreResolvedInstructionPayload;
|
||||||
|
/// Returns a hexadecimal prefix no longer than the available byte slice.
|
||||||
|
pub(crate) use self::payload::bounded_hexadecimal_prefix as solana_core_bounded_hexadecimal_prefix;
|
||||||
|
/// Extracts one exact bounded byte slice with checked arithmetic.
|
||||||
|
pub(crate) use self::payload::bounded_slice as solana_core_bounded_slice;
|
||||||
|
/// Decodes one retained base64 instruction payload with an explicit byte limit.
|
||||||
|
pub(crate) use self::payload::decode_instruction_data as solana_core_decode_instruction_data;
|
||||||
|
/// Returns the decoded target payload length when retained and valid.
|
||||||
|
pub(crate) use self::payload::decoded_payload_length as solana_core_decoded_payload_length;
|
||||||
|
/// Returns the SHA-256 of the decoded target instruction data when available.
|
||||||
|
pub(crate) use self::payload::decoded_payload_sha256 as solana_core_decoded_payload_sha256;
|
||||||
|
/// Returns the lowercase SHA-256 of one bounded byte slice.
|
||||||
|
pub(crate) use self::payload::hash_bytes as solana_core_hash_bytes;
|
||||||
|
/// Returns a normalized lowercase hexadecimal prefix.
|
||||||
|
pub(crate) use self::payload::hexadecimal_prefix as solana_core_hexadecimal_prefix;
|
||||||
|
/// Returns a stable payload hash from core or computes it from retained JSON.
|
||||||
|
pub(crate) use self::payload::payload_hash as solana_core_payload_hash;
|
||||||
|
/// Reads one little-endian `u32` from an exact byte range.
|
||||||
|
pub(crate) use self::payload::read_u32_le as solana_core_read_u32_le;
|
||||||
|
/// Reads one little-endian `u64` from an exact byte range.
|
||||||
|
pub(crate) use self::payload::read_u64_le as solana_core_read_u64_le;
|
||||||
|
/// Resolves a secp256k1 instruction reference. The runtime format has no current-instruction
|
||||||
|
/// sentinel: every `u8` value is an explicit outer instruction index.
|
||||||
|
pub(crate) use self::payload::resolve_u8_instruction_payload as solana_core_resolve_u8_instruction_payload;
|
||||||
|
/// Resolves an Ed25519 or secp256r1 instruction reference using the official `u16::MAX` sentinel.
|
||||||
|
pub(crate) use self::payload::resolve_u16_instruction_payload as solana_core_resolve_u16_instruction_payload;
|
||||||
|
/// Returns the numeric outer index of the target instruction.
|
||||||
|
pub(crate) use self::payload::target_outer_instruction_index as solana_core_target_outer_instruction_index;
|
||||||
|
/// Returns declared signature precompile coverage.
|
||||||
|
pub(crate) use self::precompiles::precompiles_coverage as solana_core_precompiles_coverage;
|
||||||
|
/// Decodes one signature precompile instruction structurally without cryptographic recomputation.
|
||||||
|
pub(crate) use self::precompiles::precompiles_decode as solana_core_precompiles_decode;
|
||||||
|
/// Recognizes one signature precompile instruction without resolving referenced data.
|
||||||
|
pub(crate) use self::precompiles::precompiles_recognize as solana_core_precompiles_recognize;
|
||||||
|
/// Returns declared Slashing Program instruction coverage.
|
||||||
|
pub(crate) use self::slashing::slashing_coverage as solana_core_slashing_coverage;
|
||||||
|
/// Decodes one Slashing Program instruction.
|
||||||
|
pub(crate) use self::slashing::slashing_decode as solana_core_slashing_decode;
|
||||||
|
/// Recognizes one Slashing Program instruction without producing an event.
|
||||||
|
pub(crate) use self::slashing::slashing_recognize as solana_core_slashing_recognize;
|
||||||
|
/// Returns declared Stake Program instruction coverage.
|
||||||
|
pub(crate) use self::stake::stake_coverage as solana_core_stake_coverage;
|
||||||
|
/// Decodes one Stake Program instruction.
|
||||||
|
pub(crate) use self::stake::stake_decode as solana_core_stake_decode;
|
||||||
|
/// Recognizes one Stake Program instruction without producing an event.
|
||||||
|
pub(crate) use self::stake::stake_recognize as solana_core_stake_recognize;
|
||||||
|
/// Returns declared System Program instruction coverage.
|
||||||
|
pub(crate) use self::system::system_coverage as solana_core_system_coverage;
|
||||||
|
/// Decodes one System Program instruction.
|
||||||
|
pub(crate) use self::system::system_decode as solana_core_system_decode;
|
||||||
|
/// Recognizes one System Program instruction without producing an event.
|
||||||
|
pub(crate) use self::system::system_recognize as solana_core_system_recognize;
|
||||||
|
/// Returns declared Vote Program instruction coverage.
|
||||||
|
pub(crate) use self::vote::vote_coverage as solana_core_vote_coverage;
|
||||||
|
/// Decodes one Vote Program instruction.
|
||||||
|
pub(crate) use self::vote::vote_decode as solana_core_vote_decode;
|
||||||
|
/// Recognizes one Vote Program instruction without producing an event.
|
||||||
|
pub(crate) use self::vote::vote_recognize as solana_core_vote_recognize;
|
||||||
|
/// Returns declared ZK ElGamal Proof instruction coverage.
|
||||||
|
pub(crate) use self::zk_elgamal::zk_elgamal_coverage as solana_core_zk_elgamal_coverage;
|
||||||
|
/// Decodes one native ZK ElGamal Proof instruction without recomputing the proof.
|
||||||
|
pub(crate) use self::zk_elgamal::zk_elgamal_decode as solana_core_zk_elgamal_decode;
|
||||||
|
/// Recognizes one native ZK ElGamal Proof instruction.
|
||||||
|
pub(crate) use self::zk_elgamal::zk_elgamal_recognize as solana_core_zk_elgamal_recognize;
|
||||||
|
/// Returns declared historical and current ZK Token Proof coverage.
|
||||||
|
pub(crate) use self::zk_token_proof::zk_token_proof_coverage as solana_core_zk_token_proof_coverage;
|
||||||
|
/// Decodes one historical ZK Token Proof layout without recomputing any proof.
|
||||||
|
pub(crate) use self::zk_token_proof::zk_token_proof_decode as solana_core_zk_token_proof_decode;
|
||||||
|
/// Recognizes one historical ZK Token Proof layout or the current no-op runtime fallback.
|
||||||
|
pub(crate) use self::zk_token_proof::zk_token_proof_recognize as solana_core_zk_token_proof_recognize;
|
||||||
|
|
||||||
|
/// Runtime-native Solana decoder with phased maximal native coverage.
|
||||||
|
pub use self::decoder::SolanaCoreDecoder;
|
||||||
|
|||||||
137
kb-lib/src/decoder/solana/core/accounts.rs
Normal file
137
kb-lib/src/decoder/solana/core/accounts.rs
Normal file
@@ -0,0 +1,137 @@
|
|||||||
|
// file: kb-lib/src/decoder/solana/core/accounts.rs
|
||||||
|
// version: 4
|
||||||
|
|
||||||
|
//! Resolved account-role validation for native instructions.
|
||||||
|
|
||||||
|
/// Expected role and privileges for one positional instruction account.
|
||||||
|
#[derive(Clone, Copy, Debug)]
|
||||||
|
pub(crate) struct AccountRole {
|
||||||
|
/// Stable role code.
|
||||||
|
pub(crate) role: &'static str,
|
||||||
|
/// Whether the official instruction contract marks the account writable.
|
||||||
|
pub(crate) expected_writable: bool,
|
||||||
|
/// Whether the official instruction contract marks the account as a signer.
|
||||||
|
pub(crate) expected_signer: std::option::Option<bool>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AccountRole {
|
||||||
|
/// Builds one account role declaration.
|
||||||
|
pub(crate) const fn new(
|
||||||
|
role: &'static str,
|
||||||
|
expected_writable: bool,
|
||||||
|
expected_signer: bool,
|
||||||
|
) -> Self {
|
||||||
|
return Self {
|
||||||
|
role,
|
||||||
|
expected_writable,
|
||||||
|
expected_signer: std::option::Option::Some(expected_signer),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Builds one role whose signer requirement differs across supported runtime generations.
|
||||||
|
pub(crate) const fn optional_signer(role: &'static str, expected_writable: bool) -> Self {
|
||||||
|
return Self {
|
||||||
|
role,
|
||||||
|
expected_writable,
|
||||||
|
expected_signer: std::option::Option::None,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resolves positional instruction accounts and validates their core indexes.
|
||||||
|
pub(crate) fn resolve_accounts(
|
||||||
|
input: &crate::CoreInstructionReplayInput,
|
||||||
|
roles: &[crate::SolanaCoreAccountRole],
|
||||||
|
minimum_count: usize,
|
||||||
|
maximum_count: std::option::Option<usize>,
|
||||||
|
) -> kb_core::Result<serde_json::Value> {
|
||||||
|
let instruction_accounts = match input.instruction_accounts_json.as_array() {
|
||||||
|
std::option::Option::Some(value) => value,
|
||||||
|
std::option::Option::None => {
|
||||||
|
return std::result::Result::Err(kb_core::Error::invalid_state(
|
||||||
|
"native instruction accounts must be a JSON array",
|
||||||
|
));
|
||||||
|
},
|
||||||
|
};
|
||||||
|
if instruction_accounts.len() < minimum_count {
|
||||||
|
return std::result::Result::Err(kb_core::Error::invalid_state(format!(
|
||||||
|
"native instruction requires at least {minimum_count} accounts but received {}",
|
||||||
|
instruction_accounts.len()
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
if maximum_count.is_some_and(|value| return instruction_accounts.len() > value) {
|
||||||
|
return std::result::Result::Err(kb_core::Error::invalid_state(format!(
|
||||||
|
"native instruction accepts at most {} accounts but received {}",
|
||||||
|
maximum_count.unwrap_or(0),
|
||||||
|
instruction_accounts.len()
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
let account_keys = match input.account_keys_json.as_array() {
|
||||||
|
std::option::Option::Some(value) => value,
|
||||||
|
std::option::Option::None => {
|
||||||
|
return std::result::Result::Err(kb_core::Error::invalid_state(
|
||||||
|
"native transaction account keys must be a JSON array",
|
||||||
|
));
|
||||||
|
},
|
||||||
|
};
|
||||||
|
let mut output = std::vec::Vec::with_capacity(instruction_accounts.len());
|
||||||
|
for (position, instruction_account) in instruction_accounts.iter().enumerate() {
|
||||||
|
let account_index =
|
||||||
|
match instruction_account.get("accountIndex").and_then(serde_json::Value::as_u64) {
|
||||||
|
std::option::Option::Some(value) => value,
|
||||||
|
std::option::Option::None => {
|
||||||
|
return std::result::Result::Err(kb_core::Error::invalid_state(format!(
|
||||||
|
"native instruction account {position} has no valid accountIndex"
|
||||||
|
)));
|
||||||
|
},
|
||||||
|
};
|
||||||
|
let instruction_key =
|
||||||
|
match instruction_account.get("accountKey").and_then(serde_json::Value::as_str) {
|
||||||
|
std::option::Option::Some(value) => value,
|
||||||
|
std::option::Option::None => {
|
||||||
|
return std::result::Result::Err(kb_core::Error::invalid_state(format!(
|
||||||
|
"native instruction account {position} has no accountKey"
|
||||||
|
)));
|
||||||
|
},
|
||||||
|
};
|
||||||
|
let resolved = account_keys.iter().find(|candidate| {
|
||||||
|
return candidate.get("accountIndex").and_then(serde_json::Value::as_u64)
|
||||||
|
== std::option::Option::Some(account_index);
|
||||||
|
});
|
||||||
|
let resolved = match resolved {
|
||||||
|
std::option::Option::Some(value) => value,
|
||||||
|
std::option::Option::None => {
|
||||||
|
return std::result::Result::Err(kb_core::Error::invalid_state(format!(
|
||||||
|
"native instruction account index {account_index} is absent from resolved keys"
|
||||||
|
)));
|
||||||
|
},
|
||||||
|
};
|
||||||
|
let resolved_key = match resolved.get("accountKey").and_then(serde_json::Value::as_str) {
|
||||||
|
std::option::Option::Some(value) => value,
|
||||||
|
std::option::Option::None => {
|
||||||
|
return std::result::Result::Err(kb_core::Error::invalid_state(format!(
|
||||||
|
"resolved native account index {account_index} has no accountKey"
|
||||||
|
)));
|
||||||
|
},
|
||||||
|
};
|
||||||
|
if resolved_key != instruction_key {
|
||||||
|
return std::result::Result::Err(kb_core::Error::invalid_state(format!(
|
||||||
|
"native instruction account index {account_index} resolves to a different key"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
let role = roles.get(position);
|
||||||
|
output.push(serde_json::json!({
|
||||||
|
"position": position,
|
||||||
|
"accountIndex": account_index,
|
||||||
|
"accountKey": instruction_key,
|
||||||
|
"role": role.map(|value| return value.role).unwrap_or("additional_account"),
|
||||||
|
"expectedWritable": role.map(|value| return value.expected_writable),
|
||||||
|
"expectedSigner": role.map(|value| return value.expected_signer),
|
||||||
|
"writable": resolved.get("writable").and_then(serde_json::Value::as_bool),
|
||||||
|
"signer": resolved.get("signer").and_then(serde_json::Value::as_bool),
|
||||||
|
"executable": resolved.get("executable").and_then(serde_json::Value::as_bool),
|
||||||
|
"source": resolved.get("source").cloned().unwrap_or(serde_json::Value::Null),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
return std::result::Result::Ok(serde_json::Value::Array(output));
|
||||||
|
}
|
||||||
505
kb-lib/src/decoder/solana/core/address_lookup_table.rs
Normal file
505
kb-lib/src/decoder/solana/core/address_lookup_table.rs
Normal file
@@ -0,0 +1,505 @@
|
|||||||
|
// file: kb-lib/src/decoder/solana/core/address_lookup_table.rs
|
||||||
|
// version: 5
|
||||||
|
|
||||||
|
//! Exact Address Lookup Table instruction decoding through the official interface schema.
|
||||||
|
|
||||||
|
const SOURCE: &str =
|
||||||
|
"solana-address-lookup-table-interface@3.1.0 ProgramInstruction wincode contract";
|
||||||
|
|
||||||
|
const CREATE_ROLES: &[crate::SolanaCoreAccountRole] = &[
|
||||||
|
crate::SolanaCoreAccountRole::new("lookup_table", true, false),
|
||||||
|
crate::SolanaCoreAccountRole::optional_signer("authority", false),
|
||||||
|
crate::SolanaCoreAccountRole::new("payer", true, true),
|
||||||
|
crate::SolanaCoreAccountRole::new("system_program", false, false),
|
||||||
|
];
|
||||||
|
const AUTHORITY_ROLES: &[crate::SolanaCoreAccountRole] = &[
|
||||||
|
crate::SolanaCoreAccountRole::new("lookup_table", true, false),
|
||||||
|
crate::SolanaCoreAccountRole::new("authority", false, true),
|
||||||
|
];
|
||||||
|
const EXTEND_ROLES: &[crate::SolanaCoreAccountRole] = &[
|
||||||
|
crate::SolanaCoreAccountRole::new("lookup_table", true, false),
|
||||||
|
crate::SolanaCoreAccountRole::new("authority", false, true),
|
||||||
|
crate::SolanaCoreAccountRole::new("payer", true, true),
|
||||||
|
crate::SolanaCoreAccountRole::new("system_program", false, false),
|
||||||
|
];
|
||||||
|
const CLOSE_ROLES: &[crate::SolanaCoreAccountRole] = &[
|
||||||
|
crate::SolanaCoreAccountRole::new("lookup_table", true, false),
|
||||||
|
crate::SolanaCoreAccountRole::new("authority", false, true),
|
||||||
|
crate::SolanaCoreAccountRole::new("recipient", true, false),
|
||||||
|
];
|
||||||
|
|
||||||
|
/// Returns declared Address Lookup Table instruction coverage.
|
||||||
|
pub(crate) fn address_lookup_table_coverage() -> std::vec::Vec<crate::DecoderCoverageDeclaration> {
|
||||||
|
return vec![
|
||||||
|
coverage_entry(
|
||||||
|
"create_lookup_table",
|
||||||
|
solana_address_lookup_table_interface::instruction::ProgramInstruction::CreateLookupTable {
|
||||||
|
recent_slot: 0,
|
||||||
|
bump_seed: 0,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
coverage_entry(
|
||||||
|
"freeze_lookup_table",
|
||||||
|
solana_address_lookup_table_interface::instruction::ProgramInstruction::FreezeLookupTable,
|
||||||
|
),
|
||||||
|
coverage_entry(
|
||||||
|
"extend_lookup_table",
|
||||||
|
solana_address_lookup_table_interface::instruction::ProgramInstruction::ExtendLookupTable {
|
||||||
|
new_addresses: std::vec::Vec::new(),
|
||||||
|
},
|
||||||
|
),
|
||||||
|
coverage_entry(
|
||||||
|
"deactivate_lookup_table",
|
||||||
|
solana_address_lookup_table_interface::instruction::ProgramInstruction::DeactivateLookupTable,
|
||||||
|
),
|
||||||
|
coverage_entry(
|
||||||
|
"close_lookup_table",
|
||||||
|
solana_address_lookup_table_interface::instruction::ProgramInstruction::CloseLookupTable,
|
||||||
|
),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Recognizes one Address Lookup Table instruction without producing an event.
|
||||||
|
pub(crate) fn address_lookup_table_recognize(
|
||||||
|
input: &crate::CoreInstructionReplayInput,
|
||||||
|
priority: u16,
|
||||||
|
) -> crate::DecoderRecognition {
|
||||||
|
let bytes_result = crate::solana_core_decode_instruction_data(input);
|
||||||
|
let bytes = match bytes_result {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(_error) => {
|
||||||
|
return crate::DecoderRecognition::compatible(
|
||||||
|
false,
|
||||||
|
priority,
|
||||||
|
std::option::Option::Some(
|
||||||
|
crate::SOLANA_CORE_ADDRESS_LOOKUP_TABLE_SURFACE_CODE.to_string(),
|
||||||
|
),
|
||||||
|
std::option::Option::Some("malformed_address_lookup_table_instruction".to_string()),
|
||||||
|
std::option::Option::None,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
let tag_result = crate::solana_core_read_u32_le(bytes.as_slice(), 0);
|
||||||
|
let tag = match tag_result {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(_error) => {
|
||||||
|
return crate::DecoderRecognition::compatible(
|
||||||
|
false,
|
||||||
|
priority,
|
||||||
|
std::option::Option::Some(
|
||||||
|
crate::SOLANA_CORE_ADDRESS_LOOKUP_TABLE_SURFACE_CODE.to_string(),
|
||||||
|
),
|
||||||
|
std::option::Option::Some("malformed_address_lookup_table_instruction".to_string()),
|
||||||
|
crate::solana_core_hexadecimal_prefix(bytes.as_slice(), bytes.len().min(4)),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
let entry_code = entry_code(tag);
|
||||||
|
let exact = if entry_code.is_some() {
|
||||||
|
wincode::deserialize_exact::<
|
||||||
|
solana_address_lookup_table_interface::instruction::ProgramInstruction,
|
||||||
|
>(bytes.as_slice())
|
||||||
|
.is_ok()
|
||||||
|
} else {
|
||||||
|
false
|
||||||
|
};
|
||||||
|
return crate::DecoderRecognition::compatible(
|
||||||
|
exact,
|
||||||
|
priority,
|
||||||
|
std::option::Option::Some(crate::SOLANA_CORE_ADDRESS_LOOKUP_TABLE_SURFACE_CODE.to_string()),
|
||||||
|
entry_code.map(str::to_string).or_else(|| {
|
||||||
|
return std::option::Option::Some(
|
||||||
|
"unknown_address_lookup_table_instruction".to_string(),
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
crate::solana_core_hexadecimal_prefix(bytes.as_slice(), 4),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Decodes one Address Lookup Table instruction.
|
||||||
|
pub(crate) fn address_lookup_table_decode(
|
||||||
|
input: &crate::CoreInstructionReplayInput,
|
||||||
|
) -> crate::DecoderExecutionResult {
|
||||||
|
let bytes_result = crate::solana_core_decode_instruction_data(input);
|
||||||
|
let bytes = match bytes_result {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => {
|
||||||
|
return crate::solana_core_failed_result(
|
||||||
|
std::option::Option::Some("malformed_address_lookup_table_instruction"),
|
||||||
|
"address_lookup_table_payload_invalid",
|
||||||
|
error.to_string(),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
let tag_result = crate::solana_core_read_u32_le(bytes.as_slice(), 0);
|
||||||
|
let tag = match tag_result {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => {
|
||||||
|
return crate::solana_core_failed_result(
|
||||||
|
std::option::Option::Some("malformed_address_lookup_table_instruction"),
|
||||||
|
"address_lookup_table_tag_truncated",
|
||||||
|
error.to_string(),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
let entry_code = match entry_code(tag) {
|
||||||
|
std::option::Option::Some(value) => value,
|
||||||
|
std::option::Option::None => {
|
||||||
|
return crate::solana_core_unsupported_result(
|
||||||
|
"unknown_address_lookup_table_instruction",
|
||||||
|
"address_lookup_table_tag_unknown",
|
||||||
|
format!(
|
||||||
|
"unknown Address Lookup Table instruction tag {tag}; payload_sha256={}",
|
||||||
|
crate::solana_core_payload_hash(input)
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
let instruction_result = wincode::deserialize_exact::<
|
||||||
|
solana_address_lookup_table_interface::instruction::ProgramInstruction,
|
||||||
|
>(bytes.as_slice());
|
||||||
|
let instruction = match instruction_result {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => {
|
||||||
|
return crate::solana_core_failed_result(
|
||||||
|
std::option::Option::Some(entry_code),
|
||||||
|
"address_lookup_table_wincode_invalid",
|
||||||
|
format!("cannot decode exact Address Lookup Table {entry_code}: {error}"),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
return decoded_instruction(input, instruction);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn decoded_instruction(
|
||||||
|
input: &crate::CoreInstructionReplayInput,
|
||||||
|
instruction: solana_address_lookup_table_interface::instruction::ProgramInstruction,
|
||||||
|
) -> crate::DecoderExecutionResult {
|
||||||
|
return match instruction {
|
||||||
|
solana_address_lookup_table_interface::instruction::ProgramInstruction::CreateLookupTable {
|
||||||
|
recent_slot,
|
||||||
|
bump_seed,
|
||||||
|
} => build(
|
||||||
|
input,
|
||||||
|
"create_lookup_table",
|
||||||
|
CREATE_ROLES,
|
||||||
|
4,
|
||||||
|
std::option::Option::Some(4),
|
||||||
|
serde_json::json!({
|
||||||
|
"recentSlot": recent_slot,
|
||||||
|
"bumpSeed": bump_seed,
|
||||||
|
"authoritySignerPolicy": "optional_current_historical_required",
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
solana_address_lookup_table_interface::instruction::ProgramInstruction::FreezeLookupTable => {
|
||||||
|
build(
|
||||||
|
input,
|
||||||
|
"freeze_lookup_table",
|
||||||
|
AUTHORITY_ROLES,
|
||||||
|
2,
|
||||||
|
std::option::Option::Some(2),
|
||||||
|
serde_json::json!({}),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
solana_address_lookup_table_interface::instruction::ProgramInstruction::ExtendLookupTable {
|
||||||
|
new_addresses,
|
||||||
|
} => {
|
||||||
|
let account_count = input
|
||||||
|
.instruction_accounts_json
|
||||||
|
.as_array()
|
||||||
|
.map(std::vec::Vec::len)
|
||||||
|
.unwrap_or(0);
|
||||||
|
if account_count != 2 && account_count != 4 {
|
||||||
|
return crate::solana_core_failed_result(
|
||||||
|
std::option::Option::Some("extend_lookup_table"),
|
||||||
|
"address_lookup_table_extend_accounts_invalid",
|
||||||
|
format!(
|
||||||
|
"Address Lookup Table extend requires 2 accounts or the complete optional payer/system pair of 4 accounts but received {account_count}"
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
let addresses = new_addresses
|
||||||
|
.iter()
|
||||||
|
.map(std::string::ToString::to_string)
|
||||||
|
.collect::<std::vec::Vec<_>>();
|
||||||
|
build(
|
||||||
|
input,
|
||||||
|
"extend_lookup_table",
|
||||||
|
EXTEND_ROLES,
|
||||||
|
2,
|
||||||
|
std::option::Option::Some(4),
|
||||||
|
serde_json::json!({
|
||||||
|
"newAddresses": addresses,
|
||||||
|
"newAddressCount": new_addresses.len(),
|
||||||
|
"fundingAccountsPresent": account_count == 4,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
solana_address_lookup_table_interface::instruction::ProgramInstruction::DeactivateLookupTable => {
|
||||||
|
build(
|
||||||
|
input,
|
||||||
|
"deactivate_lookup_table",
|
||||||
|
AUTHORITY_ROLES,
|
||||||
|
2,
|
||||||
|
std::option::Option::Some(2),
|
||||||
|
serde_json::json!({}),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
solana_address_lookup_table_interface::instruction::ProgramInstruction::CloseLookupTable => {
|
||||||
|
build(
|
||||||
|
input,
|
||||||
|
"close_lookup_table",
|
||||||
|
CLOSE_ROLES,
|
||||||
|
3,
|
||||||
|
std::option::Option::Some(3),
|
||||||
|
serde_json::json!({}),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build(
|
||||||
|
input: &crate::CoreInstructionReplayInput,
|
||||||
|
entry_code: &str,
|
||||||
|
roles: &[crate::SolanaCoreAccountRole],
|
||||||
|
minimum_count: usize,
|
||||||
|
maximum_count: std::option::Option<usize>,
|
||||||
|
parameters: serde_json::Value,
|
||||||
|
) -> crate::DecoderExecutionResult {
|
||||||
|
let accounts_result =
|
||||||
|
crate::solana_core_resolve_accounts(input, roles, minimum_count, maximum_count);
|
||||||
|
let accounts = match accounts_result {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => {
|
||||||
|
return crate::solana_core_failed_result(
|
||||||
|
std::option::Option::Some(entry_code),
|
||||||
|
"address_lookup_table_accounts_invalid",
|
||||||
|
error.to_string(),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
return crate::solana_core_decoded_result(
|
||||||
|
input,
|
||||||
|
crate::SOLANA_CORE_ADDRESS_LOOKUP_TABLE_SURFACE_CODE,
|
||||||
|
entry_code,
|
||||||
|
crate::EventFamily::Lifecycle,
|
||||||
|
false,
|
||||||
|
accounts,
|
||||||
|
parameters,
|
||||||
|
SOURCE,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn entry_code(tag: u32) -> std::option::Option<&'static str> {
|
||||||
|
return match tag {
|
||||||
|
0 => std::option::Option::Some("create_lookup_table"),
|
||||||
|
1 => std::option::Option::Some("freeze_lookup_table"),
|
||||||
|
2 => std::option::Option::Some("extend_lookup_table"),
|
||||||
|
3 => std::option::Option::Some("deactivate_lookup_table"),
|
||||||
|
4 => std::option::Option::Some("close_lookup_table"),
|
||||||
|
_ => std::option::Option::None,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
fn coverage_entry(
|
||||||
|
entry_code: &str,
|
||||||
|
instruction: solana_address_lookup_table_interface::instruction::ProgramInstruction,
|
||||||
|
) -> crate::DecoderCoverageDeclaration {
|
||||||
|
let discriminator_hex = match wincode::serialize(&instruction) {
|
||||||
|
std::result::Result::Ok(bytes) => {
|
||||||
|
crate::solana_core_hexadecimal_prefix(bytes.as_slice(), 4)
|
||||||
|
},
|
||||||
|
std::result::Result::Err(_error) => std::option::Option::None,
|
||||||
|
};
|
||||||
|
return crate::DecoderCoverageDeclaration {
|
||||||
|
program_id: kb_program_ids::ADDRESS_LOOKUP_TABLE_PROGRAM_ID.to_string(),
|
||||||
|
surface_code: std::option::Option::Some(
|
||||||
|
crate::SOLANA_CORE_ADDRESS_LOOKUP_TABLE_SURFACE_CODE.to_string(),
|
||||||
|
),
|
||||||
|
entry_kind: crate::DecoderCoverageEntryKind::Instruction,
|
||||||
|
entry_code: entry_code.to_string(),
|
||||||
|
discriminator_hex,
|
||||||
|
historical: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use base64::Engine; // rust-rules: trait-import
|
||||||
|
|
||||||
|
fn replay_input(
|
||||||
|
instruction: &solana_address_lookup_table_interface::instruction::ProgramInstruction,
|
||||||
|
account_count: usize,
|
||||||
|
transaction_failed: bool,
|
||||||
|
) -> crate::CoreInstructionReplayInput {
|
||||||
|
let bytes_result = wincode::serialize(instruction);
|
||||||
|
let bytes = match bytes_result {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => panic!("ALT fixture serialization failed: {error}"),
|
||||||
|
};
|
||||||
|
return replay_input_bytes(bytes.as_slice(), account_count, transaction_failed);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn replay_input_bytes(
|
||||||
|
bytes: &[u8],
|
||||||
|
account_count: usize,
|
||||||
|
transaction_failed: bool,
|
||||||
|
) -> crate::CoreInstructionReplayInput {
|
||||||
|
let encoded = base64::engine::general_purpose::STANDARD.encode(bytes);
|
||||||
|
let account_keys = (0..account_count)
|
||||||
|
.map(|index| {
|
||||||
|
return serde_json::json!({
|
||||||
|
"accountIndex": index,
|
||||||
|
"accountKey": format!("account{index}"),
|
||||||
|
"source": "static",
|
||||||
|
"writable": true,
|
||||||
|
"signer": true,
|
||||||
|
"executable": false,
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.collect::<std::vec::Vec<_>>();
|
||||||
|
let instruction_accounts = (0..account_count)
|
||||||
|
.map(|index| {
|
||||||
|
return serde_json::json!({
|
||||||
|
"accountIndex": index,
|
||||||
|
"accountKey": format!("account{index}"),
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.collect::<std::vec::Vec<_>>();
|
||||||
|
let result = crate::CoreInstructionReplayInput::new(
|
||||||
|
"signature:0",
|
||||||
|
"signature",
|
||||||
|
42,
|
||||||
|
"0",
|
||||||
|
kb_program_ids::ADDRESS_LOOKUP_TABLE_PROGRAM_ID,
|
||||||
|
transaction_failed,
|
||||||
|
if transaction_failed {
|
||||||
|
std::option::Option::Some(serde_json::json!({"InstructionError": [0, "Custom"]}))
|
||||||
|
} else {
|
||||||
|
std::option::Option::None
|
||||||
|
},
|
||||||
|
serde_json::Value::Array(account_keys),
|
||||||
|
serde_json::Value::Array(instruction_accounts),
|
||||||
|
std::option::Option::Some(serde_json::json!({"dataBase64": encoded})),
|
||||||
|
std::option::Option::Some("payload-hash".to_string()),
|
||||||
|
serde_json::json!([]),
|
||||||
|
serde_json::json!([]),
|
||||||
|
serde_json::json!([]),
|
||||||
|
serde_json::json!([]),
|
||||||
|
);
|
||||||
|
return match result {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => panic!("ALT replay input failed: {error}"),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn every_official_variant_decodes() {
|
||||||
|
let address = solana_sdk_ids::address_lookup_table::id();
|
||||||
|
let fixtures = [
|
||||||
|
(
|
||||||
|
solana_address_lookup_table_interface::instruction::ProgramInstruction::CreateLookupTable {
|
||||||
|
recent_slot: u64::MAX,
|
||||||
|
bump_seed: u8::MAX,
|
||||||
|
},
|
||||||
|
4,
|
||||||
|
"create_lookup_table",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
solana_address_lookup_table_interface::instruction::ProgramInstruction::FreezeLookupTable,
|
||||||
|
2,
|
||||||
|
"freeze_lookup_table",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
solana_address_lookup_table_interface::instruction::ProgramInstruction::ExtendLookupTable {
|
||||||
|
new_addresses: vec![address, address],
|
||||||
|
},
|
||||||
|
4,
|
||||||
|
"extend_lookup_table",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
solana_address_lookup_table_interface::instruction::ProgramInstruction::DeactivateLookupTable,
|
||||||
|
2,
|
||||||
|
"deactivate_lookup_table",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
solana_address_lookup_table_interface::instruction::ProgramInstruction::CloseLookupTable,
|
||||||
|
3,
|
||||||
|
"close_lookup_table",
|
||||||
|
),
|
||||||
|
];
|
||||||
|
for (instruction, account_count, expected) in fixtures {
|
||||||
|
let result = crate::solana_core_address_lookup_table_decode(&replay_input(
|
||||||
|
&instruction,
|
||||||
|
account_count,
|
||||||
|
false,
|
||||||
|
));
|
||||||
|
assert_eq!(result.status, crate::DecoderOutcomeStatus::Decoded);
|
||||||
|
assert_eq!(
|
||||||
|
result.recognized_entry_code.as_deref(),
|
||||||
|
std::option::Option::Some(expected)
|
||||||
|
);
|
||||||
|
assert_eq!(result.observations.len(), 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn extend_accepts_only_absent_or_complete_funding_pair() {
|
||||||
|
let instruction = solana_address_lookup_table_interface::instruction::ProgramInstruction::ExtendLookupTable {
|
||||||
|
new_addresses: std::vec::Vec::new(),
|
||||||
|
};
|
||||||
|
let without_funding =
|
||||||
|
crate::solana_core_address_lookup_table_decode(&replay_input(&instruction, 2, false));
|
||||||
|
assert_eq!(without_funding.status, crate::DecoderOutcomeStatus::Decoded);
|
||||||
|
let incomplete =
|
||||||
|
crate::solana_core_address_lookup_table_decode(&replay_input(&instruction, 3, false));
|
||||||
|
assert_eq!(incomplete.status, crate::DecoderOutcomeStatus::Failed);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn truncated_unknown_and_trailing_payloads_are_distinguished() {
|
||||||
|
let truncated = crate::solana_core_address_lookup_table_decode(&replay_input_bytes(
|
||||||
|
&[0, 0, 0],
|
||||||
|
4,
|
||||||
|
false,
|
||||||
|
));
|
||||||
|
assert_eq!(truncated.status, crate::DecoderOutcomeStatus::Failed);
|
||||||
|
let unknown = crate::solana_core_address_lookup_table_decode(&replay_input_bytes(
|
||||||
|
&[255, 0, 0, 0],
|
||||||
|
0,
|
||||||
|
false,
|
||||||
|
));
|
||||||
|
assert_eq!(unknown.status, crate::DecoderOutcomeStatus::Unsupported);
|
||||||
|
let mut valid = match wincode::serialize(
|
||||||
|
&solana_address_lookup_table_interface::instruction::ProgramInstruction::FreezeLookupTable,
|
||||||
|
) {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => panic!("ALT fixture serialization failed: {error}"),
|
||||||
|
};
|
||||||
|
valid.push(1);
|
||||||
|
let trailing = crate::solana_core_address_lookup_table_decode(&replay_input_bytes(
|
||||||
|
valid.as_slice(),
|
||||||
|
2,
|
||||||
|
false,
|
||||||
|
));
|
||||||
|
assert_eq!(trailing.status, crate::DecoderOutcomeStatus::Failed);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn failed_transaction_is_decoded_as_uncommitted_lifecycle_intent() {
|
||||||
|
let instruction = solana_address_lookup_table_interface::instruction::ProgramInstruction::DeactivateLookupTable;
|
||||||
|
let result =
|
||||||
|
crate::solana_core_address_lookup_table_decode(&replay_input(&instruction, 2, true));
|
||||||
|
assert_eq!(result.status, crate::DecoderOutcomeStatus::Decoded);
|
||||||
|
assert!(!result.observations[0].observation_committed);
|
||||||
|
assert_eq!(result.observations[0].payload_json["transactionSucceeded"], false);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn coverage_uses_official_encoder_discriminants() {
|
||||||
|
let coverage = crate::solana_core_address_lookup_table_coverage();
|
||||||
|
assert_eq!(coverage.len(), 5);
|
||||||
|
assert_eq!(coverage[0].discriminator_hex.as_deref(), std::option::Option::Some("00000000"));
|
||||||
|
assert_eq!(coverage[4].discriminator_hex.as_deref(), std::option::Option::Some("04000000"));
|
||||||
|
}
|
||||||
|
}
|
||||||
896
kb-lib/src/decoder/solana/core/compute_budget.rs
Normal file
896
kb-lib/src/decoder/solana/core/compute_budget.rs
Normal file
@@ -0,0 +1,896 @@
|
|||||||
|
// file: kb-lib/src/decoder/solana/core/compute_budget.rs
|
||||||
|
// version: 6
|
||||||
|
|
||||||
|
//! Maximal current and historical Compute Budget instruction decoding.
|
||||||
|
|
||||||
|
use base64::Engine; // rust-rules: trait-import
|
||||||
|
|
||||||
|
const SOURCE_CURRENT: &str = "solana-compute-budget-interface@3.0.0; solana-compute-budget-instruction@4.1.1::try_from_slice_unchecked";
|
||||||
|
const SOURCE_HISTORICAL: &str =
|
||||||
|
"solana-sdk compute_budget::ComputeBudgetInstruction RequestUnitsDeprecated";
|
||||||
|
|
||||||
|
/// Returns declared Compute Budget instruction coverage.
|
||||||
|
pub(crate) fn compute_budget_coverage() -> std::vec::Vec<crate::DecoderCoverageDeclaration> {
|
||||||
|
return vec![
|
||||||
|
coverage_entry("unused_reserved", "00", false),
|
||||||
|
coverage_entry("request_units_deprecated", "00", true),
|
||||||
|
coverage_entry("request_heap_frame", "01", false),
|
||||||
|
coverage_entry("set_compute_unit_limit", "02", false),
|
||||||
|
coverage_entry("set_compute_unit_price", "03", false),
|
||||||
|
coverage_entry("set_loaded_accounts_data_size_limit", "04", false),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Recognizes one Compute Budget instruction without producing an event.
|
||||||
|
pub(crate) fn compute_budget_recognize(
|
||||||
|
input: &crate::CoreInstructionReplayInput,
|
||||||
|
priority: u16,
|
||||||
|
) -> crate::DecoderRecognition {
|
||||||
|
let bytes_result = crate::solana_core_decode_instruction_data(input);
|
||||||
|
let bytes = match bytes_result {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(_error) => {
|
||||||
|
return crate::DecoderRecognition::compatible(
|
||||||
|
false,
|
||||||
|
priority,
|
||||||
|
std::option::Option::Some(
|
||||||
|
crate::SOLANA_CORE_COMPUTE_BUDGET_SURFACE_CODE.to_string(),
|
||||||
|
),
|
||||||
|
std::option::Option::Some("malformed_compute_budget_instruction".to_string()),
|
||||||
|
std::option::Option::None,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
let entry_code = entry_code(bytes.as_slice());
|
||||||
|
return crate::DecoderRecognition::compatible(
|
||||||
|
entry_code.is_some(),
|
||||||
|
priority,
|
||||||
|
std::option::Option::Some(crate::SOLANA_CORE_COMPUTE_BUDGET_SURFACE_CODE.to_string()),
|
||||||
|
entry_code.map(str::to_string).or_else(|| {
|
||||||
|
return std::option::Option::Some("unknown_compute_budget_instruction".to_string());
|
||||||
|
}),
|
||||||
|
crate::solana_core_hexadecimal_prefix(bytes.as_slice(), 1),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Decodes one Compute Budget instruction.
|
||||||
|
pub(crate) fn compute_budget_decode(
|
||||||
|
input: &crate::CoreInstructionReplayInput,
|
||||||
|
) -> crate::DecoderExecutionResult {
|
||||||
|
let bytes_result = crate::solana_core_decode_instruction_data(input);
|
||||||
|
let bytes = match bytes_result {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => {
|
||||||
|
return crate::solana_core_failed_result(
|
||||||
|
std::option::Option::Some("malformed_compute_budget_instruction"),
|
||||||
|
"compute_budget_payload_invalid",
|
||||||
|
error.to_string(),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
if bytes.is_empty() {
|
||||||
|
return crate::solana_core_failed_result(
|
||||||
|
std::option::Option::Some("malformed_compute_budget_instruction"),
|
||||||
|
"compute_budget_payload_empty",
|
||||||
|
"Compute Budget instruction payload is empty",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
let accounts_result =
|
||||||
|
crate::solana_core_resolve_accounts(input, &[], 0, std::option::Option::None);
|
||||||
|
let accounts = match accounts_result {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => {
|
||||||
|
return crate::solana_core_failed_result(
|
||||||
|
entry_code(bytes.as_slice()),
|
||||||
|
"compute_budget_accounts_invalid",
|
||||||
|
error.to_string(),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
return match bytes[0] {
|
||||||
|
0 if bytes.len() == 1 => crate::DecoderExecutionResult::ignored(std::option::Option::Some(
|
||||||
|
"unused_reserved".to_string(),
|
||||||
|
)),
|
||||||
|
0 if bytes.len() == 9 => {
|
||||||
|
let units_result = crate::solana_core_read_u32_le(bytes.as_slice(), 1);
|
||||||
|
let units = match units_result {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => {
|
||||||
|
return crate::solana_core_failed_result(
|
||||||
|
std::option::Option::Some("request_units_deprecated"),
|
||||||
|
"compute_budget_request_units_truncated",
|
||||||
|
error.to_string(),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
let additional_fee_result = crate::solana_core_read_u32_le(bytes.as_slice(), 5);
|
||||||
|
let additional_fee = match additional_fee_result {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => {
|
||||||
|
return crate::solana_core_failed_result(
|
||||||
|
std::option::Option::Some("request_units_deprecated"),
|
||||||
|
"compute_budget_request_units_truncated",
|
||||||
|
error.to_string(),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
let mut parameters = serde_json::Map::new();
|
||||||
|
parameters.insert("units".to_string(), serde_json::json!(units));
|
||||||
|
parameters.insert("additionalFee".to_string(), serde_json::json!(additional_fee));
|
||||||
|
append_transaction_profile(input, &mut parameters);
|
||||||
|
crate::solana_core_decoded_result(
|
||||||
|
input,
|
||||||
|
crate::SOLANA_CORE_COMPUTE_BUDGET_SURFACE_CODE,
|
||||||
|
"request_units_deprecated",
|
||||||
|
crate::EventFamily::Audit,
|
||||||
|
true,
|
||||||
|
accounts,
|
||||||
|
serde_json::Value::Object(parameters),
|
||||||
|
SOURCE_HISTORICAL,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
0 => crate::solana_core_failed_result(
|
||||||
|
std::option::Option::Some("request_units_deprecated"),
|
||||||
|
"compute_budget_request_units_size_invalid",
|
||||||
|
format!(
|
||||||
|
"historical RequestUnitsDeprecated requires 9 bytes but received {}",
|
||||||
|
bytes.len()
|
||||||
|
),
|
||||||
|
),
|
||||||
|
1 => {
|
||||||
|
decode_u32_instruction(input, bytes.as_slice(), accounts, "request_heap_frame", "bytes")
|
||||||
|
},
|
||||||
|
2 => decode_u32_instruction(
|
||||||
|
input,
|
||||||
|
bytes.as_slice(),
|
||||||
|
accounts,
|
||||||
|
"set_compute_unit_limit",
|
||||||
|
"computeUnitLimit",
|
||||||
|
),
|
||||||
|
3 => decode_u64_instruction(
|
||||||
|
input,
|
||||||
|
bytes.as_slice(),
|
||||||
|
accounts,
|
||||||
|
"set_compute_unit_price",
|
||||||
|
"microLamports",
|
||||||
|
),
|
||||||
|
4 => decode_u32_instruction(
|
||||||
|
input,
|
||||||
|
bytes.as_slice(),
|
||||||
|
accounts,
|
||||||
|
"set_loaded_accounts_data_size_limit",
|
||||||
|
"bytes",
|
||||||
|
),
|
||||||
|
tag => crate::solana_core_unsupported_result(
|
||||||
|
"unknown_compute_budget_instruction",
|
||||||
|
"compute_budget_tag_unknown",
|
||||||
|
format!(
|
||||||
|
"unknown Compute Budget instruction tag {tag}; payload_sha256={}",
|
||||||
|
crate::solana_core_payload_hash(input)
|
||||||
|
),
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
fn decode_u32_instruction(
|
||||||
|
input: &crate::CoreInstructionReplayInput,
|
||||||
|
bytes: &[u8],
|
||||||
|
accounts: serde_json::Value,
|
||||||
|
entry_code: &str,
|
||||||
|
field_name: &str,
|
||||||
|
) -> crate::DecoderExecutionResult {
|
||||||
|
if bytes.len() < 5 {
|
||||||
|
return crate::solana_core_failed_result(
|
||||||
|
std::option::Option::Some(entry_code),
|
||||||
|
"compute_budget_instruction_size_invalid",
|
||||||
|
format!(
|
||||||
|
"Compute Budget {entry_code} requires at least 5 bytes but received {}",
|
||||||
|
bytes.len()
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
let value_result = crate::solana_core_read_u32_le(bytes, 1);
|
||||||
|
let value = match value_result {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => {
|
||||||
|
return crate::solana_core_failed_result(
|
||||||
|
std::option::Option::Some(entry_code),
|
||||||
|
"compute_budget_instruction_truncated",
|
||||||
|
error.to_string(),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
let mut parameters = serde_json::Map::new();
|
||||||
|
parameters.insert(field_name.to_string(), serde_json::json!(value));
|
||||||
|
append_runtime_ignored_trailing_data(&mut parameters, bytes, 5);
|
||||||
|
append_transaction_profile(input, &mut parameters);
|
||||||
|
return crate::solana_core_decoded_result(
|
||||||
|
input,
|
||||||
|
crate::SOLANA_CORE_COMPUTE_BUDGET_SURFACE_CODE,
|
||||||
|
entry_code,
|
||||||
|
crate::EventFamily::Audit,
|
||||||
|
false,
|
||||||
|
accounts,
|
||||||
|
serde_json::Value::Object(parameters),
|
||||||
|
SOURCE_CURRENT,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn decode_u64_instruction(
|
||||||
|
input: &crate::CoreInstructionReplayInput,
|
||||||
|
bytes: &[u8],
|
||||||
|
accounts: serde_json::Value,
|
||||||
|
entry_code: &str,
|
||||||
|
field_name: &str,
|
||||||
|
) -> crate::DecoderExecutionResult {
|
||||||
|
if bytes.len() < 9 {
|
||||||
|
return crate::solana_core_failed_result(
|
||||||
|
std::option::Option::Some(entry_code),
|
||||||
|
"compute_budget_instruction_size_invalid",
|
||||||
|
format!(
|
||||||
|
"Compute Budget {entry_code} requires at least 9 bytes but received {}",
|
||||||
|
bytes.len()
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
let value_result = crate::solana_core_read_u64_le(bytes, 1);
|
||||||
|
let value = match value_result {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => {
|
||||||
|
return crate::solana_core_failed_result(
|
||||||
|
std::option::Option::Some(entry_code),
|
||||||
|
"compute_budget_instruction_truncated",
|
||||||
|
error.to_string(),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
let mut parameters = serde_json::Map::new();
|
||||||
|
parameters.insert(field_name.to_string(), serde_json::json!(value));
|
||||||
|
append_runtime_ignored_trailing_data(&mut parameters, bytes, 9);
|
||||||
|
append_transaction_profile(input, &mut parameters);
|
||||||
|
return crate::solana_core_decoded_result(
|
||||||
|
input,
|
||||||
|
crate::SOLANA_CORE_COMPUTE_BUDGET_SURFACE_CODE,
|
||||||
|
entry_code,
|
||||||
|
crate::EventFamily::Audit,
|
||||||
|
false,
|
||||||
|
accounts,
|
||||||
|
serde_json::Value::Object(parameters),
|
||||||
|
SOURCE_CURRENT,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn append_transaction_profile(
|
||||||
|
input: &crate::CoreInstructionReplayInput,
|
||||||
|
parameters: &mut serde_json::Map<std::string::String, serde_json::Value>,
|
||||||
|
) {
|
||||||
|
parameters.insert("transactionProfile".to_string(), transaction_profile(input));
|
||||||
|
}
|
||||||
|
|
||||||
|
fn transaction_profile(input: &crate::CoreInstructionReplayInput) -> serde_json::Value {
|
||||||
|
let target_path = input.instruction_path.as_str();
|
||||||
|
let mut instruction_rows = std::vec::Vec::new();
|
||||||
|
let mut effective = serde_json::Map::new();
|
||||||
|
let mut decoded_count = 0_u64;
|
||||||
|
let mut unsupported_count = 0_u64;
|
||||||
|
let mut failed_count = 0_u64;
|
||||||
|
let mut trailing_count = 0_u64;
|
||||||
|
let outer_rows = compute_budget_outer_rows(input);
|
||||||
|
for row in outer_rows {
|
||||||
|
if row.status == "decoded" {
|
||||||
|
decoded_count = decoded_count.saturating_add(1);
|
||||||
|
} else if row.status == "unsupported" {
|
||||||
|
unsupported_count = unsupported_count.saturating_add(1);
|
||||||
|
} else if row.status == "failed" {
|
||||||
|
failed_count = failed_count.saturating_add(1);
|
||||||
|
}
|
||||||
|
if row.trailing_data_present {
|
||||||
|
trailing_count = trailing_count.saturating_add(1);
|
||||||
|
}
|
||||||
|
apply_effective_value(&mut effective, &row);
|
||||||
|
instruction_rows.push(row.to_json());
|
||||||
|
}
|
||||||
|
let emitter_path = profile_emitter_path(instruction_rows.as_slice());
|
||||||
|
let current_is_emitter = emitter_path.as_deref() == std::option::Option::Some(target_path);
|
||||||
|
return serde_json::json!({
|
||||||
|
"profileVersion": 1,
|
||||||
|
"profileSemantics": "transaction_compute_budget_last_write_wins",
|
||||||
|
"currentInstructionIsProfileEmitter": current_is_emitter,
|
||||||
|
"profileEmitterInstructionPath": emitter_path,
|
||||||
|
"computeBudgetInstructionCount": instruction_rows.len(),
|
||||||
|
"decodedInstructionCount": decoded_count,
|
||||||
|
"unsupportedInstructionCount": unsupported_count,
|
||||||
|
"failedInstructionCount": failed_count,
|
||||||
|
"runtimeIgnoredTrailingDataInstructionCount": trailing_count,
|
||||||
|
"effectiveValues": serde_json::Value::Object(effective),
|
||||||
|
"instructions": instruction_rows,
|
||||||
|
"transactionFinalRuntimeMetricsCaptured": false,
|
||||||
|
"computeUnitsConsumedCaptured": false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
struct ComputeBudgetProfileRow {
|
||||||
|
instruction_index: u64,
|
||||||
|
instruction_path: std::string::String,
|
||||||
|
entry_code: std::string::String,
|
||||||
|
status: &'static str,
|
||||||
|
parameters: serde_json::Value,
|
||||||
|
payload_hash: serde_json::Value,
|
||||||
|
trailing_data_present: bool,
|
||||||
|
diagnostic: serde_json::Value,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ComputeBudgetProfileRow {
|
||||||
|
fn to_json(&self) -> serde_json::Value {
|
||||||
|
return serde_json::json!({
|
||||||
|
"instructionIndex": self.instruction_index,
|
||||||
|
"instructionPath": self.instruction_path,
|
||||||
|
"entryCode": self.entry_code,
|
||||||
|
"status": self.status,
|
||||||
|
"parameters": self.parameters,
|
||||||
|
"payloadHash": self.payload_hash,
|
||||||
|
"trailingDataPresent": self.trailing_data_present,
|
||||||
|
"diagnostic": self.diagnostic,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn compute_budget_outer_rows(
|
||||||
|
input: &crate::CoreInstructionReplayInput,
|
||||||
|
) -> std::vec::Vec<ComputeBudgetProfileRow> {
|
||||||
|
let mut rows = std::vec::Vec::new();
|
||||||
|
let outer_values = match input.outer_instructions_json.as_array() {
|
||||||
|
std::option::Option::Some(value) => value,
|
||||||
|
std::option::Option::None => return rows,
|
||||||
|
};
|
||||||
|
for value in outer_values {
|
||||||
|
if value.get("programId").and_then(serde_json::Value::as_str)
|
||||||
|
!= std::option::Option::Some(kb_program_ids::COMPUTE_BUDGET_PROGRAM_ID)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let instruction_index =
|
||||||
|
value.get("instructionIndex").and_then(serde_json::Value::as_u64).unwrap_or(0);
|
||||||
|
let instruction_path = value
|
||||||
|
.get("instructionPath")
|
||||||
|
.and_then(serde_json::Value::as_str)
|
||||||
|
.unwrap_or("")
|
||||||
|
.to_string();
|
||||||
|
let payload_hash = value.get("payloadHash").cloned().unwrap_or(serde_json::Value::Null);
|
||||||
|
let payload_json = match value.get("payloadJson") {
|
||||||
|
std::option::Option::Some(payload) if !payload.is_null() => payload,
|
||||||
|
_ => {
|
||||||
|
rows.push(profile_failure_row(
|
||||||
|
instruction_index,
|
||||||
|
instruction_path,
|
||||||
|
payload_hash,
|
||||||
|
"payload_missing",
|
||||||
|
"outer Compute Budget instruction payload is not retained",
|
||||||
|
));
|
||||||
|
continue;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
let bytes_result = decode_payload_json_for_profile(payload_json);
|
||||||
|
let bytes = match bytes_result {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => {
|
||||||
|
rows.push(profile_failure_row(
|
||||||
|
instruction_index,
|
||||||
|
instruction_path,
|
||||||
|
payload_hash,
|
||||||
|
"payload_invalid",
|
||||||
|
error.as_str(),
|
||||||
|
));
|
||||||
|
continue;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
rows.push(profile_row_from_bytes(
|
||||||
|
instruction_index,
|
||||||
|
instruction_path,
|
||||||
|
payload_hash,
|
||||||
|
bytes.as_slice(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if rows.is_empty() && input.program_id == kb_program_ids::COMPUTE_BUDGET_PROGRAM_ID {
|
||||||
|
let bytes_result = crate::solana_core_decode_instruction_data(input);
|
||||||
|
let bytes = match bytes_result {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => {
|
||||||
|
rows.push(profile_failure_row(
|
||||||
|
input.instruction_path.parse::<u64>().unwrap_or(0),
|
||||||
|
input.instruction_path.clone(),
|
||||||
|
serde_json::Value::String(crate::solana_core_payload_hash(input)),
|
||||||
|
"payload_invalid",
|
||||||
|
error.to_string().as_str(),
|
||||||
|
));
|
||||||
|
return rows;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
rows.push(profile_row_from_bytes(
|
||||||
|
input.instruction_path.parse::<u64>().unwrap_or(0),
|
||||||
|
input.instruction_path.clone(),
|
||||||
|
serde_json::Value::String(crate::solana_core_payload_hash(input)),
|
||||||
|
bytes.as_slice(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
return rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
fn profile_row_from_bytes(
|
||||||
|
instruction_index: u64,
|
||||||
|
instruction_path: std::string::String,
|
||||||
|
payload_hash: serde_json::Value,
|
||||||
|
bytes: &[u8],
|
||||||
|
) -> ComputeBudgetProfileRow {
|
||||||
|
let entry = entry_code(bytes).unwrap_or("unknown_compute_budget_instruction");
|
||||||
|
let mut diagnostic = serde_json::Value::Null;
|
||||||
|
let mut status = "decoded";
|
||||||
|
let mut parameters = serde_json::Map::new();
|
||||||
|
let mut trailing_data_present = false;
|
||||||
|
match entry {
|
||||||
|
"unused_reserved" => {},
|
||||||
|
"request_units_deprecated" => {
|
||||||
|
if bytes.len() == 9 {
|
||||||
|
if let std::result::Result::Ok(value) = crate::solana_core_read_u32_le(bytes, 1) {
|
||||||
|
parameters.insert("units".to_string(), serde_json::json!(value));
|
||||||
|
}
|
||||||
|
if let std::result::Result::Ok(value) = crate::solana_core_read_u32_le(bytes, 5) {
|
||||||
|
parameters.insert("additionalFee".to_string(), serde_json::json!(value));
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
status = "failed";
|
||||||
|
diagnostic = serde_json::json!("historical request units payload size is invalid");
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"request_heap_frame" | "set_compute_unit_limit" | "set_loaded_accounts_data_size_limit" => {
|
||||||
|
if bytes.len() >= 5 {
|
||||||
|
if let std::result::Result::Ok(value) = crate::solana_core_read_u32_le(bytes, 1) {
|
||||||
|
let field_name = match entry {
|
||||||
|
"request_heap_frame" => "bytes",
|
||||||
|
"set_compute_unit_limit" => "computeUnitLimit",
|
||||||
|
_ => "bytes",
|
||||||
|
};
|
||||||
|
parameters.insert(field_name.to_string(), serde_json::json!(value));
|
||||||
|
}
|
||||||
|
trailing_data_present = bytes.len() > 5;
|
||||||
|
} else {
|
||||||
|
status = "failed";
|
||||||
|
diagnostic = serde_json::json!("current u32 Compute Budget payload is truncated");
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"set_compute_unit_price" => {
|
||||||
|
if bytes.len() >= 9 {
|
||||||
|
if let std::result::Result::Ok(value) = crate::solana_core_read_u64_le(bytes, 1) {
|
||||||
|
parameters.insert("microLamports".to_string(), serde_json::json!(value));
|
||||||
|
}
|
||||||
|
trailing_data_present = bytes.len() > 9;
|
||||||
|
} else {
|
||||||
|
status = "failed";
|
||||||
|
diagnostic = serde_json::json!("current u64 Compute Budget payload is truncated");
|
||||||
|
}
|
||||||
|
},
|
||||||
|
_ => {
|
||||||
|
status = "unsupported";
|
||||||
|
diagnostic = serde_json::json!("unknown Compute Budget instruction tag");
|
||||||
|
},
|
||||||
|
}
|
||||||
|
return ComputeBudgetProfileRow {
|
||||||
|
instruction_index,
|
||||||
|
instruction_path,
|
||||||
|
entry_code: entry.to_string(),
|
||||||
|
status,
|
||||||
|
parameters: serde_json::Value::Object(parameters),
|
||||||
|
payload_hash,
|
||||||
|
trailing_data_present,
|
||||||
|
diagnostic,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
fn profile_failure_row(
|
||||||
|
instruction_index: u64,
|
||||||
|
instruction_path: std::string::String,
|
||||||
|
payload_hash: serde_json::Value,
|
||||||
|
code: &str,
|
||||||
|
message: &str,
|
||||||
|
) -> ComputeBudgetProfileRow {
|
||||||
|
return ComputeBudgetProfileRow {
|
||||||
|
instruction_index,
|
||||||
|
instruction_path,
|
||||||
|
entry_code: "malformed_compute_budget_instruction".to_string(),
|
||||||
|
status: "failed",
|
||||||
|
parameters: serde_json::Value::Null,
|
||||||
|
payload_hash,
|
||||||
|
trailing_data_present: false,
|
||||||
|
diagnostic: serde_json::json!({"code": code, "message": message}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
fn decode_payload_json_for_profile(
|
||||||
|
payload: &serde_json::Value,
|
||||||
|
) -> std::result::Result<std::vec::Vec<u8>, std::string::String> {
|
||||||
|
let encoded = match payload.get("dataBase64").and_then(serde_json::Value::as_str) {
|
||||||
|
std::option::Option::Some(value) => value,
|
||||||
|
std::option::Option::None => {
|
||||||
|
return std::result::Result::Err("payload does not contain dataBase64".to_string());
|
||||||
|
},
|
||||||
|
};
|
||||||
|
let maximum_encoded_length = crate::SOLANA_CORE_MAX_NATIVE_INSTRUCTION_PAYLOAD_BYTES
|
||||||
|
.saturating_mul(4)
|
||||||
|
.saturating_div(3)
|
||||||
|
.saturating_add(4);
|
||||||
|
if encoded.len() > maximum_encoded_length {
|
||||||
|
return std::result::Result::Err(format!(
|
||||||
|
"payload base64 exceeds {maximum_encoded_length} bytes"
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let decoded_result = base64::engine::general_purpose::STANDARD.decode(encoded.as_bytes());
|
||||||
|
let decoded = match decoded_result {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => {
|
||||||
|
return std::result::Result::Err(format!("payload is not valid base64: {error}"));
|
||||||
|
},
|
||||||
|
};
|
||||||
|
if decoded.len() > crate::SOLANA_CORE_MAX_NATIVE_INSTRUCTION_PAYLOAD_BYTES {
|
||||||
|
return std::result::Result::Err(format!(
|
||||||
|
"payload exceeds {} decoded bytes",
|
||||||
|
crate::SOLANA_CORE_MAX_NATIVE_INSTRUCTION_PAYLOAD_BYTES
|
||||||
|
));
|
||||||
|
}
|
||||||
|
return std::result::Result::Ok(decoded);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn apply_effective_value(
|
||||||
|
effective: &mut serde_json::Map<std::string::String, serde_json::Value>,
|
||||||
|
row: &ComputeBudgetProfileRow,
|
||||||
|
) {
|
||||||
|
if row.status != "decoded" {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let value = match row.entry_code.as_str() {
|
||||||
|
"request_units_deprecated" => serde_json::json!({
|
||||||
|
"units": row.parameters.get("units").cloned().unwrap_or(serde_json::Value::Null),
|
||||||
|
"additionalFee": row.parameters.get("additionalFee").cloned().unwrap_or(serde_json::Value::Null),
|
||||||
|
}),
|
||||||
|
"request_heap_frame" => {
|
||||||
|
row.parameters.get("bytes").cloned().unwrap_or(serde_json::Value::Null)
|
||||||
|
},
|
||||||
|
"set_compute_unit_limit" => row
|
||||||
|
.parameters
|
||||||
|
.get("computeUnitLimit")
|
||||||
|
.cloned()
|
||||||
|
.unwrap_or(serde_json::Value::Null),
|
||||||
|
"set_compute_unit_price" => {
|
||||||
|
row.parameters.get("microLamports").cloned().unwrap_or(serde_json::Value::Null)
|
||||||
|
},
|
||||||
|
"set_loaded_accounts_data_size_limit" => {
|
||||||
|
row.parameters.get("bytes").cloned().unwrap_or(serde_json::Value::Null)
|
||||||
|
},
|
||||||
|
_ => return,
|
||||||
|
};
|
||||||
|
effective.insert(
|
||||||
|
row.entry_code.clone(),
|
||||||
|
serde_json::json!({
|
||||||
|
"value": value,
|
||||||
|
"sourceInstructionIndex": row.instruction_index,
|
||||||
|
"sourceInstructionPath": row.instruction_path,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn profile_emitter_path(
|
||||||
|
instruction_rows: &[serde_json::Value],
|
||||||
|
) -> std::option::Option<std::string::String> {
|
||||||
|
for row in instruction_rows {
|
||||||
|
if row.get("status").and_then(serde_json::Value::as_str)
|
||||||
|
!= std::option::Option::Some("decoded")
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if row.get("entryCode").and_then(serde_json::Value::as_str)
|
||||||
|
== std::option::Option::Some("unused_reserved")
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
return row
|
||||||
|
.get("instructionPath")
|
||||||
|
.and_then(serde_json::Value::as_str)
|
||||||
|
.map(|value| return value.to_string());
|
||||||
|
}
|
||||||
|
return std::option::Option::None;
|
||||||
|
}
|
||||||
|
|
||||||
|
fn append_runtime_ignored_trailing_data(
|
||||||
|
parameters: &mut serde_json::Map<std::string::String, serde_json::Value>,
|
||||||
|
bytes: &[u8],
|
||||||
|
decoded_byte_length: usize,
|
||||||
|
) {
|
||||||
|
let trailing_data = match bytes.get(decoded_byte_length..) {
|
||||||
|
std::option::Option::Some(value) if !value.is_empty() => value,
|
||||||
|
_ => return,
|
||||||
|
};
|
||||||
|
parameters.insert("trailingDataByteLength".to_string(), serde_json::json!(trailing_data.len()));
|
||||||
|
parameters.insert(
|
||||||
|
"trailingDataSha256".to_string(),
|
||||||
|
serde_json::json!(crate::solana_core_hash_bytes(trailing_data)),
|
||||||
|
);
|
||||||
|
parameters.insert(
|
||||||
|
"trailingDataPrefixHex".to_string(),
|
||||||
|
serde_json::json!(crate::solana_core_hexadecimal_prefix(
|
||||||
|
trailing_data,
|
||||||
|
trailing_data.len().min(16),
|
||||||
|
)),
|
||||||
|
);
|
||||||
|
parameters.insert(
|
||||||
|
"trailingDataSemantics".to_string(),
|
||||||
|
serde_json::json!("ignored_by_runtime_borsh_unchecked"),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn entry_code(bytes: &[u8]) -> std::option::Option<&'static str> {
|
||||||
|
let tag = match bytes.first() {
|
||||||
|
std::option::Option::Some(value) => *value,
|
||||||
|
std::option::Option::None => return std::option::Option::None,
|
||||||
|
};
|
||||||
|
return match tag {
|
||||||
|
0 if bytes.len() == 1 => std::option::Option::Some("unused_reserved"),
|
||||||
|
0 if bytes.len() == 9 => std::option::Option::Some("request_units_deprecated"),
|
||||||
|
1 => std::option::Option::Some("request_heap_frame"),
|
||||||
|
2 => std::option::Option::Some("set_compute_unit_limit"),
|
||||||
|
3 => std::option::Option::Some("set_compute_unit_price"),
|
||||||
|
4 => std::option::Option::Some("set_loaded_accounts_data_size_limit"),
|
||||||
|
_ => std::option::Option::None,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
fn coverage_entry(
|
||||||
|
entry_code: &str,
|
||||||
|
discriminator_hex: &str,
|
||||||
|
historical: bool,
|
||||||
|
) -> crate::DecoderCoverageDeclaration {
|
||||||
|
return crate::DecoderCoverageDeclaration {
|
||||||
|
program_id: kb_program_ids::COMPUTE_BUDGET_PROGRAM_ID.to_string(),
|
||||||
|
surface_code: std::option::Option::Some(
|
||||||
|
crate::SOLANA_CORE_COMPUTE_BUDGET_SURFACE_CODE.to_string(),
|
||||||
|
),
|
||||||
|
entry_kind: crate::DecoderCoverageEntryKind::Instruction,
|
||||||
|
entry_code: entry_code.to_string(),
|
||||||
|
discriminator_hex: std::option::Option::Some(discriminator_hex.to_string()),
|
||||||
|
historical,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use base64::Engine; // rust-rules: trait-import
|
||||||
|
|
||||||
|
fn replay_input(bytes: &[u8], transaction_failed: bool) -> crate::CoreInstructionReplayInput {
|
||||||
|
let encoded = base64::engine::general_purpose::STANDARD.encode(bytes);
|
||||||
|
let result = crate::CoreInstructionReplayInput::new(
|
||||||
|
"signature:0",
|
||||||
|
"signature",
|
||||||
|
42,
|
||||||
|
"0",
|
||||||
|
kb_program_ids::COMPUTE_BUDGET_PROGRAM_ID,
|
||||||
|
transaction_failed,
|
||||||
|
if transaction_failed {
|
||||||
|
std::option::Option::Some(serde_json::json!({"InstructionError": [0, "Custom"]}))
|
||||||
|
} else {
|
||||||
|
std::option::Option::None
|
||||||
|
},
|
||||||
|
serde_json::json!([]),
|
||||||
|
serde_json::json!([]),
|
||||||
|
std::option::Option::Some(serde_json::json!({"dataBase64": encoded})),
|
||||||
|
std::option::Option::Some("payload-hash".to_string()),
|
||||||
|
serde_json::json!([]),
|
||||||
|
serde_json::json!([]),
|
||||||
|
serde_json::json!([]),
|
||||||
|
serde_json::json!([]),
|
||||||
|
);
|
||||||
|
return match result {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => panic!("replay input failed: {error}"),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
fn replay_input_with_outer(
|
||||||
|
bytes: &[u8],
|
||||||
|
instruction_path: &str,
|
||||||
|
outer_instructions_json: serde_json::Value,
|
||||||
|
) -> crate::CoreInstructionReplayInput {
|
||||||
|
let encoded = base64::engine::general_purpose::STANDARD.encode(bytes);
|
||||||
|
let result = crate::CoreInstructionReplayInput::new(
|
||||||
|
"signature:0",
|
||||||
|
"signature",
|
||||||
|
42,
|
||||||
|
instruction_path,
|
||||||
|
kb_program_ids::COMPUTE_BUDGET_PROGRAM_ID,
|
||||||
|
false,
|
||||||
|
std::option::Option::None,
|
||||||
|
serde_json::json!([]),
|
||||||
|
serde_json::json!([]),
|
||||||
|
std::option::Option::Some(serde_json::json!({"dataBase64": encoded})),
|
||||||
|
std::option::Option::Some("payload-hash".to_string()),
|
||||||
|
outer_instructions_json,
|
||||||
|
serde_json::json!([]),
|
||||||
|
serde_json::json!([]),
|
||||||
|
serde_json::json!([]),
|
||||||
|
);
|
||||||
|
return match result {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => panic!("replay input failed: {error}"),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
fn outer_instruction(
|
||||||
|
index: u64,
|
||||||
|
path: &str,
|
||||||
|
program_id: &str,
|
||||||
|
bytes: &[u8],
|
||||||
|
) -> serde_json::Value {
|
||||||
|
let encoded = base64::engine::general_purpose::STANDARD.encode(bytes);
|
||||||
|
return serde_json::json!({
|
||||||
|
"instructionIndex": index,
|
||||||
|
"instructionPath": path,
|
||||||
|
"programId": program_id,
|
||||||
|
"payloadJson": {"dataBase64": encoded},
|
||||||
|
"payloadHash": format!("hash-{path}"),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn transaction_profile_aggregates_outer_compute_budget_instructions_once() {
|
||||||
|
let outer = serde_json::json!([
|
||||||
|
outer_instruction(
|
||||||
|
0,
|
||||||
|
"0",
|
||||||
|
kb_program_ids::COMPUTE_BUDGET_PROGRAM_ID,
|
||||||
|
&[2, 64, 66, 15, 0],
|
||||||
|
),
|
||||||
|
outer_instruction(1, "1", kb_program_ids::SYSTEM_PROGRAM_ID, &[2, 1, 0, 0, 0],),
|
||||||
|
outer_instruction(
|
||||||
|
2,
|
||||||
|
"2",
|
||||||
|
kb_program_ids::COMPUTE_BUDGET_PROGRAM_ID,
|
||||||
|
&[3, 7, 0, 0, 0, 0, 0, 0, 0],
|
||||||
|
),
|
||||||
|
outer_instruction(
|
||||||
|
3,
|
||||||
|
"3",
|
||||||
|
kb_program_ids::COMPUTE_BUDGET_PROGRAM_ID,
|
||||||
|
&[2, 128, 132, 30, 0],
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
let result = crate::solana_core_compute_budget_decode(&replay_input_with_outer(
|
||||||
|
&[2, 64, 66, 15, 0],
|
||||||
|
"0",
|
||||||
|
outer,
|
||||||
|
));
|
||||||
|
assert_eq!(result.status, crate::DecoderOutcomeStatus::Decoded);
|
||||||
|
let profile = &result.observations[0].payload_json["parameters"]["transactionProfile"];
|
||||||
|
assert_eq!(profile["currentInstructionIsProfileEmitter"], true);
|
||||||
|
assert_eq!(profile["computeBudgetInstructionCount"], 3);
|
||||||
|
assert_eq!(profile["effectiveValues"]["set_compute_unit_limit"]["value"], 2_000_000);
|
||||||
|
assert_eq!(
|
||||||
|
profile["effectiveValues"]["set_compute_unit_limit"]["sourceInstructionPath"],
|
||||||
|
"3"
|
||||||
|
);
|
||||||
|
assert_eq!(profile["effectiveValues"]["set_compute_unit_price"]["value"], 7);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn official_current_encoder_matches_documented_layout() {
|
||||||
|
let instruction =
|
||||||
|
solana_compute_budget_interface::ComputeBudgetInstruction::SetComputeUnitPrice(7);
|
||||||
|
let bytes_result = borsh::to_vec(&instruction);
|
||||||
|
let bytes = match bytes_result {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => {
|
||||||
|
panic!("compute budget fixture serialization failed: {error}")
|
||||||
|
},
|
||||||
|
};
|
||||||
|
assert_eq!(bytes, [3, 7, 0, 0, 0, 0, 0, 0, 0]);
|
||||||
|
let result =
|
||||||
|
crate::solana_core_compute_budget_decode(&replay_input(bytes.as_slice(), false));
|
||||||
|
assert_eq!(result.status, crate::DecoderOutcomeStatus::Decoded);
|
||||||
|
assert_eq!(
|
||||||
|
result.recognized_entry_code.as_deref(),
|
||||||
|
std::option::Option::Some("set_compute_unit_price")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn all_current_variants_decode_with_exact_numeric_values() {
|
||||||
|
let fixtures = [
|
||||||
|
(vec![1, 0, 0, 4, 0], "request_heap_frame"),
|
||||||
|
(vec![2, 255, 255, 255, 255], "set_compute_unit_limit"),
|
||||||
|
(vec![3, 255, 255, 255, 255, 255, 255, 255, 255], "set_compute_unit_price"),
|
||||||
|
(vec![4, 0, 0, 0, 1], "set_loaded_accounts_data_size_limit"),
|
||||||
|
];
|
||||||
|
for (bytes, expected) in fixtures {
|
||||||
|
let result =
|
||||||
|
crate::solana_core_compute_budget_decode(&replay_input(bytes.as_slice(), false));
|
||||||
|
assert_eq!(result.status, crate::DecoderOutcomeStatus::Decoded);
|
||||||
|
assert_eq!(
|
||||||
|
result.recognized_entry_code.as_deref(),
|
||||||
|
std::option::Option::Some(expected)
|
||||||
|
);
|
||||||
|
assert_eq!(result.observations.len(), 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn historical_request_units_is_decoded() {
|
||||||
|
let result = crate::solana_core_compute_budget_decode(&replay_input(
|
||||||
|
&[0, 64, 13, 3, 0, 7, 0, 0, 0],
|
||||||
|
false,
|
||||||
|
));
|
||||||
|
assert_eq!(result.status, crate::DecoderOutcomeStatus::Decoded);
|
||||||
|
assert_eq!(
|
||||||
|
result.recognized_entry_code.as_deref(),
|
||||||
|
std::option::Option::Some("request_units_deprecated")
|
||||||
|
);
|
||||||
|
assert_eq!(result.observations[0].payload_json["parameters"]["units"], 200_000);
|
||||||
|
assert_eq!(result.observations[0].payload_json["parameters"]["additionalFee"], 7);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn reserved_unused_tag_is_ignored_without_false_event() {
|
||||||
|
let result = crate::solana_core_compute_budget_decode(&replay_input(&[0], false));
|
||||||
|
assert_eq!(result.status, crate::DecoderOutcomeStatus::Ignored);
|
||||||
|
assert!(result.observations.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn truncated_and_unknown_payloads_are_distinguished() {
|
||||||
|
let truncated = crate::solana_core_compute_budget_decode(&replay_input(&[2, 1, 2], false));
|
||||||
|
assert_eq!(truncated.status, crate::DecoderOutcomeStatus::Failed);
|
||||||
|
let unknown =
|
||||||
|
crate::solana_core_compute_budget_decode(&replay_input(&[255, 1, 2, 3], false));
|
||||||
|
assert_eq!(unknown.status, crate::DecoderOutcomeStatus::Unsupported);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn current_variants_accept_and_preserve_runtime_ignored_trailing_bytes() {
|
||||||
|
let result = crate::solana_core_compute_budget_decode(&replay_input(
|
||||||
|
&[2, 64, 13, 3, 0, 1, 2, 3, 4, 5, 6, 7],
|
||||||
|
true,
|
||||||
|
));
|
||||||
|
assert_eq!(result.status, crate::DecoderOutcomeStatus::Decoded);
|
||||||
|
assert_eq!(
|
||||||
|
result.recognized_entry_code.as_deref(),
|
||||||
|
std::option::Option::Some("set_compute_unit_limit")
|
||||||
|
);
|
||||||
|
assert_eq!(result.observations[0].payload_json["parameters"]["computeUnitLimit"], 200_000);
|
||||||
|
assert_eq!(result.observations[0].payload_json["parameters"]["trailingDataByteLength"], 7);
|
||||||
|
assert_eq!(
|
||||||
|
result.observations[0].payload_json["parameters"]["trailingDataPrefixHex"],
|
||||||
|
"01020304050607"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
result.observations[0].payload_json["parameters"]["trailingDataSemantics"],
|
||||||
|
"ignored_by_runtime_borsh_unchecked"
|
||||||
|
);
|
||||||
|
assert!(result.observations[0].transaction_failed);
|
||||||
|
assert!(!result.observations[0].observation_committed);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn failed_transaction_event_is_not_committed() {
|
||||||
|
let result =
|
||||||
|
crate::solana_core_compute_budget_decode(&replay_input(&[2, 64, 13, 3, 0], true));
|
||||||
|
assert_eq!(result.status, crate::DecoderOutcomeStatus::Decoded);
|
||||||
|
assert!(result.observations[0].transaction_failed);
|
||||||
|
assert!(!result.observations[0].observation_committed);
|
||||||
|
assert_eq!(result.observations[0].payload_json["transactionSucceeded"], false);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn coverage_declares_current_and_historical_entries() {
|
||||||
|
let coverage = crate::solana_core_compute_budget_coverage();
|
||||||
|
assert_eq!(coverage.len(), 6);
|
||||||
|
assert!(coverage.iter().any(|entry| {
|
||||||
|
return entry.entry_code == "request_units_deprecated" && entry.historical;
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
491
kb-lib/src/decoder/solana/core/config.rs
Normal file
491
kb-lib/src/decoder/solana/core/config.rs
Normal file
@@ -0,0 +1,491 @@
|
|||||||
|
// file: kb-lib/src/decoder/solana/core/config.rs
|
||||||
|
// version: 4
|
||||||
|
|
||||||
|
//! Generic bounded Config Program store decoding without inventing payload semantics.
|
||||||
|
|
||||||
|
const SOURCE: &str =
|
||||||
|
"solana-config-interface@2.0.0 plus solana-config-program@2.2.20 processor contract";
|
||||||
|
const MAX_CONFIG_KEYS: usize = 128;
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||||
|
struct ParsedConfigPayload {
|
||||||
|
keys: std::vec::Vec<(std::string::String, bool)>,
|
||||||
|
data_offset: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns declared Config Program instruction coverage.
|
||||||
|
pub(crate) fn config_coverage() -> std::vec::Vec<crate::DecoderCoverageDeclaration> {
|
||||||
|
return vec![crate::DecoderCoverageDeclaration {
|
||||||
|
program_id: kb_program_ids::CONFIG_PROGRAM_ID.to_string(),
|
||||||
|
surface_code: std::option::Option::Some(crate::SOLANA_CORE_CONFIG_SURFACE_CODE.to_string()),
|
||||||
|
entry_kind: crate::DecoderCoverageEntryKind::Instruction,
|
||||||
|
entry_code: "store".to_string(),
|
||||||
|
discriminator_hex: std::option::Option::None,
|
||||||
|
historical: false,
|
||||||
|
}];
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Recognizes one generic Config Program store instruction.
|
||||||
|
pub(crate) fn config_recognize(
|
||||||
|
input: &crate::CoreInstructionReplayInput,
|
||||||
|
priority: u16,
|
||||||
|
) -> crate::DecoderRecognition {
|
||||||
|
let bytes_result = crate::solana_core_decode_instruction_data(input);
|
||||||
|
let bytes = match bytes_result {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(_error) => {
|
||||||
|
return crate::DecoderRecognition::compatible(
|
||||||
|
false,
|
||||||
|
priority,
|
||||||
|
std::option::Option::Some(crate::SOLANA_CORE_CONFIG_SURFACE_CODE.to_string()),
|
||||||
|
std::option::Option::Some("malformed_config_store".to_string()),
|
||||||
|
std::option::Option::None,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
let exact = parse_payload(bytes.as_slice()).is_ok();
|
||||||
|
return crate::DecoderRecognition::compatible(
|
||||||
|
exact,
|
||||||
|
priority,
|
||||||
|
std::option::Option::Some(crate::SOLANA_CORE_CONFIG_SURFACE_CODE.to_string()),
|
||||||
|
std::option::Option::Some(if exact {
|
||||||
|
"store".to_string()
|
||||||
|
} else {
|
||||||
|
"malformed_config_store".to_string()
|
||||||
|
}),
|
||||||
|
crate::solana_core_hexadecimal_prefix(bytes.as_slice(), bytes.len().min(3)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Decodes one generic Config Program store instruction.
|
||||||
|
pub(crate) fn config_decode(
|
||||||
|
input: &crate::CoreInstructionReplayInput,
|
||||||
|
) -> crate::DecoderExecutionResult {
|
||||||
|
let bytes_result = crate::solana_core_decode_instruction_data(input);
|
||||||
|
let bytes = match bytes_result {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => {
|
||||||
|
return crate::solana_core_failed_result(
|
||||||
|
std::option::Option::Some("malformed_config_store"),
|
||||||
|
"config_payload_invalid",
|
||||||
|
error.to_string(),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
let parsed_result = parse_payload(bytes.as_slice());
|
||||||
|
let parsed = match parsed_result {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => {
|
||||||
|
return crate::solana_core_failed_result(
|
||||||
|
std::option::Option::Some("store"),
|
||||||
|
"config_keys_invalid",
|
||||||
|
error.to_string(),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
let accounts_result = resolve_and_validate_accounts(input, &parsed);
|
||||||
|
let accounts = match accounts_result {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => {
|
||||||
|
return crate::solana_core_failed_result(
|
||||||
|
std::option::Option::Some("store"),
|
||||||
|
"config_accounts_invalid",
|
||||||
|
error.to_string(),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
let data = match bytes.get(parsed.data_offset..) {
|
||||||
|
std::option::Option::Some(value) => value,
|
||||||
|
std::option::Option::None => {
|
||||||
|
return crate::solana_core_failed_result(
|
||||||
|
std::option::Option::Some("store"),
|
||||||
|
"config_data_offset_invalid",
|
||||||
|
"Config data offset exceeds retained payload",
|
||||||
|
);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
let keys_json = parsed
|
||||||
|
.keys
|
||||||
|
.iter()
|
||||||
|
.map(|(pubkey, signer)| {
|
||||||
|
return serde_json::json!({"pubkey": pubkey, "signer": signer});
|
||||||
|
})
|
||||||
|
.collect::<std::vec::Vec<_>>();
|
||||||
|
let signer_count = parsed.keys.iter().filter(|(_, signer)| return *signer).count();
|
||||||
|
return crate::solana_core_decoded_result(
|
||||||
|
input,
|
||||||
|
crate::SOLANA_CORE_CONFIG_SURFACE_CODE,
|
||||||
|
"store",
|
||||||
|
crate::EventFamily::Admin,
|
||||||
|
false,
|
||||||
|
accounts,
|
||||||
|
serde_json::json!({
|
||||||
|
"configuredKeys": keys_json,
|
||||||
|
"configuredKeyCount": parsed.keys.len(),
|
||||||
|
"configuredSignerCount": signer_count,
|
||||||
|
"configDataByteLength": data.len(),
|
||||||
|
"configDataSha256": crate::solana_core_hash_bytes(data),
|
||||||
|
"configDataPrefixHex": crate::solana_core_hexadecimal_prefix(data, data.len().min(16)),
|
||||||
|
"configDataSemantics": "opaque_program_specific",
|
||||||
|
}),
|
||||||
|
SOURCE,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_payload(bytes: &[u8]) -> kb_core::Result<ParsedConfigPayload> {
|
||||||
|
let length_result = read_compact_u16(bytes);
|
||||||
|
let (key_count, mut offset) = match length_result {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
let key_count_usize = usize::from(key_count);
|
||||||
|
if key_count_usize > MAX_CONFIG_KEYS {
|
||||||
|
return std::result::Result::Err(kb_core::Error::invalid_state(format!(
|
||||||
|
"Config key count {key_count_usize} exceeds bounded maximum {}",
|
||||||
|
MAX_CONFIG_KEYS
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
let required = offset.saturating_add(key_count_usize.saturating_mul(33));
|
||||||
|
if required > bytes.len() {
|
||||||
|
return std::result::Result::Err(kb_core::Error::invalid_state(format!(
|
||||||
|
"Config key vector requires {required} bytes but payload contains {}",
|
||||||
|
bytes.len()
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
let mut keys = std::vec::Vec::with_capacity(key_count_usize);
|
||||||
|
for index in 0..key_count_usize {
|
||||||
|
let pubkey_end = offset.saturating_add(32);
|
||||||
|
let pubkey_bytes = match bytes.get(offset..pubkey_end) {
|
||||||
|
std::option::Option::Some(value) => value,
|
||||||
|
std::option::Option::None => {
|
||||||
|
return std::result::Result::Err(kb_core::Error::invalid_state(format!(
|
||||||
|
"Config key {index} pubkey is truncated"
|
||||||
|
)));
|
||||||
|
},
|
||||||
|
};
|
||||||
|
let signer_byte = match bytes.get(pubkey_end) {
|
||||||
|
std::option::Option::Some(value) => *value,
|
||||||
|
std::option::Option::None => {
|
||||||
|
return std::result::Result::Err(kb_core::Error::invalid_state(format!(
|
||||||
|
"Config key {index} signer flag is truncated"
|
||||||
|
)));
|
||||||
|
},
|
||||||
|
};
|
||||||
|
let signer = match signer_byte {
|
||||||
|
0 => false,
|
||||||
|
1 => true,
|
||||||
|
value => {
|
||||||
|
return std::result::Result::Err(kb_core::Error::invalid_state(format!(
|
||||||
|
"Config key {index} signer flag {value} is not a canonical bool"
|
||||||
|
)));
|
||||||
|
},
|
||||||
|
};
|
||||||
|
let pubkey = bs58::encode(pubkey_bytes).into_string();
|
||||||
|
if keys.iter().any(|(existing_pubkey, existing_signer)| {
|
||||||
|
return existing_pubkey == &pubkey && *existing_signer == signer;
|
||||||
|
}) {
|
||||||
|
return std::result::Result::Err(kb_core::Error::invalid_state(format!(
|
||||||
|
"Config key {index} duplicates an earlier key/signature pair"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
keys.push((pubkey, signer));
|
||||||
|
offset = pubkey_end.saturating_add(1);
|
||||||
|
}
|
||||||
|
return std::result::Result::Ok(ParsedConfigPayload { keys, data_offset: offset });
|
||||||
|
}
|
||||||
|
|
||||||
|
fn read_compact_u16(bytes: &[u8]) -> kb_core::Result<(u16, usize)> {
|
||||||
|
let first = match bytes.first() {
|
||||||
|
std::option::Option::Some(value) => *value,
|
||||||
|
std::option::Option::None => {
|
||||||
|
return std::result::Result::Err(kb_core::Error::invalid_state(
|
||||||
|
"Config compact-u16 key count is missing",
|
||||||
|
));
|
||||||
|
},
|
||||||
|
};
|
||||||
|
let mut value = u16::from(first & 0x7f);
|
||||||
|
if first & 0x80 == 0 {
|
||||||
|
return std::result::Result::Ok((value, 1));
|
||||||
|
}
|
||||||
|
let second = match bytes.get(1) {
|
||||||
|
std::option::Option::Some(value) => *value,
|
||||||
|
std::option::Option::None => {
|
||||||
|
return std::result::Result::Err(kb_core::Error::invalid_state(
|
||||||
|
"Config compact-u16 key count is truncated after one byte",
|
||||||
|
));
|
||||||
|
},
|
||||||
|
};
|
||||||
|
value |= u16::from(second & 0x7f) << 7;
|
||||||
|
if second & 0x80 == 0 {
|
||||||
|
if value < 128 {
|
||||||
|
return std::result::Result::Err(kb_core::Error::invalid_state(
|
||||||
|
"Config compact-u16 key count uses a non-canonical two-byte encoding",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
return std::result::Result::Ok((value, 2));
|
||||||
|
}
|
||||||
|
let third = match bytes.get(2) {
|
||||||
|
std::option::Option::Some(value) => *value,
|
||||||
|
std::option::Option::None => {
|
||||||
|
return std::result::Result::Err(kb_core::Error::invalid_state(
|
||||||
|
"Config compact-u16 key count is truncated after two bytes",
|
||||||
|
));
|
||||||
|
},
|
||||||
|
};
|
||||||
|
if third & 0xfc != 0 {
|
||||||
|
return std::result::Result::Err(kb_core::Error::invalid_state(
|
||||||
|
"Config compact-u16 key count exceeds u16 or has a continuation bit",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
value |= u16::from(third) << 14;
|
||||||
|
if value < 16_384 {
|
||||||
|
return std::result::Result::Err(kb_core::Error::invalid_state(
|
||||||
|
"Config compact-u16 key count uses a non-canonical three-byte encoding",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
return std::result::Result::Ok((value, 3));
|
||||||
|
}
|
||||||
|
|
||||||
|
fn resolve_and_validate_accounts(
|
||||||
|
input: &crate::CoreInstructionReplayInput,
|
||||||
|
parsed: &ParsedConfigPayload,
|
||||||
|
) -> kb_core::Result<serde_json::Value> {
|
||||||
|
let instruction_accounts = match input.instruction_accounts_json.as_array() {
|
||||||
|
std::option::Option::Some(value) => value,
|
||||||
|
std::option::Option::None => {
|
||||||
|
return std::result::Result::Err(kb_core::Error::invalid_state(
|
||||||
|
"Config instruction accounts must be an array",
|
||||||
|
));
|
||||||
|
},
|
||||||
|
};
|
||||||
|
let config_key = match instruction_accounts
|
||||||
|
.first()
|
||||||
|
.and_then(|value| return value.get("accountKey"))
|
||||||
|
.and_then(serde_json::Value::as_str)
|
||||||
|
{
|
||||||
|
std::option::Option::Some(value) => value,
|
||||||
|
std::option::Option::None => {
|
||||||
|
return std::result::Result::Err(kb_core::Error::invalid_state(
|
||||||
|
"Config instruction requires a config account at position zero",
|
||||||
|
));
|
||||||
|
},
|
||||||
|
};
|
||||||
|
let required_signers = parsed
|
||||||
|
.keys
|
||||||
|
.iter()
|
||||||
|
.filter(|(pubkey, signer)| return *signer && pubkey != config_key)
|
||||||
|
.map(|(pubkey, _signer)| return pubkey.as_str())
|
||||||
|
.collect::<std::vec::Vec<_>>();
|
||||||
|
let mut roles = std::vec::Vec::with_capacity(required_signers.len().saturating_add(1));
|
||||||
|
roles.push(crate::SolanaCoreAccountRole::optional_signer("config_account", true));
|
||||||
|
for _signer in &required_signers {
|
||||||
|
roles.push(crate::SolanaCoreAccountRole::new("configured_signer", true, true));
|
||||||
|
}
|
||||||
|
let minimum_count = required_signers.len().saturating_add(1);
|
||||||
|
let accounts = match crate::solana_core_resolve_accounts(
|
||||||
|
input,
|
||||||
|
roles.as_slice(),
|
||||||
|
minimum_count,
|
||||||
|
std::option::Option::None,
|
||||||
|
) {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
let resolved = match accounts.as_array() {
|
||||||
|
std::option::Option::Some(value) => value,
|
||||||
|
std::option::Option::None => {
|
||||||
|
return std::result::Result::Err(kb_core::Error::invalid_state(
|
||||||
|
"resolved Config accounts must be an array",
|
||||||
|
));
|
||||||
|
},
|
||||||
|
};
|
||||||
|
for (index, expected) in required_signers.iter().enumerate() {
|
||||||
|
let position = index.saturating_add(1);
|
||||||
|
let actual = resolved
|
||||||
|
.get(position)
|
||||||
|
.and_then(|value| return value.get("accountKey"))
|
||||||
|
.and_then(serde_json::Value::as_str);
|
||||||
|
if actual != std::option::Option::Some(*expected) {
|
||||||
|
return std::result::Result::Err(kb_core::Error::invalid_state(format!(
|
||||||
|
"Config signer account {position} must match encoded key {expected}"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return std::result::Result::Ok(accounts);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use base64::Engine; // rust-rules: trait-import
|
||||||
|
|
||||||
|
fn encode_payload(keys: &[([u8; 32], bool)], data: &[u8]) -> std::vec::Vec<u8> {
|
||||||
|
let mut output = std::vec::Vec::new();
|
||||||
|
let count = keys.len();
|
||||||
|
if count < 128 {
|
||||||
|
output.push(count as u8);
|
||||||
|
} else {
|
||||||
|
panic!("test fixture key count exceeds one-byte compact encoding");
|
||||||
|
}
|
||||||
|
for (pubkey, signer) in keys {
|
||||||
|
output.extend_from_slice(pubkey);
|
||||||
|
output.push(u8::from(*signer));
|
||||||
|
}
|
||||||
|
output.extend_from_slice(data);
|
||||||
|
return output;
|
||||||
|
}
|
||||||
|
|
||||||
|
fn replay_input(
|
||||||
|
bytes: &[u8],
|
||||||
|
extra_accounts: &[std::string::String],
|
||||||
|
transaction_failed: bool,
|
||||||
|
) -> crate::CoreInstructionReplayInput {
|
||||||
|
let mut keys = vec!["ConfigAccount1111111111111111111111111111".to_string()];
|
||||||
|
keys.extend_from_slice(extra_accounts);
|
||||||
|
let account_keys = keys
|
||||||
|
.iter()
|
||||||
|
.enumerate()
|
||||||
|
.map(|(index, key)| {
|
||||||
|
return serde_json::json!({
|
||||||
|
"accountIndex": index,
|
||||||
|
"accountKey": key,
|
||||||
|
"source": "static",
|
||||||
|
"writable": true,
|
||||||
|
"signer": true,
|
||||||
|
"executable": false,
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.collect::<std::vec::Vec<_>>();
|
||||||
|
let instruction_accounts = keys
|
||||||
|
.iter()
|
||||||
|
.enumerate()
|
||||||
|
.map(|(index, key)| {
|
||||||
|
return serde_json::json!({"accountIndex": index, "accountKey": key});
|
||||||
|
})
|
||||||
|
.collect::<std::vec::Vec<_>>();
|
||||||
|
let result = crate::CoreInstructionReplayInput::new(
|
||||||
|
"signature:0",
|
||||||
|
"signature",
|
||||||
|
42,
|
||||||
|
"0",
|
||||||
|
kb_program_ids::CONFIG_PROGRAM_ID,
|
||||||
|
transaction_failed,
|
||||||
|
if transaction_failed {
|
||||||
|
std::option::Option::Some(serde_json::json!({"InstructionError": [0, "Custom"]}))
|
||||||
|
} else {
|
||||||
|
std::option::Option::None
|
||||||
|
},
|
||||||
|
serde_json::Value::Array(account_keys),
|
||||||
|
serde_json::Value::Array(instruction_accounts),
|
||||||
|
std::option::Option::Some(serde_json::json!({
|
||||||
|
"dataBase64": base64::engine::general_purpose::STANDARD.encode(bytes),
|
||||||
|
})),
|
||||||
|
std::option::Option::Some("payload-hash".to_string()),
|
||||||
|
serde_json::json!([]),
|
||||||
|
serde_json::json!([]),
|
||||||
|
serde_json::json!([]),
|
||||||
|
serde_json::json!([]),
|
||||||
|
);
|
||||||
|
return match result {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => panic!("Config replay input failed: {error}"),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn valid_generic_store_is_recognized_exactly() {
|
||||||
|
let payload = encode_payload(&[], &[1, 2, 3]);
|
||||||
|
let input = replay_input(payload.as_slice(), &[], false);
|
||||||
|
let recognition = crate::solana_core_config_recognize(&input, 100);
|
||||||
|
assert!(recognition.compatible);
|
||||||
|
assert!(recognition.exact);
|
||||||
|
assert_eq!(recognition.entry_code.as_deref(), std::option::Option::Some("store"));
|
||||||
|
let malformed = crate::solana_core_config_recognize(&replay_input(&[], &[], false), 100);
|
||||||
|
assert!(malformed.compatible);
|
||||||
|
assert!(!malformed.exact);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn empty_key_set_and_opaque_data_decode_without_invented_semantics() {
|
||||||
|
let payload = encode_payload(&[], &[1, 2, 3, 4]);
|
||||||
|
let result =
|
||||||
|
crate::solana_core_config_decode(&replay_input(payload.as_slice(), &[], false));
|
||||||
|
assert_eq!(result.status, crate::DecoderOutcomeStatus::Decoded);
|
||||||
|
assert_eq!(result.observations[0].payload_json["parameters"]["configuredKeyCount"], 0);
|
||||||
|
assert_eq!(result.observations[0].payload_json["parameters"]["configDataByteLength"], 4);
|
||||||
|
assert_eq!(
|
||||||
|
result.observations[0].payload_json["parameters"]["configDataSemantics"],
|
||||||
|
"opaque_program_specific"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn encoded_signer_keys_are_resolved_in_official_account_order() {
|
||||||
|
let signer_bytes = [7_u8; 32];
|
||||||
|
let signer = bs58::encode(signer_bytes).into_string();
|
||||||
|
let payload = encode_payload(&[([3_u8; 32], false), (signer_bytes, true)], &[9]);
|
||||||
|
let result =
|
||||||
|
crate::solana_core_config_decode(&replay_input(payload.as_slice(), &[signer], false));
|
||||||
|
assert_eq!(result.status, crate::DecoderOutcomeStatus::Decoded);
|
||||||
|
assert_eq!(result.observations[0].payload_json["parameters"]["configuredSignerCount"], 1);
|
||||||
|
assert_eq!(result.observations[0].payload_json["accounts"][1]["role"], "configured_signer");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn malformed_compact_length_keys_and_bool_fail_safely() {
|
||||||
|
for payload in [
|
||||||
|
vec![],
|
||||||
|
vec![0x80],
|
||||||
|
vec![1, 2, 3],
|
||||||
|
{
|
||||||
|
let mut value = vec![1];
|
||||||
|
value.extend_from_slice(&[0_u8; 32]);
|
||||||
|
value.push(2);
|
||||||
|
value
|
||||||
|
},
|
||||||
|
vec![0x80, 0],
|
||||||
|
{
|
||||||
|
let mut value = vec![2];
|
||||||
|
value.extend_from_slice(&[4_u8; 32]);
|
||||||
|
value.push(1);
|
||||||
|
value.extend_from_slice(&[4_u8; 32]);
|
||||||
|
value.push(1);
|
||||||
|
value
|
||||||
|
},
|
||||||
|
] {
|
||||||
|
let result =
|
||||||
|
crate::solana_core_config_decode(&replay_input(payload.as_slice(), &[], false));
|
||||||
|
assert_eq!(result.status, crate::DecoderOutcomeStatus::Failed);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn missing_or_mismatched_signer_accounts_fail() {
|
||||||
|
let signer_bytes = [8_u8; 32];
|
||||||
|
let payload = encode_payload(&[(signer_bytes, true)], &[]);
|
||||||
|
let missing =
|
||||||
|
crate::solana_core_config_decode(&replay_input(payload.as_slice(), &[], false));
|
||||||
|
assert_eq!(missing.status, crate::DecoderOutcomeStatus::Failed);
|
||||||
|
let mismatch = crate::solana_core_config_decode(&replay_input(
|
||||||
|
payload.as_slice(),
|
||||||
|
&[bs58::encode([9_u8; 32]).into_string()],
|
||||||
|
false,
|
||||||
|
));
|
||||||
|
assert_eq!(mismatch.status, crate::DecoderOutcomeStatus::Failed);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn failed_config_store_is_decoded_as_uncommitted_intent() {
|
||||||
|
let payload = encode_payload(&[], &[5]);
|
||||||
|
let result = crate::solana_core_config_decode(&replay_input(payload.as_slice(), &[], true));
|
||||||
|
assert_eq!(result.status, crate::DecoderOutcomeStatus::Decoded);
|
||||||
|
assert!(!result.observations[0].observation_committed);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn coverage_declares_one_generic_store_surface() {
|
||||||
|
let coverage = crate::solana_core_config_coverage();
|
||||||
|
assert_eq!(coverage.len(), 1);
|
||||||
|
assert_eq!(coverage[0].entry_code, "store");
|
||||||
|
assert!(coverage[0].discriminator_hex.is_none());
|
||||||
|
}
|
||||||
|
}
|
||||||
66
kb-lib/src/decoder/solana/core/constants.rs
Normal file
66
kb-lib/src/decoder/solana/core/constants.rs
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
// file: kb-lib/src/decoder/solana/core/constants.rs
|
||||||
|
// version: 13
|
||||||
|
|
||||||
|
//! Local constants for the Solana Core decoder component.
|
||||||
|
|
||||||
|
/// Stable protocol code shared by native Solana events.
|
||||||
|
pub(crate) const PROTOCOL_CODE: &str = "solana_native";
|
||||||
|
/// Stable System Program surface code.
|
||||||
|
pub(crate) const SYSTEM_SURFACE_CODE: &str = "solana_native_system";
|
||||||
|
/// Stable deprecated immutable BPF Loader surface code.
|
||||||
|
pub(crate) const BPF_LOADER_DEPRECATED_SURFACE_CODE: &str = "solana_native_bpf_loader_deprecated";
|
||||||
|
/// Stable immutable BPF Loader v2 surface code.
|
||||||
|
pub(crate) const BPF_LOADER_SURFACE_CODE: &str = "solana_native_bpf_loader";
|
||||||
|
/// Stable upgradeable BPF Loader surface code.
|
||||||
|
pub(crate) const BPF_LOADER_UPGRADEABLE_SURFACE_CODE: &str = "solana_native_bpf_loader_upgradeable";
|
||||||
|
/// Stable Loader v4 surface code.
|
||||||
|
pub(crate) const LOADER_V4_SURFACE_CODE: &str = "solana_native_loader_v4";
|
||||||
|
/// Stable Native Loader surface code.
|
||||||
|
pub(crate) const NATIVE_LOADER_SURFACE_CODE: &str = "solana_native_loader";
|
||||||
|
/// Stable Compute Budget surface code.
|
||||||
|
pub(crate) const COMPUTE_BUDGET_SURFACE_CODE: &str = "solana_native_compute_budget";
|
||||||
|
/// Stable Address Lookup Table surface code.
|
||||||
|
pub(crate) const ADDRESS_LOOKUP_TABLE_SURFACE_CODE: &str = "solana_native_address_lookup_table";
|
||||||
|
/// Stable Vote Program surface code.
|
||||||
|
pub(crate) const VOTE_SURFACE_CODE: &str = "solana_native_vote";
|
||||||
|
/// Stable Stake Program surface code.
|
||||||
|
pub(crate) const STAKE_SURFACE_CODE: &str = "solana_native_stake";
|
||||||
|
/// Stable Config Program surface code.
|
||||||
|
pub(crate) const CONFIG_SURFACE_CODE: &str = "solana_native_config";
|
||||||
|
/// Stable Feature Gate surface code.
|
||||||
|
pub(crate) const FEATURE_SURFACE_CODE: &str = "solana_native_feature";
|
||||||
|
/// Stable Ed25519 signature precompile surface code.
|
||||||
|
pub(crate) const ED25519_SURFACE_CODE: &str = "solana_native_ed25519";
|
||||||
|
/// Stable secp256k1 signature precompile surface code.
|
||||||
|
pub(crate) const SECP256K1_SURFACE_CODE: &str = "solana_native_secp256k1";
|
||||||
|
/// Stable secp256r1 signature precompile surface code.
|
||||||
|
pub(crate) const SECP256R1_SURFACE_CODE: &str = "solana_native_secp256r1";
|
||||||
|
/// Stable native ZK ElGamal Proof surface code.
|
||||||
|
pub(crate) const ZK_ELGAMAL_PROOF_SURFACE_CODE: &str = "solana_native_zk_elgamal_proof";
|
||||||
|
/// Stable historical ZK Token Proof surface code.
|
||||||
|
pub(crate) const ZK_TOKEN_PROOF_SURFACE_CODE: &str = "solana_native_zk_token_proof";
|
||||||
|
/// Byte length shared by compact Ed25519, secp256k1 and secp256r1 signatures.
|
||||||
|
pub(crate) const SIGNATURE_BYTES: usize = 64;
|
||||||
|
/// Byte length of an Ed25519 public key.
|
||||||
|
pub(crate) const ED25519_PUBLIC_KEY_BYTES: usize = 32;
|
||||||
|
/// Byte length of a compressed secp256r1 public key.
|
||||||
|
pub(crate) const SECP256R1_PUBLIC_KEY_BYTES: usize = 33;
|
||||||
|
/// Byte length of a secp256k1 Ethereum address.
|
||||||
|
pub(crate) const SECP256K1_ETHEREUM_ADDRESS_BYTES: usize = 20;
|
||||||
|
/// Byte length of one Ed25519 or secp256r1 offsets entry.
|
||||||
|
pub(crate) const U16_OFFSETS_BYTES: usize = 14;
|
||||||
|
/// Byte length of one secp256k1 offsets entry.
|
||||||
|
pub(crate) const U8_OFFSETS_BYTES: usize = 11;
|
||||||
|
/// Stable Slashing Program decoder surface code.
|
||||||
|
pub(crate) const SLASHING_SURFACE_CODE: &str = "solana_native_slashing";
|
||||||
|
/// Runtime maximum number of secp256r1 signatures in one precompile instruction.
|
||||||
|
pub(crate) const SECP256R1_MAX_SIGNATURES: usize = 8;
|
||||||
|
/// Maximum number of component bytes retained as a hexadecimal event prefix.
|
||||||
|
pub(crate) const PRECOMPILE_COMPONENT_PREFIX_BYTES: usize = 16;
|
||||||
|
/// Maximum retained native instruction payload accepted by the first decoder phase.
|
||||||
|
pub(crate) const MAX_NATIVE_INSTRUCTION_PAYLOAD_BYTES: usize = 4_096;
|
||||||
|
/// Current native event payload contract version.
|
||||||
|
pub(crate) const NATIVE_EVENT_VERSION: u32 = 1;
|
||||||
|
|
||||||
|
/// Canonical tracing target for the Solana Core decoder component.
|
||||||
|
pub(crate) const TRACING_TARGET: &str = "kb-lib.decoder.solana.core";
|
||||||
605
kb-lib/src/decoder/solana/core/decoder.rs
Normal file
605
kb-lib/src/decoder/solana/core/decoder.rs
Normal file
@@ -0,0 +1,605 @@
|
|||||||
|
// file: kb-lib/src/decoder/solana/core/decoder.rs
|
||||||
|
// version: 24
|
||||||
|
|
||||||
|
//! Runtime-native Solana program classifier for the common decode pipeline.
|
||||||
|
|
||||||
|
const NATIVE_SURFACES: &[crate::DecoderSurface] = &[
|
||||||
|
crate::DecoderSurface {
|
||||||
|
program_id: kb_program_ids::ADDRESS_LOOKUP_TABLE_PROGRAM_ID,
|
||||||
|
surface_code: "solana_native_address_lookup_table",
|
||||||
|
priority: 100,
|
||||||
|
},
|
||||||
|
crate::DecoderSurface {
|
||||||
|
program_id: kb_program_ids::BPF_LOADER_DEPRECATED_PROGRAM_ID,
|
||||||
|
surface_code: "solana_native_bpf_loader_deprecated",
|
||||||
|
priority: 100,
|
||||||
|
},
|
||||||
|
crate::DecoderSurface {
|
||||||
|
program_id: kb_program_ids::BPF_LOADER_PROGRAM_ID,
|
||||||
|
surface_code: "solana_native_bpf_loader",
|
||||||
|
priority: 100,
|
||||||
|
},
|
||||||
|
crate::DecoderSurface {
|
||||||
|
program_id: kb_program_ids::BPF_LOADER_UPGRADEABLE_PROGRAM_ID,
|
||||||
|
surface_code: "solana_native_bpf_loader_upgradeable",
|
||||||
|
priority: 100,
|
||||||
|
},
|
||||||
|
crate::DecoderSurface {
|
||||||
|
program_id: kb_program_ids::COMPUTE_BUDGET_PROGRAM_ID,
|
||||||
|
surface_code: "solana_native_compute_budget",
|
||||||
|
priority: 100,
|
||||||
|
},
|
||||||
|
crate::DecoderSurface {
|
||||||
|
program_id: kb_program_ids::CONFIG_PROGRAM_ID,
|
||||||
|
surface_code: "solana_native_config",
|
||||||
|
priority: 100,
|
||||||
|
},
|
||||||
|
crate::DecoderSurface {
|
||||||
|
program_id: kb_program_ids::ED25519_PROGRAM_ID,
|
||||||
|
surface_code: "solana_native_ed25519",
|
||||||
|
priority: 100,
|
||||||
|
},
|
||||||
|
crate::DecoderSurface {
|
||||||
|
program_id: kb_program_ids::FEATURE_PROGRAM_ID,
|
||||||
|
surface_code: "solana_native_feature",
|
||||||
|
priority: 100,
|
||||||
|
},
|
||||||
|
crate::DecoderSurface {
|
||||||
|
program_id: kb_program_ids::LOADER_V4_PROGRAM_ID,
|
||||||
|
surface_code: "solana_native_loader_v4",
|
||||||
|
priority: 100,
|
||||||
|
},
|
||||||
|
crate::DecoderSurface {
|
||||||
|
program_id: kb_program_ids::NATIVE_LOADER_PROGRAM_ID,
|
||||||
|
surface_code: "solana_native_loader",
|
||||||
|
priority: 100,
|
||||||
|
},
|
||||||
|
crate::DecoderSurface {
|
||||||
|
program_id: kb_program_ids::SECP256K1_PROGRAM_ID,
|
||||||
|
surface_code: "solana_native_secp256k1",
|
||||||
|
priority: 100,
|
||||||
|
},
|
||||||
|
crate::DecoderSurface {
|
||||||
|
program_id: kb_program_ids::SECP256R1_PROGRAM_ID,
|
||||||
|
surface_code: "solana_native_secp256r1",
|
||||||
|
priority: 100,
|
||||||
|
},
|
||||||
|
crate::DecoderSurface {
|
||||||
|
program_id: kb_program_ids::SLASHING_PROGRAM_ID,
|
||||||
|
surface_code: "solana_native_slashing",
|
||||||
|
priority: 100,
|
||||||
|
},
|
||||||
|
crate::DecoderSurface {
|
||||||
|
program_id: kb_program_ids::STAKE_PROGRAM_ID,
|
||||||
|
surface_code: "solana_native_stake",
|
||||||
|
priority: 100,
|
||||||
|
},
|
||||||
|
crate::DecoderSurface {
|
||||||
|
program_id: kb_program_ids::SYSTEM_PROGRAM_ID,
|
||||||
|
surface_code: "solana_native_system",
|
||||||
|
priority: 100,
|
||||||
|
},
|
||||||
|
crate::DecoderSurface {
|
||||||
|
program_id: kb_program_ids::VOTE_PROGRAM_ID,
|
||||||
|
surface_code: "solana_native_vote",
|
||||||
|
priority: 100,
|
||||||
|
},
|
||||||
|
crate::DecoderSurface {
|
||||||
|
program_id: kb_program_ids::ZK_ELGAMAL_PROOF_PROGRAM_ID,
|
||||||
|
surface_code: "solana_native_zk_elgamal_proof",
|
||||||
|
priority: 100,
|
||||||
|
},
|
||||||
|
crate::DecoderSurface {
|
||||||
|
program_id: kb_program_ids::ZK_TOKEN_PROOF_PROGRAM_ID,
|
||||||
|
surface_code: "solana_native_zk_token_proof",
|
||||||
|
priority: 100,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const LEGACY_PROGRAM_IDS: &[&str] = &[
|
||||||
|
kb_program_ids::ADDRESS_LOOKUP_TABLE_PROGRAM_ID,
|
||||||
|
kb_program_ids::BPF_LOADER_DEPRECATED_PROGRAM_ID,
|
||||||
|
kb_program_ids::BPF_LOADER_PROGRAM_ID,
|
||||||
|
kb_program_ids::BPF_LOADER_UPGRADEABLE_PROGRAM_ID,
|
||||||
|
kb_program_ids::COMPUTE_BUDGET_PROGRAM_ID,
|
||||||
|
kb_program_ids::CONFIG_PROGRAM_ID,
|
||||||
|
kb_program_ids::ED25519_PROGRAM_ID,
|
||||||
|
kb_program_ids::FEATURE_PROGRAM_ID,
|
||||||
|
kb_program_ids::LOADER_V4_PROGRAM_ID,
|
||||||
|
kb_program_ids::NATIVE_LOADER_PROGRAM_ID,
|
||||||
|
kb_program_ids::SECP256K1_PROGRAM_ID,
|
||||||
|
kb_program_ids::SECP256R1_PROGRAM_ID,
|
||||||
|
kb_program_ids::SLASHING_PROGRAM_ID,
|
||||||
|
kb_program_ids::STAKE_PROGRAM_ID,
|
||||||
|
kb_program_ids::SYSTEM_PROGRAM_ID,
|
||||||
|
kb_program_ids::VOTE_PROGRAM_ID,
|
||||||
|
kb_program_ids::ZK_ELGAMAL_PROOF_PROGRAM_ID,
|
||||||
|
kb_program_ids::ZK_TOKEN_PROOF_PROGRAM_ID,
|
||||||
|
];
|
||||||
|
|
||||||
|
/// Runtime-native Solana decoder with phased maximal instruction coverage.
|
||||||
|
#[derive(Clone, Debug, Default)]
|
||||||
|
pub struct SolanaCoreDecoder;
|
||||||
|
|
||||||
|
impl crate::ProtocolDecoder for crate::SolanaCoreDecoder {
|
||||||
|
fn decoder_name(&self) -> &'static str {
|
||||||
|
return "kb_decoder_solana_core";
|
||||||
|
}
|
||||||
|
|
||||||
|
fn decoder_version(&self) -> &'static str {
|
||||||
|
return env!("CARGO_PKG_VERSION");
|
||||||
|
}
|
||||||
|
|
||||||
|
fn program_ids(&self) -> &'static [&'static str] {
|
||||||
|
return LEGACY_PROGRAM_IDS;
|
||||||
|
}
|
||||||
|
|
||||||
|
fn supports_observation(
|
||||||
|
&self,
|
||||||
|
observation: &crate::ProgramObservation,
|
||||||
|
) -> crate::DecoderSupport {
|
||||||
|
return if crate::ProtocolDecoder::handles_program_id(self, &observation.program_id) {
|
||||||
|
crate::DecoderSupport::Maybe
|
||||||
|
} else {
|
||||||
|
crate::DecoderSupport::No
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
fn decode_observation(
|
||||||
|
&self,
|
||||||
|
_observation: &crate::ProgramObservation,
|
||||||
|
) -> kb_core::Result<std::vec::Vec<crate::DecodedProtocolEvent>> {
|
||||||
|
tracing::debug!(target: crate::SOLANA_CORE_TRACING_TARGET, "legacy native decoder adapter classified an observation without maximal decoding");
|
||||||
|
return std::result::Result::Ok(std::vec::Vec::new());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl crate::InstructionDecoder for crate::SolanaCoreDecoder {
|
||||||
|
fn identity(&self) -> crate::DecoderIdentity {
|
||||||
|
return crate::DecoderIdentity {
|
||||||
|
name: "solana_native_classifier".to_string(),
|
||||||
|
version: env!("CARGO_PKG_VERSION").to_string(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
fn surfaces(&self) -> &'static [crate::DecoderSurface] {
|
||||||
|
return NATIVE_SURFACES;
|
||||||
|
}
|
||||||
|
|
||||||
|
fn coverage(&self) -> std::vec::Vec<crate::DecoderCoverageDeclaration> {
|
||||||
|
let mut coverage = crate::solana_core_system_coverage();
|
||||||
|
coverage.extend(crate::solana_core_compute_budget_coverage());
|
||||||
|
coverage.extend(crate::solana_core_config_coverage());
|
||||||
|
coverage.extend(crate::solana_core_feature_coverage());
|
||||||
|
coverage.extend(crate::solana_core_vote_coverage());
|
||||||
|
coverage.extend(crate::solana_core_stake_coverage());
|
||||||
|
coverage.extend(crate::solana_core_address_lookup_table_coverage());
|
||||||
|
coverage.extend(crate::solana_core_loaders_coverage());
|
||||||
|
coverage.extend(crate::solana_core_precompiles_coverage());
|
||||||
|
coverage.extend(crate::solana_core_slashing_coverage());
|
||||||
|
coverage.extend(crate::solana_core_zk_elgamal_coverage());
|
||||||
|
coverage.extend(crate::solana_core_zk_token_proof_coverage());
|
||||||
|
for surface in NATIVE_SURFACES {
|
||||||
|
if surface.program_id == kb_program_ids::SYSTEM_PROGRAM_ID
|
||||||
|
|| surface.program_id == kb_program_ids::COMPUTE_BUDGET_PROGRAM_ID
|
||||||
|
|| surface.program_id == kb_program_ids::ADDRESS_LOOKUP_TABLE_PROGRAM_ID
|
||||||
|
|| surface.program_id == kb_program_ids::CONFIG_PROGRAM_ID
|
||||||
|
|| surface.program_id == kb_program_ids::FEATURE_PROGRAM_ID
|
||||||
|
|| surface.program_id == kb_program_ids::VOTE_PROGRAM_ID
|
||||||
|
|| surface.program_id == kb_program_ids::STAKE_PROGRAM_ID
|
||||||
|
|| is_signature_precompile(surface.program_id)
|
||||||
|
|| surface.program_id == kb_program_ids::SLASHING_PROGRAM_ID
|
||||||
|
|| surface.program_id == kb_program_ids::ZK_ELGAMAL_PROOF_PROGRAM_ID
|
||||||
|
|| surface.program_id == kb_program_ids::ZK_TOKEN_PROOF_PROGRAM_ID
|
||||||
|
|| is_loader_program(surface.program_id)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
coverage.push(crate::DecoderCoverageDeclaration {
|
||||||
|
program_id: surface.program_id.to_string(),
|
||||||
|
surface_code: std::option::Option::Some(surface.surface_code.to_string()),
|
||||||
|
entry_kind: crate::DecoderCoverageEntryKind::Instruction,
|
||||||
|
entry_code: "unclassified_native_instruction".to_string(),
|
||||||
|
discriminator_hex: std::option::Option::None,
|
||||||
|
historical: is_historical_native_program(surface.program_id),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return coverage;
|
||||||
|
}
|
||||||
|
|
||||||
|
fn recognize(&self, input: &crate::CoreInstructionReplayInput) -> crate::DecoderRecognition {
|
||||||
|
tracing::debug!(target: crate::SOLANA_CORE_TRACING_TARGET, action = "recognize", signature = %input.signature, slot = input.slot, instruction_path = %input.instruction_path, program_id = %input.program_id, input_key = %input.replay_input_key, transaction_failed = input.transaction_failed, "recognize native Solana contextual instruction");
|
||||||
|
let surface = NATIVE_SURFACES
|
||||||
|
.iter()
|
||||||
|
.find(|surface| return surface.program_id == input.program_id);
|
||||||
|
let surface = match surface {
|
||||||
|
std::option::Option::Some(value) => value,
|
||||||
|
std::option::Option::None => {
|
||||||
|
tracing::debug!(target: crate::SOLANA_CORE_TRACING_TARGET, action = "recognize", signature = %input.signature, instruction_path = %input.instruction_path, program_id = %input.program_id, compatible = false, "native Solana decoder does not support program id");
|
||||||
|
return crate::DecoderRecognition::incompatible();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
let recognition = if input.program_id == kb_program_ids::SYSTEM_PROGRAM_ID {
|
||||||
|
crate::solana_core_system_recognize(input, surface.priority)
|
||||||
|
} else if input.program_id == kb_program_ids::COMPUTE_BUDGET_PROGRAM_ID {
|
||||||
|
crate::solana_core_compute_budget_recognize(input, surface.priority)
|
||||||
|
} else if input.program_id == kb_program_ids::ADDRESS_LOOKUP_TABLE_PROGRAM_ID {
|
||||||
|
crate::solana_core_address_lookup_table_recognize(input, surface.priority)
|
||||||
|
} else if input.program_id == kb_program_ids::CONFIG_PROGRAM_ID {
|
||||||
|
crate::solana_core_config_recognize(input, surface.priority)
|
||||||
|
} else if input.program_id == kb_program_ids::FEATURE_PROGRAM_ID {
|
||||||
|
crate::solana_core_feature_recognize(input, surface.priority)
|
||||||
|
} else if input.program_id == kb_program_ids::VOTE_PROGRAM_ID {
|
||||||
|
crate::solana_core_vote_recognize(input, surface.priority)
|
||||||
|
} else if input.program_id == kb_program_ids::STAKE_PROGRAM_ID {
|
||||||
|
crate::solana_core_stake_recognize(input, surface.priority)
|
||||||
|
} else if input.program_id == kb_program_ids::SLASHING_PROGRAM_ID {
|
||||||
|
crate::solana_core_slashing_recognize(input, surface.priority)
|
||||||
|
} else if is_signature_precompile(input.program_id.as_str()) {
|
||||||
|
crate::solana_core_precompiles_recognize(input, surface.priority)
|
||||||
|
} else if input.program_id == kb_program_ids::ZK_ELGAMAL_PROOF_PROGRAM_ID {
|
||||||
|
crate::solana_core_zk_elgamal_recognize(input, surface.priority)
|
||||||
|
} else if input.program_id == kb_program_ids::ZK_TOKEN_PROOF_PROGRAM_ID {
|
||||||
|
crate::solana_core_zk_token_proof_recognize(input, surface.priority)
|
||||||
|
} else if is_loader_program(input.program_id.as_str()) {
|
||||||
|
crate::solana_core_loaders_recognize(input, surface.priority)
|
||||||
|
} else {
|
||||||
|
crate::DecoderRecognition::compatible(
|
||||||
|
false,
|
||||||
|
surface.priority,
|
||||||
|
std::option::Option::Some(surface.surface_code.to_string()),
|
||||||
|
std::option::Option::Some("unclassified_native_instruction".to_string()),
|
||||||
|
crate::discriminator_8_hex(input),
|
||||||
|
)
|
||||||
|
};
|
||||||
|
tracing::debug!(target: crate::SOLANA_CORE_TRACING_TARGET, action = "recognize", signature = %input.signature, instruction_path = %input.instruction_path, program_id = %input.program_id, surface_code = surface.surface_code, recognition = ?recognition, compatible = true, "native Solana decoder recognized contextual instruction");
|
||||||
|
return recognition;
|
||||||
|
}
|
||||||
|
|
||||||
|
fn decode(&self, input: &crate::CoreInstructionReplayInput) -> crate::DecoderExecutionResult {
|
||||||
|
let result = if input.program_id == kb_program_ids::SYSTEM_PROGRAM_ID {
|
||||||
|
crate::solana_core_system_decode(input)
|
||||||
|
} else if input.program_id == kb_program_ids::COMPUTE_BUDGET_PROGRAM_ID {
|
||||||
|
crate::solana_core_compute_budget_decode(input)
|
||||||
|
} else if input.program_id == kb_program_ids::ADDRESS_LOOKUP_TABLE_PROGRAM_ID {
|
||||||
|
crate::solana_core_address_lookup_table_decode(input)
|
||||||
|
} else if input.program_id == kb_program_ids::CONFIG_PROGRAM_ID {
|
||||||
|
crate::solana_core_config_decode(input)
|
||||||
|
} else if input.program_id == kb_program_ids::FEATURE_PROGRAM_ID {
|
||||||
|
crate::solana_core_feature_decode(input)
|
||||||
|
} else if input.program_id == kb_program_ids::VOTE_PROGRAM_ID {
|
||||||
|
crate::solana_core_vote_decode(input)
|
||||||
|
} else if input.program_id == kb_program_ids::STAKE_PROGRAM_ID {
|
||||||
|
crate::solana_core_stake_decode(input)
|
||||||
|
} else if input.program_id == kb_program_ids::SLASHING_PROGRAM_ID {
|
||||||
|
crate::solana_core_slashing_decode(input)
|
||||||
|
} else if is_signature_precompile(input.program_id.as_str()) {
|
||||||
|
crate::solana_core_precompiles_decode(input)
|
||||||
|
} else if input.program_id == kb_program_ids::ZK_ELGAMAL_PROOF_PROGRAM_ID {
|
||||||
|
crate::solana_core_zk_elgamal_decode(input)
|
||||||
|
} else if input.program_id == kb_program_ids::ZK_TOKEN_PROOF_PROGRAM_ID {
|
||||||
|
crate::solana_core_zk_token_proof_decode(input)
|
||||||
|
} else if is_loader_program(input.program_id.as_str()) {
|
||||||
|
crate::solana_core_loaders_decode(input)
|
||||||
|
} else {
|
||||||
|
crate::DecoderExecutionResult::unsupported(std::option::Option::Some(
|
||||||
|
"unclassified_native_instruction".to_string(),
|
||||||
|
))
|
||||||
|
};
|
||||||
|
if matches!(
|
||||||
|
result.status,
|
||||||
|
crate::DecoderOutcomeStatus::Failed | crate::DecoderOutcomeStatus::Unsupported
|
||||||
|
) {
|
||||||
|
tracing::error!(
|
||||||
|
target: crate::SOLANA_CORE_TRACING_TARGET,
|
||||||
|
action = "decode_failure",
|
||||||
|
signature = %input.signature,
|
||||||
|
slot = input.slot,
|
||||||
|
instruction_path = %input.instruction_path,
|
||||||
|
program_id = %input.program_id,
|
||||||
|
processor_name = "solana_native_classifier",
|
||||||
|
processor_version = env!("CARGO_PKG_VERSION"),
|
||||||
|
input_key = %input.replay_input_key,
|
||||||
|
payload_hash = ?input.instruction_payload_hash,
|
||||||
|
payload_length = ?crate::solana_core_decoded_payload_length(input),
|
||||||
|
transaction_failed = input.transaction_failed,
|
||||||
|
transaction_error = ?input.transaction_err_json,
|
||||||
|
result_status = ?result.status,
|
||||||
|
recognized_entry_code = ?result.recognized_entry_code,
|
||||||
|
diagnostics = ?result.diagnostics,
|
||||||
|
"native instruction was not decoded successfully"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
tracing::debug!(target: crate::SOLANA_CORE_TRACING_TARGET, action = "decode", signature = %input.signature, slot = input.slot, instruction_path = %input.instruction_path, program_id = %input.program_id, input_key = %input.replay_input_key, payload_hash = ?input.instruction_payload_hash, transaction_failed = input.transaction_failed, transaction_error = ?input.transaction_err_json, result_status = ?result.status, recognized_entry_code = ?result.recognized_entry_code, observation_count = result.observations.len(), diagnostic_count = result.diagnostics.len(), "native instruction decode completed");
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_signature_precompile(program_id: &str) -> bool {
|
||||||
|
return program_id == kb_program_ids::ED25519_PROGRAM_ID
|
||||||
|
|| program_id == kb_program_ids::SECP256K1_PROGRAM_ID
|
||||||
|
|| program_id == kb_program_ids::SECP256R1_PROGRAM_ID;
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_loader_program(program_id: &str) -> bool {
|
||||||
|
return program_id == kb_program_ids::BPF_LOADER_DEPRECATED_PROGRAM_ID
|
||||||
|
|| program_id == kb_program_ids::BPF_LOADER_PROGRAM_ID
|
||||||
|
|| program_id == kb_program_ids::BPF_LOADER_UPGRADEABLE_PROGRAM_ID
|
||||||
|
|| program_id == kb_program_ids::LOADER_V4_PROGRAM_ID
|
||||||
|
|| program_id == kb_program_ids::NATIVE_LOADER_PROGRAM_ID;
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_historical_native_program(program_id: &str) -> bool {
|
||||||
|
return program_id == kb_program_ids::BPF_LOADER_DEPRECATED_PROGRAM_ID
|
||||||
|
|| program_id == kb_program_ids::BPF_LOADER_PROGRAM_ID
|
||||||
|
|| program_id == kb_program_ids::ZK_TOKEN_PROOF_PROGRAM_ID;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
fn replay_input(program_id: &str) -> crate::CoreInstructionReplayInput {
|
||||||
|
let result = crate::CoreInstructionReplayInput::new(
|
||||||
|
"signature:0",
|
||||||
|
"signature",
|
||||||
|
42,
|
||||||
|
"0",
|
||||||
|
program_id,
|
||||||
|
false,
|
||||||
|
std::option::Option::None,
|
||||||
|
serde_json::json!([]),
|
||||||
|
serde_json::json!([]),
|
||||||
|
std::option::Option::Some(serde_json::json!({"dataBase64": "AQIDBAUGBwg="})),
|
||||||
|
std::option::Option::Some("payload-hash".to_string()),
|
||||||
|
serde_json::json!([]),
|
||||||
|
serde_json::json!([]),
|
||||||
|
serde_json::json!([]),
|
||||||
|
serde_json::json!([]),
|
||||||
|
);
|
||||||
|
return match result {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => panic!("replay input failed: {error}"),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn vote_program_is_dispatched_without_false_decode_for_unknown_tag() {
|
||||||
|
let decoder = crate::SolanaCoreDecoder;
|
||||||
|
let input = replay_input(kb_program_ids::VOTE_PROGRAM_ID);
|
||||||
|
let recognition = crate::InstructionDecoder::recognize(&decoder, &input);
|
||||||
|
assert!(recognition.compatible);
|
||||||
|
assert_eq!(
|
||||||
|
recognition.surface_code.as_deref(),
|
||||||
|
std::option::Option::Some("solana_native_vote")
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
recognition.entry_code.as_deref(),
|
||||||
|
std::option::Option::Some("unknown_vote_instruction")
|
||||||
|
);
|
||||||
|
let result = crate::InstructionDecoder::decode(&decoder, &input);
|
||||||
|
assert_eq!(result.status, crate::DecoderOutcomeStatus::Unsupported);
|
||||||
|
assert!(result.observations.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn stake_program_is_dispatched_without_false_decode_for_unknown_tag() {
|
||||||
|
let decoder = crate::SolanaCoreDecoder;
|
||||||
|
let input = replay_input(kb_program_ids::STAKE_PROGRAM_ID);
|
||||||
|
let recognition = crate::InstructionDecoder::recognize(&decoder, &input);
|
||||||
|
assert!(recognition.compatible);
|
||||||
|
assert_eq!(
|
||||||
|
recognition.surface_code.as_deref(),
|
||||||
|
std::option::Option::Some("solana_native_stake")
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
recognition.entry_code.as_deref(),
|
||||||
|
std::option::Option::Some("unknown_stake_instruction")
|
||||||
|
);
|
||||||
|
let result = crate::InstructionDecoder::decode(&decoder, &input);
|
||||||
|
assert_eq!(result.status, crate::DecoderOutcomeStatus::Unsupported);
|
||||||
|
assert!(result.observations.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn zk_token_proof_program_dispatches_to_current_noop_fallback() {
|
||||||
|
let decoder = crate::SolanaCoreDecoder;
|
||||||
|
let input = replay_input(kb_program_ids::ZK_TOKEN_PROOF_PROGRAM_ID);
|
||||||
|
let recognition = crate::InstructionDecoder::recognize(&decoder, &input);
|
||||||
|
assert!(recognition.compatible);
|
||||||
|
assert_eq!(
|
||||||
|
recognition.surface_code.as_deref(),
|
||||||
|
std::option::Option::Some("solana_native_zk_token_proof")
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
recognition.entry_code.as_deref(),
|
||||||
|
std::option::Option::Some("current_runtime_noop_invocation")
|
||||||
|
);
|
||||||
|
let result = crate::InstructionDecoder::decode(&decoder, &input);
|
||||||
|
assert_eq!(result.status, crate::DecoderOutcomeStatus::Decoded);
|
||||||
|
assert_eq!(
|
||||||
|
result.recognized_entry_code.as_deref(),
|
||||||
|
std::option::Option::Some("current_runtime_noop_invocation")
|
||||||
|
);
|
||||||
|
assert_eq!(result.observations.len(), 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn every_native_registry_program_has_one_surface() {
|
||||||
|
let decoder = crate::SolanaCoreDecoder;
|
||||||
|
let surfaces = crate::InstructionDecoder::surfaces(&decoder);
|
||||||
|
assert_eq!(surfaces.len(), kb_program_ids::native_program_ids().len());
|
||||||
|
for entry in kb_program_ids::native_program_ids() {
|
||||||
|
assert!(surfaces.iter().any(|surface| return surface.program_id == entry.program_id()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn every_native_surface_has_declared_coverage_without_fallback_entries() {
|
||||||
|
let decoder = crate::SolanaCoreDecoder;
|
||||||
|
let surfaces = crate::InstructionDecoder::surfaces(&decoder);
|
||||||
|
let coverage = crate::InstructionDecoder::coverage(&decoder);
|
||||||
|
for surface in surfaces {
|
||||||
|
assert!(coverage.iter().any(|entry| {
|
||||||
|
return entry.program_id == surface.program_id
|
||||||
|
&& entry.surface_code.as_deref()
|
||||||
|
== std::option::Option::Some(surface.surface_code);
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
assert!(!coverage.iter().any(|entry| {
|
||||||
|
return entry.entry_code == "unclassified_native_instruction";
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn coverage_entries_are_unique_and_reference_declared_surfaces() {
|
||||||
|
let decoder = crate::SolanaCoreDecoder;
|
||||||
|
let surfaces = crate::InstructionDecoder::surfaces(&decoder);
|
||||||
|
let coverage = crate::InstructionDecoder::coverage(&decoder);
|
||||||
|
let mut identities = std::collections::BTreeSet::new();
|
||||||
|
for entry in coverage {
|
||||||
|
let surface_code = match entry.surface_code.as_deref() {
|
||||||
|
std::option::Option::Some(value) => value,
|
||||||
|
std::option::Option::None => panic!("native coverage entry has no surface code"),
|
||||||
|
};
|
||||||
|
assert!(surfaces.iter().any(|surface| {
|
||||||
|
return surface.program_id == entry.program_id
|
||||||
|
&& surface.surface_code == surface_code;
|
||||||
|
}));
|
||||||
|
assert!(identities.insert(format!(
|
||||||
|
"{}:{surface_code}:{}:{:?}",
|
||||||
|
entry.program_id, entry.entry_code, entry.entry_kind
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn slashing_program_is_dispatched_to_exact_decoder() {
|
||||||
|
let decoder = crate::SolanaCoreDecoder;
|
||||||
|
let input = replay_input(kb_program_ids::SLASHING_PROGRAM_ID);
|
||||||
|
let recognition = crate::InstructionDecoder::recognize(&decoder, &input);
|
||||||
|
assert!(recognition.compatible);
|
||||||
|
assert_eq!(
|
||||||
|
recognition.surface_code.as_deref(),
|
||||||
|
std::option::Option::Some("solana_native_slashing")
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
recognition.entry_code.as_deref(),
|
||||||
|
std::option::Option::Some("duplicate_block_proof")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn deprecated_stake_config_account_is_not_an_instruction_surface() {
|
||||||
|
let decoder = crate::SolanaCoreDecoder;
|
||||||
|
let surfaces = crate::InstructionDecoder::surfaces(&decoder);
|
||||||
|
assert!(!surfaces.iter().any(|surface| {
|
||||||
|
return surface.program_id == kb_program_ids::STAKE_CONFIG_ACCOUNT_ID;
|
||||||
|
}));
|
||||||
|
let input = replay_input(kb_program_ids::STAKE_CONFIG_ACCOUNT_ID);
|
||||||
|
let recognition = crate::InstructionDecoder::recognize(&decoder, &input);
|
||||||
|
assert!(!recognition.compatible);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn native_decoder_matrix_matches_registry_surfaces_and_coverage() {
|
||||||
|
let raw = include_str!("../../../../../docs/NATIVE_SOLANA_DECODER_MATRIX.json");
|
||||||
|
let parsed = match serde_json::from_str::<serde_json::Value>(raw) {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => {
|
||||||
|
panic!("native decoder matrix parsing failed: {error}")
|
||||||
|
},
|
||||||
|
};
|
||||||
|
let matrix_surfaces = match parsed.get("surfaces").and_then(serde_json::Value::as_array) {
|
||||||
|
std::option::Option::Some(value) => value,
|
||||||
|
std::option::Option::None => panic!("native decoder matrix surfaces missing"),
|
||||||
|
};
|
||||||
|
assert_eq!(matrix_surfaces.len(), 18);
|
||||||
|
let declared_surface_count =
|
||||||
|
parsed.get("surface_count").and_then(serde_json::Value::as_u64);
|
||||||
|
assert_eq!(declared_surface_count, std::option::Option::Some(18));
|
||||||
|
|
||||||
|
let decoder = crate::SolanaCoreDecoder;
|
||||||
|
let compiled_surfaces = crate::InstructionDecoder::surfaces(&decoder);
|
||||||
|
let compiled_surface_map = compiled_surfaces
|
||||||
|
.iter()
|
||||||
|
.map(|surface| {
|
||||||
|
return (surface.program_id.to_string(), surface.surface_code.to_string());
|
||||||
|
})
|
||||||
|
.collect::<std::collections::BTreeMap<_, _>>();
|
||||||
|
assert_eq!(compiled_surface_map.len(), 18);
|
||||||
|
|
||||||
|
let native_registry = kb_program_ids::native_program_ids()
|
||||||
|
.iter()
|
||||||
|
.map(|entry| return entry.program_id().to_string())
|
||||||
|
.collect::<std::collections::BTreeSet<_>>();
|
||||||
|
assert_eq!(native_registry.len(), 18);
|
||||||
|
|
||||||
|
let coverage = crate::InstructionDecoder::coverage(&decoder);
|
||||||
|
let mut compiled_coverage_counts = std::collections::BTreeMap::new();
|
||||||
|
for entry in &coverage {
|
||||||
|
let count = compiled_coverage_counts.entry(entry.program_id.clone()).or_insert(0_usize);
|
||||||
|
*count = count.saturating_add(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut matrix_surface_map = std::collections::BTreeMap::new();
|
||||||
|
let mut matrix_program_ids = std::collections::BTreeSet::new();
|
||||||
|
let mut matrix_coverage_total = 0_usize;
|
||||||
|
for surface in matrix_surfaces {
|
||||||
|
let surface_code = match surface.get("surface_code").and_then(serde_json::Value::as_str)
|
||||||
|
{
|
||||||
|
std::option::Option::Some(value) if !value.is_empty() => value,
|
||||||
|
_ => panic!("native decoder matrix surface_code missing"),
|
||||||
|
};
|
||||||
|
let program_id = match surface.get("program_id").and_then(serde_json::Value::as_str) {
|
||||||
|
std::option::Option::Some(value) if !value.is_empty() => value,
|
||||||
|
_ => panic!("native decoder matrix program_id missing"),
|
||||||
|
};
|
||||||
|
for field in ["runtime_status", "source_contract", "completeness_test"] {
|
||||||
|
let value = surface.get(field).and_then(serde_json::Value::as_str);
|
||||||
|
assert!(matches!(value, std::option::Option::Some(text) if !text.is_empty()));
|
||||||
|
}
|
||||||
|
let expected_count_u64 = match surface
|
||||||
|
.get("expected_coverage_entries")
|
||||||
|
.and_then(serde_json::Value::as_u64)
|
||||||
|
{
|
||||||
|
std::option::Option::Some(value) => value,
|
||||||
|
std::option::Option::None => {
|
||||||
|
panic!("native decoder matrix expected coverage count missing")
|
||||||
|
},
|
||||||
|
};
|
||||||
|
let expected_count = match usize::try_from(expected_count_u64) {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => {
|
||||||
|
panic!("native decoder matrix coverage count invalid: {error}")
|
||||||
|
},
|
||||||
|
};
|
||||||
|
assert!(expected_count > 0);
|
||||||
|
assert!(matrix_program_ids.insert(program_id.to_string()));
|
||||||
|
assert!(
|
||||||
|
matrix_surface_map
|
||||||
|
.insert(program_id.to_string(), surface_code.to_string())
|
||||||
|
.is_none()
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
compiled_coverage_counts.get(program_id).copied(),
|
||||||
|
std::option::Option::Some(expected_count)
|
||||||
|
);
|
||||||
|
matrix_coverage_total = matrix_coverage_total.saturating_add(expected_count);
|
||||||
|
}
|
||||||
|
|
||||||
|
let declared_coverage_count =
|
||||||
|
parsed.get("coverage_entry_count").and_then(serde_json::Value::as_u64);
|
||||||
|
assert_eq!(declared_coverage_count, std::option::Option::Some(121));
|
||||||
|
assert_eq!(matrix_coverage_total, 121);
|
||||||
|
assert_eq!(coverage.len(), matrix_coverage_total);
|
||||||
|
assert_eq!(matrix_surface_map, compiled_surface_map);
|
||||||
|
assert_eq!(matrix_program_ids, native_registry);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn unrelated_program_is_not_dispatched() {
|
||||||
|
let decoder = crate::SolanaCoreDecoder;
|
||||||
|
let input = replay_input(kb_program_ids::SPL_TOKEN_PROGRAM_ID);
|
||||||
|
let recognition = crate::InstructionDecoder::recognize(&decoder, &input);
|
||||||
|
assert!(!recognition.compatible);
|
||||||
|
}
|
||||||
|
}
|
||||||
101
kb-lib/src/decoder/solana/core/event.rs
Normal file
101
kb-lib/src/decoder/solana/core/event.rs
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
// file: kb-lib/src/decoder/solana/core/event.rs
|
||||||
|
// version: 3
|
||||||
|
|
||||||
|
//! Stable native event and decoder result builders.
|
||||||
|
|
||||||
|
/// Builds one exact decoded native observation.
|
||||||
|
pub(crate) fn decoded_result(
|
||||||
|
input: &crate::CoreInstructionReplayInput,
|
||||||
|
surface_code: &str,
|
||||||
|
entry_code: &str,
|
||||||
|
event_family: crate::EventFamily,
|
||||||
|
historical: bool,
|
||||||
|
accounts: serde_json::Value,
|
||||||
|
parameters: serde_json::Value,
|
||||||
|
source_evidence: &str,
|
||||||
|
) -> crate::DecoderExecutionResult {
|
||||||
|
let event = crate::DecodedProtocolEvent {
|
||||||
|
signature: crate::Signature(input.signature.clone()),
|
||||||
|
slot: crate::Slot(input.slot),
|
||||||
|
instruction_path: crate::InstructionPath(input.instruction_path.clone()),
|
||||||
|
program_id: crate::ProgramId(input.program_id.clone()),
|
||||||
|
protocol_code: crate::ProtocolCode(crate::SOLANA_CORE_PROTOCOL_CODE.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: if input.instruction_path.contains('/') {
|
||||||
|
crate::EventSourceKind::InnerInstruction
|
||||||
|
} else {
|
||||||
|
crate::EventSourceKind::Instruction
|
||||||
|
},
|
||||||
|
confidence: crate::DecoderConfidence::ManualExact,
|
||||||
|
};
|
||||||
|
let payload_hash = crate::solana_core_payload_hash(input);
|
||||||
|
let observation = crate::DecodedObservation {
|
||||||
|
event_key: format!("{entry_code}:0"),
|
||||||
|
event,
|
||||||
|
payload_json: serde_json::json!({
|
||||||
|
"eventVersion": crate::SOLANA_CORE_NATIVE_EVENT_VERSION,
|
||||||
|
"programId": input.program_id,
|
||||||
|
"surfaceCode": surface_code,
|
||||||
|
"entryCode": entry_code,
|
||||||
|
"historical": historical,
|
||||||
|
"instructionPath": input.instruction_path,
|
||||||
|
"transactionSucceeded": !input.transaction_failed,
|
||||||
|
"payloadHash": payload_hash,
|
||||||
|
"accounts": accounts,
|
||||||
|
"parameters": parameters,
|
||||||
|
}),
|
||||||
|
transaction_failed: input.transaction_failed,
|
||||||
|
transaction_error: input.transaction_err_json.clone(),
|
||||||
|
observation_committed: !input.transaction_failed,
|
||||||
|
proof: crate::DecoderProof {
|
||||||
|
kind: crate::DecoderProofKind::Manual,
|
||||||
|
confidence: crate::DecoderConfidence::ManualExact,
|
||||||
|
evidence: vec![source_evidence.to_string(), format!("payload_sha256:{payload_hash}")],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
return crate::DecoderExecutionResult {
|
||||||
|
status: crate::DecoderOutcomeStatus::Decoded,
|
||||||
|
recognized_entry_code: std::option::Option::Some(entry_code.to_string()),
|
||||||
|
observations: vec![observation],
|
||||||
|
diagnostics: std::vec::Vec::new(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Builds one failed native decode result.
|
||||||
|
pub(crate) fn failed_result(
|
||||||
|
entry_code: std::option::Option<&str>,
|
||||||
|
code: &str,
|
||||||
|
message: impl std::convert::Into<std::string::String>,
|
||||||
|
) -> crate::DecoderExecutionResult {
|
||||||
|
return crate::DecoderExecutionResult {
|
||||||
|
status: crate::DecoderOutcomeStatus::Failed,
|
||||||
|
recognized_entry_code: entry_code.map(str::to_string),
|
||||||
|
observations: std::vec::Vec::new(),
|
||||||
|
diagnostics: vec![crate::DecoderDiagnostic {
|
||||||
|
code: code.to_string(),
|
||||||
|
message: message.into(),
|
||||||
|
retriable: false,
|
||||||
|
}],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Builds one unsupported native decode result with a bounded diagnostic.
|
||||||
|
pub(crate) fn unsupported_result(
|
||||||
|
entry_code: &str,
|
||||||
|
code: &str,
|
||||||
|
message: impl std::convert::Into<std::string::String>,
|
||||||
|
) -> crate::DecoderExecutionResult {
|
||||||
|
return crate::DecoderExecutionResult {
|
||||||
|
status: crate::DecoderOutcomeStatus::Unsupported,
|
||||||
|
recognized_entry_code: std::option::Option::Some(entry_code.to_string()),
|
||||||
|
observations: std::vec::Vec::new(),
|
||||||
|
diagnostics: vec![crate::DecoderDiagnostic {
|
||||||
|
code: code.to_string(),
|
||||||
|
message: message.into(),
|
||||||
|
retriable: false,
|
||||||
|
}],
|
||||||
|
};
|
||||||
|
}
|
||||||
278
kb-lib/src/decoder/solana/core/feature.rs
Normal file
278
kb-lib/src/decoder/solana/core/feature.rs
Normal file
@@ -0,0 +1,278 @@
|
|||||||
|
// file: kb-lib/src/decoder/solana/core/feature.rs
|
||||||
|
// version: 4
|
||||||
|
|
||||||
|
//! Exact Feature Gate instruction decoding from the official interface contract.
|
||||||
|
|
||||||
|
const SOURCE: &str = "solana-feature-gate-interface@4.0.0 revoke_pending_activation contract";
|
||||||
|
const REVOKE_ROLES: &[crate::SolanaCoreAccountRole] = &[
|
||||||
|
crate::SolanaCoreAccountRole::new("feature_account", true, true),
|
||||||
|
crate::SolanaCoreAccountRole::new("incinerator", true, false),
|
||||||
|
crate::SolanaCoreAccountRole::new("system_program", false, false),
|
||||||
|
];
|
||||||
|
|
||||||
|
/// Returns declared Feature Gate instruction coverage.
|
||||||
|
pub(crate) fn feature_coverage() -> std::vec::Vec<crate::DecoderCoverageDeclaration> {
|
||||||
|
return vec![crate::DecoderCoverageDeclaration {
|
||||||
|
program_id: kb_program_ids::FEATURE_PROGRAM_ID.to_string(),
|
||||||
|
surface_code: std::option::Option::Some(
|
||||||
|
crate::SOLANA_CORE_FEATURE_SURFACE_CODE.to_string(),
|
||||||
|
),
|
||||||
|
entry_kind: crate::DecoderCoverageEntryKind::Instruction,
|
||||||
|
entry_code: "revoke_pending_activation".to_string(),
|
||||||
|
discriminator_hex: std::option::Option::Some("00".to_string()),
|
||||||
|
historical: false,
|
||||||
|
}];
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Recognizes one Feature Gate instruction without producing an event.
|
||||||
|
pub(crate) fn feature_recognize(
|
||||||
|
input: &crate::CoreInstructionReplayInput,
|
||||||
|
priority: u16,
|
||||||
|
) -> crate::DecoderRecognition {
|
||||||
|
let bytes_result = crate::solana_core_decode_instruction_data(input);
|
||||||
|
let bytes = match bytes_result {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(_error) => {
|
||||||
|
return crate::DecoderRecognition::compatible(
|
||||||
|
false,
|
||||||
|
priority,
|
||||||
|
std::option::Option::Some(crate::SOLANA_CORE_FEATURE_SURFACE_CODE.to_string()),
|
||||||
|
std::option::Option::Some("malformed_feature_instruction".to_string()),
|
||||||
|
std::option::Option::None,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
let entry_code = match bytes.first() {
|
||||||
|
std::option::Option::Some(0) => "revoke_pending_activation",
|
||||||
|
std::option::Option::Some(_value) => "unknown_feature_instruction",
|
||||||
|
std::option::Option::None => "malformed_feature_instruction",
|
||||||
|
};
|
||||||
|
return crate::DecoderRecognition::compatible(
|
||||||
|
bytes.as_slice() == [0],
|
||||||
|
priority,
|
||||||
|
std::option::Option::Some(crate::SOLANA_CORE_FEATURE_SURFACE_CODE.to_string()),
|
||||||
|
std::option::Option::Some(entry_code.to_string()),
|
||||||
|
crate::solana_core_hexadecimal_prefix(bytes.as_slice(), bytes.len().min(1)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Decodes one Feature Gate instruction.
|
||||||
|
pub(crate) fn feature_decode(
|
||||||
|
input: &crate::CoreInstructionReplayInput,
|
||||||
|
) -> crate::DecoderExecutionResult {
|
||||||
|
let bytes_result = crate::solana_core_decode_instruction_data(input);
|
||||||
|
let bytes = match bytes_result {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => {
|
||||||
|
return crate::solana_core_failed_result(
|
||||||
|
std::option::Option::Some("malformed_feature_instruction"),
|
||||||
|
"feature_payload_invalid",
|
||||||
|
error.to_string(),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
if bytes.is_empty() {
|
||||||
|
return crate::solana_core_failed_result(
|
||||||
|
std::option::Option::Some("malformed_feature_instruction"),
|
||||||
|
"feature_payload_empty",
|
||||||
|
"Feature Gate instruction payload is empty",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if bytes[0] != 0 {
|
||||||
|
return crate::solana_core_unsupported_result(
|
||||||
|
"unknown_feature_instruction",
|
||||||
|
"feature_tag_unknown",
|
||||||
|
format!(
|
||||||
|
"unknown Feature Gate instruction tag {}; payload_sha256={}",
|
||||||
|
bytes[0],
|
||||||
|
crate::solana_core_payload_hash(input)
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if bytes.len() != 1 {
|
||||||
|
return crate::solana_core_failed_result(
|
||||||
|
std::option::Option::Some("revoke_pending_activation"),
|
||||||
|
"feature_instruction_size_invalid",
|
||||||
|
format!(
|
||||||
|
"Feature Gate revoke_pending_activation requires 1 byte but received {}",
|
||||||
|
bytes.len()
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
let accounts_result =
|
||||||
|
crate::solana_core_resolve_accounts(input, REVOKE_ROLES, 3, std::option::Option::Some(3));
|
||||||
|
let accounts = match accounts_result {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => {
|
||||||
|
return crate::solana_core_failed_result(
|
||||||
|
std::option::Option::Some("revoke_pending_activation"),
|
||||||
|
"feature_accounts_invalid",
|
||||||
|
error.to_string(),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
let fixed_accounts_result = validate_fixed_accounts(&accounts);
|
||||||
|
if let std::result::Result::Err(error) = fixed_accounts_result {
|
||||||
|
return crate::solana_core_failed_result(
|
||||||
|
std::option::Option::Some("revoke_pending_activation"),
|
||||||
|
"feature_fixed_account_invalid",
|
||||||
|
error.to_string(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return crate::solana_core_decoded_result(
|
||||||
|
input,
|
||||||
|
crate::SOLANA_CORE_FEATURE_SURFACE_CODE,
|
||||||
|
"revoke_pending_activation",
|
||||||
|
crate::EventFamily::Lifecycle,
|
||||||
|
false,
|
||||||
|
accounts,
|
||||||
|
serde_json::json!({
|
||||||
|
"burnsFeatureLamports": true,
|
||||||
|
"requiresPendingActivation": true,
|
||||||
|
}),
|
||||||
|
SOURCE,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn validate_fixed_accounts(accounts: &serde_json::Value) -> kb_core::Result<()> {
|
||||||
|
let array = match accounts.as_array() {
|
||||||
|
std::option::Option::Some(value) => value,
|
||||||
|
std::option::Option::None => {
|
||||||
|
return std::result::Result::Err(kb_core::Error::invalid_state(
|
||||||
|
"resolved Feature Gate accounts must be an array",
|
||||||
|
));
|
||||||
|
},
|
||||||
|
};
|
||||||
|
for (position, expected) in [
|
||||||
|
(1_usize, kb_program_ids::INCINERATOR_PROGRAM_ID),
|
||||||
|
(2_usize, kb_program_ids::SYSTEM_PROGRAM_ID),
|
||||||
|
] {
|
||||||
|
let actual = array
|
||||||
|
.get(position)
|
||||||
|
.and_then(|value| return value.get("accountKey"))
|
||||||
|
.and_then(serde_json::Value::as_str);
|
||||||
|
if actual != std::option::Option::Some(expected) {
|
||||||
|
return std::result::Result::Err(kb_core::Error::invalid_state(format!(
|
||||||
|
"Feature Gate account {position} must be {expected}"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return std::result::Result::Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use base64::Engine; // rust-rules: trait-import
|
||||||
|
|
||||||
|
fn replay_input(bytes: &[u8], transaction_failed: bool) -> crate::CoreInstructionReplayInput {
|
||||||
|
let keys = [
|
||||||
|
"FeatureAccount11111111111111111111111111111",
|
||||||
|
kb_program_ids::INCINERATOR_PROGRAM_ID,
|
||||||
|
kb_program_ids::SYSTEM_PROGRAM_ID,
|
||||||
|
];
|
||||||
|
let account_keys = keys
|
||||||
|
.iter()
|
||||||
|
.enumerate()
|
||||||
|
.map(|(index, key)| {
|
||||||
|
return serde_json::json!({
|
||||||
|
"accountIndex": index,
|
||||||
|
"accountKey": key,
|
||||||
|
"source": "static",
|
||||||
|
"writable": index != 2,
|
||||||
|
"signer": index == 0,
|
||||||
|
"executable": index == 2,
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.collect::<std::vec::Vec<_>>();
|
||||||
|
let instruction_accounts = keys
|
||||||
|
.iter()
|
||||||
|
.enumerate()
|
||||||
|
.map(|(index, key)| {
|
||||||
|
return serde_json::json!({"accountIndex": index, "accountKey": key});
|
||||||
|
})
|
||||||
|
.collect::<std::vec::Vec<_>>();
|
||||||
|
let result = crate::CoreInstructionReplayInput::new(
|
||||||
|
"signature:0",
|
||||||
|
"signature",
|
||||||
|
42,
|
||||||
|
"0",
|
||||||
|
kb_program_ids::FEATURE_PROGRAM_ID,
|
||||||
|
transaction_failed,
|
||||||
|
if transaction_failed {
|
||||||
|
std::option::Option::Some(serde_json::json!({"InstructionError": [0, "Custom"]}))
|
||||||
|
} else {
|
||||||
|
std::option::Option::None
|
||||||
|
},
|
||||||
|
serde_json::Value::Array(account_keys),
|
||||||
|
serde_json::Value::Array(instruction_accounts),
|
||||||
|
std::option::Option::Some(serde_json::json!({
|
||||||
|
"dataBase64": base64::engine::general_purpose::STANDARD.encode(bytes),
|
||||||
|
})),
|
||||||
|
std::option::Option::Some("payload-hash".to_string()),
|
||||||
|
serde_json::json!([]),
|
||||||
|
serde_json::json!([]),
|
||||||
|
serde_json::json!([]),
|
||||||
|
serde_json::json!([]),
|
||||||
|
);
|
||||||
|
return match result {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => panic!("Feature Gate replay input failed: {error}"),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn official_payload_is_recognized_exactly() {
|
||||||
|
let input = replay_input(&[0], false);
|
||||||
|
let recognition = crate::solana_core_feature_recognize(&input, 100);
|
||||||
|
assert!(recognition.compatible);
|
||||||
|
assert!(recognition.exact);
|
||||||
|
assert_eq!(
|
||||||
|
recognition.entry_code.as_deref(),
|
||||||
|
std::option::Option::Some("revoke_pending_activation")
|
||||||
|
);
|
||||||
|
let unknown = crate::solana_core_feature_recognize(&replay_input(&[7], false), 100);
|
||||||
|
assert!(unknown.compatible);
|
||||||
|
assert!(!unknown.exact);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn revoke_pending_activation_decodes_exact_official_layout() {
|
||||||
|
let result = crate::solana_core_feature_decode(&replay_input(&[0], false));
|
||||||
|
assert_eq!(result.status, crate::DecoderOutcomeStatus::Decoded);
|
||||||
|
assert_eq!(
|
||||||
|
result.recognized_entry_code.as_deref(),
|
||||||
|
std::option::Option::Some("revoke_pending_activation")
|
||||||
|
);
|
||||||
|
assert_eq!(result.observations[0].payload_json["parameters"]["burnsFeatureLamports"], true);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn unknown_truncated_trailing_and_wrong_fixed_accounts_are_safe() {
|
||||||
|
let empty = crate::solana_core_feature_decode(&replay_input(&[], false));
|
||||||
|
assert_eq!(empty.status, crate::DecoderOutcomeStatus::Failed);
|
||||||
|
let unknown = crate::solana_core_feature_decode(&replay_input(&[7], false));
|
||||||
|
assert_eq!(unknown.status, crate::DecoderOutcomeStatus::Unsupported);
|
||||||
|
let trailing = crate::solana_core_feature_decode(&replay_input(&[0, 1], false));
|
||||||
|
assert_eq!(trailing.status, crate::DecoderOutcomeStatus::Failed);
|
||||||
|
let mut wrong = replay_input(&[0], false);
|
||||||
|
wrong.instruction_accounts_json[1]["accountKey"] = serde_json::json!("wrong");
|
||||||
|
wrong.account_keys_json[1]["accountKey"] = serde_json::json!("wrong");
|
||||||
|
let invalid = crate::solana_core_feature_decode(&wrong);
|
||||||
|
assert_eq!(invalid.status, crate::DecoderOutcomeStatus::Failed);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn failed_feature_revoke_is_uncommitted() {
|
||||||
|
let result = crate::solana_core_feature_decode(&replay_input(&[0], true));
|
||||||
|
assert_eq!(result.status, crate::DecoderOutcomeStatus::Decoded);
|
||||||
|
assert!(!result.observations[0].observation_committed);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn coverage_declares_the_single_official_instruction() {
|
||||||
|
let coverage = crate::solana_core_feature_coverage();
|
||||||
|
assert_eq!(coverage.len(), 1);
|
||||||
|
assert_eq!(coverage[0].entry_code, "revoke_pending_activation");
|
||||||
|
assert_eq!(coverage[0].discriminator_hex.as_deref(), std::option::Option::Some("00"));
|
||||||
|
}
|
||||||
|
}
|
||||||
1321
kb-lib/src/decoder/solana/core/loaders.rs
Normal file
1321
kb-lib/src/decoder/solana/core/loaders.rs
Normal file
File diff suppressed because it is too large
Load Diff
328
kb-lib/src/decoder/solana/core/payload.rs
Normal file
328
kb-lib/src/decoder/solana/core/payload.rs
Normal file
@@ -0,0 +1,328 @@
|
|||||||
|
// file: kb-lib/src/decoder/solana/core/payload.rs
|
||||||
|
// version: 6
|
||||||
|
|
||||||
|
//! Bounded native instruction payload helpers.
|
||||||
|
|
||||||
|
use base64::Engine; // rust-rules: trait-import
|
||||||
|
use sha2::Digest; // rust-rules: trait-import
|
||||||
|
|
||||||
|
/// One resolved outer instruction payload and its provenance relative to the target instruction.
|
||||||
|
pub(crate) struct ResolvedInstructionPayload {
|
||||||
|
/// Numeric outer instruction index in the transaction message.
|
||||||
|
pub(crate) instruction_index: usize,
|
||||||
|
/// Stable outer instruction path retained by the core store.
|
||||||
|
pub(crate) instruction_path: std::string::String,
|
||||||
|
/// Decoded instruction data.
|
||||||
|
pub(crate) bytes: std::vec::Vec<u8>,
|
||||||
|
/// Stable provenance code used by decoded precompile events.
|
||||||
|
pub(crate) provenance: &'static str,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Decodes one retained base64 instruction payload with an explicit byte limit.
|
||||||
|
pub(crate) fn decode_instruction_data(
|
||||||
|
input: &crate::CoreInstructionReplayInput,
|
||||||
|
) -> kb_core::Result<std::vec::Vec<u8>> {
|
||||||
|
let payload = match input.instruction_payload_json.as_ref() {
|
||||||
|
std::option::Option::Some(value) => value,
|
||||||
|
std::option::Option::None => {
|
||||||
|
return std::result::Result::Err(kb_core::Error::invalid_state(
|
||||||
|
"native instruction payload is not retained",
|
||||||
|
));
|
||||||
|
},
|
||||||
|
};
|
||||||
|
return decode_payload_json(payload, "native instruction");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resolves an Ed25519 or secp256r1 instruction reference using the official `u16::MAX` sentinel.
|
||||||
|
pub(crate) fn resolve_u16_instruction_payload(
|
||||||
|
input: &crate::CoreInstructionReplayInput,
|
||||||
|
raw_instruction_index: u16,
|
||||||
|
) -> kb_core::Result<crate::SolanaCoreResolvedInstructionPayload> {
|
||||||
|
if raw_instruction_index == u16::MAX {
|
||||||
|
let target_index_result = crate::solana_core_target_outer_instruction_index(input);
|
||||||
|
let target_index = match target_index_result {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
let bytes_result = crate::solana_core_decode_instruction_data(input);
|
||||||
|
let bytes = match bytes_result {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
return std::result::Result::Ok(crate::SolanaCoreResolvedInstructionPayload {
|
||||||
|
instruction_index: target_index,
|
||||||
|
instruction_path: input.instruction_path.clone(),
|
||||||
|
bytes,
|
||||||
|
provenance: "current_instruction",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return resolve_explicit_outer_instruction(input, usize::from(raw_instruction_index));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resolves a secp256k1 instruction reference. The runtime format has no current-instruction
|
||||||
|
/// sentinel: every `u8` value is an explicit outer instruction index.
|
||||||
|
pub(crate) fn resolve_u8_instruction_payload(
|
||||||
|
input: &crate::CoreInstructionReplayInput,
|
||||||
|
raw_instruction_index: u8,
|
||||||
|
) -> kb_core::Result<crate::SolanaCoreResolvedInstructionPayload> {
|
||||||
|
return resolve_explicit_outer_instruction(input, usize::from(raw_instruction_index));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the numeric outer index of the target instruction.
|
||||||
|
pub(crate) fn target_outer_instruction_index(
|
||||||
|
input: &crate::CoreInstructionReplayInput,
|
||||||
|
) -> kb_core::Result<usize> {
|
||||||
|
if input.instruction_path.contains('/') {
|
||||||
|
return std::result::Result::Err(kb_core::Error::invalid_state(
|
||||||
|
"signature precompiles can only resolve outer instruction paths",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let parse_result = input.instruction_path.parse::<usize>();
|
||||||
|
return match parse_result {
|
||||||
|
std::result::Result::Ok(value) => std::result::Result::Ok(value),
|
||||||
|
std::result::Result::Err(error) => std::result::Result::Err(kb_core::Error::invalid_state(
|
||||||
|
format!("target outer instruction path is not numeric: {error}"),
|
||||||
|
)),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Extracts one exact bounded byte slice with checked arithmetic.
|
||||||
|
pub(crate) fn bounded_slice<'a>(
|
||||||
|
bytes: &'a [u8],
|
||||||
|
offset: usize,
|
||||||
|
length: usize,
|
||||||
|
label: &str,
|
||||||
|
) -> kb_core::Result<&'a [u8]> {
|
||||||
|
let end = match offset.checked_add(length) {
|
||||||
|
std::option::Option::Some(value) => value,
|
||||||
|
std::option::Option::None => {
|
||||||
|
return std::result::Result::Err(kb_core::Error::invalid_state(format!(
|
||||||
|
"{label} range overflows usize: offset={offset}, length={length}"
|
||||||
|
)));
|
||||||
|
},
|
||||||
|
};
|
||||||
|
return match bytes.get(offset..end) {
|
||||||
|
std::option::Option::Some(value) => std::result::Result::Ok(value),
|
||||||
|
std::option::Option::None => {
|
||||||
|
std::result::Result::Err(kb_core::Error::invalid_state(format!(
|
||||||
|
"{label} range is outside referenced instruction: offset={offset}, length={length}, instructionLength={}",
|
||||||
|
bytes.len()
|
||||||
|
)))
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the decoded target payload length when retained and valid.
|
||||||
|
pub(crate) fn decoded_payload_length(
|
||||||
|
input: &crate::CoreInstructionReplayInput,
|
||||||
|
) -> std::option::Option<usize> {
|
||||||
|
return crate::solana_core_decode_instruction_data(input)
|
||||||
|
.ok()
|
||||||
|
.map(|bytes| return bytes.len());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the SHA-256 of the decoded target instruction data when available.
|
||||||
|
pub(crate) fn decoded_payload_sha256(
|
||||||
|
input: &crate::CoreInstructionReplayInput,
|
||||||
|
) -> std::option::Option<std::string::String> {
|
||||||
|
let bytes_result = crate::solana_core_decode_instruction_data(input);
|
||||||
|
return match bytes_result {
|
||||||
|
std::result::Result::Ok(bytes) => {
|
||||||
|
std::option::Option::Some(crate::solana_core_hash_bytes(bytes.as_slice()))
|
||||||
|
},
|
||||||
|
std::result::Result::Err(_error) => std::option::Option::None,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns a stable payload hash from core or computes it from retained JSON.
|
||||||
|
pub(crate) fn payload_hash(input: &crate::CoreInstructionReplayInput) -> std::string::String {
|
||||||
|
if let std::option::Option::Some(value) = input.instruction_payload_hash.as_ref() {
|
||||||
|
return value.clone();
|
||||||
|
}
|
||||||
|
let mut value = match input.instruction_payload_json.clone() {
|
||||||
|
std::option::Option::Some(value) => value,
|
||||||
|
std::option::Option::None => serde_json::Value::Null,
|
||||||
|
};
|
||||||
|
let hash_result = crate::deterministic_json_hash(&mut value);
|
||||||
|
return match hash_result {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(_error) => "payload_hash_unavailable".to_string(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reads one little-endian `u32` from an exact byte range.
|
||||||
|
pub(crate) fn read_u32_le(bytes: &[u8], offset: usize) -> kb_core::Result<u32> {
|
||||||
|
let slice_result = crate::solana_core_bounded_slice(bytes, offset, 4, "native instruction u32");
|
||||||
|
let slice = match slice_result {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
let array_result = <[u8; 4]>::try_from(slice);
|
||||||
|
return match array_result {
|
||||||
|
std::result::Result::Ok(value) => std::result::Result::Ok(u32::from_le_bytes(value)),
|
||||||
|
std::result::Result::Err(_error) => std::result::Result::Err(
|
||||||
|
kb_core::Error::invalid_state("native instruction u32 slice has an invalid length"),
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reads one little-endian `u64` from an exact byte range.
|
||||||
|
pub(crate) fn read_u64_le(bytes: &[u8], offset: usize) -> kb_core::Result<u64> {
|
||||||
|
let slice_result = crate::solana_core_bounded_slice(bytes, offset, 8, "native instruction u64");
|
||||||
|
let slice = match slice_result {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
let array_result = <[u8; 8]>::try_from(slice);
|
||||||
|
return match array_result {
|
||||||
|
std::result::Result::Ok(value) => std::result::Result::Ok(u64::from_le_bytes(value)),
|
||||||
|
std::result::Result::Err(_error) => std::result::Result::Err(
|
||||||
|
kb_core::Error::invalid_state("native instruction u64 slice has an invalid length"),
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the lowercase SHA-256 of one bounded byte slice.
|
||||||
|
pub(crate) fn hash_bytes(bytes: &[u8]) -> std::string::String {
|
||||||
|
let digest = sha2::Sha256::digest(bytes);
|
||||||
|
let mut output = std::string::String::with_capacity(digest.len().saturating_mul(2));
|
||||||
|
for byte in digest {
|
||||||
|
output.push_str(format!("{byte:02x}").as_str());
|
||||||
|
}
|
||||||
|
return output;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns a normalized lowercase hexadecimal prefix.
|
||||||
|
pub(crate) fn hexadecimal_prefix(
|
||||||
|
bytes: &[u8],
|
||||||
|
count: usize,
|
||||||
|
) -> std::option::Option<std::string::String> {
|
||||||
|
let selected = match bytes.get(..count) {
|
||||||
|
std::option::Option::Some(value) => value,
|
||||||
|
std::option::Option::None => return std::option::Option::None,
|
||||||
|
};
|
||||||
|
let mut output = std::string::String::with_capacity(count.saturating_mul(2));
|
||||||
|
for byte in selected {
|
||||||
|
output.push_str(format!("{byte:02x}").as_str());
|
||||||
|
}
|
||||||
|
return std::option::Option::Some(output);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns a hexadecimal prefix no longer than the available byte slice.
|
||||||
|
pub(crate) fn bounded_hexadecimal_prefix(
|
||||||
|
bytes: &[u8],
|
||||||
|
maximum_count: usize,
|
||||||
|
) -> std::string::String {
|
||||||
|
let count = std::cmp::min(bytes.len(), maximum_count);
|
||||||
|
return match crate::solana_core_hexadecimal_prefix(bytes, count) {
|
||||||
|
std::option::Option::Some(value) => value,
|
||||||
|
std::option::Option::None => std::string::String::new(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
fn resolve_explicit_outer_instruction(
|
||||||
|
input: &crate::CoreInstructionReplayInput,
|
||||||
|
instruction_index: usize,
|
||||||
|
) -> kb_core::Result<crate::SolanaCoreResolvedInstructionPayload> {
|
||||||
|
let entries = match input.outer_instructions_json.as_array() {
|
||||||
|
std::option::Option::Some(value) => value,
|
||||||
|
std::option::Option::None => {
|
||||||
|
return std::result::Result::Err(kb_core::Error::invalid_state(
|
||||||
|
"outer instruction context is not a JSON array",
|
||||||
|
));
|
||||||
|
},
|
||||||
|
};
|
||||||
|
let target_index_result = crate::solana_core_target_outer_instruction_index(input);
|
||||||
|
let target_index = match target_index_result {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
let entry = entries.iter().find(|entry| {
|
||||||
|
let value = entry
|
||||||
|
.get("instructionIndex")
|
||||||
|
.and_then(serde_json::Value::as_u64)
|
||||||
|
.and_then(|value| return usize::try_from(value).ok());
|
||||||
|
return value == std::option::Option::Some(instruction_index);
|
||||||
|
});
|
||||||
|
let entry = match entry {
|
||||||
|
std::option::Option::Some(value) => value,
|
||||||
|
std::option::Option::None => {
|
||||||
|
return std::result::Result::Err(kb_core::Error::invalid_state(format!(
|
||||||
|
"referenced outer instruction index is absent: {instruction_index}"
|
||||||
|
)));
|
||||||
|
},
|
||||||
|
};
|
||||||
|
let instruction_path = match entry.get("instructionPath").and_then(serde_json::Value::as_str) {
|
||||||
|
std::option::Option::Some(value) if !value.trim().is_empty() => value.to_string(),
|
||||||
|
_ => {
|
||||||
|
return std::result::Result::Err(kb_core::Error::invalid_state(format!(
|
||||||
|
"referenced outer instruction {instruction_index} has no instructionPath"
|
||||||
|
)));
|
||||||
|
},
|
||||||
|
};
|
||||||
|
let payload_json = match entry.get("payloadJson") {
|
||||||
|
std::option::Option::Some(value) if !value.is_null() => value,
|
||||||
|
_ => {
|
||||||
|
return std::result::Result::Err(kb_core::Error::invalid_state(format!(
|
||||||
|
"referenced outer instruction {instruction_index} has no retained payloadJson"
|
||||||
|
)));
|
||||||
|
},
|
||||||
|
};
|
||||||
|
let bytes_result = decode_payload_json(
|
||||||
|
payload_json,
|
||||||
|
format!("referenced outer instruction {instruction_index}").as_str(),
|
||||||
|
);
|
||||||
|
let bytes = match bytes_result {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
return std::result::Result::Ok(crate::SolanaCoreResolvedInstructionPayload {
|
||||||
|
instruction_index,
|
||||||
|
instruction_path,
|
||||||
|
bytes,
|
||||||
|
provenance: if instruction_index == target_index {
|
||||||
|
"current_instruction"
|
||||||
|
} else {
|
||||||
|
"outer_instruction"
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
fn decode_payload_json(
|
||||||
|
payload: &serde_json::Value,
|
||||||
|
label: &str,
|
||||||
|
) -> kb_core::Result<std::vec::Vec<u8>> {
|
||||||
|
let encoded = match payload.get("dataBase64").and_then(serde_json::Value::as_str) {
|
||||||
|
std::option::Option::Some(value) => value,
|
||||||
|
std::option::Option::None => {
|
||||||
|
return std::result::Result::Err(kb_core::Error::invalid_state(format!(
|
||||||
|
"{label} payload does not contain dataBase64"
|
||||||
|
)));
|
||||||
|
},
|
||||||
|
};
|
||||||
|
let maximum_encoded_length = crate::SOLANA_CORE_MAX_NATIVE_INSTRUCTION_PAYLOAD_BYTES
|
||||||
|
.saturating_mul(4)
|
||||||
|
.saturating_div(3)
|
||||||
|
.saturating_add(4);
|
||||||
|
if encoded.len() > maximum_encoded_length {
|
||||||
|
return std::result::Result::Err(kb_core::Error::invalid_state(format!(
|
||||||
|
"{label} base64 payload exceeds {maximum_encoded_length} bytes"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
let decoded_result = base64::engine::general_purpose::STANDARD.decode(encoded.as_bytes());
|
||||||
|
let decoded = match decoded_result {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => {
|
||||||
|
return std::result::Result::Err(kb_core::Error::invalid_state(format!(
|
||||||
|
"{label} payload is not valid base64: {error}"
|
||||||
|
)));
|
||||||
|
},
|
||||||
|
};
|
||||||
|
if decoded.len() > crate::SOLANA_CORE_MAX_NATIVE_INSTRUCTION_PAYLOAD_BYTES {
|
||||||
|
return std::result::Result::Err(kb_core::Error::invalid_state(format!(
|
||||||
|
"{label} payload exceeds {} decoded bytes",
|
||||||
|
crate::SOLANA_CORE_MAX_NATIVE_INSTRUCTION_PAYLOAD_BYTES
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
return std::result::Result::Ok(decoded);
|
||||||
|
}
|
||||||
1663
kb-lib/src/decoder/solana/core/precompiles.rs
Normal file
1663
kb-lib/src/decoder/solana/core/precompiles.rs
Normal file
File diff suppressed because it is too large
Load Diff
685
kb-lib/src/decoder/solana/core/slashing.rs
Normal file
685
kb-lib/src/decoder/solana/core/slashing.rs
Normal file
@@ -0,0 +1,685 @@
|
|||||||
|
// file: kb-lib/src/decoder/solana/core/slashing.rs
|
||||||
|
// version: 3
|
||||||
|
|
||||||
|
//! Exact structural decoding for the enshrined stateless Slashing Program.
|
||||||
|
|
||||||
|
const SOURCE: &str = "solana-program/slashing@fe8da3a instruction contract and Agave v4.1.1 stateless builtin verified build";
|
||||||
|
const DUPLICATE_BLOCK_PROOF_DATA_BYTES: usize = 304;
|
||||||
|
const DUPLICATE_BLOCK_PROOF_INSTRUCTION_BYTES: usize = 305;
|
||||||
|
const SIGNATURE_BYTES: usize = 64;
|
||||||
|
const HASH_BYTES: usize = 32;
|
||||||
|
const PUBKEY_BYTES: usize = 32;
|
||||||
|
const CLOSE_ROLES: &[crate::SolanaCoreAccountRole] = &[
|
||||||
|
crate::SolanaCoreAccountRole::new("violation_report", true, false),
|
||||||
|
crate::SolanaCoreAccountRole::new("destination", true, false),
|
||||||
|
];
|
||||||
|
const DUPLICATE_BLOCK_ROLES: &[crate::SolanaCoreAccountRole] = &[
|
||||||
|
crate::SolanaCoreAccountRole::new("proof_account", false, false),
|
||||||
|
crate::SolanaCoreAccountRole::new("violation_report", true, false),
|
||||||
|
crate::SolanaCoreAccountRole::new("instructions_sysvar", false, false),
|
||||||
|
crate::SolanaCoreAccountRole::new("system_program", false, false),
|
||||||
|
];
|
||||||
|
|
||||||
|
/// Returns declared Slashing Program instruction coverage.
|
||||||
|
pub(crate) fn slashing_coverage() -> std::vec::Vec<crate::DecoderCoverageDeclaration> {
|
||||||
|
return vec![
|
||||||
|
crate::DecoderCoverageDeclaration {
|
||||||
|
program_id: kb_program_ids::SLASHING_PROGRAM_ID.to_string(),
|
||||||
|
surface_code: std::option::Option::Some(
|
||||||
|
crate::SOLANA_CORE_SLASHING_SURFACE_CODE.to_string(),
|
||||||
|
),
|
||||||
|
entry_kind: crate::DecoderCoverageEntryKind::Instruction,
|
||||||
|
entry_code: "close_violation_report".to_string(),
|
||||||
|
discriminator_hex: std::option::Option::Some("00".to_string()),
|
||||||
|
historical: false,
|
||||||
|
},
|
||||||
|
crate::DecoderCoverageDeclaration {
|
||||||
|
program_id: kb_program_ids::SLASHING_PROGRAM_ID.to_string(),
|
||||||
|
surface_code: std::option::Option::Some(
|
||||||
|
crate::SOLANA_CORE_SLASHING_SURFACE_CODE.to_string(),
|
||||||
|
),
|
||||||
|
entry_kind: crate::DecoderCoverageEntryKind::Instruction,
|
||||||
|
entry_code: "duplicate_block_proof".to_string(),
|
||||||
|
discriminator_hex: std::option::Option::Some("01".to_string()),
|
||||||
|
historical: false,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Recognizes one Slashing Program instruction without producing an event.
|
||||||
|
pub(crate) fn slashing_recognize(
|
||||||
|
input: &crate::CoreInstructionReplayInput,
|
||||||
|
priority: u16,
|
||||||
|
) -> crate::DecoderRecognition {
|
||||||
|
let bytes_result = crate::solana_core_decode_instruction_data(input);
|
||||||
|
let bytes = match bytes_result {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(_error) => {
|
||||||
|
return crate::DecoderRecognition::compatible(
|
||||||
|
false,
|
||||||
|
priority,
|
||||||
|
std::option::Option::Some(crate::SOLANA_CORE_SLASHING_SURFACE_CODE.to_string()),
|
||||||
|
std::option::Option::Some("malformed_slashing_instruction".to_string()),
|
||||||
|
std::option::Option::None,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
let (entry_code, exact) = match bytes.first() {
|
||||||
|
std::option::Option::Some(0) => ("close_violation_report", bytes.len() == 1),
|
||||||
|
std::option::Option::Some(1) => {
|
||||||
|
("duplicate_block_proof", bytes.len() == DUPLICATE_BLOCK_PROOF_INSTRUCTION_BYTES)
|
||||||
|
},
|
||||||
|
std::option::Option::Some(_value) => ("unknown_slashing_instruction", false),
|
||||||
|
std::option::Option::None => ("malformed_slashing_instruction", false),
|
||||||
|
};
|
||||||
|
return crate::DecoderRecognition::compatible(
|
||||||
|
exact,
|
||||||
|
priority,
|
||||||
|
std::option::Option::Some(crate::SOLANA_CORE_SLASHING_SURFACE_CODE.to_string()),
|
||||||
|
std::option::Option::Some(entry_code.to_string()),
|
||||||
|
crate::solana_core_hexadecimal_prefix(bytes.as_slice(), bytes.len().min(1)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Decodes one Slashing Program instruction.
|
||||||
|
pub(crate) fn slashing_decode(
|
||||||
|
input: &crate::CoreInstructionReplayInput,
|
||||||
|
) -> crate::DecoderExecutionResult {
|
||||||
|
let bytes_result = crate::solana_core_decode_instruction_data(input);
|
||||||
|
let bytes = match bytes_result {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => {
|
||||||
|
return crate::solana_core_failed_result(
|
||||||
|
std::option::Option::Some("malformed_slashing_instruction"),
|
||||||
|
"slashing_payload_invalid",
|
||||||
|
error.to_string(),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
let tag = match bytes.first() {
|
||||||
|
std::option::Option::Some(value) => *value,
|
||||||
|
std::option::Option::None => {
|
||||||
|
return crate::solana_core_failed_result(
|
||||||
|
std::option::Option::Some("malformed_slashing_instruction"),
|
||||||
|
"slashing_payload_empty",
|
||||||
|
"Slashing Program instruction payload is empty",
|
||||||
|
);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
return match tag {
|
||||||
|
0 => decode_close_violation_report(input, bytes.as_slice()),
|
||||||
|
1 => decode_duplicate_block_proof(input, bytes.as_slice()),
|
||||||
|
_ => crate::solana_core_unsupported_result(
|
||||||
|
"unknown_slashing_instruction",
|
||||||
|
"slashing_tag_unknown",
|
||||||
|
format!(
|
||||||
|
"unknown Slashing Program instruction tag {tag}; payload_sha256={}",
|
||||||
|
crate::solana_core_payload_hash(input)
|
||||||
|
),
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
fn decode_close_violation_report(
|
||||||
|
input: &crate::CoreInstructionReplayInput,
|
||||||
|
bytes: &[u8],
|
||||||
|
) -> crate::DecoderExecutionResult {
|
||||||
|
if bytes.len() != 1 {
|
||||||
|
return crate::solana_core_failed_result(
|
||||||
|
std::option::Option::Some("close_violation_report"),
|
||||||
|
"slashing_close_size_invalid",
|
||||||
|
format!("close_violation_report requires exactly 1 byte but received {}", bytes.len()),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
let accounts_result =
|
||||||
|
crate::solana_core_resolve_accounts(input, CLOSE_ROLES, 2, std::option::Option::None);
|
||||||
|
let accounts = match accounts_result {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => {
|
||||||
|
return crate::solana_core_failed_result(
|
||||||
|
std::option::Option::Some("close_violation_report"),
|
||||||
|
"slashing_close_accounts_invalid",
|
||||||
|
error.to_string(),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
return crate::solana_core_decoded_result(
|
||||||
|
input,
|
||||||
|
crate::SOLANA_CORE_SLASHING_SURFACE_CODE,
|
||||||
|
"close_violation_report",
|
||||||
|
crate::EventFamily::Lifecycle,
|
||||||
|
false,
|
||||||
|
accounts,
|
||||||
|
serde_json::json!({
|
||||||
|
"proofType": "duplicate_block",
|
||||||
|
"minimumRetentionEpochs": 3,
|
||||||
|
"runtimeMutation": runtime_mutation(
|
||||||
|
input,
|
||||||
|
"violation_report_closed_lamports_transferred_owner_reset_to_system_program",
|
||||||
|
),
|
||||||
|
"transactionFinalAccountStateCaptured": false,
|
||||||
|
}),
|
||||||
|
SOURCE,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn decode_duplicate_block_proof(
|
||||||
|
input: &crate::CoreInstructionReplayInput,
|
||||||
|
bytes: &[u8],
|
||||||
|
) -> crate::DecoderExecutionResult {
|
||||||
|
if bytes.len() != DUPLICATE_BLOCK_PROOF_INSTRUCTION_BYTES {
|
||||||
|
return crate::solana_core_failed_result(
|
||||||
|
std::option::Option::Some("duplicate_block_proof"),
|
||||||
|
"slashing_duplicate_block_size_invalid",
|
||||||
|
format!(
|
||||||
|
"duplicate_block_proof requires {} bytes but received {}",
|
||||||
|
DUPLICATE_BLOCK_PROOF_INSTRUCTION_BYTES,
|
||||||
|
bytes.len()
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
let accounts_result = crate::solana_core_resolve_accounts(
|
||||||
|
input,
|
||||||
|
DUPLICATE_BLOCK_ROLES,
|
||||||
|
4,
|
||||||
|
std::option::Option::None,
|
||||||
|
);
|
||||||
|
let accounts = match accounts_result {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => {
|
||||||
|
return crate::solana_core_failed_result(
|
||||||
|
std::option::Option::Some("duplicate_block_proof"),
|
||||||
|
"slashing_duplicate_block_accounts_invalid",
|
||||||
|
error.to_string(),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
let fixed_accounts_result = validate_duplicate_block_fixed_accounts(&accounts);
|
||||||
|
if let std::result::Result::Err(error) = fixed_accounts_result {
|
||||||
|
return crate::solana_core_failed_result(
|
||||||
|
std::option::Option::Some("duplicate_block_proof"),
|
||||||
|
"slashing_duplicate_block_fixed_account_invalid",
|
||||||
|
error.to_string(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
let parsed_result = parse_duplicate_block_parameters(input, bytes);
|
||||||
|
let parameters = match parsed_result {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => {
|
||||||
|
return crate::solana_core_failed_result(
|
||||||
|
std::option::Option::Some("duplicate_block_proof"),
|
||||||
|
"slashing_duplicate_block_data_invalid",
|
||||||
|
error.to_string(),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
return crate::solana_core_decoded_result(
|
||||||
|
input,
|
||||||
|
crate::SOLANA_CORE_SLASHING_SURFACE_CODE,
|
||||||
|
"duplicate_block_proof",
|
||||||
|
crate::EventFamily::Audit,
|
||||||
|
false,
|
||||||
|
accounts,
|
||||||
|
parameters,
|
||||||
|
SOURCE,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_duplicate_block_parameters(
|
||||||
|
input: &crate::CoreInstructionReplayInput,
|
||||||
|
bytes: &[u8],
|
||||||
|
) -> kb_core::Result<serde_json::Value> {
|
||||||
|
let offset_result = crate::solana_core_read_u64_le(bytes, 1);
|
||||||
|
let offset = match offset_result {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
let slot_result = crate::solana_core_read_u64_le(bytes, 9);
|
||||||
|
let slot = match slot_result {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
let node_pubkey_result = read_pubkey(bytes, 17, "slashing node pubkey");
|
||||||
|
let node_pubkey = match node_pubkey_result {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
let reporter_result = read_pubkey(bytes, 49, "slashing reporter");
|
||||||
|
let reporter = match reporter_result {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
let destination_result = read_pubkey(bytes, 81, "slashing destination");
|
||||||
|
let destination = match destination_result {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
let shred_1_root_result = crate::solana_core_bounded_slice(
|
||||||
|
bytes,
|
||||||
|
113,
|
||||||
|
HASH_BYTES,
|
||||||
|
"slashing first shred merkle root",
|
||||||
|
);
|
||||||
|
let shred_1_root = match shred_1_root_result {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
let shred_1_signature_result = crate::solana_core_bounded_slice(
|
||||||
|
bytes,
|
||||||
|
145,
|
||||||
|
SIGNATURE_BYTES,
|
||||||
|
"slashing first shred signature",
|
||||||
|
);
|
||||||
|
let shred_1_signature = match shred_1_signature_result {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
let shred_2_root_result = crate::solana_core_bounded_slice(
|
||||||
|
bytes,
|
||||||
|
209,
|
||||||
|
HASH_BYTES,
|
||||||
|
"slashing second shred merkle root",
|
||||||
|
);
|
||||||
|
let shred_2_root = match shred_2_root_result {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
let shred_2_signature_result = crate::solana_core_bounded_slice(
|
||||||
|
bytes,
|
||||||
|
241,
|
||||||
|
SIGNATURE_BYTES,
|
||||||
|
"slashing second shred signature",
|
||||||
|
);
|
||||||
|
let shred_2_signature = match shred_2_signature_result {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
return std::result::Result::Ok(serde_json::json!({
|
||||||
|
"proofType": "duplicate_block",
|
||||||
|
"proofAccountOffset": offset,
|
||||||
|
"violationSlot": slot,
|
||||||
|
"nodePubkey": node_pubkey,
|
||||||
|
"reporter": reporter,
|
||||||
|
"destination": destination,
|
||||||
|
"shred1MerkleRoot": hash_summary(shred_1_root),
|
||||||
|
"shred1Signature": signature_summary(shred_1_signature),
|
||||||
|
"shred2MerkleRoot": hash_summary(shred_2_root),
|
||||||
|
"shred2Signature": signature_summary(shred_2_signature),
|
||||||
|
"proofAccountDataCaptured": false,
|
||||||
|
"proofAccountDataSemantics": "external_account_not_captured_by_transaction_core",
|
||||||
|
"reportAccountMustBePrefunded": true,
|
||||||
|
"prefundingProvenanceInspectedByDecoder": false,
|
||||||
|
"violationReportPdaDerivationVerifiedByDecoder": false,
|
||||||
|
"precedingEd25519Instruction": preceding_ed25519_instruction(input),
|
||||||
|
"cryptographicVerificationPerformedByDecoder": false,
|
||||||
|
"runtimeVerification": if input.transaction_failed {
|
||||||
|
"not_asserted_transaction_failed"
|
||||||
|
} else {
|
||||||
|
"accepted_in_successful_transaction"
|
||||||
|
},
|
||||||
|
"runtimeMutation": runtime_mutation(
|
||||||
|
input,
|
||||||
|
"violation_report_pda_assigned_allocated_and_data_stored_after_duplicate_block_proof_acceptance",
|
||||||
|
),
|
||||||
|
"penaltyAppliedByProgram": false,
|
||||||
|
"programSemantics": "records_verified_violation_report_for_external_consensus_enforcement",
|
||||||
|
"instructionDataBytes": DUPLICATE_BLOCK_PROOF_DATA_BYTES,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
fn validate_duplicate_block_fixed_accounts(accounts: &serde_json::Value) -> kb_core::Result<()> {
|
||||||
|
let array = match accounts.as_array() {
|
||||||
|
std::option::Option::Some(value) => value,
|
||||||
|
std::option::Option::None => {
|
||||||
|
return std::result::Result::Err(kb_core::Error::invalid_state(
|
||||||
|
"resolved Slashing Program accounts must be an array",
|
||||||
|
));
|
||||||
|
},
|
||||||
|
};
|
||||||
|
for (position, expected) in [
|
||||||
|
(2_usize, kb_program_ids::SYSVAR_INSTRUCTIONS_PROGRAM_ID),
|
||||||
|
(3_usize, kb_program_ids::SYSTEM_PROGRAM_ID),
|
||||||
|
] {
|
||||||
|
let actual = array
|
||||||
|
.get(position)
|
||||||
|
.and_then(|value| return value.get("accountKey"))
|
||||||
|
.and_then(serde_json::Value::as_str);
|
||||||
|
if actual != std::option::Option::Some(expected) {
|
||||||
|
return std::result::Result::Err(kb_core::Error::invalid_state(format!(
|
||||||
|
"Slashing Program account {position} must be {expected}"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return std::result::Result::Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
fn read_pubkey(bytes: &[u8], offset: usize, label: &str) -> kb_core::Result<std::string::String> {
|
||||||
|
let slice_result = crate::solana_core_bounded_slice(bytes, offset, PUBKEY_BYTES, label);
|
||||||
|
return match slice_result {
|
||||||
|
std::result::Result::Ok(value) => {
|
||||||
|
std::result::Result::Ok(bs58::encode(value).into_string())
|
||||||
|
},
|
||||||
|
std::result::Result::Err(error) => std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
fn hash_summary(bytes: &[u8]) -> serde_json::Value {
|
||||||
|
return serde_json::json!({
|
||||||
|
"length": bytes.len(),
|
||||||
|
"hex": crate::solana_core_bounded_hexadecimal_prefix(bytes, bytes.len()),
|
||||||
|
"sha256": crate::solana_core_hash_bytes(bytes),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
fn signature_summary(bytes: &[u8]) -> serde_json::Value {
|
||||||
|
return serde_json::json!({
|
||||||
|
"length": bytes.len(),
|
||||||
|
"prefixHex": crate::solana_core_bounded_hexadecimal_prefix(
|
||||||
|
bytes,
|
||||||
|
crate::SOLANA_CORE_PRECOMPILE_COMPONENT_PREFIX_BYTES,
|
||||||
|
),
|
||||||
|
"sha256": crate::solana_core_hash_bytes(bytes),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
fn preceding_ed25519_instruction(input: &crate::CoreInstructionReplayInput) -> serde_json::Value {
|
||||||
|
let target_index = match crate::solana_core_target_outer_instruction_index(input) {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => {
|
||||||
|
return serde_json::json!({
|
||||||
|
"requiredRelativeInstructionOffset": -1,
|
||||||
|
"expectedProgramId": kb_program_ids::ED25519_PROGRAM_ID,
|
||||||
|
"resolved": false,
|
||||||
|
"matchesExpectedProgram": false,
|
||||||
|
"diagnostic": error.to_string(),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
||||||
|
let previous_index = match target_index.checked_sub(1) {
|
||||||
|
std::option::Option::Some(value) => value,
|
||||||
|
std::option::Option::None => {
|
||||||
|
return serde_json::json!({
|
||||||
|
"requiredRelativeInstructionOffset": -1,
|
||||||
|
"currentInstructionIndex": target_index,
|
||||||
|
"expectedProgramId": kb_program_ids::ED25519_PROGRAM_ID,
|
||||||
|
"resolved": false,
|
||||||
|
"matchesExpectedProgram": false,
|
||||||
|
"diagnostic": "target instruction has no preceding outer instruction",
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
||||||
|
let previous = input.outer_instructions_json.as_array().and_then(|instructions| {
|
||||||
|
return instructions.iter().find(|instruction| {
|
||||||
|
return instruction
|
||||||
|
.get("instructionIndex")
|
||||||
|
.and_then(serde_json::Value::as_u64)
|
||||||
|
.and_then(|value| return usize::try_from(value).ok())
|
||||||
|
== std::option::Option::Some(previous_index);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
let program_id = previous
|
||||||
|
.and_then(|instruction| return instruction.get("programId"))
|
||||||
|
.and_then(serde_json::Value::as_str);
|
||||||
|
let instruction_path = previous
|
||||||
|
.and_then(|instruction| return instruction.get("instructionPath"))
|
||||||
|
.and_then(serde_json::Value::as_str);
|
||||||
|
return serde_json::json!({
|
||||||
|
"requiredRelativeInstructionOffset": -1,
|
||||||
|
"currentInstructionIndex": target_index,
|
||||||
|
"resolvedInstructionIndex": previous_index,
|
||||||
|
"resolvedInstructionPath": instruction_path,
|
||||||
|
"expectedProgramId": kb_program_ids::ED25519_PROGRAM_ID,
|
||||||
|
"resolvedProgramId": program_id,
|
||||||
|
"resolved": previous.is_some(),
|
||||||
|
"matchesExpectedProgram": program_id == std::option::Option::Some(kb_program_ids::ED25519_PROGRAM_ID),
|
||||||
|
"expectedSignatureCount": 2,
|
||||||
|
"signatureTableInspectedByDecoder": false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
fn runtime_mutation(
|
||||||
|
input: &crate::CoreInstructionReplayInput,
|
||||||
|
successful_value: &'static str,
|
||||||
|
) -> &'static str {
|
||||||
|
return if input.transaction_failed {
|
||||||
|
"not_asserted_transaction_failed"
|
||||||
|
} else {
|
||||||
|
successful_value
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use base64::Engine; // rust-rules: trait-import
|
||||||
|
|
||||||
|
fn duplicate_payload() -> std::vec::Vec<u8> {
|
||||||
|
let mut bytes = vec![1_u8];
|
||||||
|
bytes.extend_from_slice(&34_u64.to_le_bytes());
|
||||||
|
bytes.extend_from_slice(&42_u64.to_le_bytes());
|
||||||
|
bytes.extend_from_slice(&[1_u8; 32]);
|
||||||
|
bytes.extend_from_slice(&[2_u8; 32]);
|
||||||
|
bytes.extend_from_slice(&[3_u8; 32]);
|
||||||
|
bytes.extend_from_slice(&[4_u8; 32]);
|
||||||
|
bytes.extend_from_slice(&[5_u8; 64]);
|
||||||
|
bytes.extend_from_slice(&[6_u8; 32]);
|
||||||
|
bytes.extend_from_slice(&[7_u8; 64]);
|
||||||
|
return bytes;
|
||||||
|
}
|
||||||
|
|
||||||
|
fn replay_input(
|
||||||
|
bytes: &[u8],
|
||||||
|
transaction_failed: bool,
|
||||||
|
instruction_path: &str,
|
||||||
|
) -> crate::CoreInstructionReplayInput {
|
||||||
|
let keys = [
|
||||||
|
"Proof1111111111111111111111111111111111111",
|
||||||
|
"Report111111111111111111111111111111111111",
|
||||||
|
kb_program_ids::SYSVAR_INSTRUCTIONS_PROGRAM_ID,
|
||||||
|
kb_program_ids::SYSTEM_PROGRAM_ID,
|
||||||
|
];
|
||||||
|
let account_keys = keys
|
||||||
|
.iter()
|
||||||
|
.enumerate()
|
||||||
|
.map(|(index, key)| {
|
||||||
|
return serde_json::json!({
|
||||||
|
"accountIndex": index,
|
||||||
|
"accountKey": key,
|
||||||
|
"source": "static",
|
||||||
|
"writable": index == 1,
|
||||||
|
"signer": false,
|
||||||
|
"executable": index == 3,
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.collect::<std::vec::Vec<_>>();
|
||||||
|
let instruction_accounts = keys
|
||||||
|
.iter()
|
||||||
|
.enumerate()
|
||||||
|
.map(|(index, key)| {
|
||||||
|
return serde_json::json!({"accountIndex": index, "accountKey": key});
|
||||||
|
})
|
||||||
|
.collect::<std::vec::Vec<_>>();
|
||||||
|
let current_index = instruction_path.parse::<usize>().unwrap_or(2);
|
||||||
|
let previous_index = current_index.saturating_sub(1);
|
||||||
|
let outer = serde_json::json!([
|
||||||
|
{
|
||||||
|
"instructionIndex": previous_index,
|
||||||
|
"instructionPath": previous_index.to_string(),
|
||||||
|
"programId": kb_program_ids::ED25519_PROGRAM_ID,
|
||||||
|
"payloadJson": {"dataBase64": ""},
|
||||||
|
"payloadHash": "ed25519-hash"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"instructionIndex": current_index,
|
||||||
|
"instructionPath": instruction_path,
|
||||||
|
"programId": kb_program_ids::SLASHING_PROGRAM_ID,
|
||||||
|
"payloadJson": {
|
||||||
|
"dataBase64": base64::engine::general_purpose::STANDARD.encode(bytes),
|
||||||
|
},
|
||||||
|
"payloadHash": "slashing-hash"
|
||||||
|
}
|
||||||
|
]);
|
||||||
|
let result = crate::CoreInstructionReplayInput::new(
|
||||||
|
format!("signature:{instruction_path}"),
|
||||||
|
"signature",
|
||||||
|
42,
|
||||||
|
instruction_path,
|
||||||
|
kb_program_ids::SLASHING_PROGRAM_ID,
|
||||||
|
transaction_failed,
|
||||||
|
if transaction_failed {
|
||||||
|
std::option::Option::Some(serde_json::json!({"InstructionError": [2, "Custom"]}))
|
||||||
|
} else {
|
||||||
|
std::option::Option::None
|
||||||
|
},
|
||||||
|
serde_json::Value::Array(account_keys),
|
||||||
|
serde_json::Value::Array(instruction_accounts),
|
||||||
|
std::option::Option::Some(serde_json::json!({
|
||||||
|
"dataBase64": base64::engine::general_purpose::STANDARD.encode(bytes),
|
||||||
|
})),
|
||||||
|
std::option::Option::Some("payload-hash".to_string()),
|
||||||
|
outer,
|
||||||
|
serde_json::json!([]),
|
||||||
|
serde_json::json!([]),
|
||||||
|
serde_json::json!([]),
|
||||||
|
);
|
||||||
|
return match result {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => panic!("Slashing replay input failed: {error}"),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
fn close_replay_input(transaction_failed: bool) -> crate::CoreInstructionReplayInput {
|
||||||
|
let mut input = replay_input(&[0], transaction_failed, "0");
|
||||||
|
input.account_keys_json = serde_json::json!([
|
||||||
|
{
|
||||||
|
"accountIndex": 0,
|
||||||
|
"accountKey": "Report111111111111111111111111111111111111",
|
||||||
|
"source": "static",
|
||||||
|
"writable": true,
|
||||||
|
"signer": false,
|
||||||
|
"executable": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"accountIndex": 1,
|
||||||
|
"accountKey": "Destination11111111111111111111111111111111",
|
||||||
|
"source": "static",
|
||||||
|
"writable": true,
|
||||||
|
"signer": false,
|
||||||
|
"executable": false
|
||||||
|
}
|
||||||
|
]);
|
||||||
|
input.instruction_accounts_json = serde_json::json!([
|
||||||
|
{"accountIndex": 0, "accountKey": "Report111111111111111111111111111111111111"},
|
||||||
|
{"accountIndex": 1, "accountKey": "Destination11111111111111111111111111111111"}
|
||||||
|
]);
|
||||||
|
return input;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn coverage_declares_exactly_two_official_instructions() {
|
||||||
|
let coverage = crate::solana_core_slashing_coverage();
|
||||||
|
assert_eq!(coverage.len(), 2);
|
||||||
|
assert_eq!(coverage[0].entry_code, "close_violation_report");
|
||||||
|
assert_eq!(coverage[1].entry_code, "duplicate_block_proof");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn close_violation_report_decodes_exact_runtime_contract() {
|
||||||
|
let result = crate::solana_core_slashing_decode(&close_replay_input(false));
|
||||||
|
assert_eq!(result.status, crate::DecoderOutcomeStatus::Decoded);
|
||||||
|
assert_eq!(
|
||||||
|
result.recognized_entry_code.as_deref(),
|
||||||
|
std::option::Option::Some("close_violation_report")
|
||||||
|
);
|
||||||
|
let parameters = &result.observations[0].payload_json["parameters"];
|
||||||
|
assert_eq!(parameters["minimumRetentionEpochs"], 3);
|
||||||
|
assert_eq!(
|
||||||
|
parameters["runtimeMutation"],
|
||||||
|
"violation_report_closed_lamports_transferred_owner_reset_to_system_program"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn duplicate_block_proof_decodes_exact_official_layout() {
|
||||||
|
let payload = duplicate_payload();
|
||||||
|
assert_eq!(payload.len(), super::DUPLICATE_BLOCK_PROOF_INSTRUCTION_BYTES);
|
||||||
|
let result =
|
||||||
|
crate::solana_core_slashing_decode(&replay_input(payload.as_slice(), false, "2"));
|
||||||
|
assert_eq!(result.status, crate::DecoderOutcomeStatus::Decoded);
|
||||||
|
let parameters = &result.observations[0].payload_json["parameters"];
|
||||||
|
assert_eq!(parameters["proofAccountOffset"], 34);
|
||||||
|
assert_eq!(parameters["violationSlot"], 42);
|
||||||
|
assert_eq!(parameters["shred1Signature"]["length"], 64);
|
||||||
|
assert_eq!(parameters["shred2MerkleRoot"]["length"], 32);
|
||||||
|
assert_eq!(parameters["proofAccountDataCaptured"], false);
|
||||||
|
assert_eq!(parameters["reportAccountMustBePrefunded"], true);
|
||||||
|
assert_eq!(parameters["prefundingProvenanceInspectedByDecoder"], false);
|
||||||
|
assert_eq!(parameters["violationReportPdaDerivationVerifiedByDecoder"], false);
|
||||||
|
assert_eq!(parameters["cryptographicVerificationPerformedByDecoder"], false);
|
||||||
|
assert_eq!(parameters["penaltyAppliedByProgram"], false);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn duplicate_block_proof_reports_preceding_ed25519_context() {
|
||||||
|
let result = crate::solana_core_slashing_decode(&replay_input(
|
||||||
|
duplicate_payload().as_slice(),
|
||||||
|
false,
|
||||||
|
"2",
|
||||||
|
));
|
||||||
|
let preceding =
|
||||||
|
&result.observations[0].payload_json["parameters"]["precedingEd25519Instruction"];
|
||||||
|
assert_eq!(preceding["resolvedInstructionIndex"], 1);
|
||||||
|
assert_eq!(preceding["matchesExpectedProgram"], true);
|
||||||
|
assert_eq!(preceding["expectedSignatureCount"], 2);
|
||||||
|
assert_eq!(preceding["signatureTableInspectedByDecoder"], false);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn malformed_unknown_and_trailing_payloads_fail_safely() {
|
||||||
|
for bytes in [vec![], vec![0, 7], vec![1, 2, 3]] {
|
||||||
|
let result =
|
||||||
|
crate::solana_core_slashing_decode(&replay_input(bytes.as_slice(), false, "2"));
|
||||||
|
assert_eq!(result.status, crate::DecoderOutcomeStatus::Failed);
|
||||||
|
}
|
||||||
|
let unknown = crate::solana_core_slashing_decode(&replay_input(&[9], false, "2"));
|
||||||
|
assert_eq!(unknown.status, crate::DecoderOutcomeStatus::Unsupported);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn wrong_fixed_accounts_fail_safely() {
|
||||||
|
let mut input = replay_input(duplicate_payload().as_slice(), false, "2");
|
||||||
|
input.instruction_accounts_json[2]["accountKey"] = serde_json::json!("wrong");
|
||||||
|
input.account_keys_json[2]["accountKey"] = serde_json::json!("wrong");
|
||||||
|
let result = crate::solana_core_slashing_decode(&input);
|
||||||
|
assert_eq!(result.status, crate::DecoderOutcomeStatus::Failed);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn failed_transaction_is_decoded_without_runtime_or_mutation_claim() {
|
||||||
|
let result = crate::solana_core_slashing_decode(&replay_input(
|
||||||
|
duplicate_payload().as_slice(),
|
||||||
|
true,
|
||||||
|
"2",
|
||||||
|
));
|
||||||
|
assert_eq!(result.status, crate::DecoderOutcomeStatus::Decoded);
|
||||||
|
assert!(!result.observations[0].observation_committed);
|
||||||
|
let parameters = &result.observations[0].payload_json["parameters"];
|
||||||
|
assert_eq!(parameters["runtimeVerification"], "not_asserted_transaction_failed");
|
||||||
|
assert_eq!(parameters["runtimeMutation"], "not_asserted_transaction_failed");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn serialization_is_deterministic() {
|
||||||
|
let input = replay_input(duplicate_payload().as_slice(), false, "2");
|
||||||
|
let first = crate::solana_core_slashing_decode(&input);
|
||||||
|
let second = crate::solana_core_slashing_decode(&input);
|
||||||
|
let first_json = match serde_json::to_string(&first) {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => panic!("first 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 serialization failed: {error}"),
|
||||||
|
};
|
||||||
|
assert_eq!(first_json, second_json);
|
||||||
|
}
|
||||||
|
}
|
||||||
1347
kb-lib/src/decoder/solana/core/stake.rs
Normal file
1347
kb-lib/src/decoder/solana/core/stake.rs
Normal file
File diff suppressed because it is too large
Load Diff
688
kb-lib/src/decoder/solana/core/system.rs
Normal file
688
kb-lib/src/decoder/solana/core/system.rs
Normal file
@@ -0,0 +1,688 @@
|
|||||||
|
// file: kb-lib/src/decoder/solana/core/system.rs
|
||||||
|
// version: 6
|
||||||
|
|
||||||
|
//! Maximal current System Program instruction decoding through the official interface enum and wincode schema.
|
||||||
|
|
||||||
|
const SOURCE: &str = "solana-system-interface@3.2.0 SystemInstruction wincode-compatible contract";
|
||||||
|
|
||||||
|
const CREATE_ACCOUNT_ROLES: &[crate::SolanaCoreAccountRole] = &[
|
||||||
|
crate::SolanaCoreAccountRole::new("funding_account", true, true),
|
||||||
|
crate::SolanaCoreAccountRole::new("new_account", true, true),
|
||||||
|
];
|
||||||
|
const ASSIGN_ROLES: &[crate::SolanaCoreAccountRole] =
|
||||||
|
&[crate::SolanaCoreAccountRole::new("assigned_account", true, true)];
|
||||||
|
const TRANSFER_ROLES: &[crate::SolanaCoreAccountRole] = &[
|
||||||
|
crate::SolanaCoreAccountRole::new("funding_account", true, true),
|
||||||
|
crate::SolanaCoreAccountRole::new("recipient_account", true, false),
|
||||||
|
];
|
||||||
|
const CREATE_ACCOUNT_WITH_SEED_ROLES: &[crate::SolanaCoreAccountRole] = &[
|
||||||
|
crate::SolanaCoreAccountRole::new("funding_account", true, true),
|
||||||
|
crate::SolanaCoreAccountRole::new("new_account", true, false),
|
||||||
|
crate::SolanaCoreAccountRole::new("base_account", false, true),
|
||||||
|
];
|
||||||
|
const ADVANCE_NONCE_ROLES: &[crate::SolanaCoreAccountRole] = &[
|
||||||
|
crate::SolanaCoreAccountRole::new("nonce_account", true, false),
|
||||||
|
crate::SolanaCoreAccountRole::new("recent_blockhashes_sysvar", false, false),
|
||||||
|
crate::SolanaCoreAccountRole::new("nonce_authority", false, true),
|
||||||
|
];
|
||||||
|
const WITHDRAW_NONCE_ROLES: &[crate::SolanaCoreAccountRole] = &[
|
||||||
|
crate::SolanaCoreAccountRole::new("nonce_account", true, false),
|
||||||
|
crate::SolanaCoreAccountRole::new("recipient_account", true, false),
|
||||||
|
crate::SolanaCoreAccountRole::new("recent_blockhashes_sysvar", false, false),
|
||||||
|
crate::SolanaCoreAccountRole::new("rent_sysvar", false, false),
|
||||||
|
crate::SolanaCoreAccountRole::new("nonce_authority", false, true),
|
||||||
|
];
|
||||||
|
const INITIALIZE_NONCE_ROLES: &[crate::SolanaCoreAccountRole] = &[
|
||||||
|
crate::SolanaCoreAccountRole::new("nonce_account", true, false),
|
||||||
|
crate::SolanaCoreAccountRole::new("recent_blockhashes_sysvar", false, false),
|
||||||
|
crate::SolanaCoreAccountRole::new("rent_sysvar", false, false),
|
||||||
|
];
|
||||||
|
const AUTHORIZE_NONCE_ROLES: &[crate::SolanaCoreAccountRole] = &[
|
||||||
|
crate::SolanaCoreAccountRole::new("nonce_account", true, false),
|
||||||
|
crate::SolanaCoreAccountRole::new("nonce_authority", false, true),
|
||||||
|
];
|
||||||
|
const ALLOCATE_ROLES: &[crate::SolanaCoreAccountRole] =
|
||||||
|
&[crate::SolanaCoreAccountRole::new("allocated_account", true, true)];
|
||||||
|
const SEEDED_ACCOUNT_ROLES: &[crate::SolanaCoreAccountRole] = &[
|
||||||
|
crate::SolanaCoreAccountRole::new("derived_account", true, false),
|
||||||
|
crate::SolanaCoreAccountRole::new("base_account", false, true),
|
||||||
|
];
|
||||||
|
const TRANSFER_WITH_SEED_ROLES: &[crate::SolanaCoreAccountRole] = &[
|
||||||
|
crate::SolanaCoreAccountRole::new("funding_account", true, false),
|
||||||
|
crate::SolanaCoreAccountRole::new("base_account", false, true),
|
||||||
|
crate::SolanaCoreAccountRole::new("recipient_account", true, false),
|
||||||
|
];
|
||||||
|
const UPGRADE_NONCE_ROLES: &[crate::SolanaCoreAccountRole] =
|
||||||
|
&[crate::SolanaCoreAccountRole::new("nonce_account", true, false)];
|
||||||
|
const CREATE_ACCOUNT_ALLOW_PREFUND_ROLES: &[crate::SolanaCoreAccountRole] = &[
|
||||||
|
crate::SolanaCoreAccountRole::new("new_account", true, true),
|
||||||
|
crate::SolanaCoreAccountRole::new("funding_account", true, true),
|
||||||
|
];
|
||||||
|
|
||||||
|
/// Returns declared System Program instruction coverage.
|
||||||
|
pub(crate) fn system_coverage() -> std::vec::Vec<crate::DecoderCoverageDeclaration> {
|
||||||
|
let entries = [
|
||||||
|
("create_account", 0_u32),
|
||||||
|
("assign", 1_u32),
|
||||||
|
("transfer", 2_u32),
|
||||||
|
("create_account_with_seed", 3_u32),
|
||||||
|
("advance_nonce_account", 4_u32),
|
||||||
|
("withdraw_nonce_account", 5_u32),
|
||||||
|
("initialize_nonce_account", 6_u32),
|
||||||
|
("authorize_nonce_account", 7_u32),
|
||||||
|
("allocate", 8_u32),
|
||||||
|
("allocate_with_seed", 9_u32),
|
||||||
|
("assign_with_seed", 10_u32),
|
||||||
|
("transfer_with_seed", 11_u32),
|
||||||
|
("upgrade_nonce_account", 12_u32),
|
||||||
|
("create_account_allow_prefund", 13_u32),
|
||||||
|
];
|
||||||
|
return entries
|
||||||
|
.into_iter()
|
||||||
|
.map(|(entry_code, tag)| {
|
||||||
|
return crate::DecoderCoverageDeclaration {
|
||||||
|
program_id: kb_program_ids::SYSTEM_PROGRAM_ID.to_string(),
|
||||||
|
surface_code: std::option::Option::Some(
|
||||||
|
crate::SOLANA_CORE_SYSTEM_SURFACE_CODE.to_string(),
|
||||||
|
),
|
||||||
|
entry_kind: crate::DecoderCoverageEntryKind::Instruction,
|
||||||
|
entry_code: entry_code.to_string(),
|
||||||
|
discriminator_hex: std::option::Option::Some(tag_hex(tag).to_string()),
|
||||||
|
historical: false,
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Recognizes one System Program instruction without producing an event.
|
||||||
|
pub(crate) fn system_recognize(
|
||||||
|
input: &crate::CoreInstructionReplayInput,
|
||||||
|
priority: u16,
|
||||||
|
) -> crate::DecoderRecognition {
|
||||||
|
let bytes_result = crate::solana_core_decode_instruction_data(input);
|
||||||
|
let bytes = match bytes_result {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(_error) => {
|
||||||
|
return crate::DecoderRecognition::compatible(
|
||||||
|
false,
|
||||||
|
priority,
|
||||||
|
std::option::Option::Some(crate::SOLANA_CORE_SYSTEM_SURFACE_CODE.to_string()),
|
||||||
|
std::option::Option::Some("malformed_system_instruction".to_string()),
|
||||||
|
std::option::Option::None,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
let tag_result = crate::solana_core_read_u32_le(bytes.as_slice(), 0);
|
||||||
|
let tag = match tag_result {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(_error) => {
|
||||||
|
return crate::DecoderRecognition::compatible(
|
||||||
|
false,
|
||||||
|
priority,
|
||||||
|
std::option::Option::Some(crate::SOLANA_CORE_SYSTEM_SURFACE_CODE.to_string()),
|
||||||
|
std::option::Option::Some("malformed_system_instruction".to_string()),
|
||||||
|
crate::solana_core_hexadecimal_prefix(bytes.as_slice(), bytes.len().min(4)),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
let entry_code = entry_code(tag);
|
||||||
|
return crate::DecoderRecognition::compatible(
|
||||||
|
entry_code.is_some(),
|
||||||
|
priority,
|
||||||
|
std::option::Option::Some(crate::SOLANA_CORE_SYSTEM_SURFACE_CODE.to_string()),
|
||||||
|
entry_code.map(str::to_string).or_else(|| {
|
||||||
|
return std::option::Option::Some("unknown_system_instruction".to_string());
|
||||||
|
}),
|
||||||
|
std::option::Option::Some(tag_hex(tag).to_string()),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Decodes one System Program instruction.
|
||||||
|
pub(crate) fn system_decode(
|
||||||
|
input: &crate::CoreInstructionReplayInput,
|
||||||
|
) -> crate::DecoderExecutionResult {
|
||||||
|
let bytes_result = crate::solana_core_decode_instruction_data(input);
|
||||||
|
let bytes = match bytes_result {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => {
|
||||||
|
return crate::solana_core_failed_result(
|
||||||
|
std::option::Option::Some("malformed_system_instruction"),
|
||||||
|
"system_payload_invalid",
|
||||||
|
error.to_string(),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
let tag_result = crate::solana_core_read_u32_le(bytes.as_slice(), 0);
|
||||||
|
let tag = match tag_result {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => {
|
||||||
|
return crate::solana_core_failed_result(
|
||||||
|
std::option::Option::Some("malformed_system_instruction"),
|
||||||
|
"system_tag_truncated",
|
||||||
|
error.to_string(),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
let entry_code = match entry_code(tag) {
|
||||||
|
std::option::Option::Some(value) => value,
|
||||||
|
std::option::Option::None => {
|
||||||
|
return crate::solana_core_unsupported_result(
|
||||||
|
"unknown_system_instruction",
|
||||||
|
"system_tag_unknown",
|
||||||
|
format!(
|
||||||
|
"unknown System Program instruction tag {tag}; payload_sha256={}",
|
||||||
|
crate::solana_core_payload_hash(input)
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
let instruction_result = wincode::deserialize_exact::<
|
||||||
|
solana_system_interface::instruction::SystemInstruction,
|
||||||
|
>(bytes.as_slice());
|
||||||
|
let instruction = match instruction_result {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => {
|
||||||
|
return crate::solana_core_failed_result(
|
||||||
|
std::option::Option::Some(entry_code),
|
||||||
|
"system_wincode_invalid",
|
||||||
|
format!("cannot decode exact System Program {entry_code}: {error}"),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
return decoded_instruction(input, instruction);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn decoded_instruction(
|
||||||
|
input: &crate::CoreInstructionReplayInput,
|
||||||
|
instruction: solana_system_interface::instruction::SystemInstruction,
|
||||||
|
) -> crate::DecoderExecutionResult {
|
||||||
|
return match instruction {
|
||||||
|
solana_system_interface::instruction::SystemInstruction::CreateAccount {
|
||||||
|
lamports,
|
||||||
|
space,
|
||||||
|
owner,
|
||||||
|
} => build(
|
||||||
|
input,
|
||||||
|
"create_account",
|
||||||
|
crate::EventFamily::Lifecycle,
|
||||||
|
CREATE_ACCOUNT_ROLES,
|
||||||
|
2,
|
||||||
|
serde_json::json!({"lamports": lamports, "space": space, "owner": owner.to_string()}),
|
||||||
|
),
|
||||||
|
solana_system_interface::instruction::SystemInstruction::Assign { owner } => build(
|
||||||
|
input,
|
||||||
|
"assign",
|
||||||
|
crate::EventFamily::Admin,
|
||||||
|
ASSIGN_ROLES,
|
||||||
|
1,
|
||||||
|
serde_json::json!({"owner": owner.to_string()}),
|
||||||
|
),
|
||||||
|
solana_system_interface::instruction::SystemInstruction::Transfer { lamports } => build(
|
||||||
|
input,
|
||||||
|
"transfer",
|
||||||
|
crate::EventFamily::Audit,
|
||||||
|
TRANSFER_ROLES,
|
||||||
|
2,
|
||||||
|
serde_json::json!({"lamports": lamports}),
|
||||||
|
),
|
||||||
|
solana_system_interface::instruction::SystemInstruction::CreateAccountWithSeed {
|
||||||
|
base,
|
||||||
|
seed,
|
||||||
|
lamports,
|
||||||
|
space,
|
||||||
|
owner,
|
||||||
|
} => build(
|
||||||
|
input,
|
||||||
|
"create_account_with_seed",
|
||||||
|
crate::EventFamily::Lifecycle,
|
||||||
|
CREATE_ACCOUNT_WITH_SEED_ROLES,
|
||||||
|
2,
|
||||||
|
serde_json::json!({
|
||||||
|
"base": base.to_string(),
|
||||||
|
"seed": seed,
|
||||||
|
"lamports": lamports,
|
||||||
|
"space": space,
|
||||||
|
"owner": owner.to_string(),
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
solana_system_interface::instruction::SystemInstruction::AdvanceNonceAccount => build(
|
||||||
|
input,
|
||||||
|
"advance_nonce_account",
|
||||||
|
crate::EventFamily::Lifecycle,
|
||||||
|
ADVANCE_NONCE_ROLES,
|
||||||
|
3,
|
||||||
|
serde_json::json!({}),
|
||||||
|
),
|
||||||
|
solana_system_interface::instruction::SystemInstruction::WithdrawNonceAccount(lamports) => {
|
||||||
|
build(
|
||||||
|
input,
|
||||||
|
"withdraw_nonce_account",
|
||||||
|
crate::EventFamily::Lifecycle,
|
||||||
|
WITHDRAW_NONCE_ROLES,
|
||||||
|
5,
|
||||||
|
serde_json::json!({"lamports": lamports}),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
solana_system_interface::instruction::SystemInstruction::InitializeNonceAccount(
|
||||||
|
authority,
|
||||||
|
) => build(
|
||||||
|
input,
|
||||||
|
"initialize_nonce_account",
|
||||||
|
crate::EventFamily::Lifecycle,
|
||||||
|
INITIALIZE_NONCE_ROLES,
|
||||||
|
3,
|
||||||
|
serde_json::json!({"authority": authority.to_string()}),
|
||||||
|
),
|
||||||
|
solana_system_interface::instruction::SystemInstruction::AuthorizeNonceAccount(
|
||||||
|
authority,
|
||||||
|
) => build(
|
||||||
|
input,
|
||||||
|
"authorize_nonce_account",
|
||||||
|
crate::EventFamily::Admin,
|
||||||
|
AUTHORIZE_NONCE_ROLES,
|
||||||
|
2,
|
||||||
|
serde_json::json!({"authority": authority.to_string()}),
|
||||||
|
),
|
||||||
|
solana_system_interface::instruction::SystemInstruction::Allocate { space } => build(
|
||||||
|
input,
|
||||||
|
"allocate",
|
||||||
|
crate::EventFamily::Lifecycle,
|
||||||
|
ALLOCATE_ROLES,
|
||||||
|
1,
|
||||||
|
serde_json::json!({"space": space}),
|
||||||
|
),
|
||||||
|
solana_system_interface::instruction::SystemInstruction::AllocateWithSeed {
|
||||||
|
base,
|
||||||
|
seed,
|
||||||
|
space,
|
||||||
|
owner,
|
||||||
|
} => build(
|
||||||
|
input,
|
||||||
|
"allocate_with_seed",
|
||||||
|
crate::EventFamily::Lifecycle,
|
||||||
|
SEEDED_ACCOUNT_ROLES,
|
||||||
|
2,
|
||||||
|
serde_json::json!({
|
||||||
|
"base": base.to_string(),
|
||||||
|
"seed": seed,
|
||||||
|
"space": space,
|
||||||
|
"owner": owner.to_string(),
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
solana_system_interface::instruction::SystemInstruction::AssignWithSeed {
|
||||||
|
base,
|
||||||
|
seed,
|
||||||
|
owner,
|
||||||
|
} => build(
|
||||||
|
input,
|
||||||
|
"assign_with_seed",
|
||||||
|
crate::EventFamily::Admin,
|
||||||
|
SEEDED_ACCOUNT_ROLES,
|
||||||
|
2,
|
||||||
|
serde_json::json!({
|
||||||
|
"base": base.to_string(),
|
||||||
|
"seed": seed,
|
||||||
|
"owner": owner.to_string(),
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
solana_system_interface::instruction::SystemInstruction::TransferWithSeed {
|
||||||
|
lamports,
|
||||||
|
from_seed,
|
||||||
|
from_owner,
|
||||||
|
} => build(
|
||||||
|
input,
|
||||||
|
"transfer_with_seed",
|
||||||
|
crate::EventFamily::Audit,
|
||||||
|
TRANSFER_WITH_SEED_ROLES,
|
||||||
|
3,
|
||||||
|
serde_json::json!({
|
||||||
|
"lamports": lamports,
|
||||||
|
"fromSeed": from_seed,
|
||||||
|
"fromOwner": from_owner.to_string(),
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
solana_system_interface::instruction::SystemInstruction::UpgradeNonceAccount => build(
|
||||||
|
input,
|
||||||
|
"upgrade_nonce_account",
|
||||||
|
crate::EventFamily::Lifecycle,
|
||||||
|
UPGRADE_NONCE_ROLES,
|
||||||
|
1,
|
||||||
|
serde_json::json!({}),
|
||||||
|
),
|
||||||
|
solana_system_interface::instruction::SystemInstruction::CreateAccountAllowPrefund {
|
||||||
|
lamports,
|
||||||
|
space,
|
||||||
|
owner,
|
||||||
|
} => build(
|
||||||
|
input,
|
||||||
|
"create_account_allow_prefund",
|
||||||
|
crate::EventFamily::Lifecycle,
|
||||||
|
CREATE_ACCOUNT_ALLOW_PREFUND_ROLES,
|
||||||
|
if lamports == 0 { 1 } else { 2 },
|
||||||
|
serde_json::json!({"lamports": lamports, "space": space, "owner": owner.to_string()}),
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build(
|
||||||
|
input: &crate::CoreInstructionReplayInput,
|
||||||
|
entry_code: &str,
|
||||||
|
event_family: crate::EventFamily,
|
||||||
|
roles: &[crate::SolanaCoreAccountRole],
|
||||||
|
minimum_count: usize,
|
||||||
|
parameters: serde_json::Value,
|
||||||
|
) -> crate::DecoderExecutionResult {
|
||||||
|
let accounts_result =
|
||||||
|
crate::solana_core_resolve_accounts(input, roles, minimum_count, std::option::Option::None);
|
||||||
|
let accounts = match accounts_result {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => {
|
||||||
|
return crate::solana_core_failed_result(
|
||||||
|
std::option::Option::Some(entry_code),
|
||||||
|
"system_accounts_invalid",
|
||||||
|
error.to_string(),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
return crate::solana_core_decoded_result(
|
||||||
|
input,
|
||||||
|
crate::SOLANA_CORE_SYSTEM_SURFACE_CODE,
|
||||||
|
entry_code,
|
||||||
|
event_family,
|
||||||
|
false,
|
||||||
|
accounts,
|
||||||
|
parameters,
|
||||||
|
SOURCE,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn entry_code(tag: u32) -> std::option::Option<&'static str> {
|
||||||
|
return match tag {
|
||||||
|
0 => std::option::Option::Some("create_account"),
|
||||||
|
1 => std::option::Option::Some("assign"),
|
||||||
|
2 => std::option::Option::Some("transfer"),
|
||||||
|
3 => std::option::Option::Some("create_account_with_seed"),
|
||||||
|
4 => std::option::Option::Some("advance_nonce_account"),
|
||||||
|
5 => std::option::Option::Some("withdraw_nonce_account"),
|
||||||
|
6 => std::option::Option::Some("initialize_nonce_account"),
|
||||||
|
7 => std::option::Option::Some("authorize_nonce_account"),
|
||||||
|
8 => std::option::Option::Some("allocate"),
|
||||||
|
9 => std::option::Option::Some("allocate_with_seed"),
|
||||||
|
10 => std::option::Option::Some("assign_with_seed"),
|
||||||
|
11 => std::option::Option::Some("transfer_with_seed"),
|
||||||
|
12 => std::option::Option::Some("upgrade_nonce_account"),
|
||||||
|
13 => std::option::Option::Some("create_account_allow_prefund"),
|
||||||
|
_ => std::option::Option::None,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
fn tag_hex(tag: u32) -> std::string::String {
|
||||||
|
let bytes = tag.to_le_bytes();
|
||||||
|
return format!("{:02x}{:02x}{:02x}{:02x}", bytes[0], bytes[1], bytes[2], bytes[3]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use base64::Engine; // rust-rules: trait-import
|
||||||
|
|
||||||
|
fn replay_input(
|
||||||
|
instruction: &solana_system_interface::instruction::SystemInstruction,
|
||||||
|
account_count: usize,
|
||||||
|
transaction_failed: bool,
|
||||||
|
) -> crate::CoreInstructionReplayInput {
|
||||||
|
let bytes_result = wincode::serialize(instruction);
|
||||||
|
let bytes = match bytes_result {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => {
|
||||||
|
panic!("system fixture serialization failed: {error}")
|
||||||
|
},
|
||||||
|
};
|
||||||
|
return replay_input_bytes(bytes.as_slice(), account_count, transaction_failed);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn replay_input_bytes(
|
||||||
|
bytes: &[u8],
|
||||||
|
account_count: usize,
|
||||||
|
transaction_failed: bool,
|
||||||
|
) -> crate::CoreInstructionReplayInput {
|
||||||
|
let encoded = base64::engine::general_purpose::STANDARD.encode(bytes);
|
||||||
|
let account_keys = (0..account_count)
|
||||||
|
.map(|index| {
|
||||||
|
return serde_json::json!({
|
||||||
|
"accountIndex": index,
|
||||||
|
"accountKey": format!("account{index}"),
|
||||||
|
"source": "static",
|
||||||
|
"writable": true,
|
||||||
|
"signer": true,
|
||||||
|
"executable": false,
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.collect::<std::vec::Vec<_>>();
|
||||||
|
let instruction_accounts = (0..account_count)
|
||||||
|
.map(|index| {
|
||||||
|
return serde_json::json!({
|
||||||
|
"accountIndex": index,
|
||||||
|
"accountKey": format!("account{index}"),
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.collect::<std::vec::Vec<_>>();
|
||||||
|
let result = crate::CoreInstructionReplayInput::new(
|
||||||
|
"signature:0",
|
||||||
|
"signature",
|
||||||
|
42,
|
||||||
|
"0",
|
||||||
|
kb_program_ids::SYSTEM_PROGRAM_ID,
|
||||||
|
transaction_failed,
|
||||||
|
if transaction_failed {
|
||||||
|
std::option::Option::Some(serde_json::json!({"InstructionError": [0, "Custom"]}))
|
||||||
|
} else {
|
||||||
|
std::option::Option::None
|
||||||
|
},
|
||||||
|
serde_json::Value::Array(account_keys),
|
||||||
|
serde_json::Value::Array(instruction_accounts),
|
||||||
|
std::option::Option::Some(serde_json::json!({"dataBase64": encoded})),
|
||||||
|
std::option::Option::Some("payload-hash".to_string()),
|
||||||
|
serde_json::json!([]),
|
||||||
|
serde_json::json!([]),
|
||||||
|
serde_json::json!([]),
|
||||||
|
serde_json::json!([]),
|
||||||
|
);
|
||||||
|
return match result {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => panic!("replay input failed: {error}"),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn every_official_variant_decodes() {
|
||||||
|
let address = solana_sdk_ids::system_program::id();
|
||||||
|
let fixtures = [
|
||||||
|
(
|
||||||
|
solana_system_interface::instruction::SystemInstruction::CreateAccount {
|
||||||
|
lamports: u64::MAX,
|
||||||
|
space: u64::MAX,
|
||||||
|
owner: address,
|
||||||
|
},
|
||||||
|
2,
|
||||||
|
"create_account",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
solana_system_interface::instruction::SystemInstruction::Assign { owner: address },
|
||||||
|
1,
|
||||||
|
"assign",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
solana_system_interface::instruction::SystemInstruction::Transfer {
|
||||||
|
lamports: u64::MAX,
|
||||||
|
},
|
||||||
|
2,
|
||||||
|
"transfer",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
solana_system_interface::instruction::SystemInstruction::CreateAccountWithSeed {
|
||||||
|
base: address,
|
||||||
|
seed: "seed".to_string(),
|
||||||
|
lamports: 1,
|
||||||
|
space: 2,
|
||||||
|
owner: address,
|
||||||
|
},
|
||||||
|
3,
|
||||||
|
"create_account_with_seed",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
solana_system_interface::instruction::SystemInstruction::AdvanceNonceAccount,
|
||||||
|
3,
|
||||||
|
"advance_nonce_account",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
solana_system_interface::instruction::SystemInstruction::WithdrawNonceAccount(1),
|
||||||
|
5,
|
||||||
|
"withdraw_nonce_account",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
solana_system_interface::instruction::SystemInstruction::InitializeNonceAccount(
|
||||||
|
address,
|
||||||
|
),
|
||||||
|
3,
|
||||||
|
"initialize_nonce_account",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
solana_system_interface::instruction::SystemInstruction::AuthorizeNonceAccount(
|
||||||
|
address,
|
||||||
|
),
|
||||||
|
2,
|
||||||
|
"authorize_nonce_account",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
solana_system_interface::instruction::SystemInstruction::Allocate { space: 2 },
|
||||||
|
1,
|
||||||
|
"allocate",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
solana_system_interface::instruction::SystemInstruction::AllocateWithSeed {
|
||||||
|
base: address,
|
||||||
|
seed: "seed".to_string(),
|
||||||
|
space: 2,
|
||||||
|
owner: address,
|
||||||
|
},
|
||||||
|
2,
|
||||||
|
"allocate_with_seed",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
solana_system_interface::instruction::SystemInstruction::AssignWithSeed {
|
||||||
|
base: address,
|
||||||
|
seed: "seed".to_string(),
|
||||||
|
owner: address,
|
||||||
|
},
|
||||||
|
2,
|
||||||
|
"assign_with_seed",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
solana_system_interface::instruction::SystemInstruction::TransferWithSeed {
|
||||||
|
lamports: 1,
|
||||||
|
from_seed: "seed".to_string(),
|
||||||
|
from_owner: address,
|
||||||
|
},
|
||||||
|
3,
|
||||||
|
"transfer_with_seed",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
solana_system_interface::instruction::SystemInstruction::UpgradeNonceAccount,
|
||||||
|
1,
|
||||||
|
"upgrade_nonce_account",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
solana_system_interface::instruction::SystemInstruction::CreateAccountAllowPrefund {
|
||||||
|
lamports: 1,
|
||||||
|
space: 2,
|
||||||
|
owner: address,
|
||||||
|
},
|
||||||
|
2,
|
||||||
|
"create_account_allow_prefund",
|
||||||
|
),
|
||||||
|
];
|
||||||
|
for (instruction, account_count, expected) in fixtures {
|
||||||
|
let input = replay_input(&instruction, account_count, false);
|
||||||
|
let result = crate::solana_core_system_decode(&input);
|
||||||
|
assert_eq!(result.status, crate::DecoderOutcomeStatus::Decoded);
|
||||||
|
assert_eq!(
|
||||||
|
result.recognized_entry_code.as_deref(),
|
||||||
|
std::option::Option::Some(expected)
|
||||||
|
);
|
||||||
|
assert_eq!(result.observations.len(), 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn additional_accounts_are_preserved_without_rejecting_valid_system_instructions() {
|
||||||
|
let instruction =
|
||||||
|
solana_system_interface::instruction::SystemInstruction::Transfer { lamports: 1 };
|
||||||
|
let result = crate::solana_core_system_decode(&replay_input(&instruction, 3, false));
|
||||||
|
assert_eq!(result.status, crate::DecoderOutcomeStatus::Decoded);
|
||||||
|
let accounts = match result.observations[0].payload_json["accounts"].as_array() {
|
||||||
|
std::option::Option::Some(value) => value,
|
||||||
|
std::option::Option::None => panic!("decoded System accounts are not an array"),
|
||||||
|
};
|
||||||
|
assert_eq!(accounts.len(), 3);
|
||||||
|
assert_eq!(accounts[2]["role"], "additional_account");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn missing_accounts_and_truncated_payload_fail_without_panic() {
|
||||||
|
let instruction =
|
||||||
|
solana_system_interface::instruction::SystemInstruction::Transfer { lamports: 1 };
|
||||||
|
let missing = crate::solana_core_system_decode(&replay_input(&instruction, 1, false));
|
||||||
|
assert_eq!(missing.status, crate::DecoderOutcomeStatus::Failed);
|
||||||
|
let truncated = crate::solana_core_system_decode(&replay_input_bytes(&[2, 0, 0], 2, false));
|
||||||
|
assert_eq!(truncated.status, crate::DecoderOutcomeStatus::Failed);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn unknown_tag_is_unsupported_and_hashable_for_replay() {
|
||||||
|
let result =
|
||||||
|
crate::solana_core_system_decode(&replay_input_bytes(&[255, 0, 0, 0], 0, false));
|
||||||
|
assert_eq!(result.status, crate::DecoderOutcomeStatus::Unsupported);
|
||||||
|
assert_eq!(
|
||||||
|
result.recognized_entry_code.as_deref(),
|
||||||
|
std::option::Option::Some("unknown_system_instruction")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn failed_transaction_is_decoded_as_uncommitted_intent() {
|
||||||
|
let instruction =
|
||||||
|
solana_system_interface::instruction::SystemInstruction::Transfer { lamports: 55 };
|
||||||
|
let result = crate::solana_core_system_decode(&replay_input(&instruction, 2, true));
|
||||||
|
assert_eq!(result.status, crate::DecoderOutcomeStatus::Decoded);
|
||||||
|
assert!(!result.observations[0].observation_committed);
|
||||||
|
assert_eq!(result.observations[0].payload_json["transactionSucceeded"], false);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn event_serialization_is_deterministic() {
|
||||||
|
let instruction =
|
||||||
|
solana_system_interface::instruction::SystemInstruction::Transfer { lamports: 55 };
|
||||||
|
let input = replay_input(&instruction, 2, false);
|
||||||
|
let first = crate::solana_core_system_decode(&input);
|
||||||
|
let second = crate::solana_core_system_decode(&input);
|
||||||
|
let first_json = match serde_json::to_string(&first) {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => panic!("first event 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 event serialization failed: {error}"),
|
||||||
|
};
|
||||||
|
assert_eq!(first_json, second_json);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn coverage_declares_all_current_system_variants() {
|
||||||
|
let coverage = crate::solana_core_system_coverage();
|
||||||
|
assert_eq!(coverage.len(), 14);
|
||||||
|
assert!(coverage.iter().any(|entry| {
|
||||||
|
return entry.entry_code == "create_account_allow_prefund"
|
||||||
|
&& entry.discriminator_hex.as_deref() == std::option::Option::Some("0d000000");
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
1068
kb-lib/src/decoder/solana/core/vote.rs
Normal file
1068
kb-lib/src/decoder/solana/core/vote.rs
Normal file
File diff suppressed because it is too large
Load Diff
721
kb-lib/src/decoder/solana/core/zk_elgamal.rs
Normal file
721
kb-lib/src/decoder/solana/core/zk_elgamal.rs
Normal file
@@ -0,0 +1,721 @@
|
|||||||
|
// file: kb-lib/src/decoder/solana/core/zk_elgamal.rs
|
||||||
|
// version: 4
|
||||||
|
|
||||||
|
//! Exact structural decoding for the native ZK ElGamal Proof program.
|
||||||
|
|
||||||
|
const SOURCE: &str = "solana-zk-elgamal-proof-interface@^0.1 ProofInstruction and Pod layouts; Agave native runtime proof-account mode";
|
||||||
|
const PROOF_ACCOUNT_INSTRUCTION_BYTES: usize = 5;
|
||||||
|
const PROOF_COMPONENT_PREFIX_BYTES: usize = 16;
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, Debug)]
|
||||||
|
struct ProofDescriptor {
|
||||||
|
entry_code: &'static str,
|
||||||
|
proof_data_size: usize,
|
||||||
|
context_data_size: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns declared ZK ElGamal Proof instruction coverage.
|
||||||
|
pub(crate) fn zk_elgamal_coverage() -> std::vec::Vec<crate::DecoderCoverageDeclaration> {
|
||||||
|
return instruction_entries()
|
||||||
|
.iter()
|
||||||
|
.map(|(tag, entry_code)| {
|
||||||
|
return crate::DecoderCoverageDeclaration {
|
||||||
|
program_id: kb_program_ids::ZK_ELGAMAL_PROOF_PROGRAM_ID.to_string(),
|
||||||
|
surface_code: std::option::Option::Some(
|
||||||
|
crate::SOLANA_CORE_ZK_ELGAMAL_PROOF_SURFACE_CODE.to_string(),
|
||||||
|
),
|
||||||
|
entry_kind: crate::DecoderCoverageEntryKind::Instruction,
|
||||||
|
entry_code: (*entry_code).to_string(),
|
||||||
|
discriminator_hex: std::option::Option::Some(format!("{tag:02x}")),
|
||||||
|
historical: false,
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Recognizes one native ZK ElGamal Proof instruction.
|
||||||
|
pub(crate) fn zk_elgamal_recognize(
|
||||||
|
input: &crate::CoreInstructionReplayInput,
|
||||||
|
priority: u16,
|
||||||
|
) -> crate::DecoderRecognition {
|
||||||
|
let bytes = match crate::solana_core_decode_instruction_data(input) {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(_error) => {
|
||||||
|
return crate::DecoderRecognition::compatible(
|
||||||
|
false,
|
||||||
|
priority,
|
||||||
|
std::option::Option::Some(
|
||||||
|
crate::SOLANA_CORE_ZK_ELGAMAL_PROOF_SURFACE_CODE.to_string(),
|
||||||
|
),
|
||||||
|
std::option::Option::Some("malformed_zk_elgamal_proof_instruction".to_string()),
|
||||||
|
std::option::Option::None,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
let instruction =
|
||||||
|
match solana_zk_elgamal_proof_interface::instruction::ProofInstruction::instruction_type(
|
||||||
|
bytes.as_slice(),
|
||||||
|
) {
|
||||||
|
std::option::Option::Some(value) => value,
|
||||||
|
std::option::Option::None => {
|
||||||
|
let entry_code = if bytes.is_empty() {
|
||||||
|
"malformed_zk_elgamal_proof_instruction"
|
||||||
|
} else {
|
||||||
|
"unknown_zk_elgamal_proof_instruction"
|
||||||
|
};
|
||||||
|
return crate::DecoderRecognition::compatible(
|
||||||
|
false,
|
||||||
|
priority,
|
||||||
|
std::option::Option::Some(
|
||||||
|
crate::SOLANA_CORE_ZK_ELGAMAL_PROOF_SURFACE_CODE.to_string(),
|
||||||
|
),
|
||||||
|
std::option::Option::Some(entry_code.to_string()),
|
||||||
|
crate::solana_core_hexadecimal_prefix(bytes.as_slice(), bytes.len().min(1)),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
let (entry_code, exact) = match instruction {
|
||||||
|
solana_zk_elgamal_proof_interface::instruction::ProofInstruction::CloseContextState => {
|
||||||
|
("close_context_state", true)
|
||||||
|
},
|
||||||
|
_ => {
|
||||||
|
let descriptor = proof_descriptor(instruction);
|
||||||
|
let descriptor = match descriptor {
|
||||||
|
std::option::Option::Some(value) => value,
|
||||||
|
std::option::Option::None => {
|
||||||
|
return crate::DecoderRecognition::compatible(
|
||||||
|
false,
|
||||||
|
priority,
|
||||||
|
std::option::Option::Some(
|
||||||
|
crate::SOLANA_CORE_ZK_ELGAMAL_PROOF_SURFACE_CODE.to_string(),
|
||||||
|
),
|
||||||
|
std::option::Option::Some(
|
||||||
|
"unknown_zk_elgamal_proof_instruction".to_string(),
|
||||||
|
),
|
||||||
|
crate::solana_core_hexadecimal_prefix(bytes.as_slice(), 1),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
let inline_size = descriptor.proof_data_size.saturating_add(1);
|
||||||
|
(
|
||||||
|
descriptor.entry_code,
|
||||||
|
bytes.len() == PROOF_ACCOUNT_INSTRUCTION_BYTES || bytes.len() == inline_size,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
};
|
||||||
|
return crate::DecoderRecognition::compatible(
|
||||||
|
exact,
|
||||||
|
priority,
|
||||||
|
std::option::Option::Some(crate::SOLANA_CORE_ZK_ELGAMAL_PROOF_SURFACE_CODE.to_string()),
|
||||||
|
std::option::Option::Some(entry_code.to_string()),
|
||||||
|
crate::solana_core_hexadecimal_prefix(bytes.as_slice(), 1),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Decodes one native ZK ElGamal Proof instruction without recomputing the proof.
|
||||||
|
pub(crate) fn zk_elgamal_decode(
|
||||||
|
input: &crate::CoreInstructionReplayInput,
|
||||||
|
) -> crate::DecoderExecutionResult {
|
||||||
|
let bytes = match crate::solana_core_decode_instruction_data(input) {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => {
|
||||||
|
return crate::solana_core_failed_result(
|
||||||
|
std::option::Option::Some("malformed_zk_elgamal_proof_instruction"),
|
||||||
|
"zk_elgamal_payload_invalid",
|
||||||
|
error.to_string(),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
let instruction =
|
||||||
|
match solana_zk_elgamal_proof_interface::instruction::ProofInstruction::instruction_type(
|
||||||
|
bytes.as_slice(),
|
||||||
|
) {
|
||||||
|
std::option::Option::Some(value) => value,
|
||||||
|
std::option::Option::None => {
|
||||||
|
if bytes.is_empty() {
|
||||||
|
return crate::solana_core_failed_result(
|
||||||
|
std::option::Option::Some("malformed_zk_elgamal_proof_instruction"),
|
||||||
|
"zk_elgamal_payload_empty",
|
||||||
|
"ZK ElGamal Proof instruction payload is empty",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
let tag = match bytes.first() {
|
||||||
|
std::option::Option::Some(value) => *value,
|
||||||
|
std::option::Option::None => {
|
||||||
|
return crate::solana_core_failed_result(
|
||||||
|
std::option::Option::Some("malformed_zk_elgamal_proof_instruction"),
|
||||||
|
"zk_elgamal_payload_empty",
|
||||||
|
"ZK ElGamal Proof instruction payload is empty",
|
||||||
|
);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
return crate::solana_core_unsupported_result(
|
||||||
|
"unknown_zk_elgamal_proof_instruction",
|
||||||
|
"zk_elgamal_tag_unknown",
|
||||||
|
format!(
|
||||||
|
"unknown ZK ElGamal Proof instruction tag {tag}; payload_sha256={}",
|
||||||
|
crate::solana_core_payload_hash(input)
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
if instruction
|
||||||
|
== solana_zk_elgamal_proof_interface::instruction::ProofInstruction::CloseContextState
|
||||||
|
{
|
||||||
|
return decode_close_context_state(input, bytes.as_slice());
|
||||||
|
}
|
||||||
|
let descriptor = match proof_descriptor(instruction) {
|
||||||
|
std::option::Option::Some(value) => value,
|
||||||
|
std::option::Option::None => {
|
||||||
|
return crate::solana_core_unsupported_result(
|
||||||
|
"unknown_zk_elgamal_proof_instruction",
|
||||||
|
"zk_elgamal_instruction_unmapped",
|
||||||
|
"official ZK ElGamal Proof instruction has no local descriptor",
|
||||||
|
);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
return decode_verify_proof(input, bytes.as_slice(), descriptor);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn decode_close_context_state(
|
||||||
|
input: &crate::CoreInstructionReplayInput,
|
||||||
|
bytes: &[u8],
|
||||||
|
) -> crate::DecoderExecutionResult {
|
||||||
|
let roles = [
|
||||||
|
crate::SolanaCoreAccountRole::new("proof_context_state", true, false),
|
||||||
|
crate::SolanaCoreAccountRole::new("lamport_destination", true, false),
|
||||||
|
crate::SolanaCoreAccountRole::new("context_state_authority", false, true),
|
||||||
|
];
|
||||||
|
let accounts = match crate::solana_core_resolve_accounts(
|
||||||
|
input,
|
||||||
|
roles.as_slice(),
|
||||||
|
3,
|
||||||
|
std::option::Option::None,
|
||||||
|
) {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => {
|
||||||
|
return crate::solana_core_failed_result(
|
||||||
|
std::option::Option::Some("close_context_state"),
|
||||||
|
"zk_elgamal_close_accounts_invalid",
|
||||||
|
error.to_string(),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
let account_array = match accounts.as_array() {
|
||||||
|
std::option::Option::Some(value) => value,
|
||||||
|
std::option::Option::None => {
|
||||||
|
return crate::solana_core_failed_result(
|
||||||
|
std::option::Option::Some("close_context_state"),
|
||||||
|
"zk_elgamal_close_accounts_not_array",
|
||||||
|
"resolved ZK ElGamal close accounts are not an array",
|
||||||
|
);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
let context_key = account_array
|
||||||
|
.first()
|
||||||
|
.and_then(|value| return value.get("accountKey"))
|
||||||
|
.and_then(serde_json::Value::as_str);
|
||||||
|
let destination_key = account_array
|
||||||
|
.get(1)
|
||||||
|
.and_then(|value| return value.get("accountKey"))
|
||||||
|
.and_then(serde_json::Value::as_str);
|
||||||
|
if context_key.is_some() && context_key == destination_key {
|
||||||
|
return crate::solana_core_failed_result(
|
||||||
|
std::option::Option::Some("close_context_state"),
|
||||||
|
"zk_elgamal_close_destination_matches_context",
|
||||||
|
"ZK ElGamal proof context account and lamport destination must differ",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
let trailing = match bytes.get(1..) {
|
||||||
|
std::option::Option::Some(value) => value,
|
||||||
|
std::option::Option::None => {
|
||||||
|
return crate::solana_core_failed_result(
|
||||||
|
std::option::Option::Some("close_context_state"),
|
||||||
|
"zk_elgamal_close_payload_missing_tag",
|
||||||
|
"ZK ElGamal close-context instruction has no discriminator byte",
|
||||||
|
);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
return crate::solana_core_decoded_result(
|
||||||
|
input,
|
||||||
|
crate::SOLANA_CORE_ZK_ELGAMAL_PROOF_SURFACE_CODE,
|
||||||
|
"close_context_state",
|
||||||
|
crate::EventFamily::Lifecycle,
|
||||||
|
false,
|
||||||
|
accounts,
|
||||||
|
serde_json::json!({
|
||||||
|
"instructionMode": "close_context_state",
|
||||||
|
"runtimeAcceptsTrailingBytes": true,
|
||||||
|
"trailingByteLength": trailing.len(),
|
||||||
|
"trailingSha256": crate::solana_core_hash_bytes(trailing),
|
||||||
|
"trailingPrefixHex": crate::solana_core_bounded_hexadecimal_prefix(
|
||||||
|
trailing,
|
||||||
|
PROOF_COMPONENT_PREFIX_BYTES,
|
||||||
|
),
|
||||||
|
"reclaimsLamports": true,
|
||||||
|
"resetsOwnerToSystemProgram": true,
|
||||||
|
"runtimeVerification": runtime_verification(input),
|
||||||
|
}),
|
||||||
|
SOURCE,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn decode_verify_proof(
|
||||||
|
input: &crate::CoreInstructionReplayInput,
|
||||||
|
bytes: &[u8],
|
||||||
|
descriptor: ProofDescriptor,
|
||||||
|
) -> crate::DecoderExecutionResult {
|
||||||
|
let external_proof = bytes.len() == PROOF_ACCOUNT_INSTRUCTION_BYTES;
|
||||||
|
let expected_inline_size = descriptor.proof_data_size.saturating_add(1);
|
||||||
|
if !external_proof && bytes.len() != expected_inline_size {
|
||||||
|
return crate::solana_core_failed_result(
|
||||||
|
std::option::Option::Some(descriptor.entry_code),
|
||||||
|
"zk_elgamal_proof_data_size_invalid",
|
||||||
|
format!(
|
||||||
|
"{} requires either {} bytes for proof-account mode or {} bytes for inline proof data but received {}",
|
||||||
|
descriptor.entry_code,
|
||||||
|
PROOF_ACCOUNT_INSTRUCTION_BYTES,
|
||||||
|
expected_inline_size,
|
||||||
|
bytes.len()
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
let account_count = match input.instruction_accounts_json.as_array() {
|
||||||
|
std::option::Option::Some(value) => value.len(),
|
||||||
|
std::option::Option::None => {
|
||||||
|
return crate::solana_core_failed_result(
|
||||||
|
std::option::Option::Some(descriptor.entry_code),
|
||||||
|
"zk_elgamal_accounts_not_array",
|
||||||
|
"ZK ElGamal Proof instruction accounts are not an array",
|
||||||
|
);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
let context_state_requested =
|
||||||
|
if external_proof { account_count >= 3 } else { account_count >= 2 };
|
||||||
|
let mut roles = std::vec::Vec::new();
|
||||||
|
if external_proof {
|
||||||
|
roles.push(crate::SolanaCoreAccountRole::new("proof_data_account", false, false));
|
||||||
|
}
|
||||||
|
if context_state_requested {
|
||||||
|
roles.push(crate::SolanaCoreAccountRole::new("proof_context_state", true, false));
|
||||||
|
roles.push(crate::SolanaCoreAccountRole::new("context_state_authority", false, false));
|
||||||
|
}
|
||||||
|
let minimum_accounts = if external_proof { 1 } else { 0 };
|
||||||
|
let accounts = match crate::solana_core_resolve_accounts(
|
||||||
|
input,
|
||||||
|
roles.as_slice(),
|
||||||
|
minimum_accounts,
|
||||||
|
std::option::Option::None,
|
||||||
|
) {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => {
|
||||||
|
return crate::solana_core_failed_result(
|
||||||
|
std::option::Option::Some(descriptor.entry_code),
|
||||||
|
"zk_elgamal_accounts_invalid",
|
||||||
|
error.to_string(),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
let proof_parameters = if external_proof {
|
||||||
|
let offset = match crate::solana_core_read_u32_le(bytes, 1) {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => {
|
||||||
|
return crate::solana_core_failed_result(
|
||||||
|
std::option::Option::Some(descriptor.entry_code),
|
||||||
|
"zk_elgamal_proof_account_offset_invalid",
|
||||||
|
error.to_string(),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
serde_json::json!({
|
||||||
|
"proofDataSource": "account",
|
||||||
|
"proofAccountOffset": offset,
|
||||||
|
"proofDataAvailability": "external_account_not_captured_by_transaction_core",
|
||||||
|
"proofDataByteLength": descriptor.proof_data_size,
|
||||||
|
"contextDataByteLength": descriptor.context_data_size,
|
||||||
|
"proofBodyByteLength": descriptor.proof_data_size.saturating_sub(descriptor.context_data_size),
|
||||||
|
"proofDataSha256": serde_json::Value::Null,
|
||||||
|
"contextDataSha256": serde_json::Value::Null,
|
||||||
|
"proofBodySha256": serde_json::Value::Null,
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
let proof_data = match bytes.get(1..) {
|
||||||
|
std::option::Option::Some(value) => value,
|
||||||
|
std::option::Option::None => {
|
||||||
|
return crate::solana_core_failed_result(
|
||||||
|
std::option::Option::Some(descriptor.entry_code),
|
||||||
|
"zk_elgamal_inline_proof_missing",
|
||||||
|
"inline ZK ElGamal proof data is missing",
|
||||||
|
);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
let context_data = match proof_data.get(..descriptor.context_data_size) {
|
||||||
|
std::option::Option::Some(value) => value,
|
||||||
|
std::option::Option::None => {
|
||||||
|
return crate::solana_core_failed_result(
|
||||||
|
std::option::Option::Some(descriptor.entry_code),
|
||||||
|
"zk_elgamal_inline_context_truncated",
|
||||||
|
"inline ZK ElGamal proof context data is truncated",
|
||||||
|
);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
let proof_body = match proof_data.get(descriptor.context_data_size..) {
|
||||||
|
std::option::Option::Some(value) => value,
|
||||||
|
std::option::Option::None => {
|
||||||
|
return crate::solana_core_failed_result(
|
||||||
|
std::option::Option::Some(descriptor.entry_code),
|
||||||
|
"zk_elgamal_inline_proof_body_truncated",
|
||||||
|
"inline ZK ElGamal proof body is truncated",
|
||||||
|
);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
serde_json::json!({
|
||||||
|
"proofDataSource": "instruction_data",
|
||||||
|
"proofAccountOffset": serde_json::Value::Null,
|
||||||
|
"proofDataAvailability": "inline_hashed_not_retained",
|
||||||
|
"proofDataByteLength": proof_data.len(),
|
||||||
|
"contextDataByteLength": context_data.len(),
|
||||||
|
"proofBodyByteLength": proof_body.len(),
|
||||||
|
"proofDataSha256": crate::solana_core_hash_bytes(proof_data),
|
||||||
|
"proofDataPrefixHex": crate::solana_core_bounded_hexadecimal_prefix(
|
||||||
|
proof_data,
|
||||||
|
PROOF_COMPONENT_PREFIX_BYTES,
|
||||||
|
),
|
||||||
|
"contextDataSha256": crate::solana_core_hash_bytes(context_data),
|
||||||
|
"contextDataPrefixHex": crate::solana_core_bounded_hexadecimal_prefix(
|
||||||
|
context_data,
|
||||||
|
PROOF_COMPONENT_PREFIX_BYTES,
|
||||||
|
),
|
||||||
|
"proofBodySha256": crate::solana_core_hash_bytes(proof_body),
|
||||||
|
"proofBodyPrefixHex": crate::solana_core_bounded_hexadecimal_prefix(
|
||||||
|
proof_body,
|
||||||
|
PROOF_COMPONENT_PREFIX_BYTES,
|
||||||
|
),
|
||||||
|
})
|
||||||
|
};
|
||||||
|
return crate::solana_core_decoded_result(
|
||||||
|
input,
|
||||||
|
crate::SOLANA_CORE_ZK_ELGAMAL_PROOF_SURFACE_CODE,
|
||||||
|
descriptor.entry_code,
|
||||||
|
crate::EventFamily::Audit,
|
||||||
|
false,
|
||||||
|
accounts,
|
||||||
|
serde_json::json!({
|
||||||
|
"proofType": descriptor.entry_code,
|
||||||
|
"proof": proof_parameters,
|
||||||
|
"contextStateRequested": context_state_requested,
|
||||||
|
"contextStateMutation": if context_state_requested {
|
||||||
|
"initialize_after_successful_verification"
|
||||||
|
} else {
|
||||||
|
"none"
|
||||||
|
},
|
||||||
|
"cryptographicVerificationPerformedByDecoder": false,
|
||||||
|
"runtimeVerification": runtime_verification(input),
|
||||||
|
}),
|
||||||
|
SOURCE,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn proof_descriptor(
|
||||||
|
instruction: solana_zk_elgamal_proof_interface::instruction::ProofInstruction,
|
||||||
|
) -> std::option::Option<ProofDescriptor> {
|
||||||
|
let descriptor = match instruction {
|
||||||
|
solana_zk_elgamal_proof_interface::instruction::ProofInstruction::CloseContextState => {
|
||||||
|
return std::option::Option::None;
|
||||||
|
},
|
||||||
|
solana_zk_elgamal_proof_interface::instruction::ProofInstruction::VerifyZeroCiphertext => {
|
||||||
|
ProofDescriptor {
|
||||||
|
entry_code: "verify_zero_ciphertext",
|
||||||
|
proof_data_size: std::mem::size_of::<solana_zk_elgamal_proof_interface::proof_data::ZeroCiphertextProofData>(),
|
||||||
|
context_data_size: std::mem::size_of::<solana_zk_elgamal_proof_interface::proof_data::ZeroCiphertextProofContext>(),
|
||||||
|
}
|
||||||
|
},
|
||||||
|
solana_zk_elgamal_proof_interface::instruction::ProofInstruction::VerifyCiphertextCiphertextEquality => ProofDescriptor {
|
||||||
|
entry_code: "verify_ciphertext_ciphertext_equality",
|
||||||
|
proof_data_size: std::mem::size_of::<solana_zk_elgamal_proof_interface::proof_data::CiphertextCiphertextEqualityProofData>(),
|
||||||
|
context_data_size: std::mem::size_of::<solana_zk_elgamal_proof_interface::proof_data::CiphertextCiphertextEqualityProofContext>(),
|
||||||
|
},
|
||||||
|
solana_zk_elgamal_proof_interface::instruction::ProofInstruction::VerifyCiphertextCommitmentEquality => ProofDescriptor {
|
||||||
|
entry_code: "verify_ciphertext_commitment_equality",
|
||||||
|
proof_data_size: std::mem::size_of::<solana_zk_elgamal_proof_interface::proof_data::CiphertextCommitmentEqualityProofData>(),
|
||||||
|
context_data_size: std::mem::size_of::<solana_zk_elgamal_proof_interface::proof_data::CiphertextCommitmentEqualityProofContext>(),
|
||||||
|
},
|
||||||
|
solana_zk_elgamal_proof_interface::instruction::ProofInstruction::VerifyPubkeyValidity => {
|
||||||
|
ProofDescriptor {
|
||||||
|
entry_code: "verify_pubkey_validity",
|
||||||
|
proof_data_size: std::mem::size_of::<solana_zk_elgamal_proof_interface::proof_data::PubkeyValidityProofData>(),
|
||||||
|
context_data_size: std::mem::size_of::<solana_zk_elgamal_proof_interface::proof_data::PubkeyValidityProofContext>(),
|
||||||
|
}
|
||||||
|
},
|
||||||
|
solana_zk_elgamal_proof_interface::instruction::ProofInstruction::VerifyPercentageWithCap => ProofDescriptor {
|
||||||
|
entry_code: "verify_percentage_with_cap",
|
||||||
|
proof_data_size: std::mem::size_of::<solana_zk_elgamal_proof_interface::proof_data::PercentageWithCapProofData>(),
|
||||||
|
context_data_size: std::mem::size_of::<solana_zk_elgamal_proof_interface::proof_data::PercentageWithCapProofContext>(),
|
||||||
|
},
|
||||||
|
solana_zk_elgamal_proof_interface::instruction::ProofInstruction::VerifyBatchedRangeProofU64 => ProofDescriptor {
|
||||||
|
entry_code: "verify_batched_range_proof_u64",
|
||||||
|
proof_data_size: std::mem::size_of::<solana_zk_elgamal_proof_interface::proof_data::BatchedRangeProofU64Data>(),
|
||||||
|
context_data_size: std::mem::size_of::<solana_zk_elgamal_proof_interface::proof_data::BatchedRangeProofContext>(),
|
||||||
|
},
|
||||||
|
solana_zk_elgamal_proof_interface::instruction::ProofInstruction::VerifyBatchedRangeProofU128 => ProofDescriptor {
|
||||||
|
entry_code: "verify_batched_range_proof_u128",
|
||||||
|
proof_data_size: std::mem::size_of::<solana_zk_elgamal_proof_interface::proof_data::BatchedRangeProofU128Data>(),
|
||||||
|
context_data_size: std::mem::size_of::<solana_zk_elgamal_proof_interface::proof_data::BatchedRangeProofContext>(),
|
||||||
|
},
|
||||||
|
solana_zk_elgamal_proof_interface::instruction::ProofInstruction::VerifyBatchedRangeProofU256 => ProofDescriptor {
|
||||||
|
entry_code: "verify_batched_range_proof_u256",
|
||||||
|
proof_data_size: std::mem::size_of::<solana_zk_elgamal_proof_interface::proof_data::BatchedRangeProofU256Data>(),
|
||||||
|
context_data_size: std::mem::size_of::<solana_zk_elgamal_proof_interface::proof_data::BatchedRangeProofContext>(),
|
||||||
|
},
|
||||||
|
solana_zk_elgamal_proof_interface::instruction::ProofInstruction::VerifyGroupedCiphertext2HandlesValidity => ProofDescriptor {
|
||||||
|
entry_code: "verify_grouped_ciphertext_2_handles_validity",
|
||||||
|
proof_data_size: std::mem::size_of::<solana_zk_elgamal_proof_interface::proof_data::GroupedCiphertext2HandlesValidityProofData>(),
|
||||||
|
context_data_size: std::mem::size_of::<solana_zk_elgamal_proof_interface::proof_data::GroupedCiphertext2HandlesValidityProofContext>(),
|
||||||
|
},
|
||||||
|
solana_zk_elgamal_proof_interface::instruction::ProofInstruction::VerifyBatchedGroupedCiphertext2HandlesValidity => ProofDescriptor {
|
||||||
|
entry_code: "verify_batched_grouped_ciphertext_2_handles_validity",
|
||||||
|
proof_data_size: std::mem::size_of::<solana_zk_elgamal_proof_interface::proof_data::BatchedGroupedCiphertext2HandlesValidityProofData>(),
|
||||||
|
context_data_size: std::mem::size_of::<solana_zk_elgamal_proof_interface::proof_data::BatchedGroupedCiphertext2HandlesValidityProofContext>(),
|
||||||
|
},
|
||||||
|
solana_zk_elgamal_proof_interface::instruction::ProofInstruction::VerifyGroupedCiphertext3HandlesValidity => ProofDescriptor {
|
||||||
|
entry_code: "verify_grouped_ciphertext_3_handles_validity",
|
||||||
|
proof_data_size: std::mem::size_of::<solana_zk_elgamal_proof_interface::proof_data::GroupedCiphertext3HandlesValidityProofData>(),
|
||||||
|
context_data_size: std::mem::size_of::<solana_zk_elgamal_proof_interface::proof_data::GroupedCiphertext3HandlesValidityProofContext>(),
|
||||||
|
},
|
||||||
|
solana_zk_elgamal_proof_interface::instruction::ProofInstruction::VerifyBatchedGroupedCiphertext3HandlesValidity => ProofDescriptor {
|
||||||
|
entry_code: "verify_batched_grouped_ciphertext_3_handles_validity",
|
||||||
|
proof_data_size: std::mem::size_of::<solana_zk_elgamal_proof_interface::proof_data::BatchedGroupedCiphertext3HandlesValidityProofData>(),
|
||||||
|
context_data_size: std::mem::size_of::<solana_zk_elgamal_proof_interface::proof_data::BatchedGroupedCiphertext3HandlesValidityProofContext>(),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
return std::option::Option::Some(descriptor);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn runtime_verification(input: &crate::CoreInstructionReplayInput) -> &'static str {
|
||||||
|
if input.transaction_failed {
|
||||||
|
return "not_asserted_transaction_failed";
|
||||||
|
}
|
||||||
|
return "accepted_in_successful_transaction";
|
||||||
|
}
|
||||||
|
|
||||||
|
fn instruction_entries() -> &'static [(u8, &'static str)] {
|
||||||
|
return &[
|
||||||
|
(0, "close_context_state"),
|
||||||
|
(1, "verify_zero_ciphertext"),
|
||||||
|
(2, "verify_ciphertext_ciphertext_equality"),
|
||||||
|
(3, "verify_ciphertext_commitment_equality"),
|
||||||
|
(4, "verify_pubkey_validity"),
|
||||||
|
(5, "verify_percentage_with_cap"),
|
||||||
|
(6, "verify_batched_range_proof_u64"),
|
||||||
|
(7, "verify_batched_range_proof_u128"),
|
||||||
|
(8, "verify_batched_range_proof_u256"),
|
||||||
|
(9, "verify_grouped_ciphertext_2_handles_validity"),
|
||||||
|
(10, "verify_batched_grouped_ciphertext_2_handles_validity"),
|
||||||
|
(11, "verify_grouped_ciphertext_3_handles_validity"),
|
||||||
|
(12, "verify_batched_grouped_ciphertext_3_handles_validity"),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use base64::Engine; // rust-rules: trait-import
|
||||||
|
|
||||||
|
fn replay_input(
|
||||||
|
bytes: &[u8],
|
||||||
|
account_count: usize,
|
||||||
|
transaction_failed: bool,
|
||||||
|
) -> crate::CoreInstructionReplayInput {
|
||||||
|
let account_keys = (0..account_count)
|
||||||
|
.map(|index| {
|
||||||
|
return serde_json::json!({
|
||||||
|
"accountIndex": index,
|
||||||
|
"accountKey": format!("ZkAccount{index:02}111111111111111111111111111"),
|
||||||
|
"source": "static",
|
||||||
|
"writable": true,
|
||||||
|
"signer": index == 2,
|
||||||
|
"executable": false,
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.collect::<std::vec::Vec<_>>();
|
||||||
|
let instruction_accounts = account_keys
|
||||||
|
.iter()
|
||||||
|
.map(|account| {
|
||||||
|
return serde_json::json!({
|
||||||
|
"accountIndex": account["accountIndex"],
|
||||||
|
"accountKey": account["accountKey"],
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.collect::<std::vec::Vec<_>>();
|
||||||
|
let result = crate::CoreInstructionReplayInput::new(
|
||||||
|
"signature:0",
|
||||||
|
"signature",
|
||||||
|
42,
|
||||||
|
"0",
|
||||||
|
kb_program_ids::ZK_ELGAMAL_PROOF_PROGRAM_ID,
|
||||||
|
transaction_failed,
|
||||||
|
if transaction_failed {
|
||||||
|
std::option::Option::Some(serde_json::json!({"InstructionError": [0, "Custom"]}))
|
||||||
|
} else {
|
||||||
|
std::option::Option::None
|
||||||
|
},
|
||||||
|
serde_json::Value::Array(account_keys),
|
||||||
|
serde_json::Value::Array(instruction_accounts),
|
||||||
|
std::option::Option::Some(serde_json::json!({
|
||||||
|
"dataBase64": base64::engine::general_purpose::STANDARD.encode(bytes),
|
||||||
|
})),
|
||||||
|
std::option::Option::Some("payload-hash".to_string()),
|
||||||
|
serde_json::json!([]),
|
||||||
|
serde_json::json!([]),
|
||||||
|
serde_json::json!([]),
|
||||||
|
serde_json::json!([]),
|
||||||
|
);
|
||||||
|
return match result {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => panic!("ZK ElGamal replay input failed: {error}"),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
fn inline_fixture(
|
||||||
|
tag: u8,
|
||||||
|
instruction: solana_zk_elgamal_proof_interface::instruction::ProofInstruction,
|
||||||
|
) -> std::vec::Vec<u8> {
|
||||||
|
let descriptor = match super::proof_descriptor(instruction) {
|
||||||
|
std::option::Option::Some(value) => value,
|
||||||
|
std::option::Option::None => panic!("proof descriptor missing"),
|
||||||
|
};
|
||||||
|
let mut bytes = vec![tag];
|
||||||
|
bytes.resize(descriptor.proof_data_size.saturating_add(1), tag);
|
||||||
|
return bytes;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn coverage_declares_all_thirteen_official_instructions() {
|
||||||
|
let coverage = crate::solana_core_zk_elgamal_coverage();
|
||||||
|
assert_eq!(coverage.len(), 13);
|
||||||
|
for (tag, entry_code) in super::instruction_entries() {
|
||||||
|
let expected_discriminator = format!("{tag:02x}");
|
||||||
|
assert!(coverage.iter().any(|entry| {
|
||||||
|
return entry.entry_code == *entry_code
|
||||||
|
&& entry.discriminator_hex.as_deref()
|
||||||
|
== std::option::Option::Some(expected_discriminator.as_str());
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn official_discriminants_map_to_stable_entry_codes() {
|
||||||
|
for (tag, entry_code) in super::instruction_entries() {
|
||||||
|
let recognition =
|
||||||
|
crate::solana_core_zk_elgamal_recognize(&replay_input(&[*tag], 3, false), 100);
|
||||||
|
assert!(recognition.compatible);
|
||||||
|
assert_eq!(recognition.entry_code.as_deref(), std::option::Option::Some(*entry_code));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn proof_account_mode_decodes_offset_and_context_request() {
|
||||||
|
let bytes = [1_u8, 44, 0, 0, 0];
|
||||||
|
let result = crate::solana_core_zk_elgamal_decode(&replay_input(&bytes, 3, false));
|
||||||
|
assert_eq!(result.status, crate::DecoderOutcomeStatus::Decoded);
|
||||||
|
let parameters = &result.observations[0].payload_json["parameters"];
|
||||||
|
assert_eq!(parameters["proof"]["proofDataSource"], "account");
|
||||||
|
assert_eq!(parameters["proof"]["proofAccountOffset"], 44);
|
||||||
|
assert_eq!(parameters["contextStateRequested"], true);
|
||||||
|
assert_eq!(parameters["cryptographicVerificationPerformedByDecoder"], false);
|
||||||
|
assert_eq!(parameters["runtimeVerification"], "accepted_in_successful_transaction");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn proof_account_mode_requires_the_proof_account() {
|
||||||
|
let result =
|
||||||
|
crate::solana_core_zk_elgamal_decode(&replay_input(&[1, 0, 0, 0, 0], 0, false));
|
||||||
|
assert_eq!(result.status, crate::DecoderOutcomeStatus::Failed);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn every_inline_proof_layout_is_bounded_by_official_pod_sizes() {
|
||||||
|
let variants = [
|
||||||
|
(1, solana_zk_elgamal_proof_interface::instruction::ProofInstruction::VerifyZeroCiphertext),
|
||||||
|
(2, solana_zk_elgamal_proof_interface::instruction::ProofInstruction::VerifyCiphertextCiphertextEquality),
|
||||||
|
(3, solana_zk_elgamal_proof_interface::instruction::ProofInstruction::VerifyCiphertextCommitmentEquality),
|
||||||
|
(4, solana_zk_elgamal_proof_interface::instruction::ProofInstruction::VerifyPubkeyValidity),
|
||||||
|
(5, solana_zk_elgamal_proof_interface::instruction::ProofInstruction::VerifyPercentageWithCap),
|
||||||
|
(6, solana_zk_elgamal_proof_interface::instruction::ProofInstruction::VerifyBatchedRangeProofU64),
|
||||||
|
(7, solana_zk_elgamal_proof_interface::instruction::ProofInstruction::VerifyBatchedRangeProofU128),
|
||||||
|
(8, solana_zk_elgamal_proof_interface::instruction::ProofInstruction::VerifyBatchedRangeProofU256),
|
||||||
|
(9, solana_zk_elgamal_proof_interface::instruction::ProofInstruction::VerifyGroupedCiphertext2HandlesValidity),
|
||||||
|
(10, solana_zk_elgamal_proof_interface::instruction::ProofInstruction::VerifyBatchedGroupedCiphertext2HandlesValidity),
|
||||||
|
(11, solana_zk_elgamal_proof_interface::instruction::ProofInstruction::VerifyGroupedCiphertext3HandlesValidity),
|
||||||
|
(12, solana_zk_elgamal_proof_interface::instruction::ProofInstruction::VerifyBatchedGroupedCiphertext3HandlesValidity),
|
||||||
|
];
|
||||||
|
for (tag, instruction) in variants {
|
||||||
|
let bytes = inline_fixture(tag, instruction);
|
||||||
|
let result =
|
||||||
|
crate::solana_core_zk_elgamal_decode(&replay_input(bytes.as_slice(), 0, true));
|
||||||
|
assert_eq!(result.status, crate::DecoderOutcomeStatus::Decoded);
|
||||||
|
assert_eq!(result.observations.len(), 1);
|
||||||
|
assert!(!result.observations[0].observation_committed);
|
||||||
|
assert_eq!(
|
||||||
|
result.observations[0].payload_json["parameters"]["proof"]["proofDataByteLength"],
|
||||||
|
bytes.len().saturating_sub(1)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn invalid_inline_sizes_unknown_tags_and_empty_payload_fail_safely() {
|
||||||
|
let truncated = crate::solana_core_zk_elgamal_decode(&replay_input(&[1, 2, 3], 0, false));
|
||||||
|
assert_eq!(truncated.status, crate::DecoderOutcomeStatus::Failed);
|
||||||
|
let unknown = crate::solana_core_zk_elgamal_decode(&replay_input(&[99], 0, false));
|
||||||
|
assert_eq!(unknown.status, crate::DecoderOutcomeStatus::Unsupported);
|
||||||
|
let empty = crate::solana_core_zk_elgamal_decode(&replay_input(&[], 0, false));
|
||||||
|
assert_eq!(empty.status, crate::DecoderOutcomeStatus::Failed);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn close_context_state_accepts_runtime_trailing_bytes_and_rejects_same_destination() {
|
||||||
|
let decoded = crate::solana_core_zk_elgamal_decode(&replay_input(&[0, 7, 8], 3, false));
|
||||||
|
assert_eq!(decoded.status, crate::DecoderOutcomeStatus::Decoded);
|
||||||
|
assert_eq!(decoded.observations[0].payload_json["parameters"]["trailingByteLength"], 2);
|
||||||
|
let mut same = replay_input(&[0], 3, false);
|
||||||
|
let accounts = match same.instruction_accounts_json.as_array_mut() {
|
||||||
|
std::option::Option::Some(value) => value,
|
||||||
|
std::option::Option::None => panic!("instruction accounts missing"),
|
||||||
|
};
|
||||||
|
accounts[1]["accountKey"] = accounts[0]["accountKey"].clone();
|
||||||
|
let keys = match same.account_keys_json.as_array_mut() {
|
||||||
|
std::option::Option::Some(value) => value,
|
||||||
|
std::option::Option::None => panic!("account keys missing"),
|
||||||
|
};
|
||||||
|
keys[1]["accountKey"] = keys[0]["accountKey"].clone();
|
||||||
|
let failed = crate::solana_core_zk_elgamal_decode(&same);
|
||||||
|
assert_eq!(failed.status, crate::DecoderOutcomeStatus::Failed);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn failed_transaction_never_claims_runtime_verification_or_commit() {
|
||||||
|
let result = crate::solana_core_zk_elgamal_decode(&replay_input(&[4, 0, 0, 0, 0], 1, true));
|
||||||
|
assert_eq!(result.status, crate::DecoderOutcomeStatus::Decoded);
|
||||||
|
assert!(!result.observations[0].observation_committed);
|
||||||
|
assert_eq!(
|
||||||
|
result.observations[0].payload_json["parameters"]["runtimeVerification"],
|
||||||
|
"not_asserted_transaction_failed"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn serialization_is_deterministic() {
|
||||||
|
let input = replay_input(&[1, 0, 0, 0, 0], 1, false);
|
||||||
|
let first = crate::solana_core_zk_elgamal_decode(&input);
|
||||||
|
let second = crate::solana_core_zk_elgamal_decode(&input);
|
||||||
|
let first_json = match serde_json::to_string(&first.observations[0].payload_json) {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => panic!("first serialization failed: {error}"),
|
||||||
|
};
|
||||||
|
let second_json = match serde_json::to_string(&second.observations[0].payload_json) {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => panic!("second serialization failed: {error}"),
|
||||||
|
};
|
||||||
|
assert_eq!(first_json, second_json);
|
||||||
|
}
|
||||||
|
}
|
||||||
973
kb-lib/src/decoder/solana/core/zk_token_proof.rs
Normal file
973
kb-lib/src/decoder/solana/core/zk_token_proof.rs
Normal file
@@ -0,0 +1,973 @@
|
|||||||
|
// file: kb-lib/src/decoder/solana/core/zk_token_proof.rs
|
||||||
|
// version: 4
|
||||||
|
|
||||||
|
//! Historical ZK Token Proof interface decoding with explicit current no-op runtime semantics.
|
||||||
|
|
||||||
|
const SOURCE: &str = "local bounded mirror of the historical ZK Token Proof wire contract audited against solana-zk-token-sdk 3.1.14 and Agave v2.0.0; Agave v4.1.1 no-op runtime stub";
|
||||||
|
const PROOF_ACCOUNT_INSTRUCTION_BYTES: usize = 5;
|
||||||
|
const PROOF_COMPONENT_PREFIX_BYTES: usize = 16;
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||||
|
#[repr(u8)]
|
||||||
|
enum HistoricalProofInstruction {
|
||||||
|
CloseContextState = 0,
|
||||||
|
VerifyZeroBalance = 1,
|
||||||
|
VerifyWithdraw = 2,
|
||||||
|
VerifyCiphertextCiphertextEquality = 3,
|
||||||
|
VerifyTransfer = 4,
|
||||||
|
VerifyTransferWithFee = 5,
|
||||||
|
VerifyPubkeyValidity = 6,
|
||||||
|
VerifyRangeProofU64 = 7,
|
||||||
|
VerifyBatchedRangeProofU64 = 8,
|
||||||
|
VerifyBatchedRangeProofU128 = 9,
|
||||||
|
VerifyBatchedRangeProofU256 = 10,
|
||||||
|
VerifyCiphertextCommitmentEquality = 11,
|
||||||
|
VerifyGroupedCiphertext2HandlesValidity = 12,
|
||||||
|
VerifyBatchedGroupedCiphertext2HandlesValidity = 13,
|
||||||
|
VerifyFeeSigma = 14,
|
||||||
|
VerifyGroupedCiphertext3HandlesValidity = 15,
|
||||||
|
VerifyBatchedGroupedCiphertext3HandlesValidity = 16,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl HistoricalProofInstruction {
|
||||||
|
fn from_tag(tag: u8) -> std::option::Option<Self> {
|
||||||
|
return match tag {
|
||||||
|
0 => std::option::Option::Some(Self::CloseContextState),
|
||||||
|
1 => std::option::Option::Some(Self::VerifyZeroBalance),
|
||||||
|
2 => std::option::Option::Some(Self::VerifyWithdraw),
|
||||||
|
3 => std::option::Option::Some(Self::VerifyCiphertextCiphertextEquality),
|
||||||
|
4 => std::option::Option::Some(Self::VerifyTransfer),
|
||||||
|
5 => std::option::Option::Some(Self::VerifyTransferWithFee),
|
||||||
|
6 => std::option::Option::Some(Self::VerifyPubkeyValidity),
|
||||||
|
7 => std::option::Option::Some(Self::VerifyRangeProofU64),
|
||||||
|
8 => std::option::Option::Some(Self::VerifyBatchedRangeProofU64),
|
||||||
|
9 => std::option::Option::Some(Self::VerifyBatchedRangeProofU128),
|
||||||
|
10 => std::option::Option::Some(Self::VerifyBatchedRangeProofU256),
|
||||||
|
11 => std::option::Option::Some(Self::VerifyCiphertextCommitmentEquality),
|
||||||
|
12 => std::option::Option::Some(Self::VerifyGroupedCiphertext2HandlesValidity),
|
||||||
|
13 => std::option::Option::Some(Self::VerifyBatchedGroupedCiphertext2HandlesValidity),
|
||||||
|
14 => std::option::Option::Some(Self::VerifyFeeSigma),
|
||||||
|
15 => std::option::Option::Some(Self::VerifyGroupedCiphertext3HandlesValidity),
|
||||||
|
16 => std::option::Option::Some(Self::VerifyBatchedGroupedCiphertext3HandlesValidity),
|
||||||
|
_ => std::option::Option::None,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn historical_instruction(bytes: &[u8]) -> std::option::Option<HistoricalProofInstruction> {
|
||||||
|
return bytes.first().copied().and_then(HistoricalProofInstruction::from_tag);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, Debug)]
|
||||||
|
struct ProofDescriptor {
|
||||||
|
entry_code: &'static str,
|
||||||
|
proof_data_size: usize,
|
||||||
|
context_data_size: usize,
|
||||||
|
historical_inline_availability: &'static str,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns declared historical and current ZK Token Proof coverage.
|
||||||
|
pub(crate) fn zk_token_proof_coverage() -> std::vec::Vec<crate::DecoderCoverageDeclaration> {
|
||||||
|
let mut coverage = instruction_entries()
|
||||||
|
.iter()
|
||||||
|
.map(|(tag, entry_code)| {
|
||||||
|
return crate::DecoderCoverageDeclaration {
|
||||||
|
program_id: kb_program_ids::ZK_TOKEN_PROOF_PROGRAM_ID.to_string(),
|
||||||
|
surface_code: std::option::Option::Some(
|
||||||
|
crate::SOLANA_CORE_ZK_TOKEN_PROOF_SURFACE_CODE.to_string(),
|
||||||
|
),
|
||||||
|
entry_kind: crate::DecoderCoverageEntryKind::Instruction,
|
||||||
|
entry_code: (*entry_code).to_string(),
|
||||||
|
discriminator_hex: std::option::Option::Some(format!("{tag:02x}")),
|
||||||
|
historical: true,
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.collect::<std::vec::Vec<_>>();
|
||||||
|
coverage.push(crate::DecoderCoverageDeclaration {
|
||||||
|
program_id: kb_program_ids::ZK_TOKEN_PROOF_PROGRAM_ID.to_string(),
|
||||||
|
surface_code: std::option::Option::Some(
|
||||||
|
crate::SOLANA_CORE_ZK_TOKEN_PROOF_SURFACE_CODE.to_string(),
|
||||||
|
),
|
||||||
|
entry_kind: crate::DecoderCoverageEntryKind::Instruction,
|
||||||
|
entry_code: "current_runtime_noop_invocation".to_string(),
|
||||||
|
discriminator_hex: std::option::Option::None,
|
||||||
|
historical: false,
|
||||||
|
});
|
||||||
|
return coverage;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Recognizes one historical ZK Token Proof layout or the current no-op runtime fallback.
|
||||||
|
pub(crate) fn zk_token_proof_recognize(
|
||||||
|
input: &crate::CoreInstructionReplayInput,
|
||||||
|
priority: u16,
|
||||||
|
) -> crate::DecoderRecognition {
|
||||||
|
let bytes = match crate::solana_core_decode_instruction_data(input) {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(_error) => {
|
||||||
|
return crate::DecoderRecognition::compatible(
|
||||||
|
false,
|
||||||
|
priority,
|
||||||
|
std::option::Option::Some(
|
||||||
|
crate::SOLANA_CORE_ZK_TOKEN_PROOF_SURFACE_CODE.to_string(),
|
||||||
|
),
|
||||||
|
std::option::Option::Some("malformed_zk_token_proof_instruction".to_string()),
|
||||||
|
std::option::Option::None,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
let instruction = historical_instruction(bytes.as_slice());
|
||||||
|
let entry_code = match instruction {
|
||||||
|
std::option::Option::Some(HistoricalProofInstruction::CloseContextState) => {
|
||||||
|
"close_context_state"
|
||||||
|
},
|
||||||
|
std::option::Option::Some(value) => {
|
||||||
|
let descriptor = proof_descriptor(value);
|
||||||
|
match descriptor {
|
||||||
|
std::option::Option::Some(value) => {
|
||||||
|
let inline_size = value.proof_data_size.checked_add(1);
|
||||||
|
if bytes.len() == PROOF_ACCOUNT_INSTRUCTION_BYTES
|
||||||
|
|| inline_size.is_some_and(|size| return bytes.len() == size)
|
||||||
|
{
|
||||||
|
value.entry_code
|
||||||
|
} else {
|
||||||
|
"current_runtime_noop_invocation"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
std::option::Option::None => "current_runtime_noop_invocation",
|
||||||
|
}
|
||||||
|
},
|
||||||
|
std::option::Option::None => "current_runtime_noop_invocation",
|
||||||
|
};
|
||||||
|
return crate::DecoderRecognition::compatible(
|
||||||
|
true,
|
||||||
|
priority,
|
||||||
|
std::option::Option::Some(crate::SOLANA_CORE_ZK_TOKEN_PROOF_SURFACE_CODE.to_string()),
|
||||||
|
std::option::Option::Some(entry_code.to_string()),
|
||||||
|
crate::solana_core_hexadecimal_prefix(bytes.as_slice(), bytes.len().min(1)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Decodes one historical ZK Token Proof layout without recomputing any proof.
|
||||||
|
pub(crate) fn zk_token_proof_decode(
|
||||||
|
input: &crate::CoreInstructionReplayInput,
|
||||||
|
) -> crate::DecoderExecutionResult {
|
||||||
|
let bytes = match crate::solana_core_decode_instruction_data(input) {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => {
|
||||||
|
return crate::solana_core_failed_result(
|
||||||
|
std::option::Option::Some("malformed_zk_token_proof_instruction"),
|
||||||
|
"zk_token_proof_payload_invalid",
|
||||||
|
error.to_string(),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
let instruction = historical_instruction(bytes.as_slice());
|
||||||
|
let instruction = match instruction {
|
||||||
|
std::option::Option::Some(value) => value,
|
||||||
|
std::option::Option::None => {
|
||||||
|
return decode_current_noop(
|
||||||
|
input,
|
||||||
|
bytes.as_slice(),
|
||||||
|
"empty_or_unknown_historical_discriminator",
|
||||||
|
);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
if instruction == HistoricalProofInstruction::CloseContextState {
|
||||||
|
return decode_close_context_state(input, bytes.as_slice());
|
||||||
|
}
|
||||||
|
let descriptor = match proof_descriptor(instruction) {
|
||||||
|
std::option::Option::Some(value) => value,
|
||||||
|
std::option::Option::None => {
|
||||||
|
return decode_current_noop(input, bytes.as_slice(), "unmapped_historical_instruction");
|
||||||
|
},
|
||||||
|
};
|
||||||
|
let inline_size = match descriptor.proof_data_size.checked_add(1) {
|
||||||
|
std::option::Option::Some(value) => value,
|
||||||
|
std::option::Option::None => {
|
||||||
|
return crate::solana_core_failed_result(
|
||||||
|
std::option::Option::Some(descriptor.entry_code),
|
||||||
|
"zk_token_proof_inline_size_overflow",
|
||||||
|
"historical ZK Token Proof inline size overflowed usize",
|
||||||
|
);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
if bytes.len() != PROOF_ACCOUNT_INSTRUCTION_BYTES && bytes.len() != inline_size {
|
||||||
|
return decode_current_noop(input, bytes.as_slice(), "historical_proof_size_mismatch");
|
||||||
|
}
|
||||||
|
return decode_verify_proof(input, bytes.as_slice(), descriptor);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn decode_current_noop(
|
||||||
|
input: &crate::CoreInstructionReplayInput,
|
||||||
|
bytes: &[u8],
|
||||||
|
historical_parse_status: &str,
|
||||||
|
) -> crate::DecoderExecutionResult {
|
||||||
|
let accounts =
|
||||||
|
match crate::solana_core_resolve_accounts(input, &[], 0, std::option::Option::None) {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => {
|
||||||
|
return crate::solana_core_failed_result(
|
||||||
|
std::option::Option::Some("current_runtime_noop_invocation"),
|
||||||
|
"zk_token_proof_accounts_invalid",
|
||||||
|
error.to_string(),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
return crate::solana_core_decoded_result(
|
||||||
|
input,
|
||||||
|
crate::SOLANA_CORE_ZK_TOKEN_PROOF_SURFACE_CODE,
|
||||||
|
"current_runtime_noop_invocation",
|
||||||
|
crate::EventFamily::Audit,
|
||||||
|
false,
|
||||||
|
accounts,
|
||||||
|
serde_json::json!({
|
||||||
|
"instructionMode": "opaque_current_runtime_noop",
|
||||||
|
"historicalParseStatus": historical_parse_status,
|
||||||
|
"historicalDiscriminator": bytes.first().copied(),
|
||||||
|
"payloadByteLength": bytes.len(),
|
||||||
|
"payloadSha256": crate::solana_core_hash_bytes(bytes),
|
||||||
|
"payloadPrefixHex": crate::solana_core_bounded_hexadecimal_prefix(
|
||||||
|
bytes,
|
||||||
|
PROOF_COMPONENT_PREFIX_BYTES,
|
||||||
|
),
|
||||||
|
"historicalActivationEvidence": "no_public_activation_epoch_documented_in_feature_gate_issue",
|
||||||
|
"decoderValidationBasis": "official_interface_runtime_sources_and_synthetic_fixtures",
|
||||||
|
"historicalRuntimeReference": "agave_v2_0_0_gated_verifier",
|
||||||
|
"currentRuntimeReference": "agave_v4_1_1_no_op_success_stub",
|
||||||
|
"currentRuntimeBehavior": "no_op_success_stub",
|
||||||
|
"cryptographicVerificationPerformedByDecoder": false,
|
||||||
|
"cryptographicVerificationClaim": verification_claim(input),
|
||||||
|
"runtimeExecutionOutcome": runtime_execution_outcome(input),
|
||||||
|
}),
|
||||||
|
SOURCE,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn decode_close_context_state(
|
||||||
|
input: &crate::CoreInstructionReplayInput,
|
||||||
|
bytes: &[u8],
|
||||||
|
) -> crate::DecoderExecutionResult {
|
||||||
|
let roles = [
|
||||||
|
crate::SolanaCoreAccountRole::new("proof_context_state", true, false),
|
||||||
|
crate::SolanaCoreAccountRole::new("lamport_destination", true, false),
|
||||||
|
crate::SolanaCoreAccountRole::new("context_state_authority", false, true),
|
||||||
|
];
|
||||||
|
let accounts = match crate::solana_core_resolve_accounts(
|
||||||
|
input,
|
||||||
|
roles.as_slice(),
|
||||||
|
0,
|
||||||
|
std::option::Option::None,
|
||||||
|
) {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => {
|
||||||
|
return crate::solana_core_failed_result(
|
||||||
|
std::option::Option::Some("close_context_state"),
|
||||||
|
"zk_token_proof_close_accounts_invalid",
|
||||||
|
error.to_string(),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
let account_array = match accounts.as_array() {
|
||||||
|
std::option::Option::Some(value) => value,
|
||||||
|
std::option::Option::None => {
|
||||||
|
return crate::solana_core_failed_result(
|
||||||
|
std::option::Option::Some("close_context_state"),
|
||||||
|
"zk_token_proof_close_accounts_not_array",
|
||||||
|
"resolved ZK Token Proof close accounts are not an array",
|
||||||
|
);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
let context_key = account_array
|
||||||
|
.first()
|
||||||
|
.and_then(|value| return value.get("accountKey"))
|
||||||
|
.and_then(serde_json::Value::as_str);
|
||||||
|
let destination_key = account_array
|
||||||
|
.get(1)
|
||||||
|
.and_then(|value| return value.get("accountKey"))
|
||||||
|
.and_then(serde_json::Value::as_str);
|
||||||
|
let historical_account_layout_valid = account_array.len() >= 3
|
||||||
|
&& context_key.is_some()
|
||||||
|
&& destination_key.is_some()
|
||||||
|
&& context_key != destination_key;
|
||||||
|
let trailing = match bytes.get(1..) {
|
||||||
|
std::option::Option::Some(value) => value,
|
||||||
|
std::option::Option::None => &[],
|
||||||
|
};
|
||||||
|
return crate::solana_core_decoded_result(
|
||||||
|
input,
|
||||||
|
crate::SOLANA_CORE_ZK_TOKEN_PROOF_SURFACE_CODE,
|
||||||
|
"close_context_state",
|
||||||
|
crate::EventFamily::Audit,
|
||||||
|
true,
|
||||||
|
accounts,
|
||||||
|
serde_json::json!({
|
||||||
|
"instructionMode": "historical_close_context_state",
|
||||||
|
"historicalAccountLayoutValid": historical_account_layout_valid,
|
||||||
|
"historicalRuntimeAcceptsTrailingBytes": true,
|
||||||
|
"trailingByteLength": trailing.len(),
|
||||||
|
"trailingSha256": crate::solana_core_hash_bytes(trailing),
|
||||||
|
"trailingPrefixHex": crate::solana_core_bounded_hexadecimal_prefix(
|
||||||
|
trailing,
|
||||||
|
PROOF_COMPONENT_PREFIX_BYTES,
|
||||||
|
),
|
||||||
|
"historicalIntendedMutation": "close_context_and_reclaim_lamports",
|
||||||
|
"historicalInnerInstructionSupport": true,
|
||||||
|
"historicalActivationEvidence": "no_public_activation_epoch_documented_in_feature_gate_issue",
|
||||||
|
"decoderValidationBasis": "official_interface_runtime_sources_and_synthetic_fixtures",
|
||||||
|
"historicalRuntimeReference": "agave_v2_0_0_gated_verifier",
|
||||||
|
"currentRuntimeReference": "agave_v4_1_1_no_op_success_stub",
|
||||||
|
"currentRuntimeBehavior": "no_op_success_stub",
|
||||||
|
"currentRuntimeMutation": "none",
|
||||||
|
"stateMutationClaim": "not_asserted",
|
||||||
|
"runtimeExecutionOutcome": runtime_execution_outcome(input),
|
||||||
|
}),
|
||||||
|
SOURCE,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn decode_verify_proof(
|
||||||
|
input: &crate::CoreInstructionReplayInput,
|
||||||
|
bytes: &[u8],
|
||||||
|
descriptor: ProofDescriptor,
|
||||||
|
) -> crate::DecoderExecutionResult {
|
||||||
|
let external_proof = bytes.len() == PROOF_ACCOUNT_INSTRUCTION_BYTES;
|
||||||
|
let account_count = match input.instruction_accounts_json.as_array() {
|
||||||
|
std::option::Option::Some(value) => value.len(),
|
||||||
|
std::option::Option::None => {
|
||||||
|
return crate::solana_core_failed_result(
|
||||||
|
std::option::Option::Some(descriptor.entry_code),
|
||||||
|
"zk_token_proof_accounts_not_array",
|
||||||
|
"ZK Token Proof instruction accounts are not an array",
|
||||||
|
);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
let historical_account_layout = historical_account_layout(external_proof, account_count);
|
||||||
|
let context_state_requested =
|
||||||
|
if external_proof { account_count >= 3 } else { account_count >= 2 };
|
||||||
|
let mut roles = std::vec::Vec::new();
|
||||||
|
if external_proof {
|
||||||
|
roles.push(crate::SolanaCoreAccountRole::new("proof_data_account", false, false));
|
||||||
|
}
|
||||||
|
if context_state_requested {
|
||||||
|
roles.push(crate::SolanaCoreAccountRole::new("proof_context_state", true, false));
|
||||||
|
roles.push(crate::SolanaCoreAccountRole::new("context_state_authority", false, false));
|
||||||
|
}
|
||||||
|
let accounts = match crate::solana_core_resolve_accounts(
|
||||||
|
input,
|
||||||
|
roles.as_slice(),
|
||||||
|
0,
|
||||||
|
std::option::Option::None,
|
||||||
|
) {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => {
|
||||||
|
return crate::solana_core_failed_result(
|
||||||
|
std::option::Option::Some(descriptor.entry_code),
|
||||||
|
"zk_token_proof_accounts_invalid",
|
||||||
|
error.to_string(),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
let proof_body_size = match descriptor.proof_data_size.checked_sub(descriptor.context_data_size)
|
||||||
|
{
|
||||||
|
std::option::Option::Some(value) => value,
|
||||||
|
std::option::Option::None => {
|
||||||
|
return crate::solana_core_failed_result(
|
||||||
|
std::option::Option::Some(descriptor.entry_code),
|
||||||
|
"zk_token_proof_official_size_inconsistent",
|
||||||
|
"historical ZK Token Proof context size exceeds proof data size",
|
||||||
|
);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
let proof_parameters = if external_proof {
|
||||||
|
let offset = match crate::solana_core_read_u32_le(bytes, 1) {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => {
|
||||||
|
return crate::solana_core_failed_result(
|
||||||
|
std::option::Option::Some(descriptor.entry_code),
|
||||||
|
"zk_token_proof_account_offset_invalid",
|
||||||
|
error.to_string(),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
serde_json::json!({
|
||||||
|
"proofDataSource": "account",
|
||||||
|
"proofAccountOffset": offset,
|
||||||
|
"proofDataAvailability": "external_account_not_captured_by_transaction_core",
|
||||||
|
"proofDataByteLength": descriptor.proof_data_size,
|
||||||
|
"contextDataByteLength": descriptor.context_data_size,
|
||||||
|
"proofBodyByteLength": proof_body_size,
|
||||||
|
"proofDataSha256": serde_json::Value::Null,
|
||||||
|
"contextDataSha256": serde_json::Value::Null,
|
||||||
|
"proofBodySha256": serde_json::Value::Null,
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
let proof_data = match bytes.get(1..) {
|
||||||
|
std::option::Option::Some(value) => value,
|
||||||
|
std::option::Option::None => &[],
|
||||||
|
};
|
||||||
|
let context_data = match proof_data.get(..descriptor.context_data_size) {
|
||||||
|
std::option::Option::Some(value) => value,
|
||||||
|
std::option::Option::None => {
|
||||||
|
return crate::solana_core_failed_result(
|
||||||
|
std::option::Option::Some(descriptor.entry_code),
|
||||||
|
"zk_token_proof_inline_context_truncated",
|
||||||
|
"inline historical ZK Token Proof context data is truncated",
|
||||||
|
);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
let proof_body = match proof_data.get(descriptor.context_data_size..) {
|
||||||
|
std::option::Option::Some(value) => value,
|
||||||
|
std::option::Option::None => {
|
||||||
|
return crate::solana_core_failed_result(
|
||||||
|
std::option::Option::Some(descriptor.entry_code),
|
||||||
|
"zk_token_proof_inline_body_truncated",
|
||||||
|
"inline historical ZK Token Proof body is truncated",
|
||||||
|
);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
serde_json::json!({
|
||||||
|
"proofDataSource": "instruction_data",
|
||||||
|
"proofAccountOffset": serde_json::Value::Null,
|
||||||
|
"proofDataAvailability": "inline_hashed_not_retained",
|
||||||
|
"proofDataByteLength": proof_data.len(),
|
||||||
|
"contextDataByteLength": context_data.len(),
|
||||||
|
"proofBodyByteLength": proof_body.len(),
|
||||||
|
"proofDataSha256": crate::solana_core_hash_bytes(proof_data),
|
||||||
|
"proofDataPrefixHex": crate::solana_core_bounded_hexadecimal_prefix(
|
||||||
|
proof_data,
|
||||||
|
PROOF_COMPONENT_PREFIX_BYTES,
|
||||||
|
),
|
||||||
|
"contextDataSha256": crate::solana_core_hash_bytes(context_data),
|
||||||
|
"contextDataPrefixHex": crate::solana_core_bounded_hexadecimal_prefix(
|
||||||
|
context_data,
|
||||||
|
PROOF_COMPONENT_PREFIX_BYTES,
|
||||||
|
),
|
||||||
|
"proofBodySha256": crate::solana_core_hash_bytes(proof_body),
|
||||||
|
"proofBodyPrefixHex": crate::solana_core_bounded_hexadecimal_prefix(
|
||||||
|
proof_body,
|
||||||
|
PROOF_COMPONENT_PREFIX_BYTES,
|
||||||
|
),
|
||||||
|
})
|
||||||
|
};
|
||||||
|
let historical_variant_availability = if external_proof {
|
||||||
|
"gated_by_enable_zk_proof_from_account"
|
||||||
|
} else {
|
||||||
|
descriptor.historical_inline_availability
|
||||||
|
};
|
||||||
|
return crate::solana_core_decoded_result(
|
||||||
|
input,
|
||||||
|
crate::SOLANA_CORE_ZK_TOKEN_PROOF_SURFACE_CODE,
|
||||||
|
descriptor.entry_code,
|
||||||
|
crate::EventFamily::Audit,
|
||||||
|
true,
|
||||||
|
accounts,
|
||||||
|
serde_json::json!({
|
||||||
|
"proofType": descriptor.entry_code,
|
||||||
|
"proof": proof_parameters,
|
||||||
|
"historicalAccountLayout": historical_account_layout,
|
||||||
|
"historicalAccountLayoutValid": historical_account_layout != "invalid_for_historical_interface",
|
||||||
|
"contextStateRequested": context_state_requested,
|
||||||
|
"historicalContextStateMutation": if context_state_requested {
|
||||||
|
"initialize_after_successful_verification"
|
||||||
|
} else {
|
||||||
|
"none"
|
||||||
|
},
|
||||||
|
"historicalVariantAvailability": historical_variant_availability,
|
||||||
|
"historicalInnerInstructionSupport": false,
|
||||||
|
"historicalActivationEvidence": "no_public_activation_epoch_documented_in_feature_gate_issue",
|
||||||
|
"decoderValidationBasis": "official_interface_runtime_sources_and_synthetic_fixtures",
|
||||||
|
"historicalRuntimeReference": "agave_v2_0_0_gated_verifier",
|
||||||
|
"currentRuntimeReference": "agave_v4_1_1_no_op_success_stub",
|
||||||
|
"currentRuntimeBehavior": "no_op_success_stub",
|
||||||
|
"cryptographicVerificationPerformedByDecoder": false,
|
||||||
|
"cryptographicVerificationClaim": verification_claim(input),
|
||||||
|
"runtimeExecutionOutcome": runtime_execution_outcome(input),
|
||||||
|
}),
|
||||||
|
SOURCE,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn historical_account_layout(external_proof: bool, account_count: usize) -> &'static str {
|
||||||
|
if external_proof {
|
||||||
|
return match account_count {
|
||||||
|
1 => "proof_account_without_context",
|
||||||
|
3.. => "proof_account_with_context",
|
||||||
|
_ => "invalid_for_historical_interface",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return match account_count {
|
||||||
|
0 => "inline_without_context",
|
||||||
|
2.. => "inline_with_context",
|
||||||
|
_ => "invalid_for_historical_interface",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
fn runtime_execution_outcome(input: &crate::CoreInstructionReplayInput) -> &'static str {
|
||||||
|
if input.transaction_failed {
|
||||||
|
return "not_asserted_transaction_failed";
|
||||||
|
}
|
||||||
|
return "accepted_in_successful_transaction";
|
||||||
|
}
|
||||||
|
|
||||||
|
fn verification_claim(input: &crate::CoreInstructionReplayInput) -> &'static str {
|
||||||
|
if input.transaction_failed {
|
||||||
|
return "not_asserted_transaction_failed";
|
||||||
|
}
|
||||||
|
return "not_asserted_decoder_does_not_bind_transaction_to_runtime_version";
|
||||||
|
}
|
||||||
|
|
||||||
|
fn proof_descriptor(
|
||||||
|
instruction: HistoricalProofInstruction,
|
||||||
|
) -> std::option::Option<ProofDescriptor> {
|
||||||
|
let descriptor = match instruction {
|
||||||
|
HistoricalProofInstruction::CloseContextState => {
|
||||||
|
return std::option::Option::None;
|
||||||
|
},
|
||||||
|
HistoricalProofInstruction::VerifyZeroBalance => ProofDescriptor {
|
||||||
|
entry_code: "verify_zero_balance",
|
||||||
|
proof_data_size: 192,
|
||||||
|
context_data_size: 96,
|
||||||
|
historical_inline_availability: "historical_runtime_implementation_present_activation_unconfirmed",
|
||||||
|
},
|
||||||
|
HistoricalProofInstruction::VerifyWithdraw => ProofDescriptor {
|
||||||
|
entry_code: "verify_withdraw",
|
||||||
|
proof_data_size: 992,
|
||||||
|
context_data_size: 96,
|
||||||
|
historical_inline_availability: "historical_runtime_implementation_present_activation_unconfirmed",
|
||||||
|
},
|
||||||
|
HistoricalProofInstruction::VerifyCiphertextCiphertextEquality => ProofDescriptor {
|
||||||
|
entry_code: "verify_ciphertext_ciphertext_equality",
|
||||||
|
proof_data_size: 416,
|
||||||
|
context_data_size: 192,
|
||||||
|
historical_inline_availability: "historical_runtime_implementation_present_activation_unconfirmed",
|
||||||
|
},
|
||||||
|
HistoricalProofInstruction::VerifyTransfer => ProofDescriptor {
|
||||||
|
entry_code: "verify_transfer",
|
||||||
|
proof_data_size: 1536,
|
||||||
|
context_data_size: 416,
|
||||||
|
historical_inline_availability: "historical_runtime_implementation_present_activation_unconfirmed",
|
||||||
|
},
|
||||||
|
HistoricalProofInstruction::VerifyTransferWithFee => ProofDescriptor {
|
||||||
|
entry_code: "verify_transfer_with_fee",
|
||||||
|
proof_data_size: 2282,
|
||||||
|
context_data_size: 650,
|
||||||
|
historical_inline_availability: "gated_by_enable_zk_transfer_with_fee",
|
||||||
|
},
|
||||||
|
HistoricalProofInstruction::VerifyPubkeyValidity => ProofDescriptor {
|
||||||
|
entry_code: "verify_pubkey_validity",
|
||||||
|
proof_data_size: 96,
|
||||||
|
context_data_size: 32,
|
||||||
|
historical_inline_availability: "historical_runtime_implementation_present_activation_unconfirmed",
|
||||||
|
},
|
||||||
|
HistoricalProofInstruction::VerifyRangeProofU64 => ProofDescriptor {
|
||||||
|
entry_code: "verify_range_proof_u64",
|
||||||
|
proof_data_size: 704,
|
||||||
|
context_data_size: 32,
|
||||||
|
historical_inline_availability: "historical_runtime_implementation_present_activation_unconfirmed",
|
||||||
|
},
|
||||||
|
HistoricalProofInstruction::VerifyBatchedRangeProofU64 => ProofDescriptor {
|
||||||
|
entry_code: "verify_batched_range_proof_u64",
|
||||||
|
proof_data_size: 936,
|
||||||
|
context_data_size: 264,
|
||||||
|
historical_inline_availability: "historical_runtime_implementation_present_activation_unconfirmed",
|
||||||
|
},
|
||||||
|
HistoricalProofInstruction::VerifyBatchedRangeProofU128 => ProofDescriptor {
|
||||||
|
entry_code: "verify_batched_range_proof_u128",
|
||||||
|
proof_data_size: 1000,
|
||||||
|
context_data_size: 264,
|
||||||
|
historical_inline_availability: "historical_runtime_implementation_present_activation_unconfirmed",
|
||||||
|
},
|
||||||
|
HistoricalProofInstruction::VerifyBatchedRangeProofU256 => ProofDescriptor {
|
||||||
|
entry_code: "verify_batched_range_proof_u256",
|
||||||
|
proof_data_size: 1064,
|
||||||
|
context_data_size: 264,
|
||||||
|
historical_inline_availability: "gated_by_enable_zk_transfer_with_fee",
|
||||||
|
},
|
||||||
|
HistoricalProofInstruction::VerifyCiphertextCommitmentEquality => ProofDescriptor {
|
||||||
|
entry_code: "verify_ciphertext_commitment_equality",
|
||||||
|
proof_data_size: 320,
|
||||||
|
context_data_size: 128,
|
||||||
|
historical_inline_availability: "historical_runtime_implementation_present_activation_unconfirmed",
|
||||||
|
},
|
||||||
|
HistoricalProofInstruction::VerifyGroupedCiphertext2HandlesValidity => ProofDescriptor {
|
||||||
|
entry_code: "verify_grouped_ciphertext_2_handles_validity",
|
||||||
|
proof_data_size: 320,
|
||||||
|
context_data_size: 160,
|
||||||
|
historical_inline_availability: "historical_runtime_implementation_present_activation_unconfirmed",
|
||||||
|
},
|
||||||
|
HistoricalProofInstruction::VerifyBatchedGroupedCiphertext2HandlesValidity => {
|
||||||
|
ProofDescriptor {
|
||||||
|
entry_code: "verify_batched_grouped_ciphertext_2_handles_validity",
|
||||||
|
proof_data_size: 416,
|
||||||
|
context_data_size: 256,
|
||||||
|
historical_inline_availability: "historical_runtime_implementation_present_activation_unconfirmed",
|
||||||
|
}
|
||||||
|
},
|
||||||
|
HistoricalProofInstruction::VerifyFeeSigma => ProofDescriptor {
|
||||||
|
entry_code: "verify_fee_sigma",
|
||||||
|
proof_data_size: 360,
|
||||||
|
context_data_size: 104,
|
||||||
|
historical_inline_availability: "gated_by_enable_zk_transfer_with_fee",
|
||||||
|
},
|
||||||
|
HistoricalProofInstruction::VerifyGroupedCiphertext3HandlesValidity => ProofDescriptor {
|
||||||
|
entry_code: "verify_grouped_ciphertext_3_handles_validity",
|
||||||
|
proof_data_size: 416,
|
||||||
|
context_data_size: 224,
|
||||||
|
historical_inline_availability: "historical_runtime_implementation_present_activation_unconfirmed",
|
||||||
|
},
|
||||||
|
HistoricalProofInstruction::VerifyBatchedGroupedCiphertext3HandlesValidity => {
|
||||||
|
ProofDescriptor {
|
||||||
|
entry_code: "verify_batched_grouped_ciphertext_3_handles_validity",
|
||||||
|
proof_data_size: 544,
|
||||||
|
context_data_size: 352,
|
||||||
|
historical_inline_availability: "historical_runtime_implementation_present_activation_unconfirmed",
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
return std::option::Option::Some(descriptor);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn instruction_entries() -> &'static [(u8, &'static str)] {
|
||||||
|
return &[
|
||||||
|
(0, "close_context_state"),
|
||||||
|
(1, "verify_zero_balance"),
|
||||||
|
(2, "verify_withdraw"),
|
||||||
|
(3, "verify_ciphertext_ciphertext_equality"),
|
||||||
|
(4, "verify_transfer"),
|
||||||
|
(5, "verify_transfer_with_fee"),
|
||||||
|
(6, "verify_pubkey_validity"),
|
||||||
|
(7, "verify_range_proof_u64"),
|
||||||
|
(8, "verify_batched_range_proof_u64"),
|
||||||
|
(9, "verify_batched_range_proof_u128"),
|
||||||
|
(10, "verify_batched_range_proof_u256"),
|
||||||
|
(11, "verify_ciphertext_commitment_equality"),
|
||||||
|
(12, "verify_grouped_ciphertext_2_handles_validity"),
|
||||||
|
(13, "verify_batched_grouped_ciphertext_2_handles_validity"),
|
||||||
|
(14, "verify_fee_sigma"),
|
||||||
|
(15, "verify_grouped_ciphertext_3_handles_validity"),
|
||||||
|
(16, "verify_batched_grouped_ciphertext_3_handles_validity"),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use base64::Engine; // rust-rules: trait-import
|
||||||
|
|
||||||
|
fn replay_input(
|
||||||
|
bytes: &[u8],
|
||||||
|
account_count: usize,
|
||||||
|
transaction_failed: bool,
|
||||||
|
instruction_path: &str,
|
||||||
|
) -> crate::CoreInstructionReplayInput {
|
||||||
|
let account_keys = (0..account_count)
|
||||||
|
.map(|index| {
|
||||||
|
return serde_json::json!({
|
||||||
|
"accountIndex": index,
|
||||||
|
"accountKey": format!("ZkToken{index:02}1111111111111111111111111"),
|
||||||
|
"source": "static",
|
||||||
|
"writable": index < 2,
|
||||||
|
"signer": index == 2,
|
||||||
|
"executable": false,
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.collect::<std::vec::Vec<_>>();
|
||||||
|
let instruction_accounts = account_keys
|
||||||
|
.iter()
|
||||||
|
.map(|account| {
|
||||||
|
return serde_json::json!({
|
||||||
|
"accountIndex": account["accountIndex"],
|
||||||
|
"accountKey": account["accountKey"],
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.collect::<std::vec::Vec<_>>();
|
||||||
|
let result = crate::CoreInstructionReplayInput::new(
|
||||||
|
format!("signature:{instruction_path}"),
|
||||||
|
"signature",
|
||||||
|
42,
|
||||||
|
instruction_path,
|
||||||
|
kb_program_ids::ZK_TOKEN_PROOF_PROGRAM_ID,
|
||||||
|
transaction_failed,
|
||||||
|
if transaction_failed {
|
||||||
|
std::option::Option::Some(serde_json::json!({"InstructionError": [0, "Custom"]}))
|
||||||
|
} else {
|
||||||
|
std::option::Option::None
|
||||||
|
},
|
||||||
|
serde_json::Value::Array(account_keys),
|
||||||
|
serde_json::Value::Array(instruction_accounts),
|
||||||
|
std::option::Option::Some(serde_json::json!({
|
||||||
|
"dataBase64": base64::engine::general_purpose::STANDARD.encode(bytes),
|
||||||
|
})),
|
||||||
|
std::option::Option::Some("payload-hash".to_string()),
|
||||||
|
serde_json::json!([]),
|
||||||
|
serde_json::json!([]),
|
||||||
|
serde_json::json!([]),
|
||||||
|
serde_json::json!([]),
|
||||||
|
);
|
||||||
|
return match result {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => {
|
||||||
|
panic!("ZK Token Proof replay input failed: {error}")
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
fn inline_fixture(instruction: super::HistoricalProofInstruction) -> std::vec::Vec<u8> {
|
||||||
|
let descriptor = match super::proof_descriptor(instruction) {
|
||||||
|
std::option::Option::Some(value) => value,
|
||||||
|
std::option::Option::None => panic!("proof descriptor missing"),
|
||||||
|
};
|
||||||
|
let inline_size = match descriptor.proof_data_size.checked_add(1) {
|
||||||
|
std::option::Option::Some(value) => value,
|
||||||
|
std::option::Option::None => panic!("inline fixture size overflow"),
|
||||||
|
};
|
||||||
|
let tag = instruction as u8;
|
||||||
|
let mut bytes = vec![tag];
|
||||||
|
bytes.resize(inline_size, tag);
|
||||||
|
return bytes;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn coverage_declares_seventeen_historical_entries_and_current_noop_fallback() {
|
||||||
|
let coverage = crate::solana_core_zk_token_proof_coverage();
|
||||||
|
assert_eq!(coverage.len(), 18);
|
||||||
|
assert_eq!(coverage.iter().filter(|entry| return entry.historical).count(), 17);
|
||||||
|
assert!(coverage.iter().any(|entry| {
|
||||||
|
return entry.entry_code == "current_runtime_noop_invocation"
|
||||||
|
&& !entry.historical
|
||||||
|
&& entry.discriminator_hex.is_none();
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn official_discriminants_map_to_stable_entry_codes() {
|
||||||
|
for (tag, entry_code) in super::instruction_entries() {
|
||||||
|
let bytes = if *tag == 0 { vec![*tag] } else { vec![*tag, 0, 0, 0, 0] };
|
||||||
|
let recognition = crate::solana_core_zk_token_proof_recognize(
|
||||||
|
&replay_input(bytes.as_slice(), 3, false, "0"),
|
||||||
|
100,
|
||||||
|
);
|
||||||
|
assert!(recognition.compatible);
|
||||||
|
assert_eq!(recognition.entry_code.as_deref(), std::option::Option::Some(*entry_code));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn every_historical_inline_layout_matches_the_audited_wire_table() {
|
||||||
|
let variants = [
|
||||||
|
(super::HistoricalProofInstruction::VerifyZeroBalance, 192, 96),
|
||||||
|
(super::HistoricalProofInstruction::VerifyWithdraw, 992, 96),
|
||||||
|
(super::HistoricalProofInstruction::VerifyCiphertextCiphertextEquality, 416, 192),
|
||||||
|
(super::HistoricalProofInstruction::VerifyTransfer, 1536, 416),
|
||||||
|
(super::HistoricalProofInstruction::VerifyTransferWithFee, 2282, 650),
|
||||||
|
(super::HistoricalProofInstruction::VerifyPubkeyValidity, 96, 32),
|
||||||
|
(super::HistoricalProofInstruction::VerifyRangeProofU64, 704, 32),
|
||||||
|
(super::HistoricalProofInstruction::VerifyBatchedRangeProofU64, 936, 264),
|
||||||
|
(super::HistoricalProofInstruction::VerifyBatchedRangeProofU128, 1000, 264),
|
||||||
|
(super::HistoricalProofInstruction::VerifyBatchedRangeProofU256, 1064, 264),
|
||||||
|
(super::HistoricalProofInstruction::VerifyCiphertextCommitmentEquality, 320, 128),
|
||||||
|
(
|
||||||
|
super::HistoricalProofInstruction::VerifyGroupedCiphertext2HandlesValidity,
|
||||||
|
320,
|
||||||
|
160,
|
||||||
|
),
|
||||||
|
(
|
||||||
|
super::HistoricalProofInstruction::VerifyBatchedGroupedCiphertext2HandlesValidity,
|
||||||
|
416,
|
||||||
|
256,
|
||||||
|
),
|
||||||
|
(super::HistoricalProofInstruction::VerifyFeeSigma, 360, 104),
|
||||||
|
(
|
||||||
|
super::HistoricalProofInstruction::VerifyGroupedCiphertext3HandlesValidity,
|
||||||
|
416,
|
||||||
|
224,
|
||||||
|
),
|
||||||
|
(
|
||||||
|
super::HistoricalProofInstruction::VerifyBatchedGroupedCiphertext3HandlesValidity,
|
||||||
|
544,
|
||||||
|
352,
|
||||||
|
),
|
||||||
|
];
|
||||||
|
for (instruction, proof_data_size, context_data_size) in variants {
|
||||||
|
let descriptor = match super::proof_descriptor(instruction) {
|
||||||
|
std::option::Option::Some(value) => value,
|
||||||
|
std::option::Option::None => panic!("proof descriptor missing"),
|
||||||
|
};
|
||||||
|
assert_eq!(descriptor.proof_data_size, proof_data_size);
|
||||||
|
assert_eq!(descriptor.context_data_size, context_data_size);
|
||||||
|
let bytes = inline_fixture(instruction);
|
||||||
|
let result = crate::solana_core_zk_token_proof_decode(&replay_input(
|
||||||
|
bytes.as_slice(),
|
||||||
|
0,
|
||||||
|
true,
|
||||||
|
"0",
|
||||||
|
));
|
||||||
|
assert_eq!(result.status, crate::DecoderOutcomeStatus::Decoded);
|
||||||
|
assert_eq!(result.observations.len(), 1);
|
||||||
|
assert!(!result.observations[0].observation_committed);
|
||||||
|
assert_eq!(
|
||||||
|
result.observations[0].payload_json["parameters"]["proof"]["proofDataByteLength"],
|
||||||
|
bytes.len() - 1
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn proof_account_mode_is_structured_and_historical_feature_gate_is_explicit() {
|
||||||
|
let result = crate::solana_core_zk_token_proof_decode(&replay_input(
|
||||||
|
&[1, 44, 0, 0, 0],
|
||||||
|
3,
|
||||||
|
false,
|
||||||
|
"0",
|
||||||
|
));
|
||||||
|
assert_eq!(result.status, crate::DecoderOutcomeStatus::Decoded);
|
||||||
|
let parameters = &result.observations[0].payload_json["parameters"];
|
||||||
|
assert_eq!(parameters["proof"]["proofDataSource"], "account");
|
||||||
|
assert_eq!(parameters["proof"]["proofAccountOffset"], 44);
|
||||||
|
assert_eq!(
|
||||||
|
parameters["historicalVariantAvailability"],
|
||||||
|
"gated_by_enable_zk_proof_from_account"
|
||||||
|
);
|
||||||
|
assert!(parameters["contextStateRequested"].as_bool().is_some_and(|value| return value));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn historical_transfer_fee_gates_are_explicit() {
|
||||||
|
let variants = [
|
||||||
|
super::HistoricalProofInstruction::VerifyTransferWithFee,
|
||||||
|
super::HistoricalProofInstruction::VerifyBatchedRangeProofU256,
|
||||||
|
super::HistoricalProofInstruction::VerifyFeeSigma,
|
||||||
|
];
|
||||||
|
for instruction in variants {
|
||||||
|
let bytes = inline_fixture(instruction);
|
||||||
|
let result = crate::solana_core_zk_token_proof_decode(&replay_input(
|
||||||
|
bytes.as_slice(),
|
||||||
|
0,
|
||||||
|
false,
|
||||||
|
"0",
|
||||||
|
));
|
||||||
|
assert_eq!(result.status, crate::DecoderOutcomeStatus::Decoded);
|
||||||
|
assert_eq!(
|
||||||
|
result.observations[0].payload_json["parameters"]["historicalVariantAvailability"],
|
||||||
|
"gated_by_enable_zk_transfer_with_fee"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn malformed_historical_shapes_decode_as_current_noop_instead_of_false_failure() {
|
||||||
|
let fixtures: &[&[u8]] = &[&[], &[99], &[1, 2, 3]];
|
||||||
|
for bytes in fixtures {
|
||||||
|
let result =
|
||||||
|
crate::solana_core_zk_token_proof_decode(&replay_input(bytes, 1, false, "0"));
|
||||||
|
assert_eq!(result.status, crate::DecoderOutcomeStatus::Decoded);
|
||||||
|
assert_eq!(
|
||||||
|
result.recognized_entry_code.as_deref(),
|
||||||
|
std::option::Option::Some("current_runtime_noop_invocation")
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
result.observations[0].payload_json["parameters"]["currentRuntimeReference"],
|
||||||
|
"agave_v4_1_1_no_op_success_stub"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
result.observations[0].payload_json["parameters"]["currentRuntimeBehavior"],
|
||||||
|
"no_op_success_stub"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn historical_account_arity_is_reported_without_overriding_current_noop_acceptance() {
|
||||||
|
let inline = inline_fixture(super::HistoricalProofInstruction::VerifyZeroBalance);
|
||||||
|
let inline_result = crate::solana_core_zk_token_proof_decode(&replay_input(
|
||||||
|
inline.as_slice(),
|
||||||
|
1,
|
||||||
|
false,
|
||||||
|
"0",
|
||||||
|
));
|
||||||
|
assert_eq!(inline_result.status, crate::DecoderOutcomeStatus::Decoded);
|
||||||
|
assert!(inline_result.observations[0].payload_json["parameters"]["historicalAccountLayoutValid"]
|
||||||
|
.as_bool()
|
||||||
|
.is_some_and(|value| return !value));
|
||||||
|
let account_result = crate::solana_core_zk_token_proof_decode(&replay_input(
|
||||||
|
&[1, 0, 0, 0, 0],
|
||||||
|
2,
|
||||||
|
false,
|
||||||
|
"0",
|
||||||
|
));
|
||||||
|
assert_eq!(account_result.status, crate::DecoderOutcomeStatus::Decoded);
|
||||||
|
assert!(account_result.observations[0].payload_json["parameters"]["historicalAccountLayoutValid"]
|
||||||
|
.as_bool()
|
||||||
|
.is_some_and(|value| return !value));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn close_context_state_preserves_historical_intent_without_claiming_mutation() {
|
||||||
|
let result =
|
||||||
|
crate::solana_core_zk_token_proof_decode(&replay_input(&[0, 7, 8], 3, false, "0"));
|
||||||
|
assert_eq!(result.status, crate::DecoderOutcomeStatus::Decoded);
|
||||||
|
let parameters = &result.observations[0].payload_json["parameters"];
|
||||||
|
assert!(
|
||||||
|
parameters["historicalAccountLayoutValid"]
|
||||||
|
.as_bool()
|
||||||
|
.is_some_and(|value| return value)
|
||||||
|
);
|
||||||
|
assert_eq!(parameters["trailingByteLength"], 2);
|
||||||
|
assert!(
|
||||||
|
parameters["historicalInnerInstructionSupport"]
|
||||||
|
.as_bool()
|
||||||
|
.is_some_and(|value| return value)
|
||||||
|
);
|
||||||
|
assert_eq!(parameters["currentRuntimeMutation"], "none");
|
||||||
|
assert_eq!(parameters["stateMutationClaim"], "not_asserted");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn inner_invocation_never_claims_historical_verification() {
|
||||||
|
let bytes = inline_fixture(super::HistoricalProofInstruction::VerifyPubkeyValidity);
|
||||||
|
let result = crate::solana_core_zk_token_proof_decode(&replay_input(
|
||||||
|
bytes.as_slice(),
|
||||||
|
0,
|
||||||
|
false,
|
||||||
|
"0/1",
|
||||||
|
));
|
||||||
|
assert_eq!(result.status, crate::DecoderOutcomeStatus::Decoded);
|
||||||
|
assert!(
|
||||||
|
result.observations[0].payload_json["parameters"]["historicalInnerInstructionSupport"]
|
||||||
|
.as_bool()
|
||||||
|
.is_some_and(|value| return !value)
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
result.observations[0].payload_json["parameters"]["cryptographicVerificationClaim"],
|
||||||
|
"not_asserted_decoder_does_not_bind_transaction_to_runtime_version"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn failed_transaction_is_non_committed_and_never_claims_verification() {
|
||||||
|
let bytes = inline_fixture(super::HistoricalProofInstruction::VerifyZeroBalance);
|
||||||
|
let result =
|
||||||
|
crate::solana_core_zk_token_proof_decode(&replay_input(bytes.as_slice(), 0, true, "0"));
|
||||||
|
assert_eq!(result.status, crate::DecoderOutcomeStatus::Decoded);
|
||||||
|
assert!(!result.observations[0].observation_committed);
|
||||||
|
assert_eq!(
|
||||||
|
result.observations[0].payload_json["parameters"]["cryptographicVerificationClaim"],
|
||||||
|
"not_asserted_transaction_failed"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn serialization_is_deterministic() {
|
||||||
|
let input = replay_input(&[1, 0, 0, 0, 0], 1, false, "0");
|
||||||
|
let first = crate::solana_core_zk_token_proof_decode(&input);
|
||||||
|
let second = crate::solana_core_zk_token_proof_decode(&input);
|
||||||
|
let first_json = match serde_json::to_string(&first.observations[0].payload_json) {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => panic!("first serialization failed: {error}"),
|
||||||
|
};
|
||||||
|
let second_json = match serde_json::to_string(&second.observations[0].payload_json) {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => panic!("second serialization failed: {error}"),
|
||||||
|
};
|
||||||
|
assert_eq!(first_json, second_json);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
// file: kb-lib/src/lib.rs
|
// file: kb-lib/src/lib.rs
|
||||||
// version: 3
|
// version: 6
|
||||||
|
|
||||||
//! Consolidated decoder, executor, materializer and shared model library.
|
//! Consolidated decoder, executor, materializer and shared model library.
|
||||||
#![warn(missing_docs)]
|
#![warn(missing_docs)]
|
||||||
@@ -11,10 +11,179 @@ pub mod executor;
|
|||||||
pub mod materializer;
|
pub mod materializer;
|
||||||
pub mod model;
|
pub mod model;
|
||||||
|
|
||||||
/// Canonical tracing target for the consolidated library.
|
/// Stable Address Lookup Table surface code.
|
||||||
pub(crate) use crate::decoder::api::constants::TRACING_TARGET;
|
pub(crate) use crate::decoder::solana::SOLANA_CORE_ADDRESS_LOOKUP_TABLE_SURFACE_CODE;
|
||||||
|
/// Stable deprecated immutable BPF Loader surface code.
|
||||||
|
pub(crate) use crate::decoder::solana::SOLANA_CORE_BPF_LOADER_DEPRECATED_SURFACE_CODE;
|
||||||
|
/// Stable immutable BPF Loader v2 surface code.
|
||||||
|
pub(crate) use crate::decoder::solana::SOLANA_CORE_BPF_LOADER_SURFACE_CODE;
|
||||||
|
/// Stable upgradeable BPF Loader surface code.
|
||||||
|
pub(crate) use crate::decoder::solana::SOLANA_CORE_BPF_LOADER_UPGRADEABLE_SURFACE_CODE;
|
||||||
|
/// Stable Compute Budget surface code.
|
||||||
|
pub(crate) use crate::decoder::solana::SOLANA_CORE_COMPUTE_BUDGET_SURFACE_CODE;
|
||||||
|
/// Stable Config Program surface code.
|
||||||
|
pub(crate) use crate::decoder::solana::SOLANA_CORE_CONFIG_SURFACE_CODE;
|
||||||
|
/// Byte length of an Ed25519 public key.
|
||||||
|
pub(crate) use crate::decoder::solana::SOLANA_CORE_ED25519_PUBLIC_KEY_BYTES;
|
||||||
|
/// Stable Ed25519 signature precompile surface code.
|
||||||
|
pub(crate) use crate::decoder::solana::SOLANA_CORE_ED25519_SURFACE_CODE;
|
||||||
|
/// Stable Feature Gate surface code.
|
||||||
|
pub(crate) use crate::decoder::solana::SOLANA_CORE_FEATURE_SURFACE_CODE;
|
||||||
|
/// Stable Loader v4 surface code.
|
||||||
|
pub(crate) use crate::decoder::solana::SOLANA_CORE_LOADER_V4_SURFACE_CODE;
|
||||||
|
/// Maximum retained native instruction payload accepted by the first decoder phase.
|
||||||
|
pub(crate) use crate::decoder::solana::SOLANA_CORE_MAX_NATIVE_INSTRUCTION_PAYLOAD_BYTES;
|
||||||
|
/// Current native event payload contract version.
|
||||||
|
pub(crate) use crate::decoder::solana::SOLANA_CORE_NATIVE_EVENT_VERSION;
|
||||||
|
/// Stable Native Loader surface code.
|
||||||
|
pub(crate) use crate::decoder::solana::SOLANA_CORE_NATIVE_LOADER_SURFACE_CODE;
|
||||||
|
/// Maximum number of component bytes retained as a hexadecimal event prefix.
|
||||||
|
pub(crate) use crate::decoder::solana::SOLANA_CORE_PRECOMPILE_COMPONENT_PREFIX_BYTES;
|
||||||
|
/// Expected role and privileges for one positional instruction account.
|
||||||
|
pub(crate) use crate::decoder::solana::SolanaCoreAccountRole;
|
||||||
|
/// Returns declared Address Lookup Table instruction coverage.
|
||||||
|
pub(crate) use crate::decoder::solana::solana_core_address_lookup_table_coverage;
|
||||||
|
/// Decodes one Address Lookup Table instruction.
|
||||||
|
pub(crate) use crate::decoder::solana::solana_core_address_lookup_table_decode;
|
||||||
|
/// Recognizes one Address Lookup Table instruction without producing an event.
|
||||||
|
pub(crate) use crate::decoder::solana::solana_core_address_lookup_table_recognize;
|
||||||
|
/// Returns declared Compute Budget instruction coverage.
|
||||||
|
pub(crate) use crate::decoder::solana::solana_core_compute_budget_coverage;
|
||||||
|
/// Decodes one Compute Budget instruction.
|
||||||
|
pub(crate) use crate::decoder::solana::solana_core_compute_budget_decode;
|
||||||
|
/// Recognizes one Compute Budget instruction without producing an event.
|
||||||
|
pub(crate) use crate::decoder::solana::solana_core_compute_budget_recognize;
|
||||||
|
/// Returns declared Config Program instruction coverage.
|
||||||
|
pub(crate) use crate::decoder::solana::solana_core_config_coverage;
|
||||||
|
/// Decodes one generic Config Program store instruction.
|
||||||
|
pub(crate) use crate::decoder::solana::solana_core_config_decode;
|
||||||
|
/// Recognizes one generic Config Program store instruction.
|
||||||
|
pub(crate) use crate::decoder::solana::solana_core_config_recognize;
|
||||||
|
/// Resolves positional instruction accounts and validates their core indexes.
|
||||||
|
pub(crate) use crate::decoder::solana::solana_core_resolve_accounts;
|
||||||
|
|
||||||
|
/// Stable protocol code shared by native Solana events.
|
||||||
|
pub(crate) use crate::decoder::solana::SOLANA_CORE_PROTOCOL_CODE;
|
||||||
|
/// Byte length of a secp256k1 Ethereum address.
|
||||||
|
pub(crate) use crate::decoder::solana::SOLANA_CORE_SECP256K1_ETHEREUM_ADDRESS_BYTES;
|
||||||
|
/// Stable secp256k1 signature precompile surface code.
|
||||||
|
pub(crate) use crate::decoder::solana::SOLANA_CORE_SECP256K1_SURFACE_CODE;
|
||||||
|
/// Runtime maximum number of secp256r1 signatures in one precompile instruction.
|
||||||
|
pub(crate) use crate::decoder::solana::SOLANA_CORE_SECP256R1_MAX_SIGNATURES;
|
||||||
|
/// Byte length of a compressed secp256r1 public key.
|
||||||
|
pub(crate) use crate::decoder::solana::SOLANA_CORE_SECP256R1_PUBLIC_KEY_BYTES;
|
||||||
|
/// Stable secp256r1 signature precompile surface code.
|
||||||
|
pub(crate) use crate::decoder::solana::SOLANA_CORE_SECP256R1_SURFACE_CODE;
|
||||||
|
/// Byte length shared by compact Ed25519, secp256k1 and secp256r1 signatures.
|
||||||
|
pub(crate) use crate::decoder::solana::SOLANA_CORE_SIGNATURE_BYTES;
|
||||||
|
/// Stable Slashing Program decoder surface code.
|
||||||
|
pub(crate) use crate::decoder::solana::SOLANA_CORE_SLASHING_SURFACE_CODE;
|
||||||
|
/// Stable Stake Program surface code.
|
||||||
|
pub(crate) use crate::decoder::solana::SOLANA_CORE_STAKE_SURFACE_CODE;
|
||||||
|
/// Stable System Program surface code.
|
||||||
|
pub(crate) use crate::decoder::solana::SOLANA_CORE_SYSTEM_SURFACE_CODE;
|
||||||
/// Canonical tracing target for this crate.
|
/// Canonical tracing target for this crate.
|
||||||
|
pub(crate) use crate::decoder::solana::SOLANA_CORE_TRACING_TARGET;
|
||||||
|
/// Byte length of one secp256k1 offsets entry.
|
||||||
|
pub(crate) use crate::decoder::solana::SOLANA_CORE_U8_OFFSETS_BYTES;
|
||||||
|
/// Byte length of one Ed25519 or secp256r1 offsets entry.
|
||||||
|
pub(crate) use crate::decoder::solana::SOLANA_CORE_U16_OFFSETS_BYTES;
|
||||||
|
/// Stable Vote Program surface code.
|
||||||
|
pub(crate) use crate::decoder::solana::SOLANA_CORE_VOTE_SURFACE_CODE;
|
||||||
|
/// Stable native ZK ElGamal Proof surface code.
|
||||||
|
pub(crate) use crate::decoder::solana::SOLANA_CORE_ZK_ELGAMAL_PROOF_SURFACE_CODE;
|
||||||
|
/// Stable historical ZK Token Proof surface code.
|
||||||
|
pub(crate) use crate::decoder::solana::SOLANA_CORE_ZK_TOKEN_PROOF_SURFACE_CODE;
|
||||||
|
/// One resolved outer instruction payload and its provenance relative to the target instruction.
|
||||||
|
pub(crate) use crate::decoder::solana::SolanaCoreResolvedInstructionPayload;
|
||||||
|
/// Returns a hexadecimal prefix no longer than the available byte slice.
|
||||||
|
pub(crate) use crate::decoder::solana::solana_core_bounded_hexadecimal_prefix;
|
||||||
|
/// Extracts one exact bounded byte slice with checked arithmetic.
|
||||||
|
pub(crate) use crate::decoder::solana::solana_core_bounded_slice;
|
||||||
|
/// Decodes one retained base64 instruction payload with an explicit byte limit.
|
||||||
|
pub(crate) use crate::decoder::solana::solana_core_decode_instruction_data;
|
||||||
|
/// Returns the decoded target payload length when retained and valid.
|
||||||
|
pub(crate) use crate::decoder::solana::solana_core_decoded_payload_length;
|
||||||
|
/// Returns the SHA-256 of the decoded target instruction data when available.
|
||||||
|
pub(crate) use crate::decoder::solana::solana_core_decoded_payload_sha256;
|
||||||
|
/// Builds one exact decoded native observation.
|
||||||
|
pub(crate) use crate::decoder::solana::solana_core_decoded_result;
|
||||||
|
/// Builds one failed native decode result.
|
||||||
|
pub(crate) use crate::decoder::solana::solana_core_failed_result;
|
||||||
|
/// Returns declared Feature Gate instruction coverage.
|
||||||
|
pub(crate) use crate::decoder::solana::solana_core_feature_coverage;
|
||||||
|
/// Decodes one Feature Gate instruction.
|
||||||
|
pub(crate) use crate::decoder::solana::solana_core_feature_decode;
|
||||||
|
/// Recognizes one Feature Gate instruction without producing an event.
|
||||||
|
pub(crate) use crate::decoder::solana::solana_core_feature_recognize;
|
||||||
|
/// Returns the lowercase SHA-256 of one bounded byte slice.
|
||||||
|
pub(crate) use crate::decoder::solana::solana_core_hash_bytes;
|
||||||
|
/// Returns a normalized lowercase hexadecimal prefix.
|
||||||
|
pub(crate) use crate::decoder::solana::solana_core_hexadecimal_prefix;
|
||||||
|
/// Returns declared loader instruction coverage.
|
||||||
|
pub(crate) use crate::decoder::solana::solana_core_loaders_coverage;
|
||||||
|
/// Decodes one loader instruction.
|
||||||
|
pub(crate) use crate::decoder::solana::solana_core_loaders_decode;
|
||||||
|
/// Recognizes one loader instruction without producing an event.
|
||||||
|
pub(crate) use crate::decoder::solana::solana_core_loaders_recognize;
|
||||||
|
/// Returns a stable payload hash from core or computes it from retained JSON.
|
||||||
|
pub(crate) use crate::decoder::solana::solana_core_payload_hash;
|
||||||
|
/// Returns declared signature precompile coverage.
|
||||||
|
pub(crate) use crate::decoder::solana::solana_core_precompiles_coverage;
|
||||||
|
/// Decodes one signature precompile instruction structurally without cryptographic recomputation.
|
||||||
|
pub(crate) use crate::decoder::solana::solana_core_precompiles_decode;
|
||||||
|
/// Recognizes one signature precompile instruction without resolving referenced data.
|
||||||
|
pub(crate) use crate::decoder::solana::solana_core_precompiles_recognize;
|
||||||
|
/// Reads one little-endian `u32` from an exact byte range.
|
||||||
|
pub(crate) use crate::decoder::solana::solana_core_read_u32_le;
|
||||||
|
/// Reads one little-endian `u64` from an exact byte range.
|
||||||
|
pub(crate) use crate::decoder::solana::solana_core_read_u64_le;
|
||||||
|
/// Resolves a secp256k1 instruction reference. The runtime format has no current-instruction
|
||||||
|
/// sentinel: every `u8` value is an explicit outer instruction index.
|
||||||
|
pub(crate) use crate::decoder::solana::solana_core_resolve_u8_instruction_payload;
|
||||||
|
/// Resolves an Ed25519 or secp256r1 instruction reference using the official `u16::MAX` sentinel.
|
||||||
|
pub(crate) use crate::decoder::solana::solana_core_resolve_u16_instruction_payload;
|
||||||
|
/// Returns declared Slashing Program instruction coverage.
|
||||||
|
pub(crate) use crate::decoder::solana::solana_core_slashing_coverage;
|
||||||
|
/// Decodes one Slashing Program instruction.
|
||||||
|
pub(crate) use crate::decoder::solana::solana_core_slashing_decode;
|
||||||
|
/// Recognizes one Slashing Program instruction without producing an event.
|
||||||
|
pub(crate) use crate::decoder::solana::solana_core_slashing_recognize;
|
||||||
|
/// Returns declared Stake Program instruction coverage.
|
||||||
|
pub(crate) use crate::decoder::solana::solana_core_stake_coverage;
|
||||||
|
/// Decodes one Stake Program instruction.
|
||||||
|
pub(crate) use crate::decoder::solana::solana_core_stake_decode;
|
||||||
|
/// Recognizes one Stake Program instruction without producing an event.
|
||||||
|
pub(crate) use crate::decoder::solana::solana_core_stake_recognize;
|
||||||
|
/// Returns declared System Program instruction coverage.
|
||||||
|
pub(crate) use crate::decoder::solana::solana_core_system_coverage;
|
||||||
|
/// Decodes one System Program instruction.
|
||||||
|
pub(crate) use crate::decoder::solana::solana_core_system_decode;
|
||||||
|
/// Recognizes one System Program instruction without producing an event.
|
||||||
|
pub(crate) use crate::decoder::solana::solana_core_system_recognize;
|
||||||
|
/// Returns the numeric outer index of the target instruction.
|
||||||
|
pub(crate) use crate::decoder::solana::solana_core_target_outer_instruction_index;
|
||||||
|
/// Builds one unsupported native decode result with a bounded diagnostic.
|
||||||
|
pub(crate) use crate::decoder::solana::solana_core_unsupported_result;
|
||||||
|
/// Returns declared Vote Program instruction coverage.
|
||||||
|
pub(crate) use crate::decoder::solana::solana_core_vote_coverage;
|
||||||
|
/// Decodes one Vote Program instruction.
|
||||||
|
pub(crate) use crate::decoder::solana::solana_core_vote_decode;
|
||||||
|
/// Recognizes one Vote Program instruction without producing an event.
|
||||||
|
pub(crate) use crate::decoder::solana::solana_core_vote_recognize;
|
||||||
|
/// Returns declared ZK ElGamal Proof instruction coverage.
|
||||||
|
pub(crate) use crate::decoder::solana::solana_core_zk_elgamal_coverage;
|
||||||
|
/// Decodes one native ZK ElGamal Proof instruction without recomputing the proof.
|
||||||
|
pub(crate) use crate::decoder::solana::solana_core_zk_elgamal_decode;
|
||||||
|
/// Recognizes one native ZK ElGamal Proof instruction.
|
||||||
|
pub(crate) use crate::decoder::solana::solana_core_zk_elgamal_recognize;
|
||||||
|
/// Returns declared historical and current ZK Token Proof coverage.
|
||||||
|
pub(crate) use crate::decoder::solana::solana_core_zk_token_proof_coverage;
|
||||||
|
/// Decodes one historical ZK Token Proof layout without recomputing any proof.
|
||||||
|
pub(crate) use crate::decoder::solana::solana_core_zk_token_proof_decode;
|
||||||
|
/// Recognizes one historical ZK Token Proof layout or the current no-op runtime fallback.
|
||||||
|
pub(crate) use crate::decoder::solana::solana_core_zk_token_proof_recognize;
|
||||||
|
|
||||||
/// Current contextual core instruction input contract version.
|
/// Current contextual core instruction input contract version.
|
||||||
pub use crate::decoder::api::contracts::CORE_INSTRUCTION_INPUT_CONTRACT_VERSION;
|
pub use crate::decoder::api::contracts::CORE_INSTRUCTION_INPUT_CONTRACT_VERSION;
|
||||||
/// Stable contextual decoded observation.
|
/// Stable contextual decoded observation.
|
||||||
@@ -55,6 +224,8 @@ pub use crate::decoder::api::decoder::DecoderSupport;
|
|||||||
pub use crate::decoder::api::decoder::InitialDecoder;
|
pub use crate::decoder::api::decoder::InitialDecoder;
|
||||||
/// Exposes the common protocol decoder trait.
|
/// Exposes the common protocol decoder trait.
|
||||||
pub use crate::decoder::api::decoder::ProtocolDecoder;
|
pub use crate::decoder::api::decoder::ProtocolDecoder;
|
||||||
|
/// Runtime-native Solana decoder with maximal native instruction coverage.
|
||||||
|
pub use crate::decoder::solana::SolanaCoreDecoder;
|
||||||
/// Exposes the blockhash policy kind.
|
/// Exposes the blockhash policy kind.
|
||||||
pub use crate::executor::api::execution::ExecutionBlockhashKind;
|
pub use crate::executor::api::execution::ExecutionBlockhashKind;
|
||||||
/// Exposes the blockhash policy.
|
/// Exposes the blockhash policy.
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
// file: kb-lib/src/model/replay.rs
|
// file: kb-lib/src/model/replay.rs
|
||||||
// version: 2
|
// version: 3
|
||||||
|
|
||||||
//! Source-neutral instruction replay input shared by decoders and stores.
|
//! Source-neutral instruction replay input shared by decoders and stores.
|
||||||
|
|
||||||
@@ -123,4 +123,21 @@ impl CoreInstructionReplayInput {
|
|||||||
}
|
}
|
||||||
return std::result::Result::Ok(());
|
return std::result::Result::Ok(());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Adds an optional stable surface hint after validation.
|
||||||
|
pub fn with_surface_code_hint(
|
||||||
|
mut self,
|
||||||
|
surface_code_hint: std::option::Option<std::string::String>,
|
||||||
|
) -> kb_core::Result<Self> {
|
||||||
|
if surface_code_hint
|
||||||
|
.as_deref()
|
||||||
|
.is_some_and(|surface| return surface.trim().is_empty())
|
||||||
|
{
|
||||||
|
return std::result::Result::Err(kb_core::Error::invalid_state(
|
||||||
|
"core replay input surface hint must not be empty when present",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
self.surface_code_hint = surface_code_hint;
|
||||||
|
return std::result::Result::Ok(self);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
19
kb-program-ids/README.md
Normal file
19
kb-program-ids/README.md
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
<!-- file: kb-program-ids/README.md -->
|
||||||
|
<!-- version: 1 -->
|
||||||
|
|
||||||
|
# kb-program-ids
|
||||||
|
|
||||||
|
`kb-program-ids` est la source unique des identifiants de programmes Solana et des comptes natifs connus utilisés par le workspace.
|
||||||
|
|
||||||
|
La tranche `0.1.0-pre.004` expose les 18 surfaces exécutables nécessaires au décodeur Solana Core, les comptes natifs associés utilisés par leurs contrats et l’identifiant SPL Token classique employé par les tests de frontière. Les identifiants SPL et protocolaires supplémentaires seront ajoutés avec leurs tranches de migration respectives.
|
||||||
|
|
||||||
|
## API publique
|
||||||
|
|
||||||
|
- les constantes `*_PROGRAM_ID` et `STAKE_CONFIG_ACCOUNT_ID` exposent les adresses Base58 canoniques ;
|
||||||
|
- `ProgramIdEntry` conserve un code stable et une adresse, accessibles par les champs compatibles `name`/`address` et par `code()`/`program_id()` ;
|
||||||
|
- `native_program_ids()` retourne exactement les 18 surfaces exécutables de Solana Core ;
|
||||||
|
- `native_well_known_account_ids()` sépare les comptes natifs non exécutables ;
|
||||||
|
- `registered_program_ids()` et son alias de compatibilité `entries()` retournent le registre actuellement migré ;
|
||||||
|
- `find_registered_program_id()` effectue une recherche exacte par adresse.
|
||||||
|
|
||||||
|
`STAKE_CONFIG_PROGRAM_ID` reste uniquement un alias de compatibilité historique. L’adresse correspondante est un compte de configuration Stake connu, pas un programme exécutable.
|
||||||
252
kb-program-ids/src/constants.rs
Normal file
252
kb-program-ids/src/constants.rs
Normal file
@@ -0,0 +1,252 @@
|
|||||||
|
// file: kb-program-ids/src/constants.rs
|
||||||
|
// version: 1
|
||||||
|
|
||||||
|
//! Canonical Solana program and well-known account identifiers.
|
||||||
|
|
||||||
|
/// Address Lookup Table program identifier.
|
||||||
|
pub const ADDRESS_LOOKUP_TABLE_PROGRAM_ID: &str = "AddressLookupTab1e1111111111111111111111111";
|
||||||
|
/// Deprecated BPF Loader program identifier.
|
||||||
|
pub const BPF_LOADER_DEPRECATED_PROGRAM_ID: &str = "BPFLoader1111111111111111111111111111111111";
|
||||||
|
/// BPF Loader version 2 program identifier.
|
||||||
|
pub const BPF_LOADER_PROGRAM_ID: &str = "BPFLoader2111111111111111111111111111111111";
|
||||||
|
/// Upgradeable BPF Loader program identifier.
|
||||||
|
pub const BPF_LOADER_UPGRADEABLE_PROGRAM_ID: &str = "BPFLoaderUpgradeab1e11111111111111111111111";
|
||||||
|
/// Compute Budget program identifier.
|
||||||
|
pub const COMPUTE_BUDGET_PROGRAM_ID: &str = "ComputeBudget111111111111111111111111111111";
|
||||||
|
/// Config program identifier.
|
||||||
|
pub const CONFIG_PROGRAM_ID: &str = "Config1111111111111111111111111111111111111";
|
||||||
|
/// Ed25519 precompile program identifier.
|
||||||
|
pub const ED25519_PROGRAM_ID: &str = "Ed25519SigVerify111111111111111111111111111";
|
||||||
|
/// Feature program identifier.
|
||||||
|
pub const FEATURE_PROGRAM_ID: &str = "Feature111111111111111111111111111111111111";
|
||||||
|
/// Incinerator well-known account identifier.
|
||||||
|
pub const INCINERATOR_PROGRAM_ID: &str = "1nc1nerator11111111111111111111111111111111";
|
||||||
|
/// Loader version 4 program identifier.
|
||||||
|
pub const LOADER_V4_PROGRAM_ID: &str = "LoaderV411111111111111111111111111111111111";
|
||||||
|
/// Native Loader program identifier.
|
||||||
|
pub const NATIVE_LOADER_PROGRAM_ID: &str = "NativeLoader1111111111111111111111111111111";
|
||||||
|
/// Secp256k1 precompile program identifier.
|
||||||
|
pub const SECP256K1_PROGRAM_ID: &str = "KeccakSecp256k11111111111111111111111111111";
|
||||||
|
/// Secp256r1 precompile program identifier.
|
||||||
|
pub const SECP256R1_PROGRAM_ID: &str = "Secp256r1SigVerify1111111111111111111111111";
|
||||||
|
/// Enshrined stateless Slashing program identifier.
|
||||||
|
pub const SLASHING_PROGRAM_ID: &str = "S1ashing11111111111111111111111111111111111";
|
||||||
|
/// Classic SPL Token program identifier.
|
||||||
|
pub const SPL_TOKEN_PROGRAM_ID: &str = "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA";
|
||||||
|
/// Historical Stake configuration account identifier.
|
||||||
|
pub const STAKE_CONFIG_ACCOUNT_ID: &str = "StakeConfig11111111111111111111111111111111";
|
||||||
|
/// Compatibility alias for the historical Stake configuration account identifier.
|
||||||
|
pub const STAKE_CONFIG_PROGRAM_ID: &str = crate::STAKE_CONFIG_ACCOUNT_ID;
|
||||||
|
/// Stake program identifier.
|
||||||
|
pub const STAKE_PROGRAM_ID: &str = "Stake11111111111111111111111111111111111111";
|
||||||
|
/// System program identifier.
|
||||||
|
pub const SYSTEM_PROGRAM_ID: &str = "11111111111111111111111111111111";
|
||||||
|
/// Clock sysvar account identifier.
|
||||||
|
pub const SYSVAR_CLOCK_PROGRAM_ID: &str = "SysvarC1ock11111111111111111111111111111111";
|
||||||
|
/// Instructions sysvar account identifier.
|
||||||
|
pub const SYSVAR_INSTRUCTIONS_PROGRAM_ID: &str = "Sysvar1nstructions1111111111111111111111111";
|
||||||
|
/// Rent sysvar account identifier.
|
||||||
|
pub const SYSVAR_RENT_PROGRAM_ID: &str = "SysvarRent111111111111111111111111111111111";
|
||||||
|
/// Slot Hashes sysvar account identifier.
|
||||||
|
pub const SYSVAR_SLOT_HASHES_PROGRAM_ID: &str = "SysvarS1otHashes111111111111111111111111111";
|
||||||
|
/// Stake History sysvar account identifier.
|
||||||
|
pub const SYSVAR_STAKE_HISTORY_PROGRAM_ID: &str = "SysvarStakeHistory1111111111111111111111111";
|
||||||
|
/// Vote program identifier.
|
||||||
|
pub const VOTE_PROGRAM_ID: &str = "Vote111111111111111111111111111111111111111";
|
||||||
|
/// ZK ElGamal Proof program identifier.
|
||||||
|
pub const ZK_ELGAMAL_PROOF_PROGRAM_ID: &str = "ZkE1Gama1Proof11111111111111111111111111111";
|
||||||
|
/// ZK Token Proof program identifier.
|
||||||
|
pub const ZK_TOKEN_PROOF_PROGRAM_ID: &str = "ZkTokenProof1111111111111111111111111111111";
|
||||||
|
|
||||||
|
pub(crate) const NATIVE_PROGRAM_ID_ENTRIES: &[crate::ProgramIdEntry] = &[
|
||||||
|
crate::ProgramIdEntry {
|
||||||
|
name: "address_lookup_table",
|
||||||
|
address: crate::ADDRESS_LOOKUP_TABLE_PROGRAM_ID,
|
||||||
|
},
|
||||||
|
crate::ProgramIdEntry {
|
||||||
|
name: "bpf_loader_deprecated",
|
||||||
|
address: crate::BPF_LOADER_DEPRECATED_PROGRAM_ID,
|
||||||
|
},
|
||||||
|
crate::ProgramIdEntry {
|
||||||
|
name: "bpf_loader",
|
||||||
|
address: crate::BPF_LOADER_PROGRAM_ID,
|
||||||
|
},
|
||||||
|
crate::ProgramIdEntry {
|
||||||
|
name: "bpf_loader_upgradeable",
|
||||||
|
address: crate::BPF_LOADER_UPGRADEABLE_PROGRAM_ID,
|
||||||
|
},
|
||||||
|
crate::ProgramIdEntry {
|
||||||
|
name: "compute_budget",
|
||||||
|
address: crate::COMPUTE_BUDGET_PROGRAM_ID,
|
||||||
|
},
|
||||||
|
crate::ProgramIdEntry {
|
||||||
|
name: "config",
|
||||||
|
address: crate::CONFIG_PROGRAM_ID,
|
||||||
|
},
|
||||||
|
crate::ProgramIdEntry {
|
||||||
|
name: "ed25519",
|
||||||
|
address: crate::ED25519_PROGRAM_ID,
|
||||||
|
},
|
||||||
|
crate::ProgramIdEntry {
|
||||||
|
name: "feature",
|
||||||
|
address: crate::FEATURE_PROGRAM_ID,
|
||||||
|
},
|
||||||
|
crate::ProgramIdEntry {
|
||||||
|
name: "loader_v4",
|
||||||
|
address: crate::LOADER_V4_PROGRAM_ID,
|
||||||
|
},
|
||||||
|
crate::ProgramIdEntry {
|
||||||
|
name: "native_loader",
|
||||||
|
address: crate::NATIVE_LOADER_PROGRAM_ID,
|
||||||
|
},
|
||||||
|
crate::ProgramIdEntry {
|
||||||
|
name: "secp256k1",
|
||||||
|
address: crate::SECP256K1_PROGRAM_ID,
|
||||||
|
},
|
||||||
|
crate::ProgramIdEntry {
|
||||||
|
name: "secp256r1",
|
||||||
|
address: crate::SECP256R1_PROGRAM_ID,
|
||||||
|
},
|
||||||
|
crate::ProgramIdEntry {
|
||||||
|
name: "slashing",
|
||||||
|
address: crate::SLASHING_PROGRAM_ID,
|
||||||
|
},
|
||||||
|
crate::ProgramIdEntry {
|
||||||
|
name: "stake",
|
||||||
|
address: crate::STAKE_PROGRAM_ID,
|
||||||
|
},
|
||||||
|
crate::ProgramIdEntry {
|
||||||
|
name: "system",
|
||||||
|
address: crate::SYSTEM_PROGRAM_ID,
|
||||||
|
},
|
||||||
|
crate::ProgramIdEntry {
|
||||||
|
name: "vote",
|
||||||
|
address: crate::VOTE_PROGRAM_ID,
|
||||||
|
},
|
||||||
|
crate::ProgramIdEntry {
|
||||||
|
name: "zk_elgamal_proof",
|
||||||
|
address: crate::ZK_ELGAMAL_PROOF_PROGRAM_ID,
|
||||||
|
},
|
||||||
|
crate::ProgramIdEntry {
|
||||||
|
name: "zk_token_proof",
|
||||||
|
address: crate::ZK_TOKEN_PROOF_PROGRAM_ID,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
pub(crate) const NATIVE_WELL_KNOWN_ACCOUNT_ID_ENTRIES: &[crate::ProgramIdEntry] = &[
|
||||||
|
crate::ProgramIdEntry {
|
||||||
|
name: "incinerator",
|
||||||
|
address: crate::INCINERATOR_PROGRAM_ID,
|
||||||
|
},
|
||||||
|
crate::ProgramIdEntry {
|
||||||
|
name: "stake_config",
|
||||||
|
address: crate::STAKE_CONFIG_ACCOUNT_ID,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
pub(crate) const PROGRAM_ID_ENTRIES: &[crate::ProgramIdEntry] = &[
|
||||||
|
crate::ProgramIdEntry {
|
||||||
|
name: "address_lookup_table",
|
||||||
|
address: crate::ADDRESS_LOOKUP_TABLE_PROGRAM_ID,
|
||||||
|
},
|
||||||
|
crate::ProgramIdEntry {
|
||||||
|
name: "bpf_loader_deprecated",
|
||||||
|
address: crate::BPF_LOADER_DEPRECATED_PROGRAM_ID,
|
||||||
|
},
|
||||||
|
crate::ProgramIdEntry {
|
||||||
|
name: "bpf_loader",
|
||||||
|
address: crate::BPF_LOADER_PROGRAM_ID,
|
||||||
|
},
|
||||||
|
crate::ProgramIdEntry {
|
||||||
|
name: "bpf_loader_upgradeable",
|
||||||
|
address: crate::BPF_LOADER_UPGRADEABLE_PROGRAM_ID,
|
||||||
|
},
|
||||||
|
crate::ProgramIdEntry {
|
||||||
|
name: "compute_budget",
|
||||||
|
address: crate::COMPUTE_BUDGET_PROGRAM_ID,
|
||||||
|
},
|
||||||
|
crate::ProgramIdEntry {
|
||||||
|
name: "config",
|
||||||
|
address: crate::CONFIG_PROGRAM_ID,
|
||||||
|
},
|
||||||
|
crate::ProgramIdEntry {
|
||||||
|
name: "ed25519",
|
||||||
|
address: crate::ED25519_PROGRAM_ID,
|
||||||
|
},
|
||||||
|
crate::ProgramIdEntry {
|
||||||
|
name: "feature",
|
||||||
|
address: crate::FEATURE_PROGRAM_ID,
|
||||||
|
},
|
||||||
|
crate::ProgramIdEntry {
|
||||||
|
name: "incinerator",
|
||||||
|
address: crate::INCINERATOR_PROGRAM_ID,
|
||||||
|
},
|
||||||
|
crate::ProgramIdEntry {
|
||||||
|
name: "loader_v4",
|
||||||
|
address: crate::LOADER_V4_PROGRAM_ID,
|
||||||
|
},
|
||||||
|
crate::ProgramIdEntry {
|
||||||
|
name: "native_loader",
|
||||||
|
address: crate::NATIVE_LOADER_PROGRAM_ID,
|
||||||
|
},
|
||||||
|
crate::ProgramIdEntry {
|
||||||
|
name: "secp256k1",
|
||||||
|
address: crate::SECP256K1_PROGRAM_ID,
|
||||||
|
},
|
||||||
|
crate::ProgramIdEntry {
|
||||||
|
name: "secp256r1",
|
||||||
|
address: crate::SECP256R1_PROGRAM_ID,
|
||||||
|
},
|
||||||
|
crate::ProgramIdEntry {
|
||||||
|
name: "slashing",
|
||||||
|
address: crate::SLASHING_PROGRAM_ID,
|
||||||
|
},
|
||||||
|
crate::ProgramIdEntry {
|
||||||
|
name: "spl_token",
|
||||||
|
address: crate::SPL_TOKEN_PROGRAM_ID,
|
||||||
|
},
|
||||||
|
crate::ProgramIdEntry {
|
||||||
|
name: "stake_config",
|
||||||
|
address: crate::STAKE_CONFIG_ACCOUNT_ID,
|
||||||
|
},
|
||||||
|
crate::ProgramIdEntry {
|
||||||
|
name: "stake",
|
||||||
|
address: crate::STAKE_PROGRAM_ID,
|
||||||
|
},
|
||||||
|
crate::ProgramIdEntry {
|
||||||
|
name: "system",
|
||||||
|
address: crate::SYSTEM_PROGRAM_ID,
|
||||||
|
},
|
||||||
|
crate::ProgramIdEntry {
|
||||||
|
name: "sysvar_clock",
|
||||||
|
address: crate::SYSVAR_CLOCK_PROGRAM_ID,
|
||||||
|
},
|
||||||
|
crate::ProgramIdEntry {
|
||||||
|
name: "sysvar_instructions",
|
||||||
|
address: crate::SYSVAR_INSTRUCTIONS_PROGRAM_ID,
|
||||||
|
},
|
||||||
|
crate::ProgramIdEntry {
|
||||||
|
name: "sysvar_rent",
|
||||||
|
address: crate::SYSVAR_RENT_PROGRAM_ID,
|
||||||
|
},
|
||||||
|
crate::ProgramIdEntry {
|
||||||
|
name: "sysvar_slot_hashes",
|
||||||
|
address: crate::SYSVAR_SLOT_HASHES_PROGRAM_ID,
|
||||||
|
},
|
||||||
|
crate::ProgramIdEntry {
|
||||||
|
name: "sysvar_stake_history",
|
||||||
|
address: crate::SYSVAR_STAKE_HISTORY_PROGRAM_ID,
|
||||||
|
},
|
||||||
|
crate::ProgramIdEntry {
|
||||||
|
name: "vote",
|
||||||
|
address: crate::VOTE_PROGRAM_ID,
|
||||||
|
},
|
||||||
|
crate::ProgramIdEntry {
|
||||||
|
name: "zk_elgamal_proof",
|
||||||
|
address: crate::ZK_ELGAMAL_PROOF_PROGRAM_ID,
|
||||||
|
},
|
||||||
|
crate::ProgramIdEntry {
|
||||||
|
name: "zk_token_proof",
|
||||||
|
address: crate::ZK_TOKEN_PROOF_PROGRAM_ID,
|
||||||
|
},
|
||||||
|
];
|
||||||
@@ -1,22 +1,190 @@
|
|||||||
// file: kb-program-ids/src/lib.rs
|
// file: kb-program-ids/src/lib.rs
|
||||||
// version: 1
|
// version: 2
|
||||||
|
|
||||||
#![forbid(unsafe_code)]
|
#![forbid(unsafe_code)]
|
||||||
#![deny(unreachable_pub)]
|
#![deny(unreachable_pub)]
|
||||||
#![warn(missing_docs)]
|
#![warn(missing_docs)]
|
||||||
|
|
||||||
//! Program identifier registry.
|
//! Shared canonical Solana program identifiers.
|
||||||
|
|
||||||
/// One program identifier entry.
|
mod constants;
|
||||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
|
||||||
|
/// Address Lookup Table program identifier.
|
||||||
|
pub use constants::ADDRESS_LOOKUP_TABLE_PROGRAM_ID;
|
||||||
|
/// Deprecated BPF Loader program identifier.
|
||||||
|
pub use constants::BPF_LOADER_DEPRECATED_PROGRAM_ID;
|
||||||
|
/// BPF Loader version 2 program identifier.
|
||||||
|
pub use constants::BPF_LOADER_PROGRAM_ID;
|
||||||
|
/// Upgradeable BPF Loader program identifier.
|
||||||
|
pub use constants::BPF_LOADER_UPGRADEABLE_PROGRAM_ID;
|
||||||
|
/// Compute Budget program identifier.
|
||||||
|
pub use constants::COMPUTE_BUDGET_PROGRAM_ID;
|
||||||
|
/// Config program identifier.
|
||||||
|
pub use constants::CONFIG_PROGRAM_ID;
|
||||||
|
/// Ed25519 precompile program identifier.
|
||||||
|
pub use constants::ED25519_PROGRAM_ID;
|
||||||
|
/// Feature program identifier.
|
||||||
|
pub use constants::FEATURE_PROGRAM_ID;
|
||||||
|
/// Incinerator well-known account identifier.
|
||||||
|
pub use constants::INCINERATOR_PROGRAM_ID;
|
||||||
|
/// Loader version 4 program identifier.
|
||||||
|
pub use constants::LOADER_V4_PROGRAM_ID;
|
||||||
|
/// Native Loader program identifier.
|
||||||
|
pub use constants::NATIVE_LOADER_PROGRAM_ID;
|
||||||
|
/// Internal list of executable native programs.
|
||||||
|
pub(crate) use constants::NATIVE_PROGRAM_ID_ENTRIES;
|
||||||
|
/// Internal list of native well-known accounts.
|
||||||
|
pub(crate) use constants::NATIVE_WELL_KNOWN_ACCOUNT_ID_ENTRIES;
|
||||||
|
/// Internal list of currently registered identifiers.
|
||||||
|
pub(crate) use constants::PROGRAM_ID_ENTRIES;
|
||||||
|
/// Secp256k1 precompile program identifier.
|
||||||
|
pub use constants::SECP256K1_PROGRAM_ID;
|
||||||
|
/// Secp256r1 precompile program identifier.
|
||||||
|
pub use constants::SECP256R1_PROGRAM_ID;
|
||||||
|
/// Enshrined stateless Slashing program identifier.
|
||||||
|
pub use constants::SLASHING_PROGRAM_ID;
|
||||||
|
/// Classic SPL Token program identifier.
|
||||||
|
pub use constants::SPL_TOKEN_PROGRAM_ID;
|
||||||
|
/// Historical Stake configuration account identifier.
|
||||||
|
pub use constants::STAKE_CONFIG_ACCOUNT_ID;
|
||||||
|
/// Compatibility alias for the historical Stake configuration account identifier.
|
||||||
|
pub use constants::STAKE_CONFIG_PROGRAM_ID;
|
||||||
|
/// Stake program identifier.
|
||||||
|
pub use constants::STAKE_PROGRAM_ID;
|
||||||
|
/// System program identifier.
|
||||||
|
pub use constants::SYSTEM_PROGRAM_ID;
|
||||||
|
/// Clock sysvar account identifier.
|
||||||
|
pub use constants::SYSVAR_CLOCK_PROGRAM_ID;
|
||||||
|
/// Instructions sysvar account identifier.
|
||||||
|
pub use constants::SYSVAR_INSTRUCTIONS_PROGRAM_ID;
|
||||||
|
/// Rent sysvar account identifier.
|
||||||
|
pub use constants::SYSVAR_RENT_PROGRAM_ID;
|
||||||
|
/// Slot Hashes sysvar account identifier.
|
||||||
|
pub use constants::SYSVAR_SLOT_HASHES_PROGRAM_ID;
|
||||||
|
/// Stake History sysvar account identifier.
|
||||||
|
pub use constants::SYSVAR_STAKE_HISTORY_PROGRAM_ID;
|
||||||
|
/// Vote program identifier.
|
||||||
|
pub use constants::VOTE_PROGRAM_ID;
|
||||||
|
/// ZK ElGamal Proof program identifier.
|
||||||
|
pub use constants::ZK_ELGAMAL_PROOF_PROGRAM_ID;
|
||||||
|
/// ZK Token Proof program identifier.
|
||||||
|
pub use constants::ZK_TOKEN_PROOF_PROGRAM_ID;
|
||||||
|
|
||||||
|
/// One enumerable program identifier entry.
|
||||||
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||||
pub struct ProgramIdEntry {
|
pub struct ProgramIdEntry {
|
||||||
/// Stable symbolic name.
|
/// Stable lower snake case registry code.
|
||||||
pub name: &'static str,
|
pub name: &'static str,
|
||||||
/// Base58 program identifier.
|
/// Base58 Solana program identifier.
|
||||||
pub address: &'static str,
|
pub address: &'static str,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Returns the initial canonical program registry.
|
impl ProgramIdEntry {
|
||||||
pub fn entries() -> &'static [crate::ProgramIdEntry] {
|
/// Returns the stable lower snake case registry code.
|
||||||
return &[];
|
pub const fn code(&self) -> &'static str {
|
||||||
|
return self.name;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the Base58 Solana program identifier.
|
||||||
|
pub const fn program_id(&self) -> &'static str {
|
||||||
|
return self.address;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns every program identifier currently registered by this crate.
|
||||||
|
pub fn entries() -> &'static [crate::ProgramIdEntry] {
|
||||||
|
return crate::PROGRAM_ID_ENTRIES;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns every program identifier currently registered by this crate.
|
||||||
|
pub fn registered_program_ids() -> &'static [crate::ProgramIdEntry] {
|
||||||
|
return crate::PROGRAM_ID_ENTRIES;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns every executable runtime-native, loader, precompile and historical native program identifier.
|
||||||
|
pub fn native_program_ids() -> &'static [crate::ProgramIdEntry] {
|
||||||
|
return crate::NATIVE_PROGRAM_ID_ENTRIES;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns native well-known accounts that are not executable programs.
|
||||||
|
pub fn native_well_known_account_ids() -> &'static [crate::ProgramIdEntry] {
|
||||||
|
return crate::NATIVE_WELL_KNOWN_ACCOUNT_ID_ENTRIES;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Finds one registered program or well-known account by exact Base58 identifier.
|
||||||
|
pub fn find_registered_program_id(
|
||||||
|
program_id: &str,
|
||||||
|
) -> std::option::Option<&'static crate::ProgramIdEntry> {
|
||||||
|
return crate::PROGRAM_ID_ENTRIES
|
||||||
|
.iter()
|
||||||
|
.find(|entry| return entry.address == program_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
#[test]
|
||||||
|
fn registry_entries_are_unique_and_non_empty() {
|
||||||
|
let mut codes = std::collections::BTreeSet::new();
|
||||||
|
let mut program_ids = std::collections::BTreeSet::new();
|
||||||
|
for entry in crate::registered_program_ids() {
|
||||||
|
assert!(!entry.code().is_empty());
|
||||||
|
assert!(!entry.program_id().is_empty());
|
||||||
|
assert!(codes.insert(entry.code()));
|
||||||
|
assert!(program_ids.insert(entry.program_id()));
|
||||||
|
}
|
||||||
|
assert_eq!(crate::registered_program_ids().len(), 26);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn native_registry_contains_exactly_every_solana_core_surface() {
|
||||||
|
let codes = crate::native_program_ids()
|
||||||
|
.iter()
|
||||||
|
.map(crate::ProgramIdEntry::code)
|
||||||
|
.collect::<std::collections::BTreeSet<_>>();
|
||||||
|
assert!(codes.contains("vote"));
|
||||||
|
assert!(codes.contains("slashing"));
|
||||||
|
assert!(codes.contains("bpf_loader_deprecated"));
|
||||||
|
assert!(codes.contains("bpf_loader"));
|
||||||
|
assert!(codes.contains("bpf_loader_upgradeable"));
|
||||||
|
assert!(codes.contains("loader_v4"));
|
||||||
|
assert_eq!(codes.len(), 18);
|
||||||
|
assert_eq!(codes.len(), crate::native_program_ids().len());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn stake_config_is_a_well_known_account_not_an_executable_program() {
|
||||||
|
assert!(crate::native_well_known_account_ids().iter().any(|entry| {
|
||||||
|
return entry.program_id() == crate::STAKE_CONFIG_ACCOUNT_ID;
|
||||||
|
}));
|
||||||
|
assert!(!crate::native_program_ids().iter().any(|entry| {
|
||||||
|
return entry.program_id() == crate::STAKE_CONFIG_ACCOUNT_ID;
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn registry_lookup_finds_system_program() {
|
||||||
|
let entry = crate::find_registered_program_id(crate::SYSTEM_PROGRAM_ID);
|
||||||
|
assert_eq!(entry.map(crate::ProgramIdEntry::code), std::option::Option::Some("system"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn compatibility_api_and_core_constants_are_complete() {
|
||||||
|
assert_eq!(crate::entries(), crate::registered_program_ids());
|
||||||
|
assert_eq!(crate::STAKE_CONFIG_PROGRAM_ID, crate::STAKE_CONFIG_ACCOUNT_ID);
|
||||||
|
let expected = [
|
||||||
|
crate::ADDRESS_LOOKUP_TABLE_PROGRAM_ID,
|
||||||
|
crate::COMPUTE_BUDGET_PROGRAM_ID,
|
||||||
|
crate::CONFIG_PROGRAM_ID,
|
||||||
|
crate::FEATURE_PROGRAM_ID,
|
||||||
|
crate::SYSTEM_PROGRAM_ID,
|
||||||
|
crate::VOTE_PROGRAM_ID,
|
||||||
|
crate::STAKE_PROGRAM_ID,
|
||||||
|
crate::SLASHING_PROGRAM_ID,
|
||||||
|
crate::ZK_ELGAMAL_PROOF_PROGRAM_ID,
|
||||||
|
crate::ZK_TOKEN_PROOF_PROGRAM_ID,
|
||||||
|
];
|
||||||
|
for program_id in expected {
|
||||||
|
assert!(crate::find_registered_program_id(program_id).is_some());
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
# file: kb-store/Cargo.toml
|
# file: kb-store/Cargo.toml
|
||||||
# version: 1
|
# version: 2
|
||||||
|
|
||||||
[package]
|
[package]
|
||||||
name = "kb-store"
|
name = "kb-store"
|
||||||
@@ -9,7 +9,15 @@ license.workspace = true
|
|||||||
publish.workspace = true
|
publish.workspace = true
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
|
async-trait.workspace = true
|
||||||
|
chrono.workspace = true
|
||||||
kb-core = { path = "../kb-core" }
|
kb-core = { path = "../kb-core" }
|
||||||
|
kb-lib = { path = "../kb-lib" }
|
||||||
|
serde.workspace = true
|
||||||
|
serde_json.workspace = true
|
||||||
|
sqlx.workspace = true
|
||||||
|
tokio.workspace = true
|
||||||
|
tracing.workspace = true
|
||||||
|
|
||||||
[lints]
|
[lints]
|
||||||
workspace = true
|
workspace = true
|
||||||
|
|||||||
126
kb-store/README.md
Normal file
126
kb-store/README.md
Normal file
@@ -0,0 +1,126 @@
|
|||||||
|
<!-- file: kb-store/README.md -->
|
||||||
|
<!-- version: 1 -->
|
||||||
|
|
||||||
|
# `kb-store`
|
||||||
|
|
||||||
|
`kb-store` regroupe les contrats de persistance neutres et l’adaptateur PostgreSQL de production de Khadhroony Bot3.
|
||||||
|
|
||||||
|
La crate remplace l’ancienne séparation physique entre contrats et PostgreSQL, sans mélanger leurs responsabilités. Tous les modules restent privés et l’API stable est réexportée depuis `src/lib.rs`.
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
```text
|
||||||
|
src/
|
||||||
|
├── lib.rs
|
||||||
|
├── constants.rs
|
||||||
|
├── contracts.rs
|
||||||
|
├── contracts/
|
||||||
|
│ ├── dto.rs
|
||||||
|
│ ├── dto/
|
||||||
|
│ ├── entity.rs
|
||||||
|
│ ├── entity/
|
||||||
|
│ ├── error.rs
|
||||||
|
│ ├── health.rs
|
||||||
|
│ ├── pagination.rs
|
||||||
|
│ └── repository.rs
|
||||||
|
├── postgres.rs
|
||||||
|
└── postgres/
|
||||||
|
├── migrations.rs
|
||||||
|
├── query.rs
|
||||||
|
├── query/
|
||||||
|
├── replay_candidates.rs
|
||||||
|
├── repository.rs
|
||||||
|
├── repository/
|
||||||
|
├── store.rs
|
||||||
|
└── test_serial.rs
|
||||||
|
```
|
||||||
|
|
||||||
|
`contracts` ne dépend d’aucun backend. `postgres` implémente ces contrats avec `sqlx`. `lib.rs` ne contient aucune logique métier.
|
||||||
|
|
||||||
|
## Direction des dépendances
|
||||||
|
|
||||||
|
```text
|
||||||
|
kb-core
|
||||||
|
↑
|
||||||
|
kb-lib ← kb-store
|
||||||
|
```
|
||||||
|
|
||||||
|
`CoreInstructionReplayInput` et sa version de contrat appartiennent à `kb-lib`, car ils sont partagés avec les décodeurs. `kb-store` les consomme et les réexporte. `kb-lib` ne dépend jamais de `kb-store`.
|
||||||
|
|
||||||
|
`kb-store` ne dépend pas de `kb-config`. L’application résout sa configuration, puis construit explicitement `PostgresStoreOptions`. Cette séparation évite de coupler la persistance à la forme évolutive des profils.
|
||||||
|
|
||||||
|
## Contrats publics
|
||||||
|
|
||||||
|
Les familles principales sont :
|
||||||
|
|
||||||
|
- transactions raw et observations d’acquisition ;
|
||||||
|
- graphe core Solana : transaction, comptes, instructions, inner instructions, logs et balances ;
|
||||||
|
- sélection et lifecycle de replay ;
|
||||||
|
- extraction core atomique ;
|
||||||
|
- décodage, couverture et matérialisation atomiques ;
|
||||||
|
- ledger de traitement versionné ;
|
||||||
|
- santé, migrations et pagination bornée.
|
||||||
|
|
||||||
|
Les traits publics sont :
|
||||||
|
|
||||||
|
- `StoreHealthStore` ;
|
||||||
|
- `RawTransactionStore` ;
|
||||||
|
- `CoreTransactionStore` ;
|
||||||
|
- `CoreExtractionStore` ;
|
||||||
|
- `DecodePipelineStore` ;
|
||||||
|
- `ProgramObservationStore` ;
|
||||||
|
- `DecodedEventStore` ;
|
||||||
|
- `MaterializedEventStore` ;
|
||||||
|
- `ProcessingLedgerStore`.
|
||||||
|
|
||||||
|
## PostgreSQL
|
||||||
|
|
||||||
|
`PostgresStore` fournit :
|
||||||
|
|
||||||
|
- connexion depuis `PostgresStoreOptions` validées ;
|
||||||
|
- création depuis un `sqlx::PgPool` existant ;
|
||||||
|
- initialisation idempotente des tables raw, core, decode, materialization et ledger ;
|
||||||
|
- diagnostics de backend, migrations et tables ;
|
||||||
|
- sélections bornées de candidats de replay ;
|
||||||
|
- implémentations des traits store-neutral.
|
||||||
|
|
||||||
|
Les transactions raw sont immuables. Les écritures core, decode et materialization conservent leur lineage et utilisent le ledger pour le skip version/hash, le force replay et l’idempotence.
|
||||||
|
|
||||||
|
Exemple :
|
||||||
|
|
||||||
|
```rust
|
||||||
|
let options = match kb_store::PostgresStoreOptions::new(
|
||||||
|
database_url,
|
||||||
|
8,
|
||||||
|
5_000,
|
||||||
|
true,
|
||||||
|
) {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
let store = match kb_store::PostgresStore::connect(options).await {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
return std::result::Result::Ok(store);
|
||||||
|
```
|
||||||
|
|
||||||
|
## Validation
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cargo fmt --all
|
||||||
|
cargo check -p kb-store
|
||||||
|
cargo test -p kb-store
|
||||||
|
cargo clippy -p kb-store --all-targets
|
||||||
|
KB_POSTGRES_TEST_URL='postgres://solana:solana@localhost:5432/solana_test' \
|
||||||
|
cargo test -p kb-store -- --nocapture
|
||||||
|
```
|
||||||
|
|
||||||
|
La validation complète du jalon ajoute :
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cargo check --workspace
|
||||||
|
cargo test --workspace
|
||||||
|
cargo clippy --workspace --all-targets
|
||||||
|
```
|
||||||
|
|
||||||
21
kb-store/maintenance/drop_raw_core_store.sql
Normal file
21
kb-store/maintenance/drop_raw_core_store.sql
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
-- file: kb-store/maintenance/drop_raw_core_store.sql
|
||||||
|
-- version: 1
|
||||||
|
|
||||||
|
-- Drop raw and core Solana storage tables in reverse dependency order.
|
||||||
|
-- This script is intended for local development and test databases only.
|
||||||
|
-- It does not drop unrelated schemas, migrations, roles or configuration state.
|
||||||
|
-- It intentionally avoids CASCADE so unexpected dependencies fail loudly.
|
||||||
|
-- No custom PostgreSQL enum/domain types are owned by these 0.2.3/0.2.4 tables.
|
||||||
|
|
||||||
|
BEGIN;
|
||||||
|
|
||||||
|
DROP TABLE IF EXISTS kb_sol_core_balance_changes;
|
||||||
|
DROP TABLE IF EXISTS kb_sol_core_logs;
|
||||||
|
DROP TABLE IF EXISTS kb_sol_core_inner_instructions;
|
||||||
|
DROP TABLE IF EXISTS kb_sol_core_instructions;
|
||||||
|
DROP TABLE IF EXISTS kb_sol_core_account_keys;
|
||||||
|
DROP TABLE IF EXISTS kb_sol_core_transactions;
|
||||||
|
DROP TABLE IF EXISTS kb_sol_obs_transaction_observations;
|
||||||
|
DROP TABLE IF EXISTS kb_sol_raw_transactions;
|
||||||
|
|
||||||
|
COMMIT;
|
||||||
94
kb-store/migrations/0001_canonical_transaction_store.sql
Normal file
94
kb-store/migrations/0001_canonical_transaction_store.sql
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
-- file: kb-store/migrations/0001_canonical_transaction_store.sql
|
||||||
|
-- version: 1
|
||||||
|
|
||||||
|
-- Current canonical transaction and acquisition observation store.
|
||||||
|
-- This baseline contains only active tables and uses the current PostgreSQL schema.
|
||||||
|
-- Historical 0.2.x upgrade logic was validated before this baseline was consolidated.
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS kb_sol_raw_transactions (
|
||||||
|
id BIGSERIAL,
|
||||||
|
signature TEXT NOT NULL,
|
||||||
|
slot BIGINT NOT NULL,
|
||||||
|
canonical_json JSONB,
|
||||||
|
canonical_json_hash TEXT,
|
||||||
|
canonical_format_version INTEGER NOT NULL DEFAULT 1,
|
||||||
|
retention_state TEXT NOT NULL DEFAULT 'full',
|
||||||
|
processing_state TEXT NOT NULL DEFAULT 'received',
|
||||||
|
lifecycle_reason TEXT,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
CONSTRAINT pk_kb_sol_raw_transactions PRIMARY KEY (id),
|
||||||
|
CONSTRAINT ck_kb_sol_raw_transactions_signature_not_empty CHECK (length(btrim(signature)) > 0),
|
||||||
|
CONSTRAINT ck_kb_sol_raw_transactions_slot_non_negative CHECK (slot >= 0),
|
||||||
|
CONSTRAINT ck_kb_sol_raw_transactions_format_version_positive CHECK (canonical_format_version > 0),
|
||||||
|
CONSTRAINT ck_kb_sol_raw_transactions_retention_state CHECK (retention_state IN ('full', 'compacted', 'archived', 'purged')),
|
||||||
|
CONSTRAINT ck_kb_sol_raw_transactions_processing_state CHECK (processing_state IN ('received', 'core_extracted', 'decoded', 'materialized', 'failed')),
|
||||||
|
CONSTRAINT ck_kb_sol_raw_transactions_full_has_json CHECK (retention_state <> 'full' OR canonical_json IS NOT NULL)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS ux_kb_sol_raw_transactions_signature ON kb_sol_raw_transactions (signature);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS ix_kb_sol_raw_transactions_slot ON kb_sol_raw_transactions (slot);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS ix_kb_sol_raw_transactions_created_at ON kb_sol_raw_transactions (created_at);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS ix_kb_sol_raw_transactions_processing ON kb_sol_raw_transactions (processing_state);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS ix_kb_sol_raw_transactions_canonical_hash ON kb_sol_raw_transactions (canonical_json_hash) WHERE canonical_json_hash IS NOT NULL;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS kb_sol_obs_transaction_observations (
|
||||||
|
id BIGSERIAL,
|
||||||
|
raw_transaction_id BIGINT,
|
||||||
|
observation_key TEXT NOT NULL,
|
||||||
|
signature TEXT,
|
||||||
|
slot BIGINT,
|
||||||
|
provider TEXT NOT NULL,
|
||||||
|
endpoint_code TEXT,
|
||||||
|
protocol TEXT NOT NULL,
|
||||||
|
acquisition_method TEXT NOT NULL,
|
||||||
|
origin TEXT NOT NULL,
|
||||||
|
commitment TEXT,
|
||||||
|
capture_session_id TEXT,
|
||||||
|
filter_code TEXT,
|
||||||
|
detected_at TIMESTAMPTZ,
|
||||||
|
received_at TIMESTAMPTZ NOT NULL,
|
||||||
|
normalized_at TIMESTAMPTZ,
|
||||||
|
persisted_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
payload_size_bytes BIGINT,
|
||||||
|
source_payload_hash TEXT,
|
||||||
|
status TEXT NOT NULL DEFAULT 'received',
|
||||||
|
error_code TEXT,
|
||||||
|
error_message TEXT,
|
||||||
|
CONSTRAINT pk_kb_sol_obs_transaction_observations PRIMARY KEY (id),
|
||||||
|
CONSTRAINT fk_kb_sol_obs_transaction_observations_raw_transaction FOREIGN KEY (raw_transaction_id) REFERENCES kb_sol_raw_transactions(id) ON DELETE SET NULL,
|
||||||
|
CONSTRAINT ck_kb_sol_obs_transaction_observations_key_not_empty CHECK (length(btrim(observation_key)) > 0),
|
||||||
|
CONSTRAINT ck_kb_sol_obs_transaction_observations_signature_not_empty CHECK (signature IS NULL OR length(btrim(signature)) > 0),
|
||||||
|
CONSTRAINT ck_kb_sol_obs_transaction_observations_slot_non_negative CHECK (slot IS NULL OR slot >= 0),
|
||||||
|
CONSTRAINT ck_kb_sol_obs_transaction_observations_provider_not_empty CHECK (length(btrim(provider)) > 0),
|
||||||
|
CONSTRAINT ck_kb_sol_obs_transaction_observations_endpoint_not_empty CHECK (endpoint_code IS NULL OR length(btrim(endpoint_code)) > 0),
|
||||||
|
CONSTRAINT ck_kb_sol_obs_transaction_observations_protocol_not_empty CHECK (length(btrim(protocol)) > 0),
|
||||||
|
CONSTRAINT ck_kb_sol_obs_transaction_observations_method_not_empty CHECK (length(btrim(acquisition_method)) > 0),
|
||||||
|
CONSTRAINT ck_kb_sol_obs_transaction_observations_origin CHECK (origin IN ('live', 'backfill', 'replay', 'repair', 'migration')),
|
||||||
|
CONSTRAINT ck_kb_sol_obs_transaction_observations_commitment_not_empty CHECK (commitment IS NULL OR length(btrim(commitment)) > 0),
|
||||||
|
CONSTRAINT ck_kb_sol_obs_transaction_observations_session_not_empty CHECK (capture_session_id IS NULL OR length(btrim(capture_session_id)) > 0),
|
||||||
|
CONSTRAINT ck_kb_sol_obs_transaction_observations_filter_not_empty CHECK (filter_code IS NULL OR length(btrim(filter_code)) > 0),
|
||||||
|
CONSTRAINT ck_kb_sol_obs_transaction_observations_payload_size_non_negative CHECK (payload_size_bytes IS NULL OR payload_size_bytes >= 0),
|
||||||
|
CONSTRAINT ck_kb_sol_obs_transaction_observations_hash_not_empty CHECK (source_payload_hash IS NULL OR length(btrim(source_payload_hash)) > 0),
|
||||||
|
CONSTRAINT ck_kb_sol_obs_transaction_observations_status CHECK (status IN ('detected', 'received', 'normalized', 'persisted', 'failed', 'missing')),
|
||||||
|
CONSTRAINT ck_kb_sol_obs_transaction_observations_error_code_not_empty CHECK (error_code IS NULL OR length(btrim(error_code)) > 0),
|
||||||
|
CONSTRAINT ck_kb_sol_obs_transaction_observations_error_message_not_empty CHECK (error_message IS NULL OR length(btrim(error_message)) > 0)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS ux_kb_sol_obs_transaction_observations_key ON kb_sol_obs_transaction_observations (observation_key);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS ix_kb_sol_obs_transaction_observations_signature ON kb_sol_obs_transaction_observations (signature) WHERE signature IS NOT NULL;
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS ix_kb_sol_obs_transaction_observations_slot ON kb_sol_obs_transaction_observations (slot) WHERE slot IS NOT NULL;
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS ix_kb_sol_obs_transaction_observations_provider_method ON kb_sol_obs_transaction_observations (provider, acquisition_method);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS ix_kb_sol_obs_transaction_observations_received_at ON kb_sol_obs_transaction_observations (received_at);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS ix_kb_sol_obs_transaction_observations_raw_transaction ON kb_sol_obs_transaction_observations (raw_transaction_id) WHERE raw_transaction_id IS NOT NULL;
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS ix_kb_sol_obs_transaction_observations_status ON kb_sol_obs_transaction_observations (status);
|
||||||
196
kb-store/migrations/0002_core_store.sql
Normal file
196
kb-store/migrations/0002_core_store.sql
Normal file
@@ -0,0 +1,196 @@
|
|||||||
|
-- file: kb-store/migrations/0002_core_store.sql
|
||||||
|
-- version: 1
|
||||||
|
|
||||||
|
-- Current normalized Solana core store.
|
||||||
|
-- This migration uses the current PostgreSQL schema from the active profile.
|
||||||
|
-- It must not create application schemas such as raw, core, obs, decode, mat or catalog.
|
||||||
|
-- Object naming convention: pk_ for primary keys, fk_ for foreign keys, ux_ for unique indexes and ix_ for non-unique indexes.
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS kb_sol_core_transactions (
|
||||||
|
id BIGSERIAL,
|
||||||
|
raw_transaction_id BIGINT,
|
||||||
|
signature TEXT NOT NULL,
|
||||||
|
slot BIGINT NOT NULL,
|
||||||
|
failed BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
|
err_json JSONB,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
CONSTRAINT pk_kb_sol_core_transactions PRIMARY KEY (id),
|
||||||
|
CONSTRAINT fk_kb_sol_core_transactions_raw_transaction FOREIGN KEY (raw_transaction_id) REFERENCES kb_sol_raw_transactions(id) ON DELETE SET NULL,
|
||||||
|
CONSTRAINT ck_kb_sol_core_transactions_signature_not_empty CHECK (length(btrim(signature)) > 0),
|
||||||
|
CONSTRAINT ck_kb_sol_core_transactions_slot_non_negative CHECK (slot >= 0)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS ux_kb_sol_core_transactions_signature
|
||||||
|
ON kb_sol_core_transactions (signature);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS ix_kb_sol_core_transactions_slot
|
||||||
|
ON kb_sol_core_transactions (slot);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS ix_kb_sol_core_transactions_created_at
|
||||||
|
ON kb_sol_core_transactions (created_at);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS kb_sol_core_account_keys (
|
||||||
|
id BIGSERIAL,
|
||||||
|
transaction_id BIGINT NOT NULL,
|
||||||
|
signature TEXT NOT NULL,
|
||||||
|
slot BIGINT NOT NULL,
|
||||||
|
account_index INTEGER NOT NULL,
|
||||||
|
account_key TEXT NOT NULL,
|
||||||
|
source TEXT NOT NULL,
|
||||||
|
writable BOOLEAN NOT NULL,
|
||||||
|
signer BOOLEAN NOT NULL,
|
||||||
|
executable BOOLEAN,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
CONSTRAINT pk_kb_sol_core_account_keys PRIMARY KEY (id),
|
||||||
|
CONSTRAINT fk_kb_sol_core_account_keys_transaction FOREIGN KEY (transaction_id) REFERENCES kb_sol_core_transactions(id) ON DELETE CASCADE,
|
||||||
|
CONSTRAINT ck_kb_sol_core_account_keys_signature_not_empty CHECK (length(btrim(signature)) > 0),
|
||||||
|
CONSTRAINT ck_kb_sol_core_account_keys_slot_non_negative CHECK (slot >= 0),
|
||||||
|
CONSTRAINT ck_kb_sol_core_account_keys_index_non_negative CHECK (account_index >= 0),
|
||||||
|
CONSTRAINT ck_kb_sol_core_account_keys_key_not_empty CHECK (length(btrim(account_key)) > 0),
|
||||||
|
CONSTRAINT ck_kb_sol_core_account_keys_source CHECK (source IN ('static', 'loaded_writable', 'loaded_readonly'))
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS ux_kb_sol_core_account_keys_sig_index
|
||||||
|
ON kb_sol_core_account_keys (signature, account_index);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS ix_kb_sol_core_account_keys_key
|
||||||
|
ON kb_sol_core_account_keys (account_key);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS kb_sol_core_instructions (
|
||||||
|
id BIGSERIAL,
|
||||||
|
transaction_id BIGINT NOT NULL,
|
||||||
|
signature TEXT NOT NULL,
|
||||||
|
slot BIGINT NOT NULL,
|
||||||
|
instruction_path TEXT NOT NULL,
|
||||||
|
program_id TEXT NOT NULL,
|
||||||
|
accounts_json JSONB NOT NULL,
|
||||||
|
payload_json JSONB,
|
||||||
|
payload_json_hash TEXT,
|
||||||
|
processing_state TEXT NOT NULL DEFAULT 'pending',
|
||||||
|
processor_name TEXT,
|
||||||
|
processor_version TEXT,
|
||||||
|
lifecycle_reason TEXT,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
CONSTRAINT pk_kb_sol_core_instructions PRIMARY KEY (id),
|
||||||
|
CONSTRAINT fk_kb_sol_core_instructions_transaction FOREIGN KEY (transaction_id) REFERENCES kb_sol_core_transactions(id) ON DELETE CASCADE,
|
||||||
|
CONSTRAINT ck_kb_sol_core_instructions_signature_not_empty CHECK (length(btrim(signature)) > 0),
|
||||||
|
CONSTRAINT ck_kb_sol_core_instructions_slot_non_negative CHECK (slot >= 0),
|
||||||
|
CONSTRAINT ck_kb_sol_core_instructions_path_not_empty CHECK (length(btrim(instruction_path)) > 0),
|
||||||
|
CONSTRAINT ck_kb_sol_core_instructions_program_not_empty CHECK (length(btrim(program_id)) > 0),
|
||||||
|
CONSTRAINT ck_kb_sol_core_instructions_processing_state CHECK (processing_state IN ('pending', 'decoded', 'materialized', 'ignored', 'failed', 'replay_requested'))
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS ux_kb_sol_core_instructions_sig_path
|
||||||
|
ON kb_sol_core_instructions (signature, instruction_path);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS ix_kb_sol_core_instructions_program
|
||||||
|
ON kb_sol_core_instructions (program_id);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS ix_kb_sol_core_instructions_slot
|
||||||
|
ON kb_sol_core_instructions (slot);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS ix_kb_sol_core_instructions_processing
|
||||||
|
ON kb_sol_core_instructions (processing_state);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS kb_sol_core_inner_instructions (
|
||||||
|
id BIGSERIAL,
|
||||||
|
transaction_id BIGINT NOT NULL,
|
||||||
|
signature TEXT NOT NULL,
|
||||||
|
slot BIGINT NOT NULL,
|
||||||
|
parent_instruction_path TEXT NOT NULL,
|
||||||
|
instruction_path TEXT NOT NULL,
|
||||||
|
program_id TEXT NOT NULL,
|
||||||
|
accounts_json JSONB NOT NULL,
|
||||||
|
payload_json JSONB,
|
||||||
|
payload_json_hash TEXT,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
CONSTRAINT pk_kb_sol_core_inner_instructions PRIMARY KEY (id),
|
||||||
|
CONSTRAINT fk_kb_sol_core_inner_instructions_transaction FOREIGN KEY (transaction_id) REFERENCES kb_sol_core_transactions(id) ON DELETE CASCADE,
|
||||||
|
CONSTRAINT ck_kb_sol_core_inner_instructions_signature_not_empty CHECK (length(btrim(signature)) > 0),
|
||||||
|
CONSTRAINT ck_kb_sol_core_inner_instructions_slot_non_negative CHECK (slot >= 0),
|
||||||
|
CONSTRAINT ck_kb_sol_core_inner_instructions_parent_not_empty CHECK (length(btrim(parent_instruction_path)) > 0),
|
||||||
|
CONSTRAINT ck_kb_sol_core_inner_instructions_path_not_empty CHECK (length(btrim(instruction_path)) > 0),
|
||||||
|
CONSTRAINT ck_kb_sol_core_inner_instructions_program_not_empty CHECK (length(btrim(program_id)) > 0)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS ux_kb_sol_core_inner_instructions_sig_path
|
||||||
|
ON kb_sol_core_inner_instructions (signature, instruction_path);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS ix_kb_sol_core_inner_instructions_parent
|
||||||
|
ON kb_sol_core_inner_instructions (signature, parent_instruction_path);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS ix_kb_sol_core_inner_instructions_program
|
||||||
|
ON kb_sol_core_inner_instructions (program_id);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS kb_sol_core_logs (
|
||||||
|
id BIGSERIAL,
|
||||||
|
transaction_id BIGINT NOT NULL,
|
||||||
|
signature TEXT NOT NULL,
|
||||||
|
slot BIGINT NOT NULL,
|
||||||
|
log_index INTEGER NOT NULL,
|
||||||
|
instruction_path TEXT,
|
||||||
|
program_id TEXT,
|
||||||
|
log_text TEXT,
|
||||||
|
log_text_hash TEXT,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
CONSTRAINT pk_kb_sol_core_logs PRIMARY KEY (id),
|
||||||
|
CONSTRAINT fk_kb_sol_core_logs_transaction FOREIGN KEY (transaction_id) REFERENCES kb_sol_core_transactions(id) ON DELETE CASCADE,
|
||||||
|
CONSTRAINT ck_kb_sol_core_logs_signature_not_empty CHECK (length(btrim(signature)) > 0),
|
||||||
|
CONSTRAINT ck_kb_sol_core_logs_slot_non_negative CHECK (slot >= 0),
|
||||||
|
CONSTRAINT ck_kb_sol_core_logs_index_non_negative CHECK (log_index >= 0),
|
||||||
|
CONSTRAINT ck_kb_sol_core_logs_text_not_empty CHECK (log_text IS NULL OR length(btrim(log_text)) > 0),
|
||||||
|
CONSTRAINT ck_kb_sol_core_logs_path_not_empty CHECK (instruction_path IS NULL OR length(btrim(instruction_path)) > 0),
|
||||||
|
CONSTRAINT ck_kb_sol_core_logs_program_not_empty CHECK (program_id IS NULL OR length(btrim(program_id)) > 0)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS ux_kb_sol_core_logs_sig_index
|
||||||
|
ON kb_sol_core_logs (signature, log_index);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS ix_kb_sol_core_logs_program
|
||||||
|
ON kb_sol_core_logs (program_id)
|
||||||
|
WHERE program_id IS NOT NULL;
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS ix_kb_sol_core_logs_path
|
||||||
|
ON kb_sol_core_logs (signature, instruction_path)
|
||||||
|
WHERE instruction_path IS NOT NULL;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS kb_sol_core_balance_changes (
|
||||||
|
id BIGSERIAL,
|
||||||
|
transaction_id BIGINT NOT NULL,
|
||||||
|
signature TEXT NOT NULL,
|
||||||
|
slot BIGINT NOT NULL,
|
||||||
|
balance_change_index INTEGER NOT NULL,
|
||||||
|
balance_kind TEXT NOT NULL,
|
||||||
|
account_index INTEGER,
|
||||||
|
account_key TEXT,
|
||||||
|
mint TEXT,
|
||||||
|
owner TEXT,
|
||||||
|
pre_balance_json JSONB,
|
||||||
|
post_balance_json JSONB,
|
||||||
|
delta_json JSONB,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
CONSTRAINT pk_kb_sol_core_balance_changes PRIMARY KEY (id),
|
||||||
|
CONSTRAINT fk_kb_sol_core_balance_changes_transaction FOREIGN KEY (transaction_id) REFERENCES kb_sol_core_transactions(id) ON DELETE CASCADE,
|
||||||
|
CONSTRAINT ck_kb_sol_core_balance_changes_signature_not_empty CHECK (length(btrim(signature)) > 0),
|
||||||
|
CONSTRAINT ck_kb_sol_core_balance_changes_slot_non_negative CHECK (slot >= 0),
|
||||||
|
CONSTRAINT ck_kb_sol_core_balance_changes_index_non_negative CHECK (balance_change_index >= 0),
|
||||||
|
CONSTRAINT ck_kb_sol_core_balance_changes_account_index_non_negative CHECK (account_index IS NULL OR account_index >= 0),
|
||||||
|
CONSTRAINT ck_kb_sol_core_balance_changes_kind CHECK (balance_kind IN ('native_lamports', 'token_amount')),
|
||||||
|
CONSTRAINT ck_kb_sol_core_balance_changes_account_not_empty CHECK (account_key IS NULL OR length(btrim(account_key)) > 0),
|
||||||
|
CONSTRAINT ck_kb_sol_core_balance_changes_mint_not_empty CHECK (mint IS NULL OR length(btrim(mint)) > 0),
|
||||||
|
CONSTRAINT ck_kb_sol_core_balance_changes_owner_not_empty CHECK (owner IS NULL OR length(btrim(owner)) > 0)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS ux_kb_sol_core_balance_changes_sig_index
|
||||||
|
ON kb_sol_core_balance_changes (signature, balance_change_index);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS ix_kb_sol_core_balance_changes_account
|
||||||
|
ON kb_sol_core_balance_changes (account_key)
|
||||||
|
WHERE account_key IS NOT NULL;
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS ix_kb_sol_core_balance_changes_mint
|
||||||
|
ON kb_sol_core_balance_changes (mint)
|
||||||
|
WHERE mint IS NOT NULL;
|
||||||
|
|
||||||
41
kb-store/migrations/0003_processing_ledger.sql
Normal file
41
kb-store/migrations/0003_processing_ledger.sql
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
-- file: kb-store/migrations/0003_processing_ledger.sql
|
||||||
|
-- version: 1
|
||||||
|
|
||||||
|
-- Current processing ledger for canonical to core extraction and later pipeline stages.
|
||||||
|
-- This migration uses the current PostgreSQL schema from the active profile.
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS kb_sol_ops_processing_ledger (
|
||||||
|
id BIGSERIAL,
|
||||||
|
stage TEXT NOT NULL,
|
||||||
|
processor_name TEXT NOT NULL,
|
||||||
|
processor_version TEXT NOT NULL,
|
||||||
|
input_key TEXT NOT NULL,
|
||||||
|
input_hash TEXT NOT NULL,
|
||||||
|
status TEXT NOT NULL,
|
||||||
|
attempt_count INTEGER NOT NULL DEFAULT 0,
|
||||||
|
started_at TIMESTAMPTZ,
|
||||||
|
finished_at TIMESTAMPTZ,
|
||||||
|
error_code TEXT,
|
||||||
|
error_message TEXT,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
CONSTRAINT pk_kb_sol_ops_processing_ledger PRIMARY KEY (id),
|
||||||
|
CONSTRAINT ck_kb_sol_ops_processing_ledger_stage_not_empty CHECK (length(btrim(stage)) > 0),
|
||||||
|
CONSTRAINT ck_kb_sol_ops_processing_ledger_processor_name_not_empty CHECK (length(btrim(processor_name)) > 0),
|
||||||
|
CONSTRAINT ck_kb_sol_ops_processing_ledger_processor_version_not_empty CHECK (length(btrim(processor_version)) > 0),
|
||||||
|
CONSTRAINT ck_kb_sol_ops_processing_ledger_input_key_not_empty CHECK (length(btrim(input_key)) > 0),
|
||||||
|
CONSTRAINT ck_kb_sol_ops_processing_ledger_input_hash_not_empty CHECK (length(btrim(input_hash)) > 0),
|
||||||
|
CONSTRAINT ck_kb_sol_ops_processing_ledger_status CHECK (status IN ('running', 'succeeded', 'failed')),
|
||||||
|
CONSTRAINT ck_kb_sol_ops_processing_ledger_attempt_count_non_negative CHECK (attempt_count >= 0),
|
||||||
|
CONSTRAINT ck_kb_sol_ops_processing_ledger_error_code_not_empty CHECK (error_code IS NULL OR length(btrim(error_code)) > 0),
|
||||||
|
CONSTRAINT ck_kb_sol_ops_processing_ledger_error_message_not_empty CHECK (error_message IS NULL OR length(btrim(error_message)) > 0)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS ux_kb_sol_ops_processing_ledger_identity
|
||||||
|
ON kb_sol_ops_processing_ledger (stage, processor_name, processor_version, input_key);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS ix_kb_sol_ops_processing_ledger_status
|
||||||
|
ON kb_sol_ops_processing_ledger (stage, processor_name, status, updated_at);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS ix_kb_sol_ops_processing_ledger_input_hash
|
||||||
|
ON kb_sol_ops_processing_ledger (input_hash);
|
||||||
132
kb-store/migrations/0004_decode_materialization_store.sql
Normal file
132
kb-store/migrations/0004_decode_materialization_store.sql
Normal file
@@ -0,0 +1,132 @@
|
|||||||
|
-- file: kb-store/migrations/0004_decode_materialization_store.sql
|
||||||
|
-- version: 1
|
||||||
|
|
||||||
|
-- Common versioned decode, coverage and materialization store.
|
||||||
|
-- This migration uses the current PostgreSQL schema from the active profile.
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS kb_sol_decode_events (
|
||||||
|
id BIGSERIAL,
|
||||||
|
processor_name TEXT NOT NULL,
|
||||||
|
processor_version TEXT NOT NULL,
|
||||||
|
input_key TEXT NOT NULL,
|
||||||
|
input_hash TEXT NOT NULL,
|
||||||
|
event_key TEXT NOT NULL,
|
||||||
|
signature TEXT NOT NULL,
|
||||||
|
slot BIGINT NOT NULL,
|
||||||
|
instruction_path TEXT NOT NULL,
|
||||||
|
program_id TEXT NOT NULL,
|
||||||
|
protocol_code TEXT NOT NULL,
|
||||||
|
surface_code TEXT NOT NULL,
|
||||||
|
event_code TEXT NOT NULL,
|
||||||
|
event_name TEXT NOT NULL,
|
||||||
|
event_family TEXT NOT NULL,
|
||||||
|
source_kind TEXT NOT NULL,
|
||||||
|
confidence TEXT NOT NULL,
|
||||||
|
proof_kind TEXT NOT NULL,
|
||||||
|
proof_jsonb JSONB NOT NULL,
|
||||||
|
payload_jsonb JSONB NOT NULL,
|
||||||
|
transaction_failed BOOLEAN NOT NULL,
|
||||||
|
transaction_error_jsonb JSONB,
|
||||||
|
observation_committed BOOLEAN NOT NULL,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
CONSTRAINT pk_kb_sol_decode_events PRIMARY KEY (id),
|
||||||
|
CONSTRAINT ck_kb_sol_decode_events_slot_non_negative CHECK (slot >= 0),
|
||||||
|
CONSTRAINT ck_kb_sol_decode_events_failed_not_committed CHECK (NOT transaction_failed OR NOT observation_committed)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS ux_kb_sol_decode_events_processor_input_event
|
||||||
|
ON kb_sol_decode_events (processor_name, processor_version, input_key, event_key);
|
||||||
|
CREATE INDEX IF NOT EXISTS ix_kb_sol_decode_events_signature_path
|
||||||
|
ON kb_sol_decode_events (signature, instruction_path);
|
||||||
|
CREATE INDEX IF NOT EXISTS ix_kb_sol_decode_events_program_surface
|
||||||
|
ON kb_sol_decode_events (program_id, surface_code, event_code);
|
||||||
|
CREATE INDEX IF NOT EXISTS ix_kb_sol_decode_events_family_commit
|
||||||
|
ON kb_sol_decode_events (event_family, transaction_failed, observation_committed);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS kb_sol_decode_coverage_declarations (
|
||||||
|
id BIGSERIAL,
|
||||||
|
processor_name TEXT NOT NULL,
|
||||||
|
processor_version TEXT NOT NULL,
|
||||||
|
program_id TEXT NOT NULL,
|
||||||
|
surface_code TEXT,
|
||||||
|
entry_kind TEXT NOT NULL,
|
||||||
|
entry_code TEXT NOT NULL,
|
||||||
|
discriminator_hex TEXT,
|
||||||
|
historical BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
CONSTRAINT pk_kb_sol_decode_coverage_declarations PRIMARY KEY (id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS ux_kb_sol_decode_coverage_declarations_identity
|
||||||
|
ON kb_sol_decode_coverage_declarations (
|
||||||
|
processor_name,
|
||||||
|
processor_version,
|
||||||
|
program_id,
|
||||||
|
COALESCE(surface_code, ''),
|
||||||
|
entry_kind,
|
||||||
|
entry_code,
|
||||||
|
COALESCE(discriminator_hex, '')
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS ix_kb_sol_decode_coverage_declarations_program
|
||||||
|
ON kb_sol_decode_coverage_declarations (program_id, processor_name, processor_version);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS kb_sol_decode_coverage_observations (
|
||||||
|
id BIGSERIAL,
|
||||||
|
processor_name TEXT NOT NULL,
|
||||||
|
processor_version TEXT NOT NULL,
|
||||||
|
input_key TEXT NOT NULL,
|
||||||
|
input_hash TEXT NOT NULL,
|
||||||
|
signature TEXT NOT NULL,
|
||||||
|
slot BIGINT NOT NULL,
|
||||||
|
instruction_path TEXT NOT NULL,
|
||||||
|
program_id TEXT NOT NULL,
|
||||||
|
surface_code TEXT,
|
||||||
|
entry_code TEXT,
|
||||||
|
discriminator_hex TEXT,
|
||||||
|
status TEXT NOT NULL,
|
||||||
|
recognized BOOLEAN NOT NULL,
|
||||||
|
decoded_count INTEGER NOT NULL DEFAULT 0,
|
||||||
|
materialized_count INTEGER NOT NULL DEFAULT 0,
|
||||||
|
error_count INTEGER NOT NULL DEFAULT 0,
|
||||||
|
transaction_failed BOOLEAN NOT NULL,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
CONSTRAINT pk_kb_sol_decode_coverage_observations PRIMARY KEY (id),
|
||||||
|
CONSTRAINT ck_kb_sol_decode_coverage_observations_slot_non_negative CHECK (slot >= 0),
|
||||||
|
CONSTRAINT ck_kb_sol_decode_coverage_observations_counts_non_negative CHECK (decoded_count >= 0 AND materialized_count >= 0 AND error_count >= 0)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS ux_kb_sol_decode_coverage_observations_identity
|
||||||
|
ON kb_sol_decode_coverage_observations (processor_name, processor_version, input_key);
|
||||||
|
CREATE INDEX IF NOT EXISTS ix_kb_sol_decode_coverage_observations_program_status
|
||||||
|
ON kb_sol_decode_coverage_observations (program_id, status, transaction_failed);
|
||||||
|
CREATE INDEX IF NOT EXISTS ix_kb_sol_decode_coverage_observations_entry
|
||||||
|
ON kb_sol_decode_coverage_observations (processor_name, processor_version, surface_code, entry_code);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS kb_sol_mat_events (
|
||||||
|
id BIGSERIAL,
|
||||||
|
processor_name TEXT NOT NULL,
|
||||||
|
processor_version TEXT NOT NULL,
|
||||||
|
input_key TEXT NOT NULL,
|
||||||
|
input_hash TEXT NOT NULL,
|
||||||
|
output_key TEXT NOT NULL,
|
||||||
|
source_event_key TEXT NOT NULL,
|
||||||
|
source_decoder_name TEXT NOT NULL,
|
||||||
|
source_decoder_version TEXT NOT NULL,
|
||||||
|
source_decode_input_key TEXT NOT NULL,
|
||||||
|
signature TEXT NOT NULL,
|
||||||
|
slot BIGINT NOT NULL,
|
||||||
|
materialized_family TEXT NOT NULL,
|
||||||
|
payload_jsonb JSONB NOT NULL,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
CONSTRAINT pk_kb_sol_mat_events PRIMARY KEY (id),
|
||||||
|
CONSTRAINT ck_kb_sol_mat_events_slot_non_negative CHECK (slot >= 0)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS ux_kb_sol_mat_events_processor_input_output
|
||||||
|
ON kb_sol_mat_events (processor_name, processor_version, input_key, output_key);
|
||||||
|
CREATE INDEX IF NOT EXISTS ix_kb_sol_mat_events_signature_family
|
||||||
|
ON kb_sol_mat_events (source_decoder_name, source_decoder_version, source_decode_input_key, signature, materialized_family);
|
||||||
7
kb-store/src/constants.rs
Normal file
7
kb-store/src/constants.rs
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
// file: kb-store/src/constants.rs
|
||||||
|
// version: 1
|
||||||
|
|
||||||
|
//! Local constants for the `kb-store` crate.
|
||||||
|
|
||||||
|
/// Canonical tracing target for this crate.
|
||||||
|
pub(crate) const TRACING_TARGET: &str = "kb-store";
|
||||||
152
kb-store/src/contracts.rs
Normal file
152
kb-store/src/contracts.rs
Normal file
@@ -0,0 +1,152 @@
|
|||||||
|
// file: kb-store/src/contracts.rs
|
||||||
|
// version: 1
|
||||||
|
|
||||||
|
//! Backend-neutral storage contracts used by pipeline crates.
|
||||||
|
|
||||||
|
mod dto;
|
||||||
|
mod entity;
|
||||||
|
mod error;
|
||||||
|
mod health;
|
||||||
|
mod pagination;
|
||||||
|
mod repository;
|
||||||
|
|
||||||
|
/// Core account key insert contract.
|
||||||
|
pub use crate::contracts::dto::CoreAccountKeyInsert;
|
||||||
|
/// Core account key source category.
|
||||||
|
pub use crate::contracts::dto::CoreAccountKeySource;
|
||||||
|
/// Core balance change insert contract.
|
||||||
|
pub use crate::contracts::dto::CoreBalanceChangeInsert;
|
||||||
|
/// Core balance change kind.
|
||||||
|
pub use crate::contracts::dto::CoreBalanceChangeKind;
|
||||||
|
/// Complete normalized core extraction write bundle.
|
||||||
|
pub use crate::contracts::dto::CoreExtractionBundle;
|
||||||
|
/// Failure details persisted for one canonical to core extraction attempt.
|
||||||
|
pub use crate::contracts::dto::CoreExtractionFailure;
|
||||||
|
/// Bounded canonical transaction selection filter for core extraction.
|
||||||
|
pub use crate::contracts::dto::CoreExtractionSelectionFilter;
|
||||||
|
/// Core inner instruction insert contract.
|
||||||
|
pub use crate::contracts::dto::CoreInnerInstructionInsert;
|
||||||
|
/// Core instruction insert contract.
|
||||||
|
pub use crate::contracts::dto::CoreInstructionInsert;
|
||||||
|
/// Core instruction lifecycle mark request.
|
||||||
|
pub use crate::contracts::dto::CoreInstructionLifecycleMark;
|
||||||
|
/// Core instruction processing state.
|
||||||
|
pub use crate::contracts::dto::CoreInstructionProcessingState;
|
||||||
|
/// Core instruction replay filter contract.
|
||||||
|
pub use crate::contracts::dto::CoreInstructionReplayFilter;
|
||||||
|
/// Core log insert contract.
|
||||||
|
pub use crate::contracts::dto::CoreLogInsert;
|
||||||
|
/// Core transaction insert contract.
|
||||||
|
pub use crate::contracts::dto::CoreTransactionInsert;
|
||||||
|
/// One machine-readable decoder coverage declaration row.
|
||||||
|
pub use crate::contracts::dto::DecodeCoverageDeclarationInsert;
|
||||||
|
/// One observed coverage classification row owned by one decode attempt.
|
||||||
|
pub use crate::contracts::dto::DecodeCoverageObservationInsert;
|
||||||
|
/// One row of aggregated decoder coverage diagnostics.
|
||||||
|
pub use crate::contracts::dto::DecodeCoverageSummaryRow;
|
||||||
|
/// Failed decode attempt persisted in the common ledger.
|
||||||
|
pub use crate::contracts::dto::DecodeFailure;
|
||||||
|
/// One processor-owned decoded observation row.
|
||||||
|
pub use crate::contracts::dto::DecodeObservationInsert;
|
||||||
|
/// Atomic persistence bundle for one decoder and one contextual input.
|
||||||
|
pub use crate::contracts::dto::DecodePersistenceBundle;
|
||||||
|
/// Bounded contextual instruction selection filter for decode campaigns.
|
||||||
|
pub use crate::contracts::dto::DecodeSelectionFilter;
|
||||||
|
/// Decoded event insert contract.
|
||||||
|
pub use crate::contracts::dto::DecodedEventInsert;
|
||||||
|
/// Insert or upsert result contract returned by repositories.
|
||||||
|
pub use crate::contracts::dto::InsertOutcome;
|
||||||
|
/// Maximum number of materialized rows returned by one bounded query.
|
||||||
|
pub use crate::contracts::dto::MAX_MATERIALIZED_EVENT_QUERY_ROWS;
|
||||||
|
/// Atomic persistence bundle for one materializer and one decoded observation.
|
||||||
|
pub use crate::contracts::dto::MaterializationPersistenceBundle;
|
||||||
|
/// Bounded read-only materialized event selection.
|
||||||
|
pub use crate::contracts::dto::MaterializedEventFilter;
|
||||||
|
/// Materialized event insert contract.
|
||||||
|
pub use crate::contracts::dto::MaterializedEventInsert;
|
||||||
|
/// One materialized output returned by a bounded query.
|
||||||
|
pub use crate::contracts::dto::MaterializedEventQueryRow;
|
||||||
|
/// One processor-owned materialized output row.
|
||||||
|
pub use crate::contracts::dto::MaterializedOutputInsert;
|
||||||
|
/// Stable processing ledger identity.
|
||||||
|
pub use crate::contracts::dto::ProcessingLedgerIdentity;
|
||||||
|
/// Processing ledger mark request contract.
|
||||||
|
pub use crate::contracts::dto::ProcessingLedgerMark;
|
||||||
|
/// Stable processing ledger status.
|
||||||
|
pub use crate::contracts::dto::ProcessingLedgerStatus;
|
||||||
|
/// Raw payload lifecycle mark request.
|
||||||
|
pub use crate::contracts::dto::RawPayloadLifecycleMark;
|
||||||
|
/// Raw payload processing state.
|
||||||
|
pub use crate::contracts::dto::RawPayloadProcessingState;
|
||||||
|
/// Raw payload retention state.
|
||||||
|
pub use crate::contracts::dto::RawPayloadRetentionState;
|
||||||
|
/// Canonical raw Solana transaction insert contract.
|
||||||
|
pub use crate::contracts::dto::RawTransactionInsert;
|
||||||
|
/// Store backend diagnostic contract.
|
||||||
|
pub use crate::contracts::dto::StoreBackendDescriptor;
|
||||||
|
/// Store backend kind contract.
|
||||||
|
pub use crate::contracts::dto::StoreBackendKind;
|
||||||
|
/// Store migration diagnostic snapshot contract.
|
||||||
|
pub use crate::contracts::dto::StoreMigrationSnapshot;
|
||||||
|
/// Store migration status contract.
|
||||||
|
pub use crate::contracts::dto::StoreMigrationStatus;
|
||||||
|
/// Transaction acquisition observation insert contract.
|
||||||
|
pub use crate::contracts::dto::TransactionObservationInsert;
|
||||||
|
/// Transaction acquisition observation origin.
|
||||||
|
pub use crate::contracts::dto::TransactionObservationOrigin;
|
||||||
|
/// Transaction acquisition observation status.
|
||||||
|
pub use crate::contracts::dto::TransactionObservationStatus;
|
||||||
|
/// Core account key SQL-like row contract.
|
||||||
|
pub use crate::contracts::entity::CoreAccountKeyRow;
|
||||||
|
/// Core balance change SQL-like row contract.
|
||||||
|
pub use crate::contracts::entity::CoreBalanceChangeRow;
|
||||||
|
/// Core inner instruction SQL-like row contract.
|
||||||
|
pub use crate::contracts::entity::CoreInnerInstructionRow;
|
||||||
|
/// Core instruction SQL-like row contract.
|
||||||
|
pub use crate::contracts::entity::CoreInstructionRow;
|
||||||
|
/// Core log SQL-like row contract.
|
||||||
|
pub use crate::contracts::entity::CoreLogRow;
|
||||||
|
/// Core transaction SQL-like row contract.
|
||||||
|
pub use crate::contracts::entity::CoreTransactionRow;
|
||||||
|
/// Decoded event SQL-like row contract.
|
||||||
|
pub use crate::contracts::entity::DecodedEventRow;
|
||||||
|
/// Materialized event SQL-like row contract.
|
||||||
|
pub use crate::contracts::entity::MaterializedEventRow;
|
||||||
|
/// Processing ledger SQL-like row contract.
|
||||||
|
pub use crate::contracts::entity::ProcessingLedgerRow;
|
||||||
|
/// Canonical raw Solana transaction SQL-like row contract.
|
||||||
|
pub use crate::contracts::entity::RawTransactionRow;
|
||||||
|
/// Transaction acquisition observation SQL-like row contract.
|
||||||
|
pub use crate::contracts::entity::TransactionObservationRow;
|
||||||
|
/// Storage error helper functions.
|
||||||
|
pub use crate::contracts::error::storage_contract_error;
|
||||||
|
/// Store backend health snapshot.
|
||||||
|
pub use crate::contracts::health::StoreHealthSnapshot;
|
||||||
|
/// Store backend health status.
|
||||||
|
pub use crate::contracts::health::StoreHealthStatus;
|
||||||
|
/// Default repository page size.
|
||||||
|
pub use crate::contracts::pagination::DEFAULT_PAGE_SIZE;
|
||||||
|
/// Maximum repository page size.
|
||||||
|
pub use crate::contracts::pagination::MAX_PAGE_SIZE;
|
||||||
|
/// Page request contract for repository list operations.
|
||||||
|
pub use crate::contracts::pagination::PageRequest;
|
||||||
|
/// Sort direction for repository list operations.
|
||||||
|
pub use crate::contracts::pagination::SortDirection;
|
||||||
|
/// Canonical transaction to core extraction storage behavior.
|
||||||
|
pub use crate::contracts::repository::CoreExtractionStore;
|
||||||
|
/// Core Solana storage behavior.
|
||||||
|
pub use crate::contracts::repository::CoreTransactionStore;
|
||||||
|
/// Contextual instruction decode and materialization storage behavior.
|
||||||
|
pub use crate::contracts::repository::DecodePipelineStore;
|
||||||
|
/// Decoded event storage behavior.
|
||||||
|
pub use crate::contracts::repository::DecodedEventStore;
|
||||||
|
/// Materialized event storage behavior.
|
||||||
|
pub use crate::contracts::repository::MaterializedEventStore;
|
||||||
|
/// Processing ledger storage behavior.
|
||||||
|
pub use crate::contracts::repository::ProcessingLedgerStore;
|
||||||
|
/// Program observation storage behavior.
|
||||||
|
pub use crate::contracts::repository::ProgramObservationStore;
|
||||||
|
/// Raw transaction storage behavior.
|
||||||
|
pub use crate::contracts::repository::RawTransactionStore;
|
||||||
|
/// Store health storage behavior.
|
||||||
|
pub use crate::contracts::repository::StoreHealthStore;
|
||||||
99
kb-store/src/contracts/dto.rs
Normal file
99
kb-store/src/contracts/dto.rs
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
// file: kb-store/src/contracts/dto.rs
|
||||||
|
// version: 1
|
||||||
|
|
||||||
|
//! Backend-neutral DTO exports for storage repository contracts.
|
||||||
|
|
||||||
|
mod core;
|
||||||
|
mod core_extraction;
|
||||||
|
mod decode;
|
||||||
|
mod event;
|
||||||
|
mod ledger;
|
||||||
|
mod raw;
|
||||||
|
mod store;
|
||||||
|
|
||||||
|
/// Core account key insert contract.
|
||||||
|
pub use crate::contracts::dto::core::CoreAccountKeyInsert;
|
||||||
|
/// Core account key source category.
|
||||||
|
pub use crate::contracts::dto::core::CoreAccountKeySource;
|
||||||
|
/// Core balance change insert contract.
|
||||||
|
pub use crate::contracts::dto::core::CoreBalanceChangeInsert;
|
||||||
|
/// Core balance change kind.
|
||||||
|
pub use crate::contracts::dto::core::CoreBalanceChangeKind;
|
||||||
|
/// Core inner instruction insert contract.
|
||||||
|
pub use crate::contracts::dto::core::CoreInnerInstructionInsert;
|
||||||
|
/// Core instruction insert contract.
|
||||||
|
pub use crate::contracts::dto::core::CoreInstructionInsert;
|
||||||
|
/// Core instruction lifecycle mark request.
|
||||||
|
pub use crate::contracts::dto::core::CoreInstructionLifecycleMark;
|
||||||
|
/// Core instruction processing state.
|
||||||
|
pub use crate::contracts::dto::core::CoreInstructionProcessingState;
|
||||||
|
/// Core instruction replay filter contract.
|
||||||
|
pub use crate::contracts::dto::core::CoreInstructionReplayFilter;
|
||||||
|
/// Core log insert contract.
|
||||||
|
pub use crate::contracts::dto::core::CoreLogInsert;
|
||||||
|
/// Core transaction insert contract.
|
||||||
|
pub use crate::contracts::dto::core::CoreTransactionInsert;
|
||||||
|
/// Complete normalized core extraction write bundle.
|
||||||
|
pub use crate::contracts::dto::core_extraction::CoreExtractionBundle;
|
||||||
|
/// Failure details persisted for one canonical to core extraction attempt.
|
||||||
|
pub use crate::contracts::dto::core_extraction::CoreExtractionFailure;
|
||||||
|
/// Bounded canonical transaction selection filter for core extraction.
|
||||||
|
pub use crate::contracts::dto::core_extraction::CoreExtractionSelectionFilter;
|
||||||
|
/// Stable processing ledger identity.
|
||||||
|
pub use crate::contracts::dto::core_extraction::ProcessingLedgerIdentity;
|
||||||
|
/// Stable processing ledger status.
|
||||||
|
pub use crate::contracts::dto::core_extraction::ProcessingLedgerStatus;
|
||||||
|
/// One machine-readable decoder coverage declaration row.
|
||||||
|
pub use crate::contracts::dto::decode::DecodeCoverageDeclarationInsert;
|
||||||
|
/// One observed coverage classification row owned by one decode attempt.
|
||||||
|
pub use crate::contracts::dto::decode::DecodeCoverageObservationInsert;
|
||||||
|
/// One row of aggregated decoder coverage diagnostics.
|
||||||
|
pub use crate::contracts::dto::decode::DecodeCoverageSummaryRow;
|
||||||
|
/// Failed decode attempt persisted in the common ledger.
|
||||||
|
pub use crate::contracts::dto::decode::DecodeFailure;
|
||||||
|
/// One processor-owned decoded observation row.
|
||||||
|
pub use crate::contracts::dto::decode::DecodeObservationInsert;
|
||||||
|
/// Atomic persistence bundle for one decoder and one contextual input.
|
||||||
|
pub use crate::contracts::dto::decode::DecodePersistenceBundle;
|
||||||
|
/// Bounded contextual instruction selection filter for decode campaigns.
|
||||||
|
pub use crate::contracts::dto::decode::DecodeSelectionFilter;
|
||||||
|
/// Maximum number of materialized rows returned by one bounded query.
|
||||||
|
pub use crate::contracts::dto::decode::MAX_MATERIALIZED_EVENT_QUERY_ROWS;
|
||||||
|
/// Atomic persistence bundle for one materializer and one decoded observation.
|
||||||
|
pub use crate::contracts::dto::decode::MaterializationPersistenceBundle;
|
||||||
|
/// Bounded read-only materialized event selection.
|
||||||
|
pub use crate::contracts::dto::decode::MaterializedEventFilter;
|
||||||
|
/// One materialized output returned by a bounded query.
|
||||||
|
pub use crate::contracts::dto::decode::MaterializedEventQueryRow;
|
||||||
|
/// One processor-owned materialized output row.
|
||||||
|
pub use crate::contracts::dto::decode::MaterializedOutputInsert;
|
||||||
|
/// Decoded event insert contract.
|
||||||
|
pub use crate::contracts::dto::event::DecodedEventInsert;
|
||||||
|
/// Insert or upsert result contract returned by repositories.
|
||||||
|
pub use crate::contracts::dto::event::InsertOutcome;
|
||||||
|
/// Materialized event insert contract.
|
||||||
|
pub use crate::contracts::dto::event::MaterializedEventInsert;
|
||||||
|
/// Processing ledger mark request contract.
|
||||||
|
pub use crate::contracts::dto::ledger::ProcessingLedgerMark;
|
||||||
|
/// Raw payload lifecycle mark request.
|
||||||
|
pub use crate::contracts::dto::raw::RawPayloadLifecycleMark;
|
||||||
|
/// Raw payload processing state.
|
||||||
|
pub use crate::contracts::dto::raw::RawPayloadProcessingState;
|
||||||
|
/// Raw payload retention state.
|
||||||
|
pub use crate::contracts::dto::raw::RawPayloadRetentionState;
|
||||||
|
/// Canonical raw Solana transaction insert contract.
|
||||||
|
pub use crate::contracts::dto::raw::RawTransactionInsert;
|
||||||
|
/// Transaction acquisition observation insert contract.
|
||||||
|
pub use crate::contracts::dto::raw::TransactionObservationInsert;
|
||||||
|
/// Transaction acquisition observation origin.
|
||||||
|
pub use crate::contracts::dto::raw::TransactionObservationOrigin;
|
||||||
|
/// Transaction acquisition observation status.
|
||||||
|
pub use crate::contracts::dto::raw::TransactionObservationStatus;
|
||||||
|
/// Store backend diagnostic contract.
|
||||||
|
pub use crate::contracts::dto::store::StoreBackendDescriptor;
|
||||||
|
/// Store backend kind contract.
|
||||||
|
pub use crate::contracts::dto::store::StoreBackendKind;
|
||||||
|
/// Store migration diagnostic snapshot contract.
|
||||||
|
pub use crate::contracts::dto::store::StoreMigrationSnapshot;
|
||||||
|
/// Store migration status contract.
|
||||||
|
pub use crate::contracts::dto::store::StoreMigrationStatus;
|
||||||
888
kb-store/src/contracts/dto/core.rs
Normal file
888
kb-store/src/contracts/dto/core.rs
Normal file
@@ -0,0 +1,888 @@
|
|||||||
|
// file: kb-store/src/contracts/dto/core.rs
|
||||||
|
// version: 1
|
||||||
|
|
||||||
|
//! Core Solana storage DTOs.
|
||||||
|
|
||||||
|
/// Processing state for one normalized core instruction.
|
||||||
|
#[derive(Clone, Copy, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
|
||||||
|
pub enum CoreInstructionProcessingState {
|
||||||
|
/// Instruction is available for replay or first processing.
|
||||||
|
Pending,
|
||||||
|
/// Instruction was decoded by at least one decoder version.
|
||||||
|
Decoded,
|
||||||
|
/// Instruction produced materialized outputs.
|
||||||
|
Materialized,
|
||||||
|
/// Instruction is intentionally skipped for the current pipeline policy.
|
||||||
|
Ignored,
|
||||||
|
/// Instruction processing failed and requires diagnostics.
|
||||||
|
Failed,
|
||||||
|
/// Instruction must be replayed even if a previous processor marked it.
|
||||||
|
ReplayRequested,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Source category for one normalized Solana account key.
|
||||||
|
#[derive(Clone, Copy, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
|
||||||
|
pub enum CoreAccountKeySource {
|
||||||
|
/// Account key came from the static transaction message account keys.
|
||||||
|
Static,
|
||||||
|
/// Account key came from loaded writable address table entries.
|
||||||
|
LoadedWritable,
|
||||||
|
/// Account key came from loaded readonly address table entries.
|
||||||
|
LoadedReadonly,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Balance change family extracted from Solana transaction metadata.
|
||||||
|
#[derive(Clone, Copy, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
|
||||||
|
pub enum CoreBalanceChangeKind {
|
||||||
|
/// Native lamports balance change.
|
||||||
|
NativeLamports,
|
||||||
|
/// SPL token balance change.
|
||||||
|
TokenAmount,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Core transaction insert contract.
|
||||||
|
#[derive(Clone, Debug, PartialEq, serde::Deserialize, serde::Serialize)]
|
||||||
|
pub struct CoreTransactionInsert {
|
||||||
|
/// Transaction signature as non-empty base58 text.
|
||||||
|
pub signature: std::string::String,
|
||||||
|
/// Transaction slot in Solana unsigned representation.
|
||||||
|
pub slot: u64,
|
||||||
|
/// Whether the transaction failed on-chain.
|
||||||
|
pub failed: bool,
|
||||||
|
/// Optional raw error JSON extracted from transaction metadata.
|
||||||
|
pub err_json: std::option::Option<serde_json::Value>,
|
||||||
|
/// Optional canonical raw transaction row id used for lineage when available.
|
||||||
|
pub raw_transaction_id: std::option::Option<i64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl CoreTransactionInsert {
|
||||||
|
/// Builds a core transaction insert contract after minimal validation.
|
||||||
|
pub fn new(
|
||||||
|
signature: impl std::convert::Into<std::string::String>,
|
||||||
|
slot: u64,
|
||||||
|
failed: bool,
|
||||||
|
err_json: std::option::Option<serde_json::Value>,
|
||||||
|
) -> kb_core::Result<Self> {
|
||||||
|
let signature_value = signature.into();
|
||||||
|
let signature_result = validate_required_text(
|
||||||
|
&signature_value,
|
||||||
|
"core transaction signature must not be empty",
|
||||||
|
);
|
||||||
|
if let std::result::Result::Err(error) = signature_result {
|
||||||
|
return std::result::Result::Err(error);
|
||||||
|
}
|
||||||
|
return std::result::Result::Ok(Self {
|
||||||
|
signature: signature_value,
|
||||||
|
slot,
|
||||||
|
failed,
|
||||||
|
err_json,
|
||||||
|
raw_transaction_id: std::option::Option::None,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Adds canonical raw transaction lineage to an already validated core transaction insert.
|
||||||
|
pub fn with_raw_transaction_id(mut self, raw_transaction_id: i64) -> Self {
|
||||||
|
self.raw_transaction_id = std::option::Option::Some(raw_transaction_id);
|
||||||
|
return self;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Core account key insert contract.
|
||||||
|
#[derive(Clone, Debug, PartialEq, serde::Deserialize, serde::Serialize)]
|
||||||
|
pub struct CoreAccountKeyInsert {
|
||||||
|
/// Transaction signature as non-empty base58 text.
|
||||||
|
pub signature: std::string::String,
|
||||||
|
/// Transaction slot in Solana unsigned representation.
|
||||||
|
pub slot: u64,
|
||||||
|
/// Stable account index after static and loaded keys are resolved.
|
||||||
|
pub account_index: u32,
|
||||||
|
/// Account public key as non-empty base58 text.
|
||||||
|
pub account_key: std::string::String,
|
||||||
|
/// Source category for this account key.
|
||||||
|
pub source: CoreAccountKeySource,
|
||||||
|
/// Whether the resolved account is writable for the transaction.
|
||||||
|
pub writable: bool,
|
||||||
|
/// Whether the resolved account signed the transaction.
|
||||||
|
pub signer: bool,
|
||||||
|
/// Whether the resolved account is executable when known.
|
||||||
|
pub executable: std::option::Option<bool>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl CoreAccountKeyInsert {
|
||||||
|
/// Builds a core account key insert contract after minimal validation.
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
|
pub fn new(
|
||||||
|
signature: impl std::convert::Into<std::string::String>,
|
||||||
|
slot: u64,
|
||||||
|
account_index: u32,
|
||||||
|
account_key: impl std::convert::Into<std::string::String>,
|
||||||
|
source: CoreAccountKeySource,
|
||||||
|
writable: bool,
|
||||||
|
signer: bool,
|
||||||
|
executable: std::option::Option<bool>,
|
||||||
|
) -> kb_core::Result<Self> {
|
||||||
|
let signature_value = signature.into();
|
||||||
|
let account_key_value = account_key.into();
|
||||||
|
let signature_result = validate_required_text(
|
||||||
|
&signature_value,
|
||||||
|
"core account key signature must not be empty",
|
||||||
|
);
|
||||||
|
if let std::result::Result::Err(error) = signature_result {
|
||||||
|
return std::result::Result::Err(error);
|
||||||
|
}
|
||||||
|
let account_key_result =
|
||||||
|
validate_required_text(&account_key_value, "core account key must not be empty");
|
||||||
|
if let std::result::Result::Err(error) = account_key_result {
|
||||||
|
return std::result::Result::Err(error);
|
||||||
|
}
|
||||||
|
return std::result::Result::Ok(Self {
|
||||||
|
signature: signature_value,
|
||||||
|
slot,
|
||||||
|
account_index,
|
||||||
|
account_key: account_key_value,
|
||||||
|
source,
|
||||||
|
writable,
|
||||||
|
signer,
|
||||||
|
executable,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Core instruction insert contract.
|
||||||
|
#[derive(Clone, Debug, PartialEq, serde::Deserialize, serde::Serialize)]
|
||||||
|
pub struct CoreInstructionInsert {
|
||||||
|
/// Transaction signature as non-empty base58 text.
|
||||||
|
pub signature: std::string::String,
|
||||||
|
/// Transaction slot in Solana unsigned representation.
|
||||||
|
pub slot: u64,
|
||||||
|
/// Stable instruction path, for example `0` or `2/1`.
|
||||||
|
pub instruction_path: std::string::String,
|
||||||
|
/// Program id as non-empty base58 text.
|
||||||
|
pub program_id: std::string::String,
|
||||||
|
/// Instruction accounts as JSON, preserving unresolved forms when needed.
|
||||||
|
pub accounts_json: serde_json::Value,
|
||||||
|
/// Instruction payload JSON, preserving raw and partially decoded forms when needed.
|
||||||
|
pub payload_json: serde_json::Value,
|
||||||
|
/// Optional deterministic payload JSON hash.
|
||||||
|
pub payload_json_hash: std::option::Option<std::string::String>,
|
||||||
|
/// Initial processing state used by replay schedulers.
|
||||||
|
pub processing_state: CoreInstructionProcessingState,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl CoreInstructionInsert {
|
||||||
|
/// Builds a core instruction insert contract after minimal validation.
|
||||||
|
pub fn new(
|
||||||
|
signature: impl std::convert::Into<std::string::String>,
|
||||||
|
slot: u64,
|
||||||
|
instruction_path: impl std::convert::Into<std::string::String>,
|
||||||
|
program_id: impl std::convert::Into<std::string::String>,
|
||||||
|
accounts_json: serde_json::Value,
|
||||||
|
payload_json: serde_json::Value,
|
||||||
|
) -> kb_core::Result<Self> {
|
||||||
|
let signature_value = signature.into();
|
||||||
|
let instruction_path_value = instruction_path.into();
|
||||||
|
let program_id_value = program_id.into();
|
||||||
|
let validation_result = validate_instruction_identity(
|
||||||
|
signature_value.as_str(),
|
||||||
|
instruction_path_value.as_str(),
|
||||||
|
program_id_value.as_str(),
|
||||||
|
"core instruction",
|
||||||
|
);
|
||||||
|
if let std::result::Result::Err(error) = validation_result {
|
||||||
|
return std::result::Result::Err(error);
|
||||||
|
}
|
||||||
|
return std::result::Result::Ok(Self {
|
||||||
|
signature: signature_value,
|
||||||
|
slot,
|
||||||
|
instruction_path: instruction_path_value,
|
||||||
|
program_id: program_id_value,
|
||||||
|
accounts_json,
|
||||||
|
payload_json,
|
||||||
|
payload_json_hash: std::option::Option::None,
|
||||||
|
processing_state: CoreInstructionProcessingState::Pending,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Adds a deterministic payload JSON hash.
|
||||||
|
pub fn with_payload_json_hash(
|
||||||
|
mut self,
|
||||||
|
payload_json_hash: impl std::convert::Into<std::string::String>,
|
||||||
|
) -> kb_core::Result<Self> {
|
||||||
|
let value = payload_json_hash.into();
|
||||||
|
if value.trim().is_empty() {
|
||||||
|
return std::result::Result::Err(kb_core::Error::db(
|
||||||
|
"core instruction payload hash must not be empty",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
self.payload_json_hash = std::option::Option::Some(value);
|
||||||
|
return std::result::Result::Ok(self);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Core inner instruction insert contract.
|
||||||
|
#[derive(Clone, Debug, PartialEq, serde::Deserialize, serde::Serialize)]
|
||||||
|
pub struct CoreInnerInstructionInsert {
|
||||||
|
/// Transaction signature as non-empty base58 text.
|
||||||
|
pub signature: std::string::String,
|
||||||
|
/// Transaction slot in Solana unsigned representation.
|
||||||
|
pub slot: u64,
|
||||||
|
/// Parent top-level or inner instruction path.
|
||||||
|
pub parent_instruction_path: std::string::String,
|
||||||
|
/// Stable inner instruction path, for example `2/1`.
|
||||||
|
pub instruction_path: std::string::String,
|
||||||
|
/// Program id as non-empty base58 text.
|
||||||
|
pub program_id: std::string::String,
|
||||||
|
/// Inner instruction accounts as JSON.
|
||||||
|
pub accounts_json: serde_json::Value,
|
||||||
|
/// Inner instruction payload JSON.
|
||||||
|
pub payload_json: serde_json::Value,
|
||||||
|
/// Optional deterministic payload JSON hash.
|
||||||
|
pub payload_json_hash: std::option::Option<std::string::String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl CoreInnerInstructionInsert {
|
||||||
|
/// Builds a core inner instruction insert contract after minimal validation.
|
||||||
|
pub fn new(
|
||||||
|
signature: impl std::convert::Into<std::string::String>,
|
||||||
|
slot: u64,
|
||||||
|
parent_instruction_path: impl std::convert::Into<std::string::String>,
|
||||||
|
instruction_path: impl std::convert::Into<std::string::String>,
|
||||||
|
program_id: impl std::convert::Into<std::string::String>,
|
||||||
|
accounts_json: serde_json::Value,
|
||||||
|
payload_json: serde_json::Value,
|
||||||
|
) -> kb_core::Result<Self> {
|
||||||
|
let signature_value = signature.into();
|
||||||
|
let parent_instruction_path_value = parent_instruction_path.into();
|
||||||
|
let instruction_path_value = instruction_path.into();
|
||||||
|
let program_id_value = program_id.into();
|
||||||
|
let parent_result = validate_required_text(
|
||||||
|
&parent_instruction_path_value,
|
||||||
|
"core inner instruction parent path must not be empty",
|
||||||
|
);
|
||||||
|
if let std::result::Result::Err(error) = parent_result {
|
||||||
|
return std::result::Result::Err(error);
|
||||||
|
}
|
||||||
|
let validation_result = validate_instruction_identity(
|
||||||
|
signature_value.as_str(),
|
||||||
|
instruction_path_value.as_str(),
|
||||||
|
program_id_value.as_str(),
|
||||||
|
"core inner instruction",
|
||||||
|
);
|
||||||
|
if let std::result::Result::Err(error) = validation_result {
|
||||||
|
return std::result::Result::Err(error);
|
||||||
|
}
|
||||||
|
return std::result::Result::Ok(Self {
|
||||||
|
signature: signature_value,
|
||||||
|
slot,
|
||||||
|
parent_instruction_path: parent_instruction_path_value,
|
||||||
|
instruction_path: instruction_path_value,
|
||||||
|
program_id: program_id_value,
|
||||||
|
accounts_json,
|
||||||
|
payload_json,
|
||||||
|
payload_json_hash: std::option::Option::None,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Adds a deterministic payload JSON hash.
|
||||||
|
pub fn with_payload_json_hash(
|
||||||
|
mut self,
|
||||||
|
payload_json_hash: impl std::convert::Into<std::string::String>,
|
||||||
|
) -> kb_core::Result<Self> {
|
||||||
|
let value = payload_json_hash.into();
|
||||||
|
if value.trim().is_empty() {
|
||||||
|
return std::result::Result::Err(kb_core::Error::db(
|
||||||
|
"core inner instruction payload hash must not be empty",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
self.payload_json_hash = std::option::Option::Some(value);
|
||||||
|
return std::result::Result::Ok(self);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Core log insert contract.
|
||||||
|
#[derive(Clone, Debug, PartialEq, serde::Deserialize, serde::Serialize)]
|
||||||
|
pub struct CoreLogInsert {
|
||||||
|
/// Transaction signature as non-empty base58 text.
|
||||||
|
pub signature: std::string::String,
|
||||||
|
/// Transaction slot in Solana unsigned representation.
|
||||||
|
pub slot: u64,
|
||||||
|
/// Log index preserving transaction log order.
|
||||||
|
pub log_index: u32,
|
||||||
|
/// Optional instruction path resolved from invocation depth when known.
|
||||||
|
pub instruction_path: std::option::Option<std::string::String>,
|
||||||
|
/// Optional program id resolved from the log line or invocation context.
|
||||||
|
pub program_id: std::option::Option<std::string::String>,
|
||||||
|
/// Original log text.
|
||||||
|
pub log_text: std::string::String,
|
||||||
|
/// Optional deterministic log text hash.
|
||||||
|
pub log_text_hash: std::option::Option<std::string::String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl CoreLogInsert {
|
||||||
|
/// Builds a core log insert contract after minimal validation.
|
||||||
|
pub fn new(
|
||||||
|
signature: impl std::convert::Into<std::string::String>,
|
||||||
|
slot: u64,
|
||||||
|
log_index: u32,
|
||||||
|
instruction_path: std::option::Option<std::string::String>,
|
||||||
|
program_id: std::option::Option<std::string::String>,
|
||||||
|
log_text: impl std::convert::Into<std::string::String>,
|
||||||
|
) -> kb_core::Result<Self> {
|
||||||
|
let signature_value = signature.into();
|
||||||
|
let log_text_value = log_text.into();
|
||||||
|
let signature_result =
|
||||||
|
validate_required_text(&signature_value, "core log signature must not be empty");
|
||||||
|
if let std::result::Result::Err(error) = signature_result {
|
||||||
|
return std::result::Result::Err(error);
|
||||||
|
}
|
||||||
|
let log_text_result =
|
||||||
|
validate_required_text(&log_text_value, "core log text must not be empty");
|
||||||
|
if let std::result::Result::Err(error) = log_text_result {
|
||||||
|
return std::result::Result::Err(error);
|
||||||
|
}
|
||||||
|
let instruction_path_result = validate_optional_text(
|
||||||
|
instruction_path.as_deref(),
|
||||||
|
"core log instruction path must not be empty when present",
|
||||||
|
);
|
||||||
|
if let std::result::Result::Err(error) = instruction_path_result {
|
||||||
|
return std::result::Result::Err(error);
|
||||||
|
}
|
||||||
|
let program_id_result = validate_optional_text(
|
||||||
|
program_id.as_deref(),
|
||||||
|
"core log program id must not be empty when present",
|
||||||
|
);
|
||||||
|
if let std::result::Result::Err(error) = program_id_result {
|
||||||
|
return std::result::Result::Err(error);
|
||||||
|
}
|
||||||
|
return std::result::Result::Ok(Self {
|
||||||
|
signature: signature_value,
|
||||||
|
slot,
|
||||||
|
log_index,
|
||||||
|
instruction_path,
|
||||||
|
program_id,
|
||||||
|
log_text: log_text_value,
|
||||||
|
log_text_hash: std::option::Option::None,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Adds a deterministic log text hash.
|
||||||
|
pub fn with_log_text_hash(
|
||||||
|
mut self,
|
||||||
|
log_text_hash: impl std::convert::Into<std::string::String>,
|
||||||
|
) -> kb_core::Result<Self> {
|
||||||
|
let value = log_text_hash.into();
|
||||||
|
if value.trim().is_empty() {
|
||||||
|
return std::result::Result::Err(kb_core::Error::db(
|
||||||
|
"core log text hash must not be empty",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
self.log_text_hash = std::option::Option::Some(value);
|
||||||
|
return std::result::Result::Ok(self);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Core balance change insert contract.
|
||||||
|
#[derive(Clone, Debug, PartialEq, serde::Deserialize, serde::Serialize)]
|
||||||
|
pub struct CoreBalanceChangeInsert {
|
||||||
|
/// Transaction signature as non-empty base58 text.
|
||||||
|
pub signature: std::string::String,
|
||||||
|
/// Transaction slot in Solana unsigned representation.
|
||||||
|
pub slot: u64,
|
||||||
|
/// Stable balance change index preserving extraction order.
|
||||||
|
pub balance_change_index: u32,
|
||||||
|
/// Balance change family.
|
||||||
|
pub balance_kind: CoreBalanceChangeKind,
|
||||||
|
/// Optional account index when available.
|
||||||
|
pub account_index: std::option::Option<u32>,
|
||||||
|
/// Optional account public key when available.
|
||||||
|
pub account_key: std::option::Option<std::string::String>,
|
||||||
|
/// Optional SPL token mint for token balances.
|
||||||
|
pub mint: std::option::Option<std::string::String>,
|
||||||
|
/// Optional owner public key for token balances.
|
||||||
|
pub owner: std::option::Option<std::string::String>,
|
||||||
|
/// Pre-balance JSON value preserving RPC representation.
|
||||||
|
pub pre_balance_json: std::option::Option<serde_json::Value>,
|
||||||
|
/// Post-balance JSON value preserving RPC representation.
|
||||||
|
pub post_balance_json: std::option::Option<serde_json::Value>,
|
||||||
|
/// Delta JSON value preserving integer or decimal-safe representation.
|
||||||
|
pub delta_json: std::option::Option<serde_json::Value>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl CoreBalanceChangeInsert {
|
||||||
|
/// Builds a core balance change insert contract after minimal validation.
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
|
pub fn new(
|
||||||
|
signature: impl std::convert::Into<std::string::String>,
|
||||||
|
slot: u64,
|
||||||
|
balance_change_index: u32,
|
||||||
|
balance_kind: CoreBalanceChangeKind,
|
||||||
|
account_index: std::option::Option<u32>,
|
||||||
|
account_key: std::option::Option<std::string::String>,
|
||||||
|
mint: std::option::Option<std::string::String>,
|
||||||
|
owner: std::option::Option<std::string::String>,
|
||||||
|
pre_balance_json: std::option::Option<serde_json::Value>,
|
||||||
|
post_balance_json: std::option::Option<serde_json::Value>,
|
||||||
|
delta_json: std::option::Option<serde_json::Value>,
|
||||||
|
) -> kb_core::Result<Self> {
|
||||||
|
let signature_value = signature.into();
|
||||||
|
let signature_result = validate_required_text(
|
||||||
|
&signature_value,
|
||||||
|
"core balance change signature must not be empty",
|
||||||
|
);
|
||||||
|
if let std::result::Result::Err(error) = signature_result {
|
||||||
|
return std::result::Result::Err(error);
|
||||||
|
}
|
||||||
|
let account_key_result = validate_optional_text(
|
||||||
|
account_key.as_deref(),
|
||||||
|
"core balance change account key must not be empty when present",
|
||||||
|
);
|
||||||
|
if let std::result::Result::Err(error) = account_key_result {
|
||||||
|
return std::result::Result::Err(error);
|
||||||
|
}
|
||||||
|
let mint_result = validate_optional_text(
|
||||||
|
mint.as_deref(),
|
||||||
|
"core balance change mint must not be empty when present",
|
||||||
|
);
|
||||||
|
if let std::result::Result::Err(error) = mint_result {
|
||||||
|
return std::result::Result::Err(error);
|
||||||
|
}
|
||||||
|
let owner_result = validate_optional_text(
|
||||||
|
owner.as_deref(),
|
||||||
|
"core balance change owner must not be empty when present",
|
||||||
|
);
|
||||||
|
if let std::result::Result::Err(error) = owner_result {
|
||||||
|
return std::result::Result::Err(error);
|
||||||
|
}
|
||||||
|
return std::result::Result::Ok(Self {
|
||||||
|
signature: signature_value,
|
||||||
|
slot,
|
||||||
|
balance_change_index,
|
||||||
|
balance_kind,
|
||||||
|
account_index,
|
||||||
|
account_key,
|
||||||
|
mint,
|
||||||
|
owner,
|
||||||
|
pre_balance_json,
|
||||||
|
post_balance_json,
|
||||||
|
delta_json,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Core instruction replay filter contract.
|
||||||
|
#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
|
||||||
|
pub struct CoreInstructionReplayFilter {
|
||||||
|
/// Optional processing state to select, usually `Pending`, `Failed` or `ReplayRequested`.
|
||||||
|
pub processing_state: std::option::Option<CoreInstructionProcessingState>,
|
||||||
|
/// Optional program id filter.
|
||||||
|
pub program_id: std::option::Option<std::string::String>,
|
||||||
|
/// Optional inclusive minimum slot.
|
||||||
|
pub min_slot: std::option::Option<u64>,
|
||||||
|
/// Optional inclusive maximum slot.
|
||||||
|
pub max_slot: std::option::Option<u64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl CoreInstructionReplayFilter {
|
||||||
|
/// Builds a replay filter after minimal validation.
|
||||||
|
pub fn new(
|
||||||
|
processing_state: std::option::Option<CoreInstructionProcessingState>,
|
||||||
|
program_id: std::option::Option<std::string::String>,
|
||||||
|
min_slot: std::option::Option<u64>,
|
||||||
|
max_slot: std::option::Option<u64>,
|
||||||
|
) -> kb_core::Result<Self> {
|
||||||
|
if let std::option::Option::Some(program_id_value) = program_id.as_ref() {
|
||||||
|
if program_id_value.trim().is_empty() {
|
||||||
|
return std::result::Result::Err(kb_core::Error::db(
|
||||||
|
"core instruction replay program id must not be empty when present",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let (
|
||||||
|
std::option::Option::Some(min_slot_value),
|
||||||
|
std::option::Option::Some(max_slot_value),
|
||||||
|
) = (min_slot, max_slot)
|
||||||
|
{
|
||||||
|
if min_slot_value > max_slot_value {
|
||||||
|
return std::result::Result::Err(kb_core::Error::db(
|
||||||
|
"core instruction replay min slot must be lower than or equal to max slot",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return std::result::Result::Ok(Self {
|
||||||
|
processing_state,
|
||||||
|
program_id,
|
||||||
|
min_slot,
|
||||||
|
max_slot,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Builds the default pending instruction replay filter.
|
||||||
|
pub fn pending() -> Self {
|
||||||
|
return Self {
|
||||||
|
processing_state: std::option::Option::Some(CoreInstructionProcessingState::Pending),
|
||||||
|
program_id: std::option::Option::None,
|
||||||
|
min_slot: std::option::Option::None,
|
||||||
|
max_slot: std::option::Option::None,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Core instruction lifecycle mark request.
|
||||||
|
#[derive(Clone, Debug, PartialEq, serde::Deserialize, serde::Serialize)]
|
||||||
|
pub struct CoreInstructionLifecycleMark {
|
||||||
|
/// Transaction signature as non-empty base58 text.
|
||||||
|
pub signature: std::string::String,
|
||||||
|
/// Stable instruction path, for example `0` or `2/1`.
|
||||||
|
pub instruction_path: std::string::String,
|
||||||
|
/// New processing state.
|
||||||
|
pub processing_state: CoreInstructionProcessingState,
|
||||||
|
/// Optional processor name that produced the lifecycle transition.
|
||||||
|
pub processor_name: std::option::Option<std::string::String>,
|
||||||
|
/// Optional processor version that produced the lifecycle transition.
|
||||||
|
pub processor_version: std::option::Option<std::string::String>,
|
||||||
|
/// Optional reason visible in diagnostics.
|
||||||
|
pub reason: std::option::Option<std::string::String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl CoreInstructionLifecycleMark {
|
||||||
|
/// Builds a core instruction lifecycle mark after minimal validation.
|
||||||
|
pub fn new(
|
||||||
|
signature: impl std::convert::Into<std::string::String>,
|
||||||
|
instruction_path: impl std::convert::Into<std::string::String>,
|
||||||
|
processing_state: CoreInstructionProcessingState,
|
||||||
|
processor_name: std::option::Option<std::string::String>,
|
||||||
|
processor_version: std::option::Option<std::string::String>,
|
||||||
|
reason: std::option::Option<std::string::String>,
|
||||||
|
) -> kb_core::Result<Self> {
|
||||||
|
let signature_value = signature.into();
|
||||||
|
let instruction_path_value = instruction_path.into();
|
||||||
|
let signature_result = validate_required_text(
|
||||||
|
&signature_value,
|
||||||
|
"core instruction lifecycle signature must not be empty",
|
||||||
|
);
|
||||||
|
if let std::result::Result::Err(error) = signature_result {
|
||||||
|
return std::result::Result::Err(error);
|
||||||
|
}
|
||||||
|
let instruction_path_result = validate_required_text(
|
||||||
|
&instruction_path_value,
|
||||||
|
"core instruction lifecycle path must not be empty",
|
||||||
|
);
|
||||||
|
if let std::result::Result::Err(error) = instruction_path_result {
|
||||||
|
return std::result::Result::Err(error);
|
||||||
|
}
|
||||||
|
let processor_name_result = validate_optional_text(
|
||||||
|
processor_name.as_deref(),
|
||||||
|
"core instruction lifecycle processor name must not be empty when present",
|
||||||
|
);
|
||||||
|
if let std::result::Result::Err(error) = processor_name_result {
|
||||||
|
return std::result::Result::Err(error);
|
||||||
|
}
|
||||||
|
let processor_version_result = validate_optional_text(
|
||||||
|
processor_version.as_deref(),
|
||||||
|
"core instruction lifecycle processor version must not be empty when present",
|
||||||
|
);
|
||||||
|
if let std::result::Result::Err(error) = processor_version_result {
|
||||||
|
return std::result::Result::Err(error);
|
||||||
|
}
|
||||||
|
let reason_result = validate_optional_text(
|
||||||
|
reason.as_deref(),
|
||||||
|
"core instruction lifecycle reason must not be empty when present",
|
||||||
|
);
|
||||||
|
if let std::result::Result::Err(error) = reason_result {
|
||||||
|
return std::result::Result::Err(error);
|
||||||
|
}
|
||||||
|
return std::result::Result::Ok(Self {
|
||||||
|
signature: signature_value,
|
||||||
|
instruction_path: instruction_path_value,
|
||||||
|
processing_state,
|
||||||
|
processor_name,
|
||||||
|
processor_version,
|
||||||
|
reason,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn validate_instruction_identity(
|
||||||
|
signature: &str,
|
||||||
|
instruction_path: &str,
|
||||||
|
program_id: &str,
|
||||||
|
label: &str,
|
||||||
|
) -> kb_core::Result<()> {
|
||||||
|
let signature_result =
|
||||||
|
validate_required_text(signature, "instruction signature must not be empty");
|
||||||
|
if let std::result::Result::Err(_error) = signature_result {
|
||||||
|
return std::result::Result::Err(kb_core::Error::db(format!(
|
||||||
|
"{label} signature must not be empty"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
let instruction_path_result =
|
||||||
|
validate_required_text(instruction_path, "instruction path must not be empty");
|
||||||
|
if let std::result::Result::Err(_error) = instruction_path_result {
|
||||||
|
return std::result::Result::Err(kb_core::Error::db(format!(
|
||||||
|
"{label} path must not be empty"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
let program_id_result =
|
||||||
|
validate_required_text(program_id, "instruction program id must not be empty");
|
||||||
|
if let std::result::Result::Err(_error) = program_id_result {
|
||||||
|
return std::result::Result::Err(kb_core::Error::db(format!(
|
||||||
|
"{label} program id must not be empty"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
return std::result::Result::Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
fn validate_required_text(
|
||||||
|
value: &str,
|
||||||
|
message: impl std::convert::Into<std::string::String>,
|
||||||
|
) -> kb_core::Result<()> {
|
||||||
|
if value.trim().is_empty() {
|
||||||
|
return std::result::Result::Err(kb_core::Error::db(message.into()));
|
||||||
|
}
|
||||||
|
return std::result::Result::Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
fn validate_optional_text(
|
||||||
|
value: std::option::Option<&str>,
|
||||||
|
message: &'static str,
|
||||||
|
) -> kb_core::Result<()> {
|
||||||
|
if let std::option::Option::Some(text_value) = value {
|
||||||
|
if text_value.trim().is_empty() {
|
||||||
|
return std::result::Result::Err(kb_core::Error::db(message));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return std::result::Result::Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
#[test]
|
||||||
|
fn core_transaction_rejects_empty_signature() {
|
||||||
|
let result = crate::CoreTransactionInsert::new(" ", 1, false, std::option::Option::None);
|
||||||
|
assert!(result.is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn core_transaction_accepts_optional_raw_lineage() {
|
||||||
|
let result = crate::CoreTransactionInsert::new("abc", 1, false, std::option::Option::None);
|
||||||
|
let input = match result {
|
||||||
|
std::result::Result::Ok(value) => value.with_raw_transaction_id(7),
|
||||||
|
std::result::Result::Err(error) => panic!("unexpected transaction error: {error}"),
|
||||||
|
};
|
||||||
|
assert_eq!(input.raw_transaction_id, std::option::Option::Some(7));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn core_account_key_rejects_empty_key() {
|
||||||
|
let result = crate::CoreAccountKeyInsert::new(
|
||||||
|
"abc",
|
||||||
|
1,
|
||||||
|
0,
|
||||||
|
" ",
|
||||||
|
crate::CoreAccountKeySource::Static,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
std::option::Option::None,
|
||||||
|
);
|
||||||
|
assert!(result.is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn core_instruction_rejects_empty_program_id() {
|
||||||
|
let result = crate::CoreInstructionInsert::new(
|
||||||
|
"abc",
|
||||||
|
1,
|
||||||
|
"0",
|
||||||
|
" ",
|
||||||
|
serde_json::json!([]),
|
||||||
|
serde_json::json!({}),
|
||||||
|
);
|
||||||
|
assert!(result.is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn core_instruction_defaults_to_pending() {
|
||||||
|
let result = crate::CoreInstructionInsert::new(
|
||||||
|
"abc",
|
||||||
|
1,
|
||||||
|
"0",
|
||||||
|
"program",
|
||||||
|
serde_json::json!([]),
|
||||||
|
serde_json::json!({}),
|
||||||
|
);
|
||||||
|
if let std::result::Result::Ok(input) = result {
|
||||||
|
assert_eq!(crate::CoreInstructionProcessingState::Pending, input.processing_state);
|
||||||
|
} else {
|
||||||
|
panic!("core instruction insert should be valid");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn core_inner_instruction_rejects_empty_parent_path() {
|
||||||
|
let result = crate::CoreInnerInstructionInsert::new(
|
||||||
|
"abc",
|
||||||
|
1,
|
||||||
|
" ",
|
||||||
|
"0/0",
|
||||||
|
"program",
|
||||||
|
serde_json::json!([]),
|
||||||
|
serde_json::json!({}),
|
||||||
|
);
|
||||||
|
assert!(result.is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn core_log_rejects_empty_text() {
|
||||||
|
let result = crate::CoreLogInsert::new(
|
||||||
|
"abc",
|
||||||
|
1,
|
||||||
|
0,
|
||||||
|
std::option::Option::None,
|
||||||
|
std::option::Option::None,
|
||||||
|
" ",
|
||||||
|
);
|
||||||
|
assert!(result.is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn core_balance_change_rejects_empty_optional_mint() {
|
||||||
|
let result = crate::CoreBalanceChangeInsert::new(
|
||||||
|
"abc",
|
||||||
|
1,
|
||||||
|
0,
|
||||||
|
crate::CoreBalanceChangeKind::TokenAmount,
|
||||||
|
std::option::Option::Some(0),
|
||||||
|
std::option::Option::Some("account".to_string()),
|
||||||
|
std::option::Option::Some(" ".to_string()),
|
||||||
|
std::option::Option::None,
|
||||||
|
std::option::Option::None,
|
||||||
|
std::option::Option::None,
|
||||||
|
std::option::Option::None,
|
||||||
|
);
|
||||||
|
assert!(result.is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn replay_input_rejects_empty_key() {
|
||||||
|
let result = crate::CoreInstructionReplayInput::new(
|
||||||
|
" ",
|
||||||
|
"abc",
|
||||||
|
1,
|
||||||
|
"0",
|
||||||
|
"program",
|
||||||
|
false,
|
||||||
|
std::option::Option::None,
|
||||||
|
serde_json::json!([]),
|
||||||
|
serde_json::json!([]),
|
||||||
|
std::option::Option::Some(serde_json::json!({})),
|
||||||
|
std::option::Option::None,
|
||||||
|
serde_json::json!([]),
|
||||||
|
serde_json::json!([]),
|
||||||
|
serde_json::json!([]),
|
||||||
|
serde_json::json!([]),
|
||||||
|
);
|
||||||
|
assert!(result.is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn replay_input_accepts_ordered_outer_instruction_array() {
|
||||||
|
let result = crate::CoreInstructionReplayInput::new(
|
||||||
|
"abc:2",
|
||||||
|
"abc",
|
||||||
|
1,
|
||||||
|
"2",
|
||||||
|
"program",
|
||||||
|
false,
|
||||||
|
std::option::Option::None,
|
||||||
|
serde_json::json!([]),
|
||||||
|
serde_json::json!([]),
|
||||||
|
std::option::Option::Some(serde_json::json!({"dataBase64": "AQ=="})),
|
||||||
|
std::option::Option::Some("payload-hash".to_string()),
|
||||||
|
serde_json::json!([
|
||||||
|
{
|
||||||
|
"instructionIndex": 0,
|
||||||
|
"instructionPath": "0",
|
||||||
|
"programId": "other",
|
||||||
|
"payloadJson": {"dataBase64": "Ag=="},
|
||||||
|
"payloadHash": "other-hash"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"instructionIndex": 2,
|
||||||
|
"instructionPath": "2",
|
||||||
|
"programId": "program",
|
||||||
|
"payloadJson": {"dataBase64": "AQ=="},
|
||||||
|
"payloadHash": "payload-hash"
|
||||||
|
}
|
||||||
|
]),
|
||||||
|
serde_json::json!([]),
|
||||||
|
serde_json::json!([]),
|
||||||
|
serde_json::json!([]),
|
||||||
|
);
|
||||||
|
let input = match result {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => panic!("unexpected replay input error: {error}"),
|
||||||
|
};
|
||||||
|
assert_eq!(input.core_contract_version, 2);
|
||||||
|
assert_eq!(
|
||||||
|
input.outer_instructions_json.as_array().map(std::vec::Vec::len),
|
||||||
|
std::option::Option::Some(2)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn replay_input_rejects_non_array_outer_instruction_context() {
|
||||||
|
let result = crate::CoreInstructionReplayInput::new(
|
||||||
|
"abc:0",
|
||||||
|
"abc",
|
||||||
|
1,
|
||||||
|
"0",
|
||||||
|
"program",
|
||||||
|
false,
|
||||||
|
std::option::Option::None,
|
||||||
|
serde_json::json!([]),
|
||||||
|
serde_json::json!([]),
|
||||||
|
std::option::Option::Some(serde_json::json!({"dataBase64": "AQ=="})),
|
||||||
|
std::option::Option::None,
|
||||||
|
serde_json::json!({"instructionIndex": 0}),
|
||||||
|
serde_json::json!([]),
|
||||||
|
serde_json::json!([]),
|
||||||
|
serde_json::json!([]),
|
||||||
|
);
|
||||||
|
assert!(result.is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn replay_filter_rejects_empty_program_id() {
|
||||||
|
let result = crate::CoreInstructionReplayFilter::new(
|
||||||
|
std::option::Option::Some(crate::CoreInstructionProcessingState::Pending),
|
||||||
|
std::option::Option::Some(" ".to_string()),
|
||||||
|
std::option::Option::None,
|
||||||
|
std::option::Option::None,
|
||||||
|
);
|
||||||
|
assert!(result.is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn replay_filter_rejects_inverted_slots() {
|
||||||
|
let result = crate::CoreInstructionReplayFilter::new(
|
||||||
|
std::option::Option::None,
|
||||||
|
std::option::Option::None,
|
||||||
|
std::option::Option::Some(10),
|
||||||
|
std::option::Option::Some(1),
|
||||||
|
);
|
||||||
|
assert!(result.is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn lifecycle_mark_rejects_empty_path() {
|
||||||
|
let result = crate::CoreInstructionLifecycleMark::new(
|
||||||
|
"signature",
|
||||||
|
" ",
|
||||||
|
crate::CoreInstructionProcessingState::Decoded,
|
||||||
|
std::option::Option::None,
|
||||||
|
std::option::Option::None,
|
||||||
|
std::option::Option::None,
|
||||||
|
);
|
||||||
|
assert!(result.is_err());
|
||||||
|
}
|
||||||
|
}
|
||||||
289
kb-store/src/contracts/dto/core_extraction.rs
Normal file
289
kb-store/src/contracts/dto/core_extraction.rs
Normal file
@@ -0,0 +1,289 @@
|
|||||||
|
// file: kb-store/src/contracts/dto/core_extraction.rs
|
||||||
|
// version: 1
|
||||||
|
|
||||||
|
//! Canonical transaction to core extraction storage contracts.
|
||||||
|
|
||||||
|
/// Stable processing status for one extraction ledger entry.
|
||||||
|
#[derive(Clone, Copy, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
|
||||||
|
pub enum ProcessingLedgerStatus {
|
||||||
|
/// Processing is currently running.
|
||||||
|
Running,
|
||||||
|
/// Processing completed successfully.
|
||||||
|
Succeeded,
|
||||||
|
/// Processing failed and may be retried.
|
||||||
|
Failed,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Bounded selection filter for canonical transactions awaiting core extraction.
|
||||||
|
#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
|
||||||
|
pub struct CoreExtractionSelectionFilter {
|
||||||
|
/// Optional exact signatures selected by the operator.
|
||||||
|
pub signatures: std::vec::Vec<std::string::String>,
|
||||||
|
/// Optional inclusive minimum slot.
|
||||||
|
pub min_slot: std::option::Option<u64>,
|
||||||
|
/// Optional inclusive maximum slot.
|
||||||
|
pub max_slot: std::option::Option<u64>,
|
||||||
|
/// Optional raw processing state restriction.
|
||||||
|
pub processing_state: std::option::Option<crate::RawPayloadProcessingState>,
|
||||||
|
/// Optional program id previously resolved in core instructions.
|
||||||
|
pub program_id: std::option::Option<std::string::String>,
|
||||||
|
/// Maximum number of canonical transactions returned.
|
||||||
|
pub limit: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl CoreExtractionSelectionFilter {
|
||||||
|
/// Builds a validated extraction selection filter.
|
||||||
|
pub fn new(
|
||||||
|
signatures: std::vec::Vec<std::string::String>,
|
||||||
|
min_slot: std::option::Option<u64>,
|
||||||
|
max_slot: std::option::Option<u64>,
|
||||||
|
processing_state: std::option::Option<crate::RawPayloadProcessingState>,
|
||||||
|
program_id: std::option::Option<std::string::String>,
|
||||||
|
limit: u32,
|
||||||
|
) -> kb_core::Result<Self> {
|
||||||
|
if limit == 0 {
|
||||||
|
return std::result::Result::Err(kb_core::Error::db(
|
||||||
|
"core extraction selection limit must be greater than zero",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if let (std::option::Option::Some(minimum), std::option::Option::Some(maximum)) =
|
||||||
|
(min_slot, max_slot)
|
||||||
|
{
|
||||||
|
if minimum > maximum {
|
||||||
|
return std::result::Result::Err(kb_core::Error::db(
|
||||||
|
"core extraction minimum slot must not exceed maximum slot",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for signature in &signatures {
|
||||||
|
if signature.trim().is_empty() {
|
||||||
|
return std::result::Result::Err(kb_core::Error::db(
|
||||||
|
"core extraction signature filter must not contain empty values",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if program_id.as_deref().is_some_and(|value| return value.trim().is_empty()) {
|
||||||
|
return std::result::Result::Err(kb_core::Error::db(
|
||||||
|
"core extraction program id filter must not be empty",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
return std::result::Result::Ok(Self {
|
||||||
|
signatures,
|
||||||
|
min_slot,
|
||||||
|
max_slot,
|
||||||
|
processing_state,
|
||||||
|
program_id,
|
||||||
|
limit,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Builds a pending raw transaction selection.
|
||||||
|
pub fn pending(limit: u32) -> kb_core::Result<Self> {
|
||||||
|
return Self::new(
|
||||||
|
std::vec::Vec::new(),
|
||||||
|
std::option::Option::None,
|
||||||
|
std::option::Option::None,
|
||||||
|
std::option::Option::Some(crate::RawPayloadProcessingState::Received),
|
||||||
|
std::option::Option::None,
|
||||||
|
limit,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Stable identity of one processor input in the processing ledger.
|
||||||
|
#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
|
||||||
|
pub struct ProcessingLedgerIdentity {
|
||||||
|
/// Processing stage code.
|
||||||
|
pub stage: std::string::String,
|
||||||
|
/// Processor implementation name.
|
||||||
|
pub processor_name: std::string::String,
|
||||||
|
/// Processor semantic version.
|
||||||
|
pub processor_version: std::string::String,
|
||||||
|
/// Stable input key.
|
||||||
|
pub input_key: std::string::String,
|
||||||
|
/// Deterministic input hash.
|
||||||
|
pub input_hash: std::string::String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ProcessingLedgerIdentity {
|
||||||
|
/// Builds a validated processing ledger identity.
|
||||||
|
pub fn new(
|
||||||
|
stage: impl std::convert::Into<std::string::String>,
|
||||||
|
processor_name: impl std::convert::Into<std::string::String>,
|
||||||
|
processor_version: impl std::convert::Into<std::string::String>,
|
||||||
|
input_key: impl std::convert::Into<std::string::String>,
|
||||||
|
input_hash: impl std::convert::Into<std::string::String>,
|
||||||
|
) -> kb_core::Result<Self> {
|
||||||
|
let value = Self {
|
||||||
|
stage: stage.into(),
|
||||||
|
processor_name: processor_name.into(),
|
||||||
|
processor_version: processor_version.into(),
|
||||||
|
input_key: input_key.into(),
|
||||||
|
input_hash: input_hash.into(),
|
||||||
|
};
|
||||||
|
if value.stage.trim().is_empty()
|
||||||
|
|| value.processor_name.trim().is_empty()
|
||||||
|
|| value.processor_version.trim().is_empty()
|
||||||
|
|| value.input_key.trim().is_empty()
|
||||||
|
|| value.input_hash.trim().is_empty()
|
||||||
|
{
|
||||||
|
return std::result::Result::Err(kb_core::Error::db(
|
||||||
|
"processing ledger identity fields must not be empty",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
return std::result::Result::Ok(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Complete set of normalized rows produced from one canonical transaction.
|
||||||
|
#[derive(Clone, Debug, PartialEq, serde::Deserialize, serde::Serialize)]
|
||||||
|
pub struct CoreExtractionBundle {
|
||||||
|
/// Source canonical raw transaction technical id.
|
||||||
|
pub raw_transaction_id: i64,
|
||||||
|
/// Processing ledger identity.
|
||||||
|
pub ledger_identity: crate::ProcessingLedgerIdentity,
|
||||||
|
/// Core transaction row.
|
||||||
|
pub transaction: crate::CoreTransactionInsert,
|
||||||
|
/// Resolved account keys.
|
||||||
|
pub account_keys: std::vec::Vec<crate::CoreAccountKeyInsert>,
|
||||||
|
/// Top-level instructions.
|
||||||
|
pub instructions: std::vec::Vec<crate::CoreInstructionInsert>,
|
||||||
|
/// Inner instructions.
|
||||||
|
pub inner_instructions: std::vec::Vec<crate::CoreInnerInstructionInsert>,
|
||||||
|
/// Ordered transaction logs.
|
||||||
|
pub logs: std::vec::Vec<crate::CoreLogInsert>,
|
||||||
|
/// Native and token balance changes.
|
||||||
|
pub balance_changes: std::vec::Vec<crate::CoreBalanceChangeInsert>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl CoreExtractionBundle {
|
||||||
|
/// Validates lineage and stable signature consistency across the bundle.
|
||||||
|
pub fn validate(&self) -> kb_core::Result<()> {
|
||||||
|
if self.raw_transaction_id <= 0 {
|
||||||
|
return std::result::Result::Err(kb_core::Error::db(
|
||||||
|
"core extraction raw transaction id must be positive",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if self.transaction.raw_transaction_id != std::option::Option::Some(self.raw_transaction_id)
|
||||||
|
{
|
||||||
|
return std::result::Result::Err(kb_core::Error::db(
|
||||||
|
"core extraction transaction lineage does not match the raw transaction id",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if self.transaction.signature != self.ledger_identity.input_key {
|
||||||
|
return std::result::Result::Err(kb_core::Error::db(
|
||||||
|
"core extraction ledger input key must equal the transaction signature",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let signature = self.transaction.signature.as_str();
|
||||||
|
for input in &self.account_keys {
|
||||||
|
if input.signature != signature {
|
||||||
|
return std::result::Result::Err(kb_core::Error::db(
|
||||||
|
"core extraction account key signature mismatch",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for input in &self.instructions {
|
||||||
|
if input.signature != signature {
|
||||||
|
return std::result::Result::Err(kb_core::Error::db(
|
||||||
|
"core extraction instruction signature mismatch",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for input in &self.inner_instructions {
|
||||||
|
if input.signature != signature {
|
||||||
|
return std::result::Result::Err(kb_core::Error::db(
|
||||||
|
"core extraction inner instruction signature mismatch",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for input in &self.logs {
|
||||||
|
if input.signature != signature {
|
||||||
|
return std::result::Result::Err(kb_core::Error::db(
|
||||||
|
"core extraction log signature mismatch",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for input in &self.balance_changes {
|
||||||
|
if input.signature != signature {
|
||||||
|
return std::result::Result::Err(kb_core::Error::db(
|
||||||
|
"core extraction balance signature mismatch",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return std::result::Result::Ok(());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Failure details persisted when canonical to core extraction cannot complete.
|
||||||
|
#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
|
||||||
|
pub struct CoreExtractionFailure {
|
||||||
|
/// Source canonical raw transaction technical id.
|
||||||
|
pub raw_transaction_id: i64,
|
||||||
|
/// Processing ledger identity.
|
||||||
|
pub ledger_identity: crate::ProcessingLedgerIdentity,
|
||||||
|
/// Stable machine-readable error code.
|
||||||
|
pub error_code: std::string::String,
|
||||||
|
/// Human-readable diagnostic message.
|
||||||
|
pub error_message: std::string::String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl CoreExtractionFailure {
|
||||||
|
/// Builds a validated failure record.
|
||||||
|
pub fn new(
|
||||||
|
raw_transaction_id: i64,
|
||||||
|
ledger_identity: crate::ProcessingLedgerIdentity,
|
||||||
|
error_code: impl std::convert::Into<std::string::String>,
|
||||||
|
error_message: impl std::convert::Into<std::string::String>,
|
||||||
|
) -> kb_core::Result<Self> {
|
||||||
|
let value = Self {
|
||||||
|
raw_transaction_id,
|
||||||
|
ledger_identity,
|
||||||
|
error_code: error_code.into(),
|
||||||
|
error_message: error_message.into(),
|
||||||
|
};
|
||||||
|
if value.raw_transaction_id <= 0
|
||||||
|
|| value.error_code.trim().is_empty()
|
||||||
|
|| value.error_message.trim().is_empty()
|
||||||
|
{
|
||||||
|
return std::result::Result::Err(kb_core::Error::db(
|
||||||
|
"core extraction failure fields are invalid",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
return std::result::Result::Ok(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
#[test]
|
||||||
|
fn pending_filter_requires_positive_limit() {
|
||||||
|
let result = crate::CoreExtractionSelectionFilter::pending(0);
|
||||||
|
assert!(result.is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn selection_filter_rejects_inverted_slots() {
|
||||||
|
let result = crate::CoreExtractionSelectionFilter::new(
|
||||||
|
std::vec::Vec::new(),
|
||||||
|
std::option::Option::Some(20),
|
||||||
|
std::option::Option::Some(10),
|
||||||
|
std::option::Option::None,
|
||||||
|
std::option::Option::None,
|
||||||
|
10,
|
||||||
|
);
|
||||||
|
assert!(result.is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn ledger_identity_requires_input_hash() {
|
||||||
|
let result = crate::ProcessingLedgerIdentity::new(
|
||||||
|
"core_extraction",
|
||||||
|
"canonical_to_core",
|
||||||
|
"1",
|
||||||
|
"signature",
|
||||||
|
" ",
|
||||||
|
);
|
||||||
|
assert!(result.is_err());
|
||||||
|
}
|
||||||
|
}
|
||||||
688
kb-store/src/contracts/dto/decode.rs
Normal file
688
kb-store/src/contracts/dto/decode.rs
Normal file
@@ -0,0 +1,688 @@
|
|||||||
|
// file: kb-store/src/contracts/dto/decode.rs
|
||||||
|
// version: 2
|
||||||
|
|
||||||
|
//! Backend-neutral decode, coverage and materialization persistence DTOs.
|
||||||
|
|
||||||
|
/// Maximum number of materialized rows returned by one bounded query.
|
||||||
|
pub const MAX_MATERIALIZED_EVENT_QUERY_ROWS: u32 = 500;
|
||||||
|
|
||||||
|
/// Bounded read-only materialized event selection.
|
||||||
|
#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
|
||||||
|
pub struct MaterializedEventFilter {
|
||||||
|
/// Optional exact materializer processor name.
|
||||||
|
pub processor_name: std::option::Option<std::string::String>,
|
||||||
|
/// Optional exact materialized family code.
|
||||||
|
pub materialized_family: std::option::Option<std::string::String>,
|
||||||
|
/// Optional partial transaction signature.
|
||||||
|
pub signature_contains: std::option::Option<std::string::String>,
|
||||||
|
/// Maximum returned rows.
|
||||||
|
pub limit: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl MaterializedEventFilter {
|
||||||
|
/// Builds and validates a bounded materialized event filter.
|
||||||
|
pub fn new(
|
||||||
|
processor_name: std::option::Option<std::string::String>,
|
||||||
|
materialized_family: std::option::Option<std::string::String>,
|
||||||
|
signature_contains: std::option::Option<std::string::String>,
|
||||||
|
limit: u32,
|
||||||
|
) -> kb_core::Result<Self> {
|
||||||
|
if limit == 0 || limit > crate::MAX_MATERIALIZED_EVENT_QUERY_ROWS {
|
||||||
|
return std::result::Result::Err(kb_core::Error::db(format!(
|
||||||
|
"materialized event query limit must be between 1 and {}",
|
||||||
|
crate::MAX_MATERIALIZED_EVENT_QUERY_ROWS
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
return std::result::Result::Ok(Self {
|
||||||
|
processor_name: crate::contracts::dto::decode::trim_optional_text(processor_name),
|
||||||
|
materialized_family: crate::contracts::dto::decode::trim_optional_text(
|
||||||
|
materialized_family,
|
||||||
|
),
|
||||||
|
signature_contains: crate::contracts::dto::decode::trim_optional_text(
|
||||||
|
signature_contains,
|
||||||
|
),
|
||||||
|
limit,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One materialized output returned by the common bounded query contract.
|
||||||
|
#[derive(Clone, Debug, PartialEq, serde::Deserialize, serde::Serialize)]
|
||||||
|
pub struct MaterializedEventQueryRow {
|
||||||
|
/// Materializer processor name.
|
||||||
|
pub processor_name: std::string::String,
|
||||||
|
/// Materializer processor version.
|
||||||
|
pub processor_version: std::string::String,
|
||||||
|
/// Stable materializer input key.
|
||||||
|
pub input_key: std::string::String,
|
||||||
|
/// Stable processor-owned output key.
|
||||||
|
pub output_key: std::string::String,
|
||||||
|
/// Source decoded event key.
|
||||||
|
pub source_event_key: std::string::String,
|
||||||
|
/// Source decoder name.
|
||||||
|
pub source_decoder_name: std::string::String,
|
||||||
|
/// Source decoder version.
|
||||||
|
pub source_decoder_version: std::string::String,
|
||||||
|
/// Source transaction signature.
|
||||||
|
pub signature: std::string::String,
|
||||||
|
/// Source transaction slot.
|
||||||
|
pub slot: u64,
|
||||||
|
/// Materialized family code.
|
||||||
|
pub materialized_family: std::string::String,
|
||||||
|
/// Typed materialized payload.
|
||||||
|
pub payload_json: serde_json::Value,
|
||||||
|
/// Creation timestamp rendered by the backend.
|
||||||
|
pub created_at: std::string::String,
|
||||||
|
/// Last replacement timestamp rendered by the backend.
|
||||||
|
pub updated_at: std::string::String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Bounded contextual instruction selection filter for decode campaigns.
|
||||||
|
#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
|
||||||
|
pub struct DecodeSelectionFilter {
|
||||||
|
/// Explicit transaction signatures to select.
|
||||||
|
pub signatures: std::vec::Vec<std::string::String>,
|
||||||
|
/// Explicit instruction processing states to select.
|
||||||
|
pub processing_states: std::vec::Vec<crate::CoreInstructionProcessingState>,
|
||||||
|
/// Optional inclusive minimum slot.
|
||||||
|
pub min_slot: std::option::Option<u64>,
|
||||||
|
/// Optional inclusive maximum slot.
|
||||||
|
pub max_slot: std::option::Option<u64>,
|
||||||
|
/// Explicit program identifiers to select.
|
||||||
|
pub program_ids: std::vec::Vec<std::string::String>,
|
||||||
|
/// Explicit stable instruction paths to select.
|
||||||
|
pub instruction_paths: std::vec::Vec<std::string::String>,
|
||||||
|
/// Expands any incomplete instruction match to every instruction in the same signature.
|
||||||
|
pub incomplete_signatures: bool,
|
||||||
|
/// Maximum number of contextual inputs, or signatures when expansion is enabled.
|
||||||
|
pub limit: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl DecodeSelectionFilter {
|
||||||
|
/// Builds a validated bounded decode selection filter.
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
|
pub fn new(
|
||||||
|
signatures: std::vec::Vec<std::string::String>,
|
||||||
|
processing_states: std::vec::Vec<crate::CoreInstructionProcessingState>,
|
||||||
|
min_slot: std::option::Option<u64>,
|
||||||
|
max_slot: std::option::Option<u64>,
|
||||||
|
program_ids: std::vec::Vec<std::string::String>,
|
||||||
|
instruction_paths: std::vec::Vec<std::string::String>,
|
||||||
|
incomplete_signatures: bool,
|
||||||
|
limit: u32,
|
||||||
|
) -> kb_core::Result<Self> {
|
||||||
|
if limit == 0 {
|
||||||
|
return std::result::Result::Err(kb_core::Error::db(
|
||||||
|
"decode selection limit must be greater than zero",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if min_slot.is_some() && max_slot.is_some() && min_slot > max_slot {
|
||||||
|
return std::result::Result::Err(kb_core::Error::db(
|
||||||
|
"decode selection minimum slot must not exceed maximum slot",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let signatures_result = validate_text_list(&signatures, "decode selection signature");
|
||||||
|
if let std::result::Result::Err(error) = signatures_result {
|
||||||
|
return std::result::Result::Err(error);
|
||||||
|
}
|
||||||
|
let program_ids_result = validate_text_list(&program_ids, "decode selection program id");
|
||||||
|
if let std::result::Result::Err(error) = program_ids_result {
|
||||||
|
return std::result::Result::Err(error);
|
||||||
|
}
|
||||||
|
let paths_result =
|
||||||
|
validate_text_list(&instruction_paths, "decode selection instruction path");
|
||||||
|
if let std::result::Result::Err(error) = paths_result {
|
||||||
|
return std::result::Result::Err(error);
|
||||||
|
}
|
||||||
|
return std::result::Result::Ok(Self {
|
||||||
|
signatures,
|
||||||
|
processing_states,
|
||||||
|
min_slot,
|
||||||
|
max_slot,
|
||||||
|
program_ids,
|
||||||
|
instruction_paths,
|
||||||
|
incomplete_signatures,
|
||||||
|
limit,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Builds the default pending, failed and replay-requested selection.
|
||||||
|
pub fn actionable(limit: u32) -> kb_core::Result<Self> {
|
||||||
|
return crate::DecodeSelectionFilter::new(
|
||||||
|
std::vec::Vec::new(),
|
||||||
|
std::vec![
|
||||||
|
crate::CoreInstructionProcessingState::Pending,
|
||||||
|
crate::CoreInstructionProcessingState::Failed,
|
||||||
|
crate::CoreInstructionProcessingState::ReplayRequested,
|
||||||
|
],
|
||||||
|
std::option::Option::None,
|
||||||
|
std::option::Option::None,
|
||||||
|
std::vec::Vec::new(),
|
||||||
|
std::vec::Vec::new(),
|
||||||
|
false,
|
||||||
|
limit,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One processor-owned decoded observation row.
|
||||||
|
#[derive(Clone, Debug, PartialEq, serde::Deserialize, serde::Serialize)]
|
||||||
|
pub struct DecodeObservationInsert {
|
||||||
|
/// Stable decode processor name.
|
||||||
|
pub processor_name: std::string::String,
|
||||||
|
/// Stable decode processor version.
|
||||||
|
pub processor_version: std::string::String,
|
||||||
|
/// Stable contextual input key.
|
||||||
|
pub input_key: std::string::String,
|
||||||
|
/// Deterministic contextual input hash.
|
||||||
|
pub input_hash: std::string::String,
|
||||||
|
/// Stable event key within the processor and input.
|
||||||
|
pub event_key: std::string::String,
|
||||||
|
/// Source transaction signature.
|
||||||
|
pub signature: std::string::String,
|
||||||
|
/// Source transaction slot.
|
||||||
|
pub slot: u64,
|
||||||
|
/// Source instruction path.
|
||||||
|
pub instruction_path: std::string::String,
|
||||||
|
/// Source program identifier.
|
||||||
|
pub program_id: std::string::String,
|
||||||
|
/// Stable protocol code.
|
||||||
|
pub protocol_code: std::string::String,
|
||||||
|
/// Stable surface code.
|
||||||
|
pub surface_code: std::string::String,
|
||||||
|
/// Stable event code.
|
||||||
|
pub event_code: std::string::String,
|
||||||
|
/// Stable event name.
|
||||||
|
pub event_name: std::string::String,
|
||||||
|
/// Stable event family code.
|
||||||
|
pub event_family: std::string::String,
|
||||||
|
/// Stable event source code.
|
||||||
|
pub source_kind: std::string::String,
|
||||||
|
/// Stable decoder confidence code.
|
||||||
|
pub confidence: std::string::String,
|
||||||
|
/// Stable proof kind code.
|
||||||
|
pub proof_kind: std::string::String,
|
||||||
|
/// Proof evidence JSON.
|
||||||
|
pub proof_json: serde_json::Value,
|
||||||
|
/// Typed decoded payload JSON.
|
||||||
|
pub payload_json: serde_json::Value,
|
||||||
|
/// Whether the source transaction failed on-chain.
|
||||||
|
pub transaction_failed: bool,
|
||||||
|
/// Optional source transaction error JSON.
|
||||||
|
pub transaction_error: std::option::Option<serde_json::Value>,
|
||||||
|
/// Whether the observed state mutation was committed on-chain.
|
||||||
|
pub observation_committed: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl DecodeObservationInsert {
|
||||||
|
/// Validates stable identities and failed transaction commit semantics.
|
||||||
|
pub fn validate(&self) -> kb_core::Result<()> {
|
||||||
|
let fields = [
|
||||||
|
self.processor_name.as_str(),
|
||||||
|
self.processor_version.as_str(),
|
||||||
|
self.input_key.as_str(),
|
||||||
|
self.input_hash.as_str(),
|
||||||
|
self.event_key.as_str(),
|
||||||
|
self.signature.as_str(),
|
||||||
|
self.instruction_path.as_str(),
|
||||||
|
self.program_id.as_str(),
|
||||||
|
self.protocol_code.as_str(),
|
||||||
|
self.surface_code.as_str(),
|
||||||
|
self.event_code.as_str(),
|
||||||
|
self.event_name.as_str(),
|
||||||
|
self.event_family.as_str(),
|
||||||
|
self.source_kind.as_str(),
|
||||||
|
self.confidence.as_str(),
|
||||||
|
self.proof_kind.as_str(),
|
||||||
|
];
|
||||||
|
if fields.iter().any(|value| return value.trim().is_empty()) {
|
||||||
|
return std::result::Result::Err(kb_core::Error::db(
|
||||||
|
"decoded observation identity fields must not be empty",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if self.transaction_failed && self.observation_committed {
|
||||||
|
return std::result::Result::Err(kb_core::Error::db(
|
||||||
|
"failed transaction decoded observations must not be committed",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
return std::result::Result::Ok(());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One machine-readable decoder coverage declaration row.
|
||||||
|
#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
|
||||||
|
pub struct DecodeCoverageDeclarationInsert {
|
||||||
|
/// Stable decoder name.
|
||||||
|
pub processor_name: std::string::String,
|
||||||
|
/// Stable decoder version.
|
||||||
|
pub processor_version: std::string::String,
|
||||||
|
/// Exact Solana program identifier.
|
||||||
|
pub program_id: std::string::String,
|
||||||
|
/// Optional stable surface code.
|
||||||
|
pub surface_code: std::option::Option<std::string::String>,
|
||||||
|
/// Stable entry kind code.
|
||||||
|
pub entry_kind: std::string::String,
|
||||||
|
/// Stable instruction, event or discriminator code.
|
||||||
|
pub entry_code: std::string::String,
|
||||||
|
/// Optional normalized hexadecimal discriminator.
|
||||||
|
pub discriminator_hex: std::option::Option<std::string::String>,
|
||||||
|
/// Whether the entry is historical or deprecated.
|
||||||
|
pub historical: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One observed coverage classification row owned by one decode attempt.
|
||||||
|
#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
|
||||||
|
pub struct DecodeCoverageObservationInsert {
|
||||||
|
/// Stable decoder name.
|
||||||
|
pub processor_name: std::string::String,
|
||||||
|
/// Stable decoder version.
|
||||||
|
pub processor_version: std::string::String,
|
||||||
|
/// Stable contextual input key.
|
||||||
|
pub input_key: std::string::String,
|
||||||
|
/// Deterministic contextual input hash.
|
||||||
|
pub input_hash: std::string::String,
|
||||||
|
/// Source transaction signature.
|
||||||
|
pub signature: std::string::String,
|
||||||
|
/// Source transaction slot.
|
||||||
|
pub slot: u64,
|
||||||
|
/// Source instruction path.
|
||||||
|
pub instruction_path: std::string::String,
|
||||||
|
/// Source program identifier.
|
||||||
|
pub program_id: std::string::String,
|
||||||
|
/// Optional stable surface code.
|
||||||
|
pub surface_code: std::option::Option<std::string::String>,
|
||||||
|
/// Optional recognized entry code.
|
||||||
|
pub entry_code: std::option::Option<std::string::String>,
|
||||||
|
/// Optional discriminator.
|
||||||
|
pub discriminator_hex: std::option::Option<std::string::String>,
|
||||||
|
/// Stable decode terminal status code.
|
||||||
|
pub status: std::string::String,
|
||||||
|
/// Whether the processor recognized the input as compatible.
|
||||||
|
pub recognized: bool,
|
||||||
|
/// Number of decoded observations produced.
|
||||||
|
pub decoded_count: u32,
|
||||||
|
/// Number of materialized outputs produced immediately after decoding.
|
||||||
|
pub materialized_count: u32,
|
||||||
|
/// Number of decoder diagnostics classified as errors.
|
||||||
|
pub error_count: u32,
|
||||||
|
/// Whether the source transaction failed on-chain.
|
||||||
|
pub transaction_failed: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Atomic persistence bundle for one decoder and one contextual input.
|
||||||
|
#[derive(Clone, Debug, PartialEq, serde::Deserialize, serde::Serialize)]
|
||||||
|
pub struct DecodePersistenceBundle {
|
||||||
|
/// Processing ledger identity for the decode attempt.
|
||||||
|
pub ledger_identity: crate::ProcessingLedgerIdentity,
|
||||||
|
/// Source transaction signature.
|
||||||
|
pub signature: std::string::String,
|
||||||
|
/// Source instruction path.
|
||||||
|
pub instruction_path: std::string::String,
|
||||||
|
/// Stable terminal status code.
|
||||||
|
pub status: std::string::String,
|
||||||
|
/// Optional stable machine-readable terminal error code.
|
||||||
|
pub error_code: std::option::Option<std::string::String>,
|
||||||
|
/// Optional human-readable terminal error message.
|
||||||
|
pub error_message: std::option::Option<std::string::String>,
|
||||||
|
/// Processor-owned decoded observations.
|
||||||
|
pub observations: std::vec::Vec<DecodeObservationInsert>,
|
||||||
|
/// Coverage observation for this attempt.
|
||||||
|
pub coverage: DecodeCoverageObservationInsert,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl DecodePersistenceBundle {
|
||||||
|
/// Validates identities shared by every atomic decode output.
|
||||||
|
pub fn validate(&self) -> kb_core::Result<()> {
|
||||||
|
if self.ledger_identity.stage != "instruction_decode"
|
||||||
|
|| self.signature.trim().is_empty()
|
||||||
|
|| self.instruction_path.trim().is_empty()
|
||||||
|
|| self.status.trim().is_empty()
|
||||||
|
{
|
||||||
|
return std::result::Result::Err(kb_core::Error::db(
|
||||||
|
"decode persistence bundle identity is invalid",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if !matches!(self.status.as_str(), "decoded" | "ignored" | "unsupported" | "failed") {
|
||||||
|
return std::result::Result::Err(kb_core::Error::db(
|
||||||
|
"decode persistence bundle status is unsupported",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if self.coverage.processor_name != self.ledger_identity.processor_name
|
||||||
|
|| self.coverage.processor_version != self.ledger_identity.processor_version
|
||||||
|
|| self.coverage.input_key != self.ledger_identity.input_key
|
||||||
|
|| self.coverage.input_hash != self.ledger_identity.input_hash
|
||||||
|
|| self.coverage.signature != self.signature
|
||||||
|
|| self.coverage.instruction_path != self.instruction_path
|
||||||
|
|| self.coverage.status != self.status
|
||||||
|
{
|
||||||
|
return std::result::Result::Err(kb_core::Error::db(
|
||||||
|
"decode coverage observation does not match bundle identity",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let observation_count_result = u32::try_from(self.observations.len());
|
||||||
|
let observation_count = match observation_count_result {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(_) => {
|
||||||
|
return std::result::Result::Err(kb_core::Error::db(
|
||||||
|
"decode observation count exceeds the supported range",
|
||||||
|
));
|
||||||
|
},
|
||||||
|
};
|
||||||
|
if self.coverage.decoded_count != observation_count
|
||||||
|
|| (self.status == "decoded" && self.observations.is_empty())
|
||||||
|
|| (self.status != "decoded" && !self.observations.is_empty())
|
||||||
|
{
|
||||||
|
return std::result::Result::Err(kb_core::Error::db(
|
||||||
|
"decode status, coverage count and observations are inconsistent",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let missing_decode_error = match (&self.error_code, &self.error_message) {
|
||||||
|
(std::option::Option::Some(code), std::option::Option::Some(message)) => {
|
||||||
|
code.trim().is_empty() || message.trim().is_empty()
|
||||||
|
},
|
||||||
|
_ => true,
|
||||||
|
};
|
||||||
|
if self.status == "failed" && missing_decode_error {
|
||||||
|
return std::result::Result::Err(kb_core::Error::db(
|
||||||
|
"failed decode bundle requires error code and message",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
for observation in &self.observations {
|
||||||
|
let validation_result = observation.validate();
|
||||||
|
if let std::result::Result::Err(error) = validation_result {
|
||||||
|
return std::result::Result::Err(error);
|
||||||
|
}
|
||||||
|
if observation.processor_name != self.ledger_identity.processor_name
|
||||||
|
|| observation.processor_version != self.ledger_identity.processor_version
|
||||||
|
|| observation.input_key != self.ledger_identity.input_key
|
||||||
|
|| observation.input_hash != self.ledger_identity.input_hash
|
||||||
|
|| observation.signature != self.signature
|
||||||
|
|| observation.instruction_path != self.instruction_path
|
||||||
|
|| observation.transaction_failed != self.coverage.transaction_failed
|
||||||
|
{
|
||||||
|
return std::result::Result::Err(kb_core::Error::db(
|
||||||
|
"decoded observation does not match bundle identity",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return std::result::Result::Ok(());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Failed decode attempt persisted in the common ledger.
|
||||||
|
#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
|
||||||
|
pub struct DecodeFailure {
|
||||||
|
/// Processing ledger identity.
|
||||||
|
pub ledger_identity: crate::ProcessingLedgerIdentity,
|
||||||
|
/// Source transaction signature.
|
||||||
|
pub signature: std::string::String,
|
||||||
|
/// Source instruction path.
|
||||||
|
pub instruction_path: std::string::String,
|
||||||
|
/// Stable machine-readable error code.
|
||||||
|
pub error_code: std::string::String,
|
||||||
|
/// Human-readable diagnostic message.
|
||||||
|
pub error_message: std::string::String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One processor-owned materialized output row.
|
||||||
|
#[derive(Clone, Debug, PartialEq, serde::Deserialize, serde::Serialize)]
|
||||||
|
pub struct MaterializedOutputInsert {
|
||||||
|
/// Stable materializer name.
|
||||||
|
pub processor_name: std::string::String,
|
||||||
|
/// Stable materializer version.
|
||||||
|
pub processor_version: std::string::String,
|
||||||
|
/// Stable decoded observation source key.
|
||||||
|
pub input_key: std::string::String,
|
||||||
|
/// Deterministic decoded observation input hash.
|
||||||
|
pub input_hash: std::string::String,
|
||||||
|
/// Stable output key within the materializer and input.
|
||||||
|
pub output_key: std::string::String,
|
||||||
|
/// Source decoded event key.
|
||||||
|
pub source_event_key: std::string::String,
|
||||||
|
/// Source transaction signature.
|
||||||
|
pub signature: std::string::String,
|
||||||
|
/// Source transaction slot.
|
||||||
|
pub slot: u64,
|
||||||
|
/// Stable materialized family code.
|
||||||
|
pub materialized_family: std::string::String,
|
||||||
|
/// Typed business payload JSON.
|
||||||
|
pub payload_json: serde_json::Value,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Atomic persistence bundle for one materializer and one decoded observation.
|
||||||
|
#[derive(Clone, Debug, PartialEq, serde::Deserialize, serde::Serialize)]
|
||||||
|
pub struct MaterializationPersistenceBundle {
|
||||||
|
/// Processing ledger identity for the materialization attempt.
|
||||||
|
pub ledger_identity: crate::ProcessingLedgerIdentity,
|
||||||
|
/// Source decoder name owning the decoded observation.
|
||||||
|
pub source_decoder_name: std::string::String,
|
||||||
|
/// Source decoder version owning the decoded observation.
|
||||||
|
pub source_decoder_version: std::string::String,
|
||||||
|
/// Source contextual decode input key.
|
||||||
|
pub source_decode_input_key: std::string::String,
|
||||||
|
/// Source transaction signature.
|
||||||
|
pub signature: std::string::String,
|
||||||
|
/// Source stable instruction path.
|
||||||
|
pub instruction_path: std::string::String,
|
||||||
|
/// Stable terminal materializer status code.
|
||||||
|
pub status: std::string::String,
|
||||||
|
/// Optional stable machine-readable terminal error code.
|
||||||
|
pub error_code: std::option::Option<std::string::String>,
|
||||||
|
/// Optional human-readable terminal error message.
|
||||||
|
pub error_message: std::option::Option<std::string::String>,
|
||||||
|
/// Processor-owned materialized outputs.
|
||||||
|
pub outputs: std::vec::Vec<MaterializedOutputInsert>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl MaterializationPersistenceBundle {
|
||||||
|
/// Validates the materializer, source decoder and output identities.
|
||||||
|
pub fn validate(&self) -> kb_core::Result<()> {
|
||||||
|
if self.ledger_identity.stage != "event_materialization"
|
||||||
|
|| self.source_decoder_name.trim().is_empty()
|
||||||
|
|| self.source_decoder_version.trim().is_empty()
|
||||||
|
|| self.source_decode_input_key.trim().is_empty()
|
||||||
|
|| self.signature.trim().is_empty()
|
||||||
|
|| self.instruction_path.trim().is_empty()
|
||||||
|
|| self.status.trim().is_empty()
|
||||||
|
{
|
||||||
|
return std::result::Result::Err(kb_core::Error::db(
|
||||||
|
"materialization persistence bundle identity is invalid",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if !matches!(
|
||||||
|
self.status.as_str(),
|
||||||
|
"inserted" | "replaced" | "ignored" | "refused" | "failed"
|
||||||
|
) {
|
||||||
|
return std::result::Result::Err(kb_core::Error::db(
|
||||||
|
"materialization persistence bundle status is unsupported",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if (matches!(self.status.as_str(), "inserted" | "replaced") && self.outputs.is_empty())
|
||||||
|
|| (matches!(self.status.as_str(), "ignored" | "refused" | "failed")
|
||||||
|
&& !self.outputs.is_empty())
|
||||||
|
{
|
||||||
|
return std::result::Result::Err(kb_core::Error::db(
|
||||||
|
"materialization status and outputs are inconsistent",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let missing_materialization_error = match (&self.error_code, &self.error_message) {
|
||||||
|
(std::option::Option::Some(code), std::option::Option::Some(message)) => {
|
||||||
|
code.trim().is_empty() || message.trim().is_empty()
|
||||||
|
},
|
||||||
|
_ => true,
|
||||||
|
};
|
||||||
|
if self.status == "failed" && missing_materialization_error {
|
||||||
|
return std::result::Result::Err(kb_core::Error::db(
|
||||||
|
"failed materialization bundle requires error code and message",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
for output in &self.outputs {
|
||||||
|
if output.output_key.trim().is_empty()
|
||||||
|
|| output.source_event_key.trim().is_empty()
|
||||||
|
|| output.materialized_family.trim().is_empty()
|
||||||
|
|| output.processor_name != self.ledger_identity.processor_name
|
||||||
|
|| output.processor_version != self.ledger_identity.processor_version
|
||||||
|
|| output.input_key != self.ledger_identity.input_key
|
||||||
|
|| output.input_hash != self.ledger_identity.input_hash
|
||||||
|
|| output.signature != self.signature
|
||||||
|
{
|
||||||
|
return std::result::Result::Err(kb_core::Error::db(
|
||||||
|
"materialized output does not match bundle identity",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return std::result::Result::Ok(());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One row of aggregated decoder coverage diagnostics.
|
||||||
|
#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
|
||||||
|
pub struct DecodeCoverageSummaryRow {
|
||||||
|
/// Stable decoder name.
|
||||||
|
pub processor_name: std::string::String,
|
||||||
|
/// Stable decoder version.
|
||||||
|
pub processor_version: std::string::String,
|
||||||
|
/// Exact Solana program identifier.
|
||||||
|
pub program_id: std::string::String,
|
||||||
|
/// Optional stable surface code.
|
||||||
|
pub surface_code: std::option::Option<std::string::String>,
|
||||||
|
/// Stable entry code or unknown classifier.
|
||||||
|
pub entry_code: std::string::String,
|
||||||
|
/// Number of declared matching entries.
|
||||||
|
pub declared_count: i64,
|
||||||
|
/// Number of observed inputs.
|
||||||
|
pub observed_count: i64,
|
||||||
|
/// Number of recognized inputs.
|
||||||
|
pub recognized_count: i64,
|
||||||
|
/// Number of decoded observations.
|
||||||
|
pub decoded_count: i64,
|
||||||
|
/// Number of materialized outputs.
|
||||||
|
pub materialized_count: i64,
|
||||||
|
/// Number of errors.
|
||||||
|
pub error_count: i64,
|
||||||
|
/// Number of observations classified as unknown or unsupported.
|
||||||
|
pub unknown_count: i64,
|
||||||
|
/// Number of successful source transactions.
|
||||||
|
pub successful_transaction_count: i64,
|
||||||
|
/// Number of failed source transactions.
|
||||||
|
pub failed_transaction_count: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn validate_text_list(values: &[std::string::String], label: &str) -> kb_core::Result<()> {
|
||||||
|
if values.iter().any(|value| return value.trim().is_empty()) {
|
||||||
|
return std::result::Result::Err(kb_core::Error::db(format!("{label} must not be empty")));
|
||||||
|
}
|
||||||
|
return std::result::Result::Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
fn trim_optional_text(
|
||||||
|
value: std::option::Option<std::string::String>,
|
||||||
|
) -> std::option::Option<std::string::String> {
|
||||||
|
return value.and_then(|text| {
|
||||||
|
let trimmed = text.trim();
|
||||||
|
if trimmed.is_empty() {
|
||||||
|
return std::option::Option::None;
|
||||||
|
}
|
||||||
|
return std::option::Option::Some(trimmed.to_string());
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
#[test]
|
||||||
|
fn actionable_filter_requires_positive_limit() {
|
||||||
|
assert!(crate::DecodeSelectionFilter::actionable(0).is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn incomplete_signature_filter_preserves_signature_limit_semantics() {
|
||||||
|
let result = crate::DecodeSelectionFilter::new(
|
||||||
|
std::vec::Vec::new(),
|
||||||
|
std::vec![crate::CoreInstructionProcessingState::Failed],
|
||||||
|
std::option::Option::None,
|
||||||
|
std::option::Option::None,
|
||||||
|
std::vec::Vec::new(),
|
||||||
|
std::vec::Vec::new(),
|
||||||
|
true,
|
||||||
|
25,
|
||||||
|
);
|
||||||
|
let filter = match result {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => panic!("unexpected filter error: {error}"),
|
||||||
|
};
|
||||||
|
assert!(filter.incomplete_signatures);
|
||||||
|
assert_eq!(filter.limit, 25);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn selection_filter_rejects_inverted_slots() {
|
||||||
|
let result = crate::DecodeSelectionFilter::new(
|
||||||
|
std::vec::Vec::new(),
|
||||||
|
std::vec::Vec::new(),
|
||||||
|
std::option::Option::Some(20),
|
||||||
|
std::option::Option::Some(10),
|
||||||
|
std::vec::Vec::new(),
|
||||||
|
std::vec::Vec::new(),
|
||||||
|
false,
|
||||||
|
10,
|
||||||
|
);
|
||||||
|
assert!(result.is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn materialized_event_filter_is_bounded_and_trims_optional_text() {
|
||||||
|
let result = crate::MaterializedEventFilter::new(
|
||||||
|
std::option::Option::Some(" transaction_annotations ".to_string()),
|
||||||
|
std::option::Option::Some(" transaction_annotation ".to_string()),
|
||||||
|
std::option::Option::Some(" signature ".to_string()),
|
||||||
|
crate::MAX_MATERIALIZED_EVENT_QUERY_ROWS,
|
||||||
|
);
|
||||||
|
let filter = match result {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => panic!("unexpected filter error: {error}"),
|
||||||
|
};
|
||||||
|
assert_eq!(
|
||||||
|
filter.processor_name.as_deref(),
|
||||||
|
std::option::Option::Some("transaction_annotations")
|
||||||
|
);
|
||||||
|
assert_eq!(filter.signature_contains.as_deref(), std::option::Option::Some("signature"));
|
||||||
|
assert!(
|
||||||
|
crate::MaterializedEventFilter::new(
|
||||||
|
std::option::Option::None,
|
||||||
|
std::option::Option::None,
|
||||||
|
std::option::Option::None,
|
||||||
|
0,
|
||||||
|
)
|
||||||
|
.is_err()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn failed_decoded_observation_cannot_be_committed() {
|
||||||
|
let input = crate::DecodeObservationInsert {
|
||||||
|
processor_name: "decoder".to_string(),
|
||||||
|
processor_version: "1".to_string(),
|
||||||
|
input_key: "signature:0".to_string(),
|
||||||
|
input_hash: "hash".to_string(),
|
||||||
|
event_key: "event".to_string(),
|
||||||
|
signature: "signature".to_string(),
|
||||||
|
slot: 1,
|
||||||
|
instruction_path: "0".to_string(),
|
||||||
|
program_id: "program".to_string(),
|
||||||
|
protocol_code: "protocol".to_string(),
|
||||||
|
surface_code: "surface".to_string(),
|
||||||
|
event_code: "event".to_string(),
|
||||||
|
event_name: "event".to_string(),
|
||||||
|
event_family: "audit".to_string(),
|
||||||
|
source_kind: "instruction".to_string(),
|
||||||
|
confidence: "exact".to_string(),
|
||||||
|
proof_kind: "exact_layout".to_string(),
|
||||||
|
proof_json: serde_json::json!({}),
|
||||||
|
payload_json: serde_json::json!({}),
|
||||||
|
transaction_failed: true,
|
||||||
|
transaction_error: std::option::Option::Some(serde_json::json!({})),
|
||||||
|
observation_committed: true,
|
||||||
|
};
|
||||||
|
assert!(input.validate().is_err());
|
||||||
|
}
|
||||||
|
}
|
||||||
143
kb-store/src/contracts/dto/event.rs
Normal file
143
kb-store/src/contracts/dto/event.rs
Normal file
@@ -0,0 +1,143 @@
|
|||||||
|
// file: kb-store/src/contracts/dto/event.rs
|
||||||
|
// version: 1
|
||||||
|
|
||||||
|
//! Decoded and materialized event storage DTOs.
|
||||||
|
|
||||||
|
/// Insert or upsert result contract returned by repositories.
|
||||||
|
#[derive(Clone, Copy, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
|
||||||
|
pub struct InsertOutcome {
|
||||||
|
/// Number of rows inserted by the repository call.
|
||||||
|
pub inserted_count: u64,
|
||||||
|
/// Number of rows updated by the repository call.
|
||||||
|
pub updated_count: u64,
|
||||||
|
/// Number of rows skipped by the repository call.
|
||||||
|
pub skipped_count: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl InsertOutcome {
|
||||||
|
/// Builds an insert outcome from explicit counters.
|
||||||
|
pub fn new(inserted_count: u64, updated_count: u64, skipped_count: u64) -> Self {
|
||||||
|
return Self {
|
||||||
|
inserted_count,
|
||||||
|
updated_count,
|
||||||
|
skipped_count,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the sum of inserted, updated and skipped rows.
|
||||||
|
pub fn total_count(&self) -> u64 {
|
||||||
|
return self.inserted_count + self.updated_count + self.skipped_count;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Decoded event insert contract.
|
||||||
|
#[derive(Clone, Debug, PartialEq, serde::Deserialize, serde::Serialize)]
|
||||||
|
pub struct DecodedEventInsert {
|
||||||
|
/// Transaction signature as non-empty base58 text.
|
||||||
|
pub signature: std::string::String,
|
||||||
|
/// Transaction slot in Solana unsigned representation.
|
||||||
|
pub slot: u64,
|
||||||
|
/// Stable instruction path, for example `0` or `2/1`.
|
||||||
|
pub instruction_path: std::string::String,
|
||||||
|
/// Program id as non-empty base58 text.
|
||||||
|
pub program_id: std::string::String,
|
||||||
|
/// Protocol family code.
|
||||||
|
pub protocol_code: std::string::String,
|
||||||
|
/// Protocol surface code.
|
||||||
|
pub surface_code: std::string::String,
|
||||||
|
/// Canonical event code.
|
||||||
|
pub event_code: std::string::String,
|
||||||
|
/// Decoded payload JSON.
|
||||||
|
pub payload_json: serde_json::Value,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl DecodedEventInsert {
|
||||||
|
/// Builds a decoded event insert contract from the shared decoded model.
|
||||||
|
pub fn from_model(
|
||||||
|
event: &kb_lib::DecodedProtocolEvent,
|
||||||
|
payload_json: serde_json::Value,
|
||||||
|
) -> kb_core::Result<Self> {
|
||||||
|
if event.signature.0.trim().is_empty() {
|
||||||
|
return std::result::Result::Err(kb_core::Error::db(
|
||||||
|
"decoded event signature must not be empty",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if event.program_id.0.trim().is_empty() {
|
||||||
|
return std::result::Result::Err(kb_core::Error::db(
|
||||||
|
"decoded event program id must not be empty",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
return std::result::Result::Ok(Self {
|
||||||
|
signature: event.signature.0.clone(),
|
||||||
|
slot: event.slot.0,
|
||||||
|
instruction_path: event.instruction_path.0.clone(),
|
||||||
|
program_id: event.program_id.0.clone(),
|
||||||
|
protocol_code: event.protocol_code.0.clone(),
|
||||||
|
surface_code: event.surface_code.0.clone(),
|
||||||
|
event_code: event.event_code.0.clone(),
|
||||||
|
payload_json,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Materialized event insert contract.
|
||||||
|
#[derive(Clone, Debug, PartialEq, serde::Deserialize, serde::Serialize)]
|
||||||
|
pub struct MaterializedEventInsert {
|
||||||
|
/// Source transaction signature as non-empty base58 text.
|
||||||
|
pub signature: std::string::String,
|
||||||
|
/// Source transaction slot in Solana unsigned representation.
|
||||||
|
pub slot: u64,
|
||||||
|
/// Materialized event family code.
|
||||||
|
pub materialized_family: std::string::String,
|
||||||
|
/// Materialized payload JSON.
|
||||||
|
pub payload_json: serde_json::Value,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl MaterializedEventInsert {
|
||||||
|
/// Builds a materialized event insert contract after minimal validation.
|
||||||
|
pub fn new(
|
||||||
|
signature: impl std::convert::Into<std::string::String>,
|
||||||
|
slot: u64,
|
||||||
|
materialized_family: impl std::convert::Into<std::string::String>,
|
||||||
|
payload_json: serde_json::Value,
|
||||||
|
) -> kb_core::Result<Self> {
|
||||||
|
let signature_value = signature.into();
|
||||||
|
let materialized_family_value = materialized_family.into();
|
||||||
|
if signature_value.trim().is_empty() {
|
||||||
|
return std::result::Result::Err(kb_core::Error::db(
|
||||||
|
"materialized event signature must not be empty",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if materialized_family_value.trim().is_empty() {
|
||||||
|
return std::result::Result::Err(kb_core::Error::db(
|
||||||
|
"materialized event family must not be empty",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
return std::result::Result::Ok(Self {
|
||||||
|
signature: signature_value,
|
||||||
|
slot,
|
||||||
|
materialized_family: materialized_family_value,
|
||||||
|
payload_json,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
#[test]
|
||||||
|
fn insert_outcome_total_counts_all_buckets() {
|
||||||
|
let outcome = crate::InsertOutcome::new(1, 2, 3);
|
||||||
|
assert_eq!(outcome.total_count(), 6);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn materialized_event_rejects_empty_family() {
|
||||||
|
let result = crate::MaterializedEventInsert::new(
|
||||||
|
"abc",
|
||||||
|
1,
|
||||||
|
" ",
|
||||||
|
serde_json::json!({"kind": "trade"}),
|
||||||
|
);
|
||||||
|
assert!(result.is_err());
|
||||||
|
}
|
||||||
|
}
|
||||||
73
kb-store/src/contracts/dto/ledger.rs
Normal file
73
kb-store/src/contracts/dto/ledger.rs
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
// file: kb-store/src/contracts/dto/ledger.rs
|
||||||
|
// version: 1
|
||||||
|
|
||||||
|
//! Processing ledger storage DTOs.
|
||||||
|
|
||||||
|
/// Processing ledger mark request contract.
|
||||||
|
#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
|
||||||
|
pub struct ProcessingLedgerMark {
|
||||||
|
/// Processing stage name, for example `raw_ingest`, `core_extract` or `decode`.
|
||||||
|
pub stage: std::string::String,
|
||||||
|
/// Processing module name.
|
||||||
|
pub module_name: std::string::String,
|
||||||
|
/// Processing module version.
|
||||||
|
pub module_version: std::string::String,
|
||||||
|
/// Stable input key, usually a signature or notification id.
|
||||||
|
pub input_key: std::string::String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ProcessingLedgerMark {
|
||||||
|
/// Builds a processing ledger mark request after minimal validation.
|
||||||
|
pub fn new(
|
||||||
|
stage: impl std::convert::Into<std::string::String>,
|
||||||
|
module_name: impl std::convert::Into<std::string::String>,
|
||||||
|
module_version: impl std::convert::Into<std::string::String>,
|
||||||
|
input_key: impl std::convert::Into<std::string::String>,
|
||||||
|
) -> kb_core::Result<Self> {
|
||||||
|
let stage_value = stage.into();
|
||||||
|
let module_name_value = module_name.into();
|
||||||
|
let module_version_value = module_version.into();
|
||||||
|
let input_key_value = input_key.into();
|
||||||
|
if stage_value.trim().is_empty() {
|
||||||
|
return std::result::Result::Err(kb_core::Error::db(
|
||||||
|
"processing ledger stage must not be empty",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if module_name_value.trim().is_empty() {
|
||||||
|
return std::result::Result::Err(kb_core::Error::db(
|
||||||
|
"processing ledger module name must not be empty",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if module_version_value.trim().is_empty() {
|
||||||
|
return std::result::Result::Err(kb_core::Error::db(
|
||||||
|
"processing ledger module version must not be empty",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if input_key_value.trim().is_empty() {
|
||||||
|
return std::result::Result::Err(kb_core::Error::db(
|
||||||
|
"processing ledger input key must not be empty",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
return std::result::Result::Ok(Self {
|
||||||
|
stage: stage_value,
|
||||||
|
module_name: module_name_value,
|
||||||
|
module_version: module_version_value,
|
||||||
|
input_key: input_key_value,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
#[test]
|
||||||
|
fn ledger_mark_rejects_empty_input_key() {
|
||||||
|
let result = crate::ProcessingLedgerMark::new("decode", "module", "1", " ");
|
||||||
|
assert!(result.is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn ledger_mark_accepts_minimal_values() {
|
||||||
|
let result = crate::ProcessingLedgerMark::new("decode", "module", "1", "signature");
|
||||||
|
assert!(result.is_ok());
|
||||||
|
}
|
||||||
|
}
|
||||||
606
kb-store/src/contracts/dto/raw.rs
Normal file
606
kb-store/src/contracts/dto/raw.rs
Normal file
@@ -0,0 +1,606 @@
|
|||||||
|
// file: kb-store/src/contracts/dto/raw.rs
|
||||||
|
// version: 1
|
||||||
|
|
||||||
|
//! Canonical Solana transaction and acquisition observation storage DTOs.
|
||||||
|
|
||||||
|
/// Retention state for a canonical raw transaction payload.
|
||||||
|
#[derive(Clone, Copy, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
|
||||||
|
pub enum RawPayloadRetentionState {
|
||||||
|
/// Full canonical payload is still present in the primary store.
|
||||||
|
Full,
|
||||||
|
/// Canonical payload was reduced to a compact audit representation.
|
||||||
|
Compacted,
|
||||||
|
/// Canonical payload was moved to an archive tier outside the primary hot store.
|
||||||
|
Archived,
|
||||||
|
/// Canonical payload was purged after derived data became authoritative enough.
|
||||||
|
Purged,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Processing state for a canonical raw transaction payload.
|
||||||
|
#[derive(Clone, Copy, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
|
||||||
|
pub enum RawPayloadProcessingState {
|
||||||
|
/// Transaction was received but not extracted yet.
|
||||||
|
Received,
|
||||||
|
/// Generic Solana core data was extracted from the canonical transaction.
|
||||||
|
CoreExtracted,
|
||||||
|
/// Decoder outputs were produced from the canonical transaction or its core extraction.
|
||||||
|
Decoded,
|
||||||
|
/// Business projections were materialized from decoded or core data.
|
||||||
|
Materialized,
|
||||||
|
/// Processing failed and requires diagnostics or replay.
|
||||||
|
Failed,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Origin of one transaction acquisition observation.
|
||||||
|
#[derive(Clone, Copy, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
|
||||||
|
pub enum TransactionObservationOrigin {
|
||||||
|
/// Transaction was observed from a current live stream.
|
||||||
|
Live,
|
||||||
|
/// Transaction was acquired by an explicit historical backfill.
|
||||||
|
Backfill,
|
||||||
|
/// Transaction was replayed from an already captured source.
|
||||||
|
Replay,
|
||||||
|
/// Transaction was fetched to repair an acquisition gap.
|
||||||
|
Repair,
|
||||||
|
/// Observation was converted from a historical storage table.
|
||||||
|
Migration,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Technical status of one transaction acquisition observation.
|
||||||
|
#[derive(Clone, Copy, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
|
||||||
|
pub enum TransactionObservationStatus {
|
||||||
|
/// A transaction candidate was detected before a complete payload was received.
|
||||||
|
Detected,
|
||||||
|
/// A source payload was received.
|
||||||
|
Received,
|
||||||
|
/// A source payload was normalized into the canonical transaction contract.
|
||||||
|
Normalized,
|
||||||
|
/// The observation and any linked canonical transaction were persisted.
|
||||||
|
Persisted,
|
||||||
|
/// Acquisition or normalization failed.
|
||||||
|
Failed,
|
||||||
|
/// The source reported or implied a transaction that was temporarily unavailable.
|
||||||
|
Missing,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Canonical raw Solana transaction insert contract.
|
||||||
|
#[derive(Clone, Debug, PartialEq, serde::Deserialize, serde::Serialize)]
|
||||||
|
pub struct RawTransactionInsert {
|
||||||
|
/// Transaction signature as non-empty base58 text.
|
||||||
|
pub signature: std::string::String,
|
||||||
|
/// Transaction slot in Solana unsigned representation.
|
||||||
|
pub slot: u64,
|
||||||
|
/// Canonical source-independent transaction document.
|
||||||
|
pub canonical_json: serde_json::Value,
|
||||||
|
/// Optional deterministic digest of the canonical document.
|
||||||
|
pub canonical_json_hash: std::option::Option<std::string::String>,
|
||||||
|
/// Positive version of the canonical transaction contract.
|
||||||
|
pub canonical_format_version: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl RawTransactionInsert {
|
||||||
|
/// Builds a canonical raw transaction insert contract after minimal validation.
|
||||||
|
pub fn new(
|
||||||
|
signature: impl std::convert::Into<std::string::String>,
|
||||||
|
slot: u64,
|
||||||
|
canonical_json: serde_json::Value,
|
||||||
|
canonical_format_version: u32,
|
||||||
|
) -> kb_core::Result<Self> {
|
||||||
|
let signature_value = signature.into();
|
||||||
|
if signature_value.trim().is_empty() {
|
||||||
|
return std::result::Result::Err(kb_core::Error::db(
|
||||||
|
"canonical transaction signature must not be empty",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if canonical_format_version == 0 {
|
||||||
|
return std::result::Result::Err(kb_core::Error::db(
|
||||||
|
"canonical transaction format version must be greater than zero",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
return std::result::Result::Ok(Self {
|
||||||
|
signature: signature_value,
|
||||||
|
slot,
|
||||||
|
canonical_json,
|
||||||
|
canonical_json_hash: std::option::Option::None,
|
||||||
|
canonical_format_version,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Builds a storage insert from the source-independent canonical transaction model.
|
||||||
|
pub fn from_canonical(transaction: &kb_lib::CanonicalTransaction) -> kb_core::Result<Self> {
|
||||||
|
let canonical_json_result = transaction.to_canonical_json();
|
||||||
|
let canonical_json = match canonical_json_result {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
let hash_result = transaction.canonical_json_hash();
|
||||||
|
let hash = match hash_result {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
let insert_result = Self::new(
|
||||||
|
transaction.primary_signature.clone(),
|
||||||
|
transaction.slot,
|
||||||
|
canonical_json,
|
||||||
|
transaction.format_version,
|
||||||
|
);
|
||||||
|
let insert = match insert_result {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
return insert.with_canonical_json_hash(hash);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Adds a precomputed deterministic canonical document hash.
|
||||||
|
pub fn with_canonical_json_hash(
|
||||||
|
mut self,
|
||||||
|
canonical_json_hash: impl std::convert::Into<std::string::String>,
|
||||||
|
) -> kb_core::Result<Self> {
|
||||||
|
let hash_value = canonical_json_hash.into();
|
||||||
|
if hash_value.trim().is_empty() {
|
||||||
|
return std::result::Result::Err(kb_core::Error::db(
|
||||||
|
"canonical transaction hash must not be empty",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
self.canonical_json_hash = std::option::Option::Some(hash_value);
|
||||||
|
return std::result::Result::Ok(self);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Lightweight transaction acquisition observation insert contract.
|
||||||
|
#[derive(Clone, Debug, PartialEq, serde::Deserialize, serde::Serialize)]
|
||||||
|
pub struct TransactionObservationInsert {
|
||||||
|
/// Stable observation key used for deduplication.
|
||||||
|
pub observation_key: std::string::String,
|
||||||
|
/// Optional known canonical transaction row id.
|
||||||
|
pub raw_transaction_id: std::option::Option<i64>,
|
||||||
|
/// Optional transaction signature when known.
|
||||||
|
pub signature: std::option::Option<std::string::String>,
|
||||||
|
/// Optional transaction slot when known.
|
||||||
|
pub slot: std::option::Option<u64>,
|
||||||
|
/// Provider code.
|
||||||
|
pub provider: std::string::String,
|
||||||
|
/// Optional endpoint code from the active configuration.
|
||||||
|
pub endpoint_code: std::option::Option<std::string::String>,
|
||||||
|
/// Source protocol.
|
||||||
|
pub protocol: std::string::String,
|
||||||
|
/// Source acquisition method.
|
||||||
|
pub acquisition_method: std::string::String,
|
||||||
|
/// Observation origin.
|
||||||
|
pub origin: TransactionObservationOrigin,
|
||||||
|
/// Optional Solana commitment.
|
||||||
|
pub commitment: std::option::Option<std::string::String>,
|
||||||
|
/// Optional capture session identifier.
|
||||||
|
pub capture_session_id: std::option::Option<std::string::String>,
|
||||||
|
/// Optional configured filter code.
|
||||||
|
pub filter_code: std::option::Option<std::string::String>,
|
||||||
|
/// Optional first detection timestamp.
|
||||||
|
pub detected_at: std::option::Option<chrono::DateTime<chrono::Utc>>,
|
||||||
|
/// Timestamp at which the source payload was received locally.
|
||||||
|
pub received_at: chrono::DateTime<chrono::Utc>,
|
||||||
|
/// Optional timestamp at which canonical normalization completed.
|
||||||
|
pub normalized_at: std::option::Option<chrono::DateTime<chrono::Utc>>,
|
||||||
|
/// Optional uncompressed source payload size in bytes.
|
||||||
|
pub payload_size_bytes: std::option::Option<u64>,
|
||||||
|
/// Optional digest of the source-specific payload without retaining that payload.
|
||||||
|
pub source_payload_hash: std::option::Option<std::string::String>,
|
||||||
|
/// Observation status.
|
||||||
|
pub status: TransactionObservationStatus,
|
||||||
|
/// Optional machine-readable error code.
|
||||||
|
pub error_code: std::option::Option<std::string::String>,
|
||||||
|
/// Optional diagnostic error message.
|
||||||
|
pub error_message: std::option::Option<std::string::String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TransactionObservationInsert {
|
||||||
|
/// Builds a lightweight transaction observation after validating required source metadata.
|
||||||
|
pub fn new(
|
||||||
|
observation_key: impl std::convert::Into<std::string::String>,
|
||||||
|
provider: impl std::convert::Into<std::string::String>,
|
||||||
|
protocol: impl std::convert::Into<std::string::String>,
|
||||||
|
acquisition_method: impl std::convert::Into<std::string::String>,
|
||||||
|
origin: TransactionObservationOrigin,
|
||||||
|
received_at: chrono::DateTime<chrono::Utc>,
|
||||||
|
) -> kb_core::Result<Self> {
|
||||||
|
let observation_key_value = observation_key.into();
|
||||||
|
let provider_value = provider.into();
|
||||||
|
let protocol_value = protocol.into();
|
||||||
|
let acquisition_method_value = acquisition_method.into();
|
||||||
|
let validation_result = validate_required_observation_texts(
|
||||||
|
observation_key_value.as_str(),
|
||||||
|
provider_value.as_str(),
|
||||||
|
protocol_value.as_str(),
|
||||||
|
acquisition_method_value.as_str(),
|
||||||
|
);
|
||||||
|
if let std::result::Result::Err(error) = validation_result {
|
||||||
|
return std::result::Result::Err(error);
|
||||||
|
}
|
||||||
|
return std::result::Result::Ok(Self {
|
||||||
|
observation_key: observation_key_value,
|
||||||
|
raw_transaction_id: std::option::Option::None,
|
||||||
|
signature: std::option::Option::None,
|
||||||
|
slot: std::option::Option::None,
|
||||||
|
provider: provider_value,
|
||||||
|
endpoint_code: std::option::Option::None,
|
||||||
|
protocol: protocol_value,
|
||||||
|
acquisition_method: acquisition_method_value,
|
||||||
|
origin,
|
||||||
|
commitment: std::option::Option::None,
|
||||||
|
capture_session_id: std::option::Option::None,
|
||||||
|
filter_code: std::option::Option::None,
|
||||||
|
detected_at: std::option::Option::None,
|
||||||
|
received_at,
|
||||||
|
normalized_at: std::option::Option::None,
|
||||||
|
payload_size_bytes: std::option::Option::None,
|
||||||
|
source_payload_hash: std::option::Option::None,
|
||||||
|
status: TransactionObservationStatus::Received,
|
||||||
|
error_code: std::option::Option::None,
|
||||||
|
error_message: std::option::Option::None,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Links the observation to a known canonical transaction row id.
|
||||||
|
pub fn with_raw_transaction_id(mut self, raw_transaction_id: i64) -> kb_core::Result<Self> {
|
||||||
|
if raw_transaction_id <= 0 {
|
||||||
|
return std::result::Result::Err(kb_core::Error::db(
|
||||||
|
"transaction observation raw transaction id must be greater than zero",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
self.raw_transaction_id = std::option::Option::Some(raw_transaction_id);
|
||||||
|
return std::result::Result::Ok(self);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Adds the transaction signature and optional slot carried by the source.
|
||||||
|
pub fn with_transaction_identity(
|
||||||
|
mut self,
|
||||||
|
signature: impl std::convert::Into<std::string::String>,
|
||||||
|
slot: std::option::Option<u64>,
|
||||||
|
) -> kb_core::Result<Self> {
|
||||||
|
let signature_value = signature.into();
|
||||||
|
if signature_value.trim().is_empty() {
|
||||||
|
return std::result::Result::Err(kb_core::Error::db(
|
||||||
|
"transaction observation signature must not be empty",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
self.signature = std::option::Option::Some(signature_value);
|
||||||
|
self.slot = slot;
|
||||||
|
return std::result::Result::Ok(self);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Adds an endpoint code after validating non-empty optional text.
|
||||||
|
pub fn with_endpoint_code(
|
||||||
|
mut self,
|
||||||
|
endpoint_code: impl std::convert::Into<std::string::String>,
|
||||||
|
) -> kb_core::Result<Self> {
|
||||||
|
let endpoint_code_value = endpoint_code.into();
|
||||||
|
let validation_result = validate_optional_text(
|
||||||
|
endpoint_code_value.as_str(),
|
||||||
|
"transaction observation endpoint code must not be empty",
|
||||||
|
);
|
||||||
|
if let std::result::Result::Err(error) = validation_result {
|
||||||
|
return std::result::Result::Err(error);
|
||||||
|
}
|
||||||
|
self.endpoint_code = std::option::Option::Some(endpoint_code_value);
|
||||||
|
return std::result::Result::Ok(self);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Adds an optional commitment value after validation.
|
||||||
|
pub fn with_commitment(
|
||||||
|
mut self,
|
||||||
|
commitment: impl std::convert::Into<std::string::String>,
|
||||||
|
) -> kb_core::Result<Self> {
|
||||||
|
let commitment_value = commitment.into();
|
||||||
|
let validation_result = validate_optional_text(
|
||||||
|
commitment_value.as_str(),
|
||||||
|
"transaction observation commitment must not be empty",
|
||||||
|
);
|
||||||
|
if let std::result::Result::Err(error) = validation_result {
|
||||||
|
return std::result::Result::Err(error);
|
||||||
|
}
|
||||||
|
self.commitment = std::option::Option::Some(commitment_value);
|
||||||
|
return std::result::Result::Ok(self);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Adds optional capture session and filter codes after validation.
|
||||||
|
pub fn with_capture_context(
|
||||||
|
mut self,
|
||||||
|
capture_session_id: std::option::Option<std::string::String>,
|
||||||
|
filter_code: std::option::Option<std::string::String>,
|
||||||
|
) -> kb_core::Result<Self> {
|
||||||
|
let session_result = validate_optional_owned_text(
|
||||||
|
capture_session_id.as_ref(),
|
||||||
|
"transaction observation capture session id must not be empty when present",
|
||||||
|
);
|
||||||
|
if let std::result::Result::Err(error) = session_result {
|
||||||
|
return std::result::Result::Err(error);
|
||||||
|
}
|
||||||
|
let filter_result = validate_optional_owned_text(
|
||||||
|
filter_code.as_ref(),
|
||||||
|
"transaction observation filter code must not be empty when present",
|
||||||
|
);
|
||||||
|
if let std::result::Result::Err(error) = filter_result {
|
||||||
|
return std::result::Result::Err(error);
|
||||||
|
}
|
||||||
|
self.capture_session_id = capture_session_id;
|
||||||
|
self.filter_code = filter_code;
|
||||||
|
return std::result::Result::Ok(self);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Adds detection and normalization timestamps.
|
||||||
|
pub fn with_timings(
|
||||||
|
mut self,
|
||||||
|
detected_at: std::option::Option<chrono::DateTime<chrono::Utc>>,
|
||||||
|
normalized_at: std::option::Option<chrono::DateTime<chrono::Utc>>,
|
||||||
|
) -> Self {
|
||||||
|
self.detected_at = detected_at;
|
||||||
|
self.normalized_at = normalized_at;
|
||||||
|
return self;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Adds source payload size and hash metadata without retaining the source payload.
|
||||||
|
pub fn with_payload_metadata(
|
||||||
|
mut self,
|
||||||
|
payload_size_bytes: std::option::Option<u64>,
|
||||||
|
source_payload_hash: std::option::Option<std::string::String>,
|
||||||
|
) -> kb_core::Result<Self> {
|
||||||
|
let hash_result = validate_optional_owned_text(
|
||||||
|
source_payload_hash.as_ref(),
|
||||||
|
"transaction observation source payload hash must not be empty when present",
|
||||||
|
);
|
||||||
|
if let std::result::Result::Err(error) = hash_result {
|
||||||
|
return std::result::Result::Err(error);
|
||||||
|
}
|
||||||
|
self.payload_size_bytes = payload_size_bytes;
|
||||||
|
self.source_payload_hash = source_payload_hash;
|
||||||
|
return std::result::Result::Ok(self);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Replaces the current observation status.
|
||||||
|
pub fn with_status(mut self, status: TransactionObservationStatus) -> Self {
|
||||||
|
self.status = status;
|
||||||
|
return self;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Adds an acquisition error and marks the observation as failed.
|
||||||
|
pub fn with_error(
|
||||||
|
mut self,
|
||||||
|
error_code: impl std::convert::Into<std::string::String>,
|
||||||
|
error_message: std::option::Option<std::string::String>,
|
||||||
|
) -> kb_core::Result<Self> {
|
||||||
|
let error_code_value = error_code.into();
|
||||||
|
let code_result = validate_optional_text(
|
||||||
|
error_code_value.as_str(),
|
||||||
|
"transaction observation error code must not be empty",
|
||||||
|
);
|
||||||
|
if let std::result::Result::Err(error) = code_result {
|
||||||
|
return std::result::Result::Err(error);
|
||||||
|
}
|
||||||
|
let message_result = validate_optional_owned_text(
|
||||||
|
error_message.as_ref(),
|
||||||
|
"transaction observation error message must not be empty when present",
|
||||||
|
);
|
||||||
|
if let std::result::Result::Err(error) = message_result {
|
||||||
|
return std::result::Result::Err(error);
|
||||||
|
}
|
||||||
|
self.error_code = std::option::Option::Some(error_code_value);
|
||||||
|
self.error_message = error_message;
|
||||||
|
self.status = TransactionObservationStatus::Failed;
|
||||||
|
return std::result::Result::Ok(self);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Canonical raw transaction lifecycle mark request.
|
||||||
|
#[derive(Clone, Debug, PartialEq, serde::Deserialize, serde::Serialize)]
|
||||||
|
pub struct RawPayloadLifecycleMark {
|
||||||
|
/// Physical canonical raw table name using the `kb_sol_<domain>_<name>` convention.
|
||||||
|
pub raw_table_name: std::string::String,
|
||||||
|
/// Stable canonical raw row key, currently the transaction signature.
|
||||||
|
pub raw_row_key: std::string::String,
|
||||||
|
/// Retention state to record for the canonical payload.
|
||||||
|
pub retention_state: RawPayloadRetentionState,
|
||||||
|
/// Processing state to record for the canonical payload.
|
||||||
|
pub processing_state: RawPayloadProcessingState,
|
||||||
|
/// Optional reason visible in diagnostics.
|
||||||
|
pub reason: std::option::Option<std::string::String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl RawPayloadLifecycleMark {
|
||||||
|
/// Builds a canonical raw payload lifecycle mark after minimal validation.
|
||||||
|
pub fn new(
|
||||||
|
raw_table_name: impl std::convert::Into<std::string::String>,
|
||||||
|
raw_row_key: impl std::convert::Into<std::string::String>,
|
||||||
|
retention_state: RawPayloadRetentionState,
|
||||||
|
processing_state: RawPayloadProcessingState,
|
||||||
|
reason: std::option::Option<std::string::String>,
|
||||||
|
) -> kb_core::Result<Self> {
|
||||||
|
let raw_table_name_value = raw_table_name.into();
|
||||||
|
let raw_row_key_value = raw_row_key.into();
|
||||||
|
if raw_table_name_value.trim().is_empty() {
|
||||||
|
return std::result::Result::Err(kb_core::Error::db(
|
||||||
|
"raw lifecycle table name must not be empty",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if raw_row_key_value.trim().is_empty() {
|
||||||
|
return std::result::Result::Err(kb_core::Error::db(
|
||||||
|
"raw lifecycle row key must not be empty",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let reason_result = validate_optional_owned_text(
|
||||||
|
reason.as_ref(),
|
||||||
|
"raw lifecycle reason must not be empty when present",
|
||||||
|
);
|
||||||
|
if let std::result::Result::Err(error) = reason_result {
|
||||||
|
return std::result::Result::Err(error);
|
||||||
|
}
|
||||||
|
return std::result::Result::Ok(Self {
|
||||||
|
raw_table_name: raw_table_name_value,
|
||||||
|
raw_row_key: raw_row_key_value,
|
||||||
|
retention_state,
|
||||||
|
processing_state,
|
||||||
|
reason,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn validate_required_observation_texts(
|
||||||
|
observation_key: &str,
|
||||||
|
provider: &str,
|
||||||
|
protocol: &str,
|
||||||
|
acquisition_method: &str,
|
||||||
|
) -> kb_core::Result<()> {
|
||||||
|
let values = [
|
||||||
|
(observation_key, "transaction observation key must not be empty"),
|
||||||
|
(provider, "transaction observation provider must not be empty"),
|
||||||
|
(protocol, "transaction observation protocol must not be empty"),
|
||||||
|
(
|
||||||
|
acquisition_method,
|
||||||
|
"transaction observation acquisition method must not be empty",
|
||||||
|
),
|
||||||
|
];
|
||||||
|
for (value, message) in values {
|
||||||
|
if value.trim().is_empty() {
|
||||||
|
return std::result::Result::Err(kb_core::Error::db(message));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return std::result::Result::Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
fn validate_optional_text(value: &str, message: &str) -> kb_core::Result<()> {
|
||||||
|
if value.trim().is_empty() {
|
||||||
|
return std::result::Result::Err(kb_core::Error::db(message));
|
||||||
|
}
|
||||||
|
return std::result::Result::Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
fn validate_optional_owned_text(
|
||||||
|
value: std::option::Option<&std::string::String>,
|
||||||
|
message: &str,
|
||||||
|
) -> kb_core::Result<()> {
|
||||||
|
if let std::option::Option::Some(text) = value {
|
||||||
|
if text.trim().is_empty() {
|
||||||
|
return std::result::Result::Err(kb_core::Error::db(message));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return std::result::Result::Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
#[test]
|
||||||
|
fn raw_transaction_rejects_empty_signature() {
|
||||||
|
let result = crate::RawTransactionInsert::new(" ", 1, serde_json::json!({"ok": true}), 1);
|
||||||
|
assert!(result.is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn raw_transaction_rejects_zero_format_version() {
|
||||||
|
let result = crate::RawTransactionInsert::new("abc", 1, serde_json::json!({"ok": true}), 0);
|
||||||
|
assert!(result.is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
fn canonical_transaction_fixture() -> kb_lib::CanonicalTransaction {
|
||||||
|
return kb_lib::CanonicalTransaction {
|
||||||
|
format_version: kb_lib::CANONICAL_TRANSACTION_FORMAT_VERSION,
|
||||||
|
primary_signature: "2Ana1pUpv2ZbMVkwF5FXapYeBEjdxDatLn7nvJkhgTSXbs59SyZSx866bXirPgj8QQVB57uxHJBG1YFvkRbFj4T".to_string(),
|
||||||
|
slot: 9,
|
||||||
|
block_time: std::option::Option::None,
|
||||||
|
version: kb_lib::CanonicalTransactionVersion::Legacy,
|
||||||
|
signatures: std::vec![
|
||||||
|
"2Ana1pUpv2ZbMVkwF5FXapYeBEjdxDatLn7nvJkhgTSXbs59SyZSx866bXirPgj8QQVB57uxHJBG1YFvkRbFj4T".to_string(),
|
||||||
|
],
|
||||||
|
message: kb_lib::CanonicalTransactionMessage {
|
||||||
|
header: kb_lib::CanonicalMessageHeader {
|
||||||
|
num_required_signatures: 1,
|
||||||
|
num_readonly_signed_accounts: 0,
|
||||||
|
num_readonly_unsigned_accounts: 1,
|
||||||
|
},
|
||||||
|
static_account_keys: std::vec![
|
||||||
|
"11111111111111111111111111111111".to_string(),
|
||||||
|
"ComputeBudget111111111111111111111111111111".to_string(),
|
||||||
|
],
|
||||||
|
recent_blockhash: "11111111111111111111111111111111".to_string(),
|
||||||
|
instructions: std::vec![kb_lib::CanonicalCompiledInstruction {
|
||||||
|
program_id_index: 1,
|
||||||
|
account_indexes: std::vec![0],
|
||||||
|
data_base64: "AQ==".to_string(),
|
||||||
|
stack_height: std::option::Option::Some(1),
|
||||||
|
}],
|
||||||
|
address_table_lookups: std::vec::Vec::new(),
|
||||||
|
loaded_addresses: kb_lib::CanonicalLoadedAddresses::default(),
|
||||||
|
},
|
||||||
|
metadata: std::option::Option::Some(kb_lib::CanonicalTransactionMetadata {
|
||||||
|
status: kb_lib::CanonicalTransactionStatus::Success,
|
||||||
|
error: std::option::Option::None,
|
||||||
|
fee: 5000,
|
||||||
|
pre_balances: std::vec![10000, 1],
|
||||||
|
post_balances: std::vec![5000, 1],
|
||||||
|
inner_instructions: std::vec::Vec::new(),
|
||||||
|
log_messages: std::vec::Vec::new(),
|
||||||
|
pre_token_balances: std::vec::Vec::new(),
|
||||||
|
post_token_balances: std::vec::Vec::new(),
|
||||||
|
rewards: std::vec::Vec::new(),
|
||||||
|
return_data: std::option::Option::None,
|
||||||
|
compute_units_consumed: std::option::Option::Some(100),
|
||||||
|
cost_units: std::option::Option::None,
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn raw_transaction_builds_from_canonical_model() {
|
||||||
|
let transaction = canonical_transaction_fixture();
|
||||||
|
let result = crate::RawTransactionInsert::from_canonical(&transaction);
|
||||||
|
let insert = match result {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => panic!("canonical insert failed: {error}"),
|
||||||
|
};
|
||||||
|
assert_eq!(insert.signature, transaction.primary_signature);
|
||||||
|
assert_eq!(insert.slot, transaction.slot);
|
||||||
|
assert_eq!(insert.canonical_format_version, kb_lib::CANONICAL_TRANSACTION_FORMAT_VERSION);
|
||||||
|
assert_eq!(insert.canonical_json_hash.as_deref().map(|value| return value.len()), Some(64));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn raw_transaction_accepts_canonical_payload() {
|
||||||
|
let result = crate::RawTransactionInsert::new("abc", 1, serde_json::json!({"ok": true}), 1);
|
||||||
|
assert!(result.is_ok());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn transaction_observation_rejects_empty_provider() {
|
||||||
|
let result = crate::TransactionObservationInsert::new(
|
||||||
|
"obs:1",
|
||||||
|
" ",
|
||||||
|
"solana_http",
|
||||||
|
"getTransaction",
|
||||||
|
crate::TransactionObservationOrigin::Backfill,
|
||||||
|
chrono::Utc::now(),
|
||||||
|
);
|
||||||
|
assert!(result.is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn transaction_observation_accepts_signatureless_failure_candidate() {
|
||||||
|
let result = crate::TransactionObservationInsert::new(
|
||||||
|
"obs:1",
|
||||||
|
"helius",
|
||||||
|
"solana_http",
|
||||||
|
"getTransaction",
|
||||||
|
crate::TransactionObservationOrigin::Repair,
|
||||||
|
chrono::Utc::now(),
|
||||||
|
);
|
||||||
|
assert!(result.is_ok());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn raw_lifecycle_rejects_empty_reason_when_present() {
|
||||||
|
let result = crate::RawPayloadLifecycleMark::new(
|
||||||
|
"kb_sol_raw_transactions",
|
||||||
|
"signature",
|
||||||
|
crate::RawPayloadRetentionState::Full,
|
||||||
|
crate::RawPayloadProcessingState::Received,
|
||||||
|
std::option::Option::Some(" ".to_string()),
|
||||||
|
);
|
||||||
|
assert!(result.is_err());
|
||||||
|
}
|
||||||
|
}
|
||||||
125
kb-store/src/contracts/dto/store.rs
Normal file
125
kb-store/src/contracts/dto/store.rs
Normal file
@@ -0,0 +1,125 @@
|
|||||||
|
// file: kb-store/src/contracts/dto/store.rs
|
||||||
|
// version: 1
|
||||||
|
|
||||||
|
//! Store backend diagnostic DTOs.
|
||||||
|
|
||||||
|
/// Store backend kind contract.
|
||||||
|
#[derive(Clone, Copy, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
|
||||||
|
pub enum StoreBackendKind {
|
||||||
|
/// PostgreSQL backend.
|
||||||
|
Postgres,
|
||||||
|
/// SQLite backend retained for tests and legacy imports.
|
||||||
|
Sqlite,
|
||||||
|
/// In-memory backend used by offline tests.
|
||||||
|
Memory,
|
||||||
|
/// Backend is not known.
|
||||||
|
Unknown,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Store migration status contract.
|
||||||
|
#[derive(Clone, Copy, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
|
||||||
|
pub enum StoreMigrationStatus {
|
||||||
|
/// Migration status is not known yet.
|
||||||
|
Unknown,
|
||||||
|
/// Store has no migration table or migration history yet.
|
||||||
|
NotInitialized,
|
||||||
|
/// Store migrations are current.
|
||||||
|
Current,
|
||||||
|
/// Store has pending migrations.
|
||||||
|
Pending,
|
||||||
|
/// Store migration history is inconsistent.
|
||||||
|
Drift,
|
||||||
|
/// Store migration check failed.
|
||||||
|
Failed,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Store backend diagnostic contract.
|
||||||
|
#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
|
||||||
|
pub struct StoreBackendDescriptor {
|
||||||
|
/// Store backend kind.
|
||||||
|
pub backend_kind: StoreBackendKind,
|
||||||
|
/// Human-readable backend label.
|
||||||
|
pub backend_label: std::string::String,
|
||||||
|
/// Masked DSN or connection descriptor safe for diagnostics.
|
||||||
|
pub masked_dsn: std::option::Option<std::string::String>,
|
||||||
|
/// Current PostgreSQL schema or equivalent namespace when known.
|
||||||
|
pub current_schema: std::option::Option<std::string::String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl StoreBackendDescriptor {
|
||||||
|
/// Builds a store backend descriptor after minimal validation.
|
||||||
|
pub fn new(
|
||||||
|
backend_kind: StoreBackendKind,
|
||||||
|
backend_label: impl std::convert::Into<std::string::String>,
|
||||||
|
masked_dsn: std::option::Option<std::string::String>,
|
||||||
|
current_schema: std::option::Option<std::string::String>,
|
||||||
|
) -> kb_core::Result<Self> {
|
||||||
|
let backend_label_value = backend_label.into();
|
||||||
|
if backend_label_value.trim().is_empty() {
|
||||||
|
return std::result::Result::Err(kb_core::Error::db(
|
||||||
|
"store backend label must not be empty",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
return std::result::Result::Ok(Self {
|
||||||
|
backend_kind,
|
||||||
|
backend_label: backend_label_value,
|
||||||
|
masked_dsn,
|
||||||
|
current_schema,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Store migration diagnostic snapshot contract.
|
||||||
|
#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
|
||||||
|
pub struct StoreMigrationSnapshot {
|
||||||
|
/// Current migration status.
|
||||||
|
pub status: StoreMigrationStatus,
|
||||||
|
/// Last applied migration identifier when known.
|
||||||
|
pub current_version: std::option::Option<std::string::String>,
|
||||||
|
/// Pending migration identifiers when known.
|
||||||
|
pub pending_versions: std::vec::Vec<std::string::String>,
|
||||||
|
/// Optional human-readable diagnostic message.
|
||||||
|
pub message: std::option::Option<std::string::String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl StoreMigrationSnapshot {
|
||||||
|
/// Builds a migration snapshot from explicit values.
|
||||||
|
pub fn new(
|
||||||
|
status: StoreMigrationStatus,
|
||||||
|
current_version: std::option::Option<std::string::String>,
|
||||||
|
pending_versions: std::vec::Vec<std::string::String>,
|
||||||
|
message: std::option::Option<std::string::String>,
|
||||||
|
) -> Self {
|
||||||
|
return Self {
|
||||||
|
status,
|
||||||
|
current_version,
|
||||||
|
pending_versions,
|
||||||
|
message,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
#[test]
|
||||||
|
fn store_descriptor_rejects_empty_label() {
|
||||||
|
let result = crate::StoreBackendDescriptor::new(
|
||||||
|
crate::StoreBackendKind::Postgres,
|
||||||
|
" ",
|
||||||
|
std::option::Option::None,
|
||||||
|
std::option::Option::None,
|
||||||
|
);
|
||||||
|
assert!(result.is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn migration_snapshot_keeps_pending_versions() {
|
||||||
|
let snapshot = crate::StoreMigrationSnapshot::new(
|
||||||
|
crate::StoreMigrationStatus::Pending,
|
||||||
|
std::option::Option::None,
|
||||||
|
std::vec![std::string::String::from("0001")],
|
||||||
|
std::option::Option::None,
|
||||||
|
);
|
||||||
|
assert_eq!(snapshot.pending_versions.len(), 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
32
kb-store/src/contracts/entity.rs
Normal file
32
kb-store/src/contracts/entity.rs
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
// file: kb-store/src/contracts/entity.rs
|
||||||
|
// version: 1
|
||||||
|
|
||||||
|
//! Backend-neutral SQL-like entity exports for storage adapters.
|
||||||
|
|
||||||
|
mod core;
|
||||||
|
mod event;
|
||||||
|
mod ledger;
|
||||||
|
mod raw;
|
||||||
|
|
||||||
|
/// Core account key SQL-like row contract.
|
||||||
|
pub use crate::contracts::entity::core::CoreAccountKeyRow;
|
||||||
|
/// Core balance change SQL-like row contract.
|
||||||
|
pub use crate::contracts::entity::core::CoreBalanceChangeRow;
|
||||||
|
/// Core inner instruction SQL-like row contract.
|
||||||
|
pub use crate::contracts::entity::core::CoreInnerInstructionRow;
|
||||||
|
/// Core instruction SQL-like row contract.
|
||||||
|
pub use crate::contracts::entity::core::CoreInstructionRow;
|
||||||
|
/// Core log SQL-like row contract.
|
||||||
|
pub use crate::contracts::entity::core::CoreLogRow;
|
||||||
|
/// Core transaction SQL-like row contract.
|
||||||
|
pub use crate::contracts::entity::core::CoreTransactionRow;
|
||||||
|
/// Decoded event SQL-like row contract.
|
||||||
|
pub use crate::contracts::entity::event::DecodedEventRow;
|
||||||
|
/// Materialized event SQL-like row contract.
|
||||||
|
pub use crate::contracts::entity::event::MaterializedEventRow;
|
||||||
|
/// Processing ledger SQL-like row contract.
|
||||||
|
pub use crate::contracts::entity::ledger::ProcessingLedgerRow;
|
||||||
|
/// Canonical raw Solana transaction SQL-like row contract.
|
||||||
|
pub use crate::contracts::entity::raw::RawTransactionRow;
|
||||||
|
/// Transaction acquisition observation SQL-like row contract.
|
||||||
|
pub use crate::contracts::entity::raw::TransactionObservationRow;
|
||||||
166
kb-store/src/contracts/entity/core.rs
Normal file
166
kb-store/src/contracts/entity/core.rs
Normal file
@@ -0,0 +1,166 @@
|
|||||||
|
// file: kb-store/src/contracts/entity/core.rs
|
||||||
|
// version: 1
|
||||||
|
|
||||||
|
//! Core Solana SQL-like entities.
|
||||||
|
|
||||||
|
/// Core transaction SQL-like row contract.
|
||||||
|
#[derive(Clone, Debug, PartialEq, serde::Deserialize, serde::Serialize)]
|
||||||
|
pub struct CoreTransactionRow {
|
||||||
|
/// Technical primary key.
|
||||||
|
pub id: i64,
|
||||||
|
/// Transaction signature as non-empty base58 text.
|
||||||
|
pub signature: std::string::String,
|
||||||
|
/// Transaction slot stored as SQL `BIGINT`.
|
||||||
|
pub slot: i64,
|
||||||
|
/// Whether the transaction failed on-chain.
|
||||||
|
pub failed: bool,
|
||||||
|
/// Optional canonical raw transaction row id used for lineage when available.
|
||||||
|
pub raw_transaction_id: std::option::Option<i64>,
|
||||||
|
/// Optional raw error JSON extracted from transaction metadata.
|
||||||
|
pub err_json: std::option::Option<serde_json::Value>,
|
||||||
|
/// Insert timestamp.
|
||||||
|
pub created_at: chrono::DateTime<chrono::Utc>,
|
||||||
|
/// Last update timestamp.
|
||||||
|
pub updated_at: chrono::DateTime<chrono::Utc>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Core account key SQL-like row contract.
|
||||||
|
#[derive(Clone, Debug, PartialEq, serde::Deserialize, serde::Serialize)]
|
||||||
|
pub struct CoreAccountKeyRow {
|
||||||
|
/// Technical primary key.
|
||||||
|
pub id: i64,
|
||||||
|
/// Parent core transaction technical key.
|
||||||
|
pub transaction_id: i64,
|
||||||
|
/// Transaction signature as non-empty base58 text.
|
||||||
|
pub signature: std::string::String,
|
||||||
|
/// Transaction slot stored as SQL `BIGINT`.
|
||||||
|
pub slot: i64,
|
||||||
|
/// Stable account index after static and loaded keys are resolved.
|
||||||
|
pub account_index: i32,
|
||||||
|
/// Account public key as non-empty base58 text.
|
||||||
|
pub account_key: std::string::String,
|
||||||
|
/// Source category for this account key.
|
||||||
|
pub source: crate::CoreAccountKeySource,
|
||||||
|
/// Whether the resolved account is writable for the transaction.
|
||||||
|
pub writable: bool,
|
||||||
|
/// Whether the resolved account signed the transaction.
|
||||||
|
pub signer: bool,
|
||||||
|
/// Whether the resolved account is executable when known.
|
||||||
|
pub executable: std::option::Option<bool>,
|
||||||
|
/// Insert timestamp.
|
||||||
|
pub created_at: chrono::DateTime<chrono::Utc>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Core instruction SQL-like row contract.
|
||||||
|
#[derive(Clone, Debug, PartialEq, serde::Deserialize, serde::Serialize)]
|
||||||
|
pub struct CoreInstructionRow {
|
||||||
|
/// Technical primary key.
|
||||||
|
pub id: i64,
|
||||||
|
/// Parent core transaction technical key.
|
||||||
|
pub transaction_id: i64,
|
||||||
|
/// Transaction signature as non-empty base58 text.
|
||||||
|
pub signature: std::string::String,
|
||||||
|
/// Transaction slot stored as SQL `BIGINT`.
|
||||||
|
pub slot: i64,
|
||||||
|
/// Stable instruction path, for example `0` or `2/1`.
|
||||||
|
pub instruction_path: std::string::String,
|
||||||
|
/// Program id as non-empty base58 text.
|
||||||
|
pub program_id: std::string::String,
|
||||||
|
/// Instruction accounts as JSON, preserving unresolved forms when needed.
|
||||||
|
pub accounts_json: serde_json::Value,
|
||||||
|
/// Instruction payload JSON while it is still retained in the hot store.
|
||||||
|
pub payload_json: std::option::Option<serde_json::Value>,
|
||||||
|
/// Optional digest of the instruction payload after compaction or purge.
|
||||||
|
pub payload_json_hash: std::option::Option<std::string::String>,
|
||||||
|
/// Current processing state used by instruction-level replay.
|
||||||
|
pub processing_state: crate::CoreInstructionProcessingState,
|
||||||
|
/// Insert timestamp.
|
||||||
|
pub created_at: chrono::DateTime<chrono::Utc>,
|
||||||
|
/// Last lifecycle update timestamp.
|
||||||
|
pub updated_at: chrono::DateTime<chrono::Utc>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Core inner instruction SQL-like row contract.
|
||||||
|
#[derive(Clone, Debug, PartialEq, serde::Deserialize, serde::Serialize)]
|
||||||
|
pub struct CoreInnerInstructionRow {
|
||||||
|
/// Technical primary key.
|
||||||
|
pub id: i64,
|
||||||
|
/// Parent core transaction technical key.
|
||||||
|
pub transaction_id: i64,
|
||||||
|
/// Transaction signature as non-empty base58 text.
|
||||||
|
pub signature: std::string::String,
|
||||||
|
/// Transaction slot stored as SQL `BIGINT`.
|
||||||
|
pub slot: i64,
|
||||||
|
/// Parent top-level or inner instruction path.
|
||||||
|
pub parent_instruction_path: std::string::String,
|
||||||
|
/// Stable inner instruction path, for example `2/1`.
|
||||||
|
pub instruction_path: std::string::String,
|
||||||
|
/// Program id as non-empty base58 text.
|
||||||
|
pub program_id: std::string::String,
|
||||||
|
/// Inner instruction accounts as JSON.
|
||||||
|
pub accounts_json: serde_json::Value,
|
||||||
|
/// Inner instruction payload JSON while it is retained in the hot store.
|
||||||
|
pub payload_json: std::option::Option<serde_json::Value>,
|
||||||
|
/// Optional digest of the inner instruction payload after compaction or purge.
|
||||||
|
pub payload_json_hash: std::option::Option<std::string::String>,
|
||||||
|
/// Insert timestamp.
|
||||||
|
pub created_at: chrono::DateTime<chrono::Utc>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Core log SQL-like row contract.
|
||||||
|
#[derive(Clone, Debug, PartialEq, serde::Deserialize, serde::Serialize)]
|
||||||
|
pub struct CoreLogRow {
|
||||||
|
/// Technical primary key.
|
||||||
|
pub id: i64,
|
||||||
|
/// Parent core transaction technical key.
|
||||||
|
pub transaction_id: i64,
|
||||||
|
/// Transaction signature as non-empty base58 text.
|
||||||
|
pub signature: std::string::String,
|
||||||
|
/// Transaction slot stored as SQL `BIGINT`.
|
||||||
|
pub slot: i64,
|
||||||
|
/// Log index preserving transaction log order.
|
||||||
|
pub log_index: i32,
|
||||||
|
/// Optional instruction path resolved from invocation depth when known.
|
||||||
|
pub instruction_path: std::option::Option<std::string::String>,
|
||||||
|
/// Optional program id resolved from the log line or invocation context.
|
||||||
|
pub program_id: std::option::Option<std::string::String>,
|
||||||
|
/// Original log text while it is retained in the hot store.
|
||||||
|
pub log_text: std::option::Option<std::string::String>,
|
||||||
|
/// Optional digest of the log text after compaction or purge.
|
||||||
|
pub log_text_hash: std::option::Option<std::string::String>,
|
||||||
|
/// Insert timestamp.
|
||||||
|
pub created_at: chrono::DateTime<chrono::Utc>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Core balance change SQL-like row contract.
|
||||||
|
#[derive(Clone, Debug, PartialEq, serde::Deserialize, serde::Serialize)]
|
||||||
|
pub struct CoreBalanceChangeRow {
|
||||||
|
/// Technical primary key.
|
||||||
|
pub id: i64,
|
||||||
|
/// Parent core transaction technical key.
|
||||||
|
pub transaction_id: i64,
|
||||||
|
/// Transaction signature as non-empty base58 text.
|
||||||
|
pub signature: std::string::String,
|
||||||
|
/// Transaction slot stored as SQL `BIGINT`.
|
||||||
|
pub slot: i64,
|
||||||
|
/// Stable balance change index preserving extraction order.
|
||||||
|
pub balance_change_index: i32,
|
||||||
|
/// Balance change family.
|
||||||
|
pub balance_kind: crate::CoreBalanceChangeKind,
|
||||||
|
/// Optional account index when available.
|
||||||
|
pub account_index: std::option::Option<i32>,
|
||||||
|
/// Optional account public key when available.
|
||||||
|
pub account_key: std::option::Option<std::string::String>,
|
||||||
|
/// Optional SPL token mint for token balances.
|
||||||
|
pub mint: std::option::Option<std::string::String>,
|
||||||
|
/// Optional owner public key for token balances.
|
||||||
|
pub owner: std::option::Option<std::string::String>,
|
||||||
|
/// Pre-balance JSON value preserving RPC representation.
|
||||||
|
pub pre_balance_json: std::option::Option<serde_json::Value>,
|
||||||
|
/// Post-balance JSON value preserving RPC representation.
|
||||||
|
pub post_balance_json: std::option::Option<serde_json::Value>,
|
||||||
|
/// Delta JSON value preserving integer or decimal-safe representation.
|
||||||
|
pub delta_json: std::option::Option<serde_json::Value>,
|
||||||
|
/// Insert timestamp.
|
||||||
|
pub created_at: chrono::DateTime<chrono::Utc>,
|
||||||
|
}
|
||||||
46
kb-store/src/contracts/entity/event.rs
Normal file
46
kb-store/src/contracts/entity/event.rs
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
// file: kb-store/src/contracts/entity/event.rs
|
||||||
|
// version: 1
|
||||||
|
|
||||||
|
//! Decoded and materialized event SQL-like entities.
|
||||||
|
|
||||||
|
/// Decoded event SQL-like row contract.
|
||||||
|
#[derive(Clone, Debug, PartialEq, serde::Deserialize, serde::Serialize)]
|
||||||
|
pub struct DecodedEventRow {
|
||||||
|
/// Technical primary key.
|
||||||
|
pub id: i64,
|
||||||
|
/// Transaction signature as non-empty base58 text.
|
||||||
|
pub signature: std::string::String,
|
||||||
|
/// Transaction slot stored as SQL `BIGINT`.
|
||||||
|
pub slot: i64,
|
||||||
|
/// Stable instruction path, for example `0` or `2/1`.
|
||||||
|
pub instruction_path: std::string::String,
|
||||||
|
/// Program id as non-empty base58 text.
|
||||||
|
pub program_id: std::string::String,
|
||||||
|
/// Protocol family code.
|
||||||
|
pub protocol_code: std::string::String,
|
||||||
|
/// Protocol surface code.
|
||||||
|
pub surface_code: std::string::String,
|
||||||
|
/// Canonical event code.
|
||||||
|
pub event_code: std::string::String,
|
||||||
|
/// Decoded payload JSON.
|
||||||
|
pub payload_json: serde_json::Value,
|
||||||
|
/// Insert timestamp.
|
||||||
|
pub created_at: chrono::DateTime<chrono::Utc>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Materialized event SQL-like row contract.
|
||||||
|
#[derive(Clone, Debug, PartialEq, serde::Deserialize, serde::Serialize)]
|
||||||
|
pub struct MaterializedEventRow {
|
||||||
|
/// Technical primary key.
|
||||||
|
pub id: i64,
|
||||||
|
/// Source transaction signature as non-empty base58 text.
|
||||||
|
pub signature: std::string::String,
|
||||||
|
/// Source transaction slot stored as SQL `BIGINT`.
|
||||||
|
pub slot: i64,
|
||||||
|
/// Materialized event family code.
|
||||||
|
pub materialized_family: std::string::String,
|
||||||
|
/// Materialized payload JSON.
|
||||||
|
pub payload_json: serde_json::Value,
|
||||||
|
/// Insert timestamp.
|
||||||
|
pub created_at: chrono::DateTime<chrono::Utc>,
|
||||||
|
}
|
||||||
37
kb-store/src/contracts/entity/ledger.rs
Normal file
37
kb-store/src/contracts/entity/ledger.rs
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
// file: kb-store/src/contracts/entity/ledger.rs
|
||||||
|
// version: 1
|
||||||
|
|
||||||
|
//! Processing ledger SQL-like entities.
|
||||||
|
|
||||||
|
/// Processing ledger SQL-like row contract.
|
||||||
|
#[derive(Clone, Debug, PartialEq, serde::Deserialize, serde::Serialize)]
|
||||||
|
pub struct ProcessingLedgerRow {
|
||||||
|
/// Technical primary key.
|
||||||
|
pub id: i64,
|
||||||
|
/// Processing stage name.
|
||||||
|
pub stage: std::string::String,
|
||||||
|
/// Processor implementation name.
|
||||||
|
pub processor_name: std::string::String,
|
||||||
|
/// Processor semantic version.
|
||||||
|
pub processor_version: std::string::String,
|
||||||
|
/// Stable input key, usually a transaction signature.
|
||||||
|
pub input_key: std::string::String,
|
||||||
|
/// Deterministic input hash.
|
||||||
|
pub input_hash: std::string::String,
|
||||||
|
/// Current processing status.
|
||||||
|
pub status: crate::ProcessingLedgerStatus,
|
||||||
|
/// Number of started attempts for this processor identity.
|
||||||
|
pub attempt_count: i32,
|
||||||
|
/// Optional start timestamp for the latest attempt.
|
||||||
|
pub started_at: std::option::Option<chrono::DateTime<chrono::Utc>>,
|
||||||
|
/// Optional finish timestamp for the latest terminal attempt.
|
||||||
|
pub finished_at: std::option::Option<chrono::DateTime<chrono::Utc>>,
|
||||||
|
/// Optional machine-readable error code.
|
||||||
|
pub error_code: std::option::Option<std::string::String>,
|
||||||
|
/// Optional diagnostic error message.
|
||||||
|
pub error_message: std::option::Option<std::string::String>,
|
||||||
|
/// Insert timestamp.
|
||||||
|
pub created_at: chrono::DateTime<chrono::Utc>,
|
||||||
|
/// Last update timestamp.
|
||||||
|
pub updated_at: chrono::DateTime<chrono::Utc>,
|
||||||
|
}
|
||||||
78
kb-store/src/contracts/entity/raw.rs
Normal file
78
kb-store/src/contracts/entity/raw.rs
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
// file: kb-store/src/contracts/entity/raw.rs
|
||||||
|
// version: 1
|
||||||
|
|
||||||
|
//! Canonical Solana transaction and acquisition observation SQL-like entities.
|
||||||
|
|
||||||
|
/// Canonical raw Solana transaction SQL-like row contract.
|
||||||
|
#[derive(Clone, Debug, PartialEq, serde::Deserialize, serde::Serialize)]
|
||||||
|
pub struct RawTransactionRow {
|
||||||
|
/// Technical primary key.
|
||||||
|
pub id: i64,
|
||||||
|
/// Transaction signature as non-empty base58 text.
|
||||||
|
pub signature: std::string::String,
|
||||||
|
/// Transaction slot stored as SQL `BIGINT`.
|
||||||
|
pub slot: i64,
|
||||||
|
/// Canonical source-independent transaction document while retained in the hot store.
|
||||||
|
pub canonical_json: std::option::Option<serde_json::Value>,
|
||||||
|
/// Optional deterministic digest of the canonical document.
|
||||||
|
pub canonical_json_hash: std::option::Option<std::string::String>,
|
||||||
|
/// Version of the canonical transaction document contract.
|
||||||
|
pub canonical_format_version: i32,
|
||||||
|
/// Current raw payload retention state.
|
||||||
|
pub retention_state: crate::RawPayloadRetentionState,
|
||||||
|
/// Current processing state derived from this canonical transaction.
|
||||||
|
pub processing_state: crate::RawPayloadProcessingState,
|
||||||
|
/// Insert timestamp.
|
||||||
|
pub created_at: chrono::DateTime<chrono::Utc>,
|
||||||
|
/// Last lifecycle update timestamp.
|
||||||
|
pub updated_at: chrono::DateTime<chrono::Utc>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Transaction acquisition observation SQL-like row contract.
|
||||||
|
#[derive(Clone, Debug, PartialEq, serde::Deserialize, serde::Serialize)]
|
||||||
|
pub struct TransactionObservationRow {
|
||||||
|
/// Technical primary key.
|
||||||
|
pub id: i64,
|
||||||
|
/// Optional linked canonical transaction row id.
|
||||||
|
pub raw_transaction_id: std::option::Option<i64>,
|
||||||
|
/// Stable observation key used for deduplication.
|
||||||
|
pub observation_key: std::string::String,
|
||||||
|
/// Optional transaction signature when known.
|
||||||
|
pub signature: std::option::Option<std::string::String>,
|
||||||
|
/// Optional transaction slot stored as SQL `BIGINT`.
|
||||||
|
pub slot: std::option::Option<i64>,
|
||||||
|
/// Provider code, for example `helius`, `triton` or `legacy_unknown`.
|
||||||
|
pub provider: std::string::String,
|
||||||
|
/// Optional endpoint code from the active configuration.
|
||||||
|
pub endpoint_code: std::option::Option<std::string::String>,
|
||||||
|
/// Acquisition protocol, for example `solana_http`, `solana_websocket` or `yellowstone_grpc`.
|
||||||
|
pub protocol: std::string::String,
|
||||||
|
/// Acquisition method, for example `getTransaction`, `transactionSubscribe` or `transactions`.
|
||||||
|
pub acquisition_method: std::string::String,
|
||||||
|
/// Acquisition origin category.
|
||||||
|
pub origin: crate::TransactionObservationOrigin,
|
||||||
|
/// Optional Solana commitment.
|
||||||
|
pub commitment: std::option::Option<std::string::String>,
|
||||||
|
/// Optional capture session identifier.
|
||||||
|
pub capture_session_id: std::option::Option<std::string::String>,
|
||||||
|
/// Optional configured filter code.
|
||||||
|
pub filter_code: std::option::Option<std::string::String>,
|
||||||
|
/// Optional first detection timestamp.
|
||||||
|
pub detected_at: std::option::Option<chrono::DateTime<chrono::Utc>>,
|
||||||
|
/// Timestamp at which the source payload was received locally.
|
||||||
|
pub received_at: chrono::DateTime<chrono::Utc>,
|
||||||
|
/// Optional timestamp at which canonical normalization completed.
|
||||||
|
pub normalized_at: std::option::Option<chrono::DateTime<chrono::Utc>>,
|
||||||
|
/// Timestamp at which the observation was persisted.
|
||||||
|
pub persisted_at: chrono::DateTime<chrono::Utc>,
|
||||||
|
/// Optional uncompressed source payload size in bytes.
|
||||||
|
pub payload_size_bytes: std::option::Option<i64>,
|
||||||
|
/// Optional digest of the source-specific payload without retaining that payload.
|
||||||
|
pub source_payload_hash: std::option::Option<std::string::String>,
|
||||||
|
/// Current observation status.
|
||||||
|
pub status: crate::TransactionObservationStatus,
|
||||||
|
/// Optional machine-readable error code.
|
||||||
|
pub error_code: std::option::Option<std::string::String>,
|
||||||
|
/// Optional diagnostic error message.
|
||||||
|
pub error_message: std::option::Option<std::string::String>,
|
||||||
|
}
|
||||||
27
kb-store/src/contracts/error.rs
Normal file
27
kb-store/src/contracts/error.rs
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
// file: kb-store/src/contracts/error.rs
|
||||||
|
// version: 1
|
||||||
|
|
||||||
|
//! Backend-neutral storage error helpers.
|
||||||
|
|
||||||
|
/// Creates a storage contract error with a stable code.
|
||||||
|
pub fn storage_contract_error(code: &str, message: &str) -> kb_core::Error {
|
||||||
|
if code.trim().is_empty() {
|
||||||
|
return kb_core::Error::db(message);
|
||||||
|
}
|
||||||
|
return kb_core::Error::new(code, message);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
#[test]
|
||||||
|
fn storage_error_preserves_non_empty_code() {
|
||||||
|
let error = crate::storage_contract_error("store_contract", "invalid value");
|
||||||
|
assert_eq!(error.code(), "store_contract");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn storage_error_falls_back_to_db_for_empty_code() {
|
||||||
|
let error = crate::storage_contract_error(" ", "invalid value");
|
||||||
|
assert_eq!(error.code(), "db");
|
||||||
|
}
|
||||||
|
}
|
||||||
58
kb-store/src/contracts/health.rs
Normal file
58
kb-store/src/contracts/health.rs
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
// file: kb-store/src/contracts/health.rs
|
||||||
|
// version: 1
|
||||||
|
|
||||||
|
//! Backend-neutral health contracts for storage implementations.
|
||||||
|
|
||||||
|
/// Store backend health status.
|
||||||
|
#[derive(Clone, Copy, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
|
||||||
|
pub enum StoreHealthStatus {
|
||||||
|
/// Health is not known yet.
|
||||||
|
Unknown,
|
||||||
|
/// Backend is reachable and usable.
|
||||||
|
Healthy,
|
||||||
|
/// Backend is reachable but not fully usable.
|
||||||
|
Degraded,
|
||||||
|
/// Backend is not usable.
|
||||||
|
Unhealthy,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Store backend health snapshot.
|
||||||
|
#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
|
||||||
|
pub struct StoreHealthSnapshot {
|
||||||
|
/// Stable backend code such as `postgres` or `sqlite`.
|
||||||
|
pub backend: std::string::String,
|
||||||
|
/// Current health status.
|
||||||
|
pub status: StoreHealthStatus,
|
||||||
|
/// Optional human-readable diagnostic message.
|
||||||
|
pub message: std::option::Option<std::string::String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl StoreHealthSnapshot {
|
||||||
|
/// Builds a store health snapshot after minimal validation.
|
||||||
|
pub fn new(
|
||||||
|
backend: impl std::convert::Into<std::string::String>,
|
||||||
|
status: StoreHealthStatus,
|
||||||
|
message: std::option::Option<std::string::String>,
|
||||||
|
) -> kb_core::Result<Self> {
|
||||||
|
let backend_value = backend.into();
|
||||||
|
if backend_value.trim().is_empty() {
|
||||||
|
return std::result::Result::Err(kb_core::Error::db(
|
||||||
|
"store health backend must not be empty",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
return std::result::Result::Ok(Self { backend: backend_value, status, message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
#[test]
|
||||||
|
fn health_snapshot_rejects_empty_backend() {
|
||||||
|
let result = crate::StoreHealthSnapshot::new(
|
||||||
|
" ",
|
||||||
|
crate::StoreHealthStatus::Unknown,
|
||||||
|
std::option::Option::None,
|
||||||
|
);
|
||||||
|
assert!(result.is_err());
|
||||||
|
}
|
||||||
|
}
|
||||||
75
kb-store/src/contracts/pagination.rs
Normal file
75
kb-store/src/contracts/pagination.rs
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
// file: kb-store/src/contracts/pagination.rs
|
||||||
|
// version: 1
|
||||||
|
|
||||||
|
//! Backend-neutral pagination and sorting contracts for repository operations.
|
||||||
|
|
||||||
|
/// Default page size for repository list operations.
|
||||||
|
pub const DEFAULT_PAGE_SIZE: u16 = 100;
|
||||||
|
|
||||||
|
/// Maximum page size for repository list operations.
|
||||||
|
pub const MAX_PAGE_SIZE: u16 = 1000;
|
||||||
|
|
||||||
|
/// Sort direction for repository list operations.
|
||||||
|
#[derive(Clone, Copy, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
|
||||||
|
pub enum SortDirection {
|
||||||
|
/// Sort values in ascending order.
|
||||||
|
Asc,
|
||||||
|
/// Sort values in descending order.
|
||||||
|
Desc,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Page request contract for repository list operations.
|
||||||
|
#[derive(Clone, Copy, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
|
||||||
|
pub struct PageRequest {
|
||||||
|
/// Maximum number of rows to return.
|
||||||
|
pub limit: u16,
|
||||||
|
/// Zero-based row offset.
|
||||||
|
pub offset: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PageRequest {
|
||||||
|
/// Builds a page request after minimal bounds validation.
|
||||||
|
pub fn new(limit: u16, offset: u64) -> kb_core::Result<Self> {
|
||||||
|
if limit == 0 {
|
||||||
|
return std::result::Result::Err(kb_core::Error::db(
|
||||||
|
"page limit must be greater than zero",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if limit > crate::MAX_PAGE_SIZE {
|
||||||
|
return std::result::Result::Err(kb_core::Error::db(
|
||||||
|
"page limit exceeds maximum page size",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
return std::result::Result::Ok(Self { limit, offset });
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Builds the default first page request.
|
||||||
|
pub fn first_page() -> Self {
|
||||||
|
return Self {
|
||||||
|
limit: crate::DEFAULT_PAGE_SIZE,
|
||||||
|
offset: 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
#[test]
|
||||||
|
fn page_request_rejects_zero_limit() {
|
||||||
|
let result = crate::PageRequest::new(0, 0);
|
||||||
|
assert!(result.is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn page_request_rejects_limit_above_maximum() {
|
||||||
|
let result = crate::PageRequest::new(crate::MAX_PAGE_SIZE + 1, 0);
|
||||||
|
assert!(result.is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn first_page_uses_default_limit() {
|
||||||
|
let request = crate::PageRequest::first_page();
|
||||||
|
assert_eq!(request.limit, crate::DEFAULT_PAGE_SIZE);
|
||||||
|
assert_eq!(request.offset, 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
237
kb-store/src/contracts/repository.rs
Normal file
237
kb-store/src/contracts/repository.rs
Normal file
@@ -0,0 +1,237 @@
|
|||||||
|
// file: kb-store/src/contracts/repository.rs
|
||||||
|
// version: 1
|
||||||
|
|
||||||
|
//! Storage trait definitions shared by concrete stores.
|
||||||
|
|
||||||
|
/// Store health storage behavior.
|
||||||
|
#[async_trait::async_trait]
|
||||||
|
pub trait StoreHealthStore {
|
||||||
|
/// Reads the backend descriptor visible to diagnostics.
|
||||||
|
async fn backend_descriptor(&self) -> kb_core::Result<crate::StoreBackendDescriptor>;
|
||||||
|
|
||||||
|
/// Reads the current health snapshot.
|
||||||
|
async fn health_snapshot(&self) -> kb_core::Result<crate::StoreHealthSnapshot>;
|
||||||
|
|
||||||
|
/// Reads the current migration snapshot when the backend supports migrations.
|
||||||
|
async fn migration_snapshot(&self) -> kb_core::Result<crate::StoreMigrationSnapshot>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Canonical raw transaction and acquisition observation storage behavior.
|
||||||
|
#[async_trait::async_trait]
|
||||||
|
pub trait RawTransactionStore {
|
||||||
|
/// Returns true when a canonical transaction signature is already stored.
|
||||||
|
async fn has_raw_transaction_signature(
|
||||||
|
&self,
|
||||||
|
signature: &kb_lib::Signature,
|
||||||
|
) -> kb_core::Result<bool>;
|
||||||
|
|
||||||
|
/// Returns true when a transaction observation key is already stored.
|
||||||
|
async fn has_transaction_observation_key(&self, observation_key: &str)
|
||||||
|
-> kb_core::Result<bool>;
|
||||||
|
|
||||||
|
/// Stores one canonical source-independent transaction payload.
|
||||||
|
async fn insert_raw_transaction(
|
||||||
|
&self,
|
||||||
|
input: &crate::RawTransactionInsert,
|
||||||
|
) -> kb_core::Result<crate::InsertOutcome>;
|
||||||
|
|
||||||
|
/// Stores one lightweight transaction acquisition observation.
|
||||||
|
async fn insert_transaction_observation(
|
||||||
|
&self,
|
||||||
|
input: &crate::TransactionObservationInsert,
|
||||||
|
) -> kb_core::Result<crate::InsertOutcome>;
|
||||||
|
|
||||||
|
/// Updates canonical raw transaction retention and processing metadata.
|
||||||
|
async fn mark_raw_payload_lifecycle(
|
||||||
|
&self,
|
||||||
|
mark: &crate::RawPayloadLifecycleMark,
|
||||||
|
) -> kb_core::Result<crate::InsertOutcome>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Core Solana storage behavior.
|
||||||
|
#[async_trait::async_trait]
|
||||||
|
pub trait CoreTransactionStore {
|
||||||
|
/// Stores one normalized core transaction.
|
||||||
|
async fn insert_core_transaction(
|
||||||
|
&self,
|
||||||
|
input: &crate::CoreTransactionInsert,
|
||||||
|
) -> kb_core::Result<crate::InsertOutcome>;
|
||||||
|
|
||||||
|
/// Stores normalized core account keys.
|
||||||
|
async fn insert_core_account_keys(
|
||||||
|
&self,
|
||||||
|
inputs: &[crate::CoreAccountKeyInsert],
|
||||||
|
) -> kb_core::Result<crate::InsertOutcome>;
|
||||||
|
|
||||||
|
/// Stores normalized core instructions.
|
||||||
|
async fn insert_core_instructions(
|
||||||
|
&self,
|
||||||
|
inputs: &[crate::CoreInstructionInsert],
|
||||||
|
) -> kb_core::Result<crate::InsertOutcome>;
|
||||||
|
|
||||||
|
/// Stores normalized core inner instructions.
|
||||||
|
async fn insert_core_inner_instructions(
|
||||||
|
&self,
|
||||||
|
inputs: &[crate::CoreInnerInstructionInsert],
|
||||||
|
) -> kb_core::Result<crate::InsertOutcome>;
|
||||||
|
|
||||||
|
/// Stores normalized core logs.
|
||||||
|
async fn insert_core_logs(
|
||||||
|
&self,
|
||||||
|
inputs: &[crate::CoreLogInsert],
|
||||||
|
) -> kb_core::Result<crate::InsertOutcome>;
|
||||||
|
|
||||||
|
/// Stores normalized core balance changes.
|
||||||
|
async fn insert_core_balance_changes(
|
||||||
|
&self,
|
||||||
|
inputs: &[crate::CoreBalanceChangeInsert],
|
||||||
|
) -> kb_core::Result<crate::InsertOutcome>;
|
||||||
|
|
||||||
|
/// Lists normalized core instructions selected for replay or first processing.
|
||||||
|
async fn list_core_instructions_for_replay(
|
||||||
|
&self,
|
||||||
|
filter: &crate::CoreInstructionReplayFilter,
|
||||||
|
page_request: &crate::PageRequest,
|
||||||
|
) -> kb_core::Result<std::vec::Vec<crate::CoreInstructionRow>>;
|
||||||
|
|
||||||
|
/// Lists replay inputs with instruction context, logs, balances and account keys.
|
||||||
|
async fn list_core_instruction_replay_inputs(
|
||||||
|
&self,
|
||||||
|
filter: &crate::CoreInstructionReplayFilter,
|
||||||
|
page_request: &crate::PageRequest,
|
||||||
|
) -> kb_core::Result<std::vec::Vec<crate::CoreInstructionReplayInput>>;
|
||||||
|
|
||||||
|
/// Updates one normalized core instruction lifecycle state.
|
||||||
|
async fn mark_core_instruction_lifecycle(
|
||||||
|
&self,
|
||||||
|
mark: &crate::CoreInstructionLifecycleMark,
|
||||||
|
) -> kb_core::Result<crate::InsertOutcome>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Canonical transaction to core extraction storage behavior.
|
||||||
|
#[async_trait::async_trait]
|
||||||
|
pub trait CoreExtractionStore {
|
||||||
|
/// Lists canonical raw transactions selected for core extraction.
|
||||||
|
async fn list_raw_transactions_for_core_extraction(
|
||||||
|
&self,
|
||||||
|
filter: &crate::CoreExtractionSelectionFilter,
|
||||||
|
) -> kb_core::Result<std::vec::Vec<crate::RawTransactionRow>>;
|
||||||
|
|
||||||
|
/// Returns true when the same processor version already succeeded for the same input hash.
|
||||||
|
async fn is_core_extraction_current(
|
||||||
|
&self,
|
||||||
|
identity: &crate::ProcessingLedgerIdentity,
|
||||||
|
) -> kb_core::Result<bool>;
|
||||||
|
|
||||||
|
/// Atomically replaces one signature core graph and marks the processing ledger as succeeded.
|
||||||
|
async fn persist_core_extraction(
|
||||||
|
&self,
|
||||||
|
bundle: &crate::CoreExtractionBundle,
|
||||||
|
force_replay: bool,
|
||||||
|
) -> kb_core::Result<crate::InsertOutcome>;
|
||||||
|
|
||||||
|
/// Persists one failed extraction attempt and marks the canonical raw transaction as failed.
|
||||||
|
async fn mark_core_extraction_failed(
|
||||||
|
&self,
|
||||||
|
failure: &crate::CoreExtractionFailure,
|
||||||
|
) -> kb_core::Result<crate::InsertOutcome>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Contextual instruction decode and materialization storage behavior.
|
||||||
|
#[async_trait::async_trait]
|
||||||
|
pub trait DecodePipelineStore {
|
||||||
|
/// Lists contextual core instructions selected for a bounded decode campaign.
|
||||||
|
async fn list_decode_inputs(
|
||||||
|
&self,
|
||||||
|
filter: &crate::DecodeSelectionFilter,
|
||||||
|
) -> kb_core::Result<std::vec::Vec<crate::CoreInstructionReplayInput>>;
|
||||||
|
|
||||||
|
/// Returns true when the same processor version already succeeded for the same input hash.
|
||||||
|
async fn is_decode_current(
|
||||||
|
&self,
|
||||||
|
identity: &crate::ProcessingLedgerIdentity,
|
||||||
|
) -> kb_core::Result<bool>;
|
||||||
|
|
||||||
|
/// Replaces declarations owned by one decoder version.
|
||||||
|
async fn persist_decode_coverage_declarations(
|
||||||
|
&self,
|
||||||
|
declarations: &[crate::DecodeCoverageDeclarationInsert],
|
||||||
|
) -> kb_core::Result<crate::InsertOutcome>;
|
||||||
|
|
||||||
|
/// Atomically persists decoded observations, coverage and the common ledger.
|
||||||
|
async fn persist_decode_result(
|
||||||
|
&self,
|
||||||
|
bundle: &crate::DecodePersistenceBundle,
|
||||||
|
force_replay: bool,
|
||||||
|
) -> kb_core::Result<crate::InsertOutcome>;
|
||||||
|
|
||||||
|
/// Persists a failed decode attempt and marks the source instruction as failed.
|
||||||
|
async fn mark_decode_failed(
|
||||||
|
&self,
|
||||||
|
failure: &crate::DecodeFailure,
|
||||||
|
) -> kb_core::Result<crate::InsertOutcome>;
|
||||||
|
|
||||||
|
/// Atomically persists materialized outputs and the common ledger.
|
||||||
|
async fn persist_materialization_result(
|
||||||
|
&self,
|
||||||
|
bundle: &crate::MaterializationPersistenceBundle,
|
||||||
|
force_replay: bool,
|
||||||
|
) -> kb_core::Result<crate::InsertOutcome>;
|
||||||
|
|
||||||
|
/// Reads aggregated machine-readable coverage diagnostics.
|
||||||
|
async fn list_decode_coverage_summary(
|
||||||
|
&self,
|
||||||
|
processor_name: std::option::Option<&str>,
|
||||||
|
processor_version: std::option::Option<&str>,
|
||||||
|
limit: u32,
|
||||||
|
) -> kb_core::Result<std::vec::Vec<crate::DecodeCoverageSummaryRow>>;
|
||||||
|
|
||||||
|
/// Lists bounded materialized outputs for read-only application views.
|
||||||
|
async fn list_materialized_events(
|
||||||
|
&self,
|
||||||
|
filter: &crate::MaterializedEventFilter,
|
||||||
|
) -> kb_core::Result<std::vec::Vec<crate::MaterializedEventQueryRow>>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Program observation storage behavior.
|
||||||
|
#[async_trait::async_trait]
|
||||||
|
pub trait ProgramObservationStore {
|
||||||
|
/// Stores program observations.
|
||||||
|
async fn store_observations(
|
||||||
|
&self,
|
||||||
|
observations: &[kb_lib::ProgramObservation],
|
||||||
|
) -> kb_core::Result<crate::InsertOutcome>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Decoded event storage behavior.
|
||||||
|
#[async_trait::async_trait]
|
||||||
|
pub trait DecodedEventStore {
|
||||||
|
/// Stores decoded protocol events.
|
||||||
|
async fn store_decoded_events(
|
||||||
|
&self,
|
||||||
|
events: &[crate::DecodedEventInsert],
|
||||||
|
) -> kb_core::Result<crate::InsertOutcome>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Materialized event storage behavior.
|
||||||
|
#[async_trait::async_trait]
|
||||||
|
pub trait MaterializedEventStore {
|
||||||
|
/// Stores materialized business events.
|
||||||
|
async fn store_materialized_events(
|
||||||
|
&self,
|
||||||
|
events: &[crate::MaterializedEventInsert],
|
||||||
|
) -> kb_core::Result<crate::InsertOutcome>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Processing ledger storage behavior.
|
||||||
|
#[async_trait::async_trait]
|
||||||
|
pub trait ProcessingLedgerStore {
|
||||||
|
/// Marks an input as processed for a module version.
|
||||||
|
async fn mark_processed(
|
||||||
|
&self,
|
||||||
|
mark: &crate::ProcessingLedgerMark,
|
||||||
|
) -> kb_core::Result<crate::InsertOutcome>;
|
||||||
|
|
||||||
|
/// Returns true when an input was already processed for a module version.
|
||||||
|
async fn is_processed(&self, mark: &crate::ProcessingLedgerMark) -> kb_core::Result<bool>;
|
||||||
|
}
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
// file: kb-store/src/core.rs
|
|
||||||
// version: 1
|
|
||||||
|
|
||||||
//! Store-neutral contracts.
|
|
||||||
|
|
||||||
/// Store health summary.
|
|
||||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
|
||||||
pub struct StoreHealth {
|
|
||||||
/// Whether the backend is available.
|
|
||||||
pub available: bool,
|
|
||||||
}
|
|
||||||
@@ -1,14 +1,257 @@
|
|||||||
// file: kb-store/src/lib.rs
|
// file: kb-store/src/lib.rs
|
||||||
// version: 1
|
// version: 3
|
||||||
|
|
||||||
#![forbid(unsafe_code)]
|
//! Backend-neutral storage contracts and PostgreSQL implementation.
|
||||||
#![deny(unreachable_pub)]
|
|
||||||
#![warn(missing_docs)]
|
#![warn(missing_docs)]
|
||||||
|
#![deny(unreachable_pub)]
|
||||||
|
#![forbid(unsafe_code)]
|
||||||
|
|
||||||
//! Consolidated storage crate.
|
mod constants;
|
||||||
|
mod contracts;
|
||||||
|
mod postgres;
|
||||||
|
|
||||||
pub mod core;
|
/// Canonical tracing target for storage operations.
|
||||||
pub mod postgres;
|
pub(crate) use crate::constants::TRACING_TARGET;
|
||||||
|
|
||||||
/// Store health summary.
|
/// Core account key insert contract.
|
||||||
pub use crate::core::StoreHealth;
|
pub use crate::contracts::CoreAccountKeyInsert;
|
||||||
|
/// Core account key SQL-like row contract.
|
||||||
|
pub use crate::contracts::CoreAccountKeyRow;
|
||||||
|
/// Core account key source category.
|
||||||
|
pub use crate::contracts::CoreAccountKeySource;
|
||||||
|
/// Core balance change insert contract.
|
||||||
|
pub use crate::contracts::CoreBalanceChangeInsert;
|
||||||
|
/// Core balance change kind.
|
||||||
|
pub use crate::contracts::CoreBalanceChangeKind;
|
||||||
|
/// Core balance change SQL-like row contract.
|
||||||
|
pub use crate::contracts::CoreBalanceChangeRow;
|
||||||
|
/// Complete normalized core extraction write bundle.
|
||||||
|
pub use crate::contracts::CoreExtractionBundle;
|
||||||
|
/// Failure details persisted for one canonical to core extraction attempt.
|
||||||
|
pub use crate::contracts::CoreExtractionFailure;
|
||||||
|
/// Bounded canonical transaction selection filter for core extraction.
|
||||||
|
pub use crate::contracts::CoreExtractionSelectionFilter;
|
||||||
|
/// Canonical transaction to core extraction storage behavior.
|
||||||
|
pub use crate::contracts::CoreExtractionStore;
|
||||||
|
/// Core inner instruction insert contract.
|
||||||
|
pub use crate::contracts::CoreInnerInstructionInsert;
|
||||||
|
/// Core inner instruction SQL-like row contract.
|
||||||
|
pub use crate::contracts::CoreInnerInstructionRow;
|
||||||
|
/// Core instruction insert contract.
|
||||||
|
pub use crate::contracts::CoreInstructionInsert;
|
||||||
|
/// Core instruction lifecycle mark request.
|
||||||
|
pub use crate::contracts::CoreInstructionLifecycleMark;
|
||||||
|
/// Core instruction processing state.
|
||||||
|
pub use crate::contracts::CoreInstructionProcessingState;
|
||||||
|
/// Core instruction replay filter contract.
|
||||||
|
pub use crate::contracts::CoreInstructionReplayFilter;
|
||||||
|
/// Core instruction SQL-like row contract.
|
||||||
|
pub use crate::contracts::CoreInstructionRow;
|
||||||
|
/// Core log insert contract.
|
||||||
|
pub use crate::contracts::CoreLogInsert;
|
||||||
|
/// Core log SQL-like row contract.
|
||||||
|
pub use crate::contracts::CoreLogRow;
|
||||||
|
/// Core transaction insert contract.
|
||||||
|
pub use crate::contracts::CoreTransactionInsert;
|
||||||
|
/// Core transaction SQL-like row contract.
|
||||||
|
pub use crate::contracts::CoreTransactionRow;
|
||||||
|
/// Core Solana storage behavior.
|
||||||
|
pub use crate::contracts::CoreTransactionStore;
|
||||||
|
/// Default repository page size.
|
||||||
|
pub use crate::contracts::DEFAULT_PAGE_SIZE;
|
||||||
|
/// One machine-readable decoder coverage declaration row.
|
||||||
|
pub use crate::contracts::DecodeCoverageDeclarationInsert;
|
||||||
|
/// One observed coverage classification row owned by one decode attempt.
|
||||||
|
pub use crate::contracts::DecodeCoverageObservationInsert;
|
||||||
|
/// One row of aggregated decoder coverage diagnostics.
|
||||||
|
pub use crate::contracts::DecodeCoverageSummaryRow;
|
||||||
|
/// Failed decode attempt persisted in the common ledger.
|
||||||
|
pub use crate::contracts::DecodeFailure;
|
||||||
|
/// One processor-owned decoded observation row.
|
||||||
|
pub use crate::contracts::DecodeObservationInsert;
|
||||||
|
/// Atomic persistence bundle for one decoder and one contextual input.
|
||||||
|
pub use crate::contracts::DecodePersistenceBundle;
|
||||||
|
/// Contextual instruction decode and materialization storage behavior.
|
||||||
|
pub use crate::contracts::DecodePipelineStore;
|
||||||
|
/// Bounded contextual instruction selection filter for decode campaigns.
|
||||||
|
pub use crate::contracts::DecodeSelectionFilter;
|
||||||
|
/// Decoded event insert contract.
|
||||||
|
pub use crate::contracts::DecodedEventInsert;
|
||||||
|
/// Decoded event SQL-like row contract.
|
||||||
|
pub use crate::contracts::DecodedEventRow;
|
||||||
|
/// Decoded event storage behavior.
|
||||||
|
pub use crate::contracts::DecodedEventStore;
|
||||||
|
/// Insert or upsert result contract returned by repositories.
|
||||||
|
pub use crate::contracts::InsertOutcome;
|
||||||
|
/// Maximum number of materialized rows returned by one bounded query.
|
||||||
|
pub use crate::contracts::MAX_MATERIALIZED_EVENT_QUERY_ROWS;
|
||||||
|
/// Maximum repository page size.
|
||||||
|
pub use crate::contracts::MAX_PAGE_SIZE;
|
||||||
|
/// Atomic persistence bundle for one materializer and one decoded observation.
|
||||||
|
pub use crate::contracts::MaterializationPersistenceBundle;
|
||||||
|
/// Bounded read-only materialized event selection.
|
||||||
|
pub use crate::contracts::MaterializedEventFilter;
|
||||||
|
/// Materialized event insert contract.
|
||||||
|
pub use crate::contracts::MaterializedEventInsert;
|
||||||
|
/// One materialized output returned by a bounded query.
|
||||||
|
pub use crate::contracts::MaterializedEventQueryRow;
|
||||||
|
/// Materialized event SQL-like row contract.
|
||||||
|
pub use crate::contracts::MaterializedEventRow;
|
||||||
|
/// Materialized event storage behavior.
|
||||||
|
pub use crate::contracts::MaterializedEventStore;
|
||||||
|
/// One processor-owned materialized output row.
|
||||||
|
pub use crate::contracts::MaterializedOutputInsert;
|
||||||
|
/// Page request contract for repository list operations.
|
||||||
|
pub use crate::contracts::PageRequest;
|
||||||
|
/// Stable processing ledger identity.
|
||||||
|
pub use crate::contracts::ProcessingLedgerIdentity;
|
||||||
|
/// Processing ledger mark request contract.
|
||||||
|
pub use crate::contracts::ProcessingLedgerMark;
|
||||||
|
/// Processing ledger SQL-like row contract.
|
||||||
|
pub use crate::contracts::ProcessingLedgerRow;
|
||||||
|
/// Stable processing ledger status.
|
||||||
|
pub use crate::contracts::ProcessingLedgerStatus;
|
||||||
|
/// Processing ledger storage behavior.
|
||||||
|
pub use crate::contracts::ProcessingLedgerStore;
|
||||||
|
/// Program observation storage behavior.
|
||||||
|
pub use crate::contracts::ProgramObservationStore;
|
||||||
|
/// Raw payload lifecycle mark request.
|
||||||
|
pub use crate::contracts::RawPayloadLifecycleMark;
|
||||||
|
/// Raw payload processing state.
|
||||||
|
pub use crate::contracts::RawPayloadProcessingState;
|
||||||
|
/// Raw payload retention state.
|
||||||
|
pub use crate::contracts::RawPayloadRetentionState;
|
||||||
|
/// Canonical raw Solana transaction insert contract.
|
||||||
|
pub use crate::contracts::RawTransactionInsert;
|
||||||
|
/// Canonical raw Solana transaction SQL-like row contract.
|
||||||
|
pub use crate::contracts::RawTransactionRow;
|
||||||
|
/// Raw transaction storage behavior.
|
||||||
|
pub use crate::contracts::RawTransactionStore;
|
||||||
|
/// Sort direction for repository list operations.
|
||||||
|
pub use crate::contracts::SortDirection;
|
||||||
|
/// Store backend diagnostic contract.
|
||||||
|
pub use crate::contracts::StoreBackendDescriptor;
|
||||||
|
/// Store backend kind contract.
|
||||||
|
pub use crate::contracts::StoreBackendKind;
|
||||||
|
/// Store backend health snapshot.
|
||||||
|
pub use crate::contracts::StoreHealthSnapshot;
|
||||||
|
/// Store backend health status.
|
||||||
|
pub use crate::contracts::StoreHealthStatus;
|
||||||
|
/// Store health storage behavior.
|
||||||
|
pub use crate::contracts::StoreHealthStore;
|
||||||
|
/// Store migration diagnostic snapshot contract.
|
||||||
|
pub use crate::contracts::StoreMigrationSnapshot;
|
||||||
|
/// Store migration status contract.
|
||||||
|
pub use crate::contracts::StoreMigrationStatus;
|
||||||
|
/// Transaction acquisition observation insert contract.
|
||||||
|
pub use crate::contracts::TransactionObservationInsert;
|
||||||
|
/// Transaction acquisition observation origin.
|
||||||
|
pub use crate::contracts::TransactionObservationOrigin;
|
||||||
|
/// Transaction acquisition observation SQL-like row contract.
|
||||||
|
pub use crate::contracts::TransactionObservationRow;
|
||||||
|
/// Transaction acquisition observation status.
|
||||||
|
pub use crate::contracts::TransactionObservationStatus;
|
||||||
|
/// Storage error helper functions.
|
||||||
|
pub use crate::contracts::storage_contract_error;
|
||||||
|
/// Allowed Solana table domains encoded in table names.
|
||||||
|
pub use crate::postgres::ALLOWED_SOLANA_TABLE_DOMAINS;
|
||||||
|
/// Core account key table name.
|
||||||
|
pub use crate::postgres::CORE_ACCOUNT_KEYS_TABLE_NAME;
|
||||||
|
/// Core balance change table name.
|
||||||
|
pub use crate::postgres::CORE_BALANCE_CHANGES_TABLE_NAME;
|
||||||
|
/// Core inner instruction table name.
|
||||||
|
pub use crate::postgres::CORE_INNER_INSTRUCTIONS_TABLE_NAME;
|
||||||
|
/// Core instruction table name.
|
||||||
|
pub use crate::postgres::CORE_INSTRUCTIONS_TABLE_NAME;
|
||||||
|
/// Core log table name.
|
||||||
|
pub use crate::postgres::CORE_LOGS_TABLE_NAME;
|
||||||
|
/// Core Solana table names.
|
||||||
|
pub use crate::postgres::CORE_STORE_TABLE_NAMES;
|
||||||
|
/// Core transaction table name.
|
||||||
|
pub use crate::postgres::CORE_TRANSACTIONS_TABLE_NAME;
|
||||||
|
/// Machine-readable decoder coverage declaration table name.
|
||||||
|
pub use crate::postgres::DECODE_COVERAGE_DECLARATIONS_TABLE_NAME;
|
||||||
|
/// Observed decoder coverage classification table name.
|
||||||
|
pub use crate::postgres::DECODE_COVERAGE_OBSERVATIONS_TABLE_NAME;
|
||||||
|
/// Versioned decoded event table name.
|
||||||
|
pub use crate::postgres::DECODE_EVENTS_TABLE_NAME;
|
||||||
|
/// Decode and materialization table names.
|
||||||
|
pub use crate::postgres::DECODE_STORE_TABLE_NAMES;
|
||||||
|
/// Default PostgreSQL schema policy.
|
||||||
|
pub use crate::postgres::DEFAULT_SCHEMA_POLICY;
|
||||||
|
/// Versioned materialized output table name.
|
||||||
|
pub use crate::postgres::MATERIALIZED_EVENTS_TABLE_NAME;
|
||||||
|
/// Maximum number of rows returned by one replay candidate query.
|
||||||
|
pub use crate::postgres::MAX_REPLAY_CANDIDATE_ROWS;
|
||||||
|
/// PostgreSQL migration strategy used by this crate.
|
||||||
|
pub use crate::postgres::MIGRATION_STRATEGY;
|
||||||
|
/// Migration table name used by sqlx.
|
||||||
|
pub use crate::postgres::MIGRATION_TABLE_NAME;
|
||||||
|
/// Processing ledger table name.
|
||||||
|
pub use crate::postgres::PROCESSING_LEDGER_TABLE_NAME;
|
||||||
|
/// PostgreSQL diagnostic snapshot.
|
||||||
|
pub use crate::postgres::PostgresBackendDiagnostics;
|
||||||
|
/// Bounded read-only filter for core entity summaries.
|
||||||
|
pub use crate::postgres::PostgresReplayEntityFilter;
|
||||||
|
/// Core entity kind used while filtering replay candidates.
|
||||||
|
pub use crate::postgres::PostgresReplayEntityKind;
|
||||||
|
/// Aggregated mint, owner or account-key occurrences from core tables.
|
||||||
|
pub use crate::postgres::PostgresReplayEntitySummary;
|
||||||
|
/// Bounded read-only filter for program summaries.
|
||||||
|
pub use crate::postgres::PostgresReplayProgramFilter;
|
||||||
|
/// Program occurrence scope used while filtering replay candidates.
|
||||||
|
pub use crate::postgres::PostgresReplayProgramScope;
|
||||||
|
/// Aggregated program occurrences across outer, inner and linked logs.
|
||||||
|
pub use crate::postgres::PostgresReplayProgramSummary;
|
||||||
|
/// One raw transaction candidate enriched with core and ledger diagnostics.
|
||||||
|
pub use crate::postgres::PostgresReplayTransactionCandidate;
|
||||||
|
/// Bounded read-only filter for transaction replay candidates.
|
||||||
|
pub use crate::postgres::PostgresReplayTransactionFilter;
|
||||||
|
/// PostgreSQL store handle.
|
||||||
|
pub use crate::postgres::PostgresStore;
|
||||||
|
/// PostgreSQL store connection options.
|
||||||
|
pub use crate::postgres::PostgresStoreOptions;
|
||||||
|
/// PostgreSQL diagnostic table specification.
|
||||||
|
pub use crate::postgres::PostgresTableDiagnosticSpec;
|
||||||
|
/// PostgreSQL table diagnostic snapshot.
|
||||||
|
pub use crate::postgres::PostgresTableDiagnostics;
|
||||||
|
/// PostgreSQL table statistics snapshot.
|
||||||
|
pub use crate::postgres::PostgresTableStatistics;
|
||||||
|
/// Canonical transaction acquisition table names.
|
||||||
|
pub use crate::postgres::RAW_STORE_TABLE_NAMES;
|
||||||
|
/// Canonical raw transaction table name.
|
||||||
|
pub use crate::postgres::RAW_TRANSACTIONS_TABLE_NAME;
|
||||||
|
/// Solana application table prefix.
|
||||||
|
pub use crate::postgres::SOLANA_TABLE_PREFIX;
|
||||||
|
/// Advisory lock id used while initializing store tables.
|
||||||
|
pub use crate::postgres::STORE_SCHEMA_ADVISORY_LOCK_ID;
|
||||||
|
/// Transaction acquisition observation table name.
|
||||||
|
pub use crate::postgres::TRANSACTION_OBSERVATIONS_TABLE_NAME;
|
||||||
|
/// Core store SQL statements applied by the idempotent initializer.
|
||||||
|
pub use crate::postgres::core_store_schema_statements;
|
||||||
|
/// Core table diagnostic specs.
|
||||||
|
pub use crate::postgres::core_store_table_diagnostic_specs;
|
||||||
|
/// Decode and materialization SQL statements applied by the idempotent initializer.
|
||||||
|
pub use crate::postgres::decode_store_schema_statements;
|
||||||
|
/// Decode and materialization table diagnostic specs.
|
||||||
|
pub use crate::postgres::decode_store_table_diagnostic_specs;
|
||||||
|
/// Returns true when a Solana table name follows canonical rules.
|
||||||
|
pub use crate::postgres::is_valid_solana_table_name;
|
||||||
|
/// Returns a DSN masked for logs and UI diagnostics.
|
||||||
|
pub use crate::postgres::mask_postgres_dsn;
|
||||||
|
/// Canonical transaction acquisition SQL statements.
|
||||||
|
pub use crate::postgres::raw_store_schema_statements;
|
||||||
|
/// Canonical transaction acquisition diagnostic specs.
|
||||||
|
pub use crate::postgres::raw_store_table_diagnostic_specs;
|
||||||
|
/// Validates core store table names.
|
||||||
|
pub use crate::postgres::validate_core_store_table_names;
|
||||||
|
/// Validates decode and materialization store table names.
|
||||||
|
pub use crate::postgres::validate_decode_store_table_names;
|
||||||
|
/// Validates canonical transaction acquisition table names.
|
||||||
|
pub use crate::postgres::validate_raw_store_table_names;
|
||||||
|
/// Validates a canonical Solana table name.
|
||||||
|
pub use crate::postgres::validate_solana_table_name;
|
||||||
|
/// Current normalized core replay input contract version.
|
||||||
|
pub use kb_lib::CORE_REPLAY_INPUT_CONTRACT_VERSION;
|
||||||
|
/// Decoder replay input containing one instruction plus extracted transaction context.
|
||||||
|
pub use kb_lib::CoreInstructionReplayInput;
|
||||||
|
|||||||
@@ -1,9 +1,111 @@
|
|||||||
// file: kb-store/src/postgres.rs
|
// file: kb-store/src/postgres.rs
|
||||||
// version: 1
|
// version: 2
|
||||||
|
|
||||||
//! PostgreSQL backend boundary.
|
//! PostgreSQL storage implementation boundary.
|
||||||
|
|
||||||
/// PostgreSQL backend migration status.
|
mod migrations;
|
||||||
pub fn migration_status() -> &'static str {
|
mod query;
|
||||||
return "pending-source-port";
|
mod replay_candidates;
|
||||||
}
|
mod repository;
|
||||||
|
mod store;
|
||||||
|
#[cfg(test)]
|
||||||
|
mod test_serial;
|
||||||
|
|
||||||
|
/// Allowed Solana table domains encoded in table names.
|
||||||
|
pub use crate::postgres::migrations::ALLOWED_SOLANA_TABLE_DOMAINS;
|
||||||
|
/// Core account key table name.
|
||||||
|
pub use crate::postgres::migrations::CORE_ACCOUNT_KEYS_TABLE_NAME;
|
||||||
|
/// Core balance change table name.
|
||||||
|
pub use crate::postgres::migrations::CORE_BALANCE_CHANGES_TABLE_NAME;
|
||||||
|
/// Core inner instruction table name.
|
||||||
|
pub use crate::postgres::migrations::CORE_INNER_INSTRUCTIONS_TABLE_NAME;
|
||||||
|
/// Core instruction table name.
|
||||||
|
pub use crate::postgres::migrations::CORE_INSTRUCTIONS_TABLE_NAME;
|
||||||
|
/// Core log table name.
|
||||||
|
pub use crate::postgres::migrations::CORE_LOGS_TABLE_NAME;
|
||||||
|
/// Core Solana table names introduced by `0.2.4`.
|
||||||
|
pub use crate::postgres::migrations::CORE_STORE_TABLE_NAMES;
|
||||||
|
/// Core transaction table name.
|
||||||
|
pub use crate::postgres::migrations::CORE_TRANSACTIONS_TABLE_NAME;
|
||||||
|
/// Machine-readable decoder coverage declaration table name.
|
||||||
|
pub use crate::postgres::migrations::DECODE_COVERAGE_DECLARATIONS_TABLE_NAME;
|
||||||
|
/// Observed decoder coverage classification table name.
|
||||||
|
pub use crate::postgres::migrations::DECODE_COVERAGE_OBSERVATIONS_TABLE_NAME;
|
||||||
|
/// Versioned decoded event table name.
|
||||||
|
pub use crate::postgres::migrations::DECODE_EVENTS_TABLE_NAME;
|
||||||
|
/// Decode and materialization table names introduced by `0.4.0`.
|
||||||
|
pub use crate::postgres::migrations::DECODE_STORE_TABLE_NAMES;
|
||||||
|
/// Default PostgreSQL schema policy.
|
||||||
|
pub use crate::postgres::migrations::DEFAULT_SCHEMA_POLICY;
|
||||||
|
/// Versioned materialized output table name.
|
||||||
|
pub use crate::postgres::migrations::MATERIALIZED_EVENTS_TABLE_NAME;
|
||||||
|
/// PostgreSQL migration strategy used by this crate.
|
||||||
|
pub use crate::postgres::migrations::MIGRATION_STRATEGY;
|
||||||
|
/// Migration table name used by sqlx when migrations are enabled later.
|
||||||
|
pub use crate::postgres::migrations::MIGRATION_TABLE_NAME;
|
||||||
|
/// Processing ledger table name.
|
||||||
|
pub use crate::postgres::migrations::PROCESSING_LEDGER_TABLE_NAME;
|
||||||
|
/// Table diagnostic metadata.
|
||||||
|
pub use crate::postgres::migrations::PostgresTableDiagnosticSpec;
|
||||||
|
/// Canonical transaction acquisition table names active since `0.3.1`.
|
||||||
|
pub use crate::postgres::migrations::RAW_STORE_TABLE_NAMES;
|
||||||
|
/// Canonical raw transaction table name.
|
||||||
|
pub use crate::postgres::migrations::RAW_TRANSACTIONS_TABLE_NAME;
|
||||||
|
/// Solana application table prefix.
|
||||||
|
pub use crate::postgres::migrations::SOLANA_TABLE_PREFIX;
|
||||||
|
/// Advisory lock id used while initializing store schemas.
|
||||||
|
pub use crate::postgres::migrations::STORE_SCHEMA_ADVISORY_LOCK_ID;
|
||||||
|
/// Transaction acquisition observation table name.
|
||||||
|
pub use crate::postgres::migrations::TRANSACTION_OBSERVATIONS_TABLE_NAME;
|
||||||
|
/// Core store SQL statements applied by the idempotent initializer.
|
||||||
|
pub use crate::postgres::migrations::core_store_schema_statements;
|
||||||
|
/// Core table diagnostic specs.
|
||||||
|
pub use crate::postgres::migrations::core_store_table_diagnostic_specs;
|
||||||
|
/// Decode and materialization SQL statements applied by the idempotent initializer.
|
||||||
|
pub use crate::postgres::migrations::decode_store_schema_statements;
|
||||||
|
/// Decode and materialization table diagnostic specs.
|
||||||
|
pub use crate::postgres::migrations::decode_store_table_diagnostic_specs;
|
||||||
|
/// Returns true when a Solana table name follows the canonical prefix and domain rules.
|
||||||
|
pub use crate::postgres::migrations::is_valid_solana_table_name;
|
||||||
|
/// Canonical transaction acquisition SQL statements applied by the initializer.
|
||||||
|
pub use crate::postgres::migrations::raw_store_schema_statements;
|
||||||
|
/// Canonical transaction acquisition diagnostic specs.
|
||||||
|
pub use crate::postgres::migrations::raw_store_table_diagnostic_specs;
|
||||||
|
/// Validates core store table names.
|
||||||
|
pub use crate::postgres::migrations::validate_core_store_table_names;
|
||||||
|
/// Validates decode and materialization store table names.
|
||||||
|
pub use crate::postgres::migrations::validate_decode_store_table_names;
|
||||||
|
/// Validates canonical transaction acquisition table names.
|
||||||
|
pub use crate::postgres::migrations::validate_raw_store_table_names;
|
||||||
|
/// Validates a canonical Solana table name.
|
||||||
|
pub use crate::postgres::migrations::validate_solana_table_name;
|
||||||
|
/// Maximum number of rows returned by one replay candidate query.
|
||||||
|
pub use crate::postgres::replay_candidates::MAX_REPLAY_CANDIDATE_ROWS;
|
||||||
|
/// Bounded read-only filter for core entity summaries.
|
||||||
|
pub use crate::postgres::replay_candidates::PostgresReplayEntityFilter;
|
||||||
|
/// Core entity kind used while filtering replay candidates.
|
||||||
|
pub use crate::postgres::replay_candidates::PostgresReplayEntityKind;
|
||||||
|
/// Aggregated mint, owner or account-key occurrences from core tables.
|
||||||
|
pub use crate::postgres::replay_candidates::PostgresReplayEntitySummary;
|
||||||
|
/// Bounded read-only filter for program summaries.
|
||||||
|
pub use crate::postgres::replay_candidates::PostgresReplayProgramFilter;
|
||||||
|
/// Program occurrence scope used while filtering replay candidates.
|
||||||
|
pub use crate::postgres::replay_candidates::PostgresReplayProgramScope;
|
||||||
|
/// Aggregated program occurrences across outer, inner and linked logs.
|
||||||
|
pub use crate::postgres::replay_candidates::PostgresReplayProgramSummary;
|
||||||
|
/// One raw transaction candidate enriched with core and ledger diagnostics.
|
||||||
|
pub use crate::postgres::replay_candidates::PostgresReplayTransactionCandidate;
|
||||||
|
/// Bounded read-only filter for transaction replay candidates.
|
||||||
|
pub use crate::postgres::replay_candidates::PostgresReplayTransactionFilter;
|
||||||
|
/// PostgreSQL diagnostic snapshot.
|
||||||
|
pub use crate::postgres::store::PostgresBackendDiagnostics;
|
||||||
|
/// Minimal PostgreSQL store handle.
|
||||||
|
pub use crate::postgres::store::PostgresStore;
|
||||||
|
/// PostgreSQL store connection options.
|
||||||
|
pub use crate::postgres::store::PostgresStoreOptions;
|
||||||
|
/// PostgreSQL table diagnostic snapshot.
|
||||||
|
pub use crate::postgres::store::PostgresTableDiagnostics;
|
||||||
|
/// PostgreSQL table statistics snapshot.
|
||||||
|
pub use crate::postgres::store::PostgresTableStatistics;
|
||||||
|
/// Returns a DSN masked for logs and UI diagnostics.
|
||||||
|
pub use crate::postgres::store::mask_postgres_dsn;
|
||||||
|
|||||||
1166
kb-store/src/postgres/migrations.rs
Normal file
1166
kb-store/src/postgres/migrations.rs
Normal file
File diff suppressed because it is too large
Load Diff
52
kb-store/src/postgres/query.rs
Normal file
52
kb-store/src/postgres/query.rs
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
// file: kb-store/src/postgres/query.rs
|
||||||
|
// version: 1
|
||||||
|
|
||||||
|
//! PostgreSQL query modules.
|
||||||
|
|
||||||
|
mod core_extraction_queries;
|
||||||
|
mod core_queries;
|
||||||
|
mod decode_pipeline_queries;
|
||||||
|
mod health_queries;
|
||||||
|
mod raw_queries;
|
||||||
|
mod replay_candidate_queries;
|
||||||
|
mod table_diagnostics_queries;
|
||||||
|
|
||||||
|
pub(in crate::postgres) use crate::postgres::query::core_extraction_queries::is_core_extraction_current;
|
||||||
|
pub(in crate::postgres) use crate::postgres::query::core_extraction_queries::list_raw_transactions_for_core_extraction;
|
||||||
|
pub(in crate::postgres) use crate::postgres::query::core_extraction_queries::mark_core_extraction_failed;
|
||||||
|
pub(in crate::postgres) use crate::postgres::query::core_extraction_queries::persist_core_extraction;
|
||||||
|
pub(in crate::postgres) use crate::postgres::query::core_queries::apply_core_store_schema;
|
||||||
|
pub(in crate::postgres) use crate::postgres::query::core_queries::insert_core_account_keys;
|
||||||
|
pub(in crate::postgres) use crate::postgres::query::core_queries::insert_core_balance_changes;
|
||||||
|
pub(in crate::postgres) use crate::postgres::query::core_queries::insert_core_inner_instructions;
|
||||||
|
pub(in crate::postgres) use crate::postgres::query::core_queries::insert_core_instructions;
|
||||||
|
pub(in crate::postgres) use crate::postgres::query::core_queries::insert_core_logs;
|
||||||
|
pub(in crate::postgres) use crate::postgres::query::core_queries::insert_core_transaction;
|
||||||
|
pub(in crate::postgres) use crate::postgres::query::core_queries::list_core_instruction_replay_inputs;
|
||||||
|
pub(in crate::postgres) use crate::postgres::query::core_queries::list_core_instructions_for_replay;
|
||||||
|
pub(in crate::postgres) use crate::postgres::query::core_queries::update_core_instruction_lifecycle;
|
||||||
|
pub(in crate::postgres) use crate::postgres::query::decode_pipeline_queries::apply_decode_store_schema;
|
||||||
|
pub(in crate::postgres) use crate::postgres::query::decode_pipeline_queries::is_decode_current;
|
||||||
|
pub(in crate::postgres) use crate::postgres::query::decode_pipeline_queries::list_decode_coverage_summary;
|
||||||
|
pub(in crate::postgres) use crate::postgres::query::decode_pipeline_queries::list_decode_inputs;
|
||||||
|
pub(in crate::postgres) use crate::postgres::query::decode_pipeline_queries::list_materialized_events;
|
||||||
|
pub(in crate::postgres) use crate::postgres::query::decode_pipeline_queries::mark_decode_failed;
|
||||||
|
pub(in crate::postgres) use crate::postgres::query::decode_pipeline_queries::persist_decode_coverage_declarations;
|
||||||
|
pub(in crate::postgres) use crate::postgres::query::decode_pipeline_queries::persist_decode_result;
|
||||||
|
pub(in crate::postgres) use crate::postgres::query::decode_pipeline_queries::persist_materialization_result;
|
||||||
|
pub(in crate::postgres) use crate::postgres::query::health_queries::load_current_schema;
|
||||||
|
pub(in crate::postgres) use crate::postgres::query::health_queries::load_latest_migration_version;
|
||||||
|
pub(in crate::postgres) use crate::postgres::query::health_queries::load_migration_table_name;
|
||||||
|
pub(in crate::postgres) use crate::postgres::query::health_queries::load_server_version;
|
||||||
|
pub(in crate::postgres) use crate::postgres::query::health_queries::run_health_check;
|
||||||
|
pub(in crate::postgres) use crate::postgres::query::raw_queries::apply_raw_store_schema;
|
||||||
|
pub(in crate::postgres) use crate::postgres::query::raw_queries::has_raw_transaction_signature;
|
||||||
|
pub(in crate::postgres) use crate::postgres::query::raw_queries::has_transaction_observation_key;
|
||||||
|
pub(in crate::postgres) use crate::postgres::query::raw_queries::insert_raw_transaction;
|
||||||
|
pub(in crate::postgres) use crate::postgres::query::raw_queries::insert_transaction_observation;
|
||||||
|
pub(in crate::postgres) use crate::postgres::query::raw_queries::update_raw_payload_lifecycle;
|
||||||
|
pub(in crate::postgres) use crate::postgres::query::replay_candidate_queries::list_replay_entity_summaries;
|
||||||
|
pub(in crate::postgres) use crate::postgres::query::replay_candidate_queries::list_replay_program_summaries;
|
||||||
|
pub(in crate::postgres) use crate::postgres::query::replay_candidate_queries::list_replay_transaction_candidates;
|
||||||
|
pub(in crate::postgres) use crate::postgres::query::table_diagnostics_queries::load_table_statistics;
|
||||||
|
pub(in crate::postgres) use crate::postgres::query::table_diagnostics_queries::table_exists;
|
||||||
1003
kb-store/src/postgres/query/core_extraction_queries.rs
Normal file
1003
kb-store/src/postgres/query/core_extraction_queries.rs
Normal file
File diff suppressed because it is too large
Load Diff
1219
kb-store/src/postgres/query/core_queries.rs
Normal file
1219
kb-store/src/postgres/query/core_queries.rs
Normal file
File diff suppressed because it is too large
Load Diff
1340
kb-store/src/postgres/query/decode_pipeline_queries.rs
Normal file
1340
kb-store/src/postgres/query/decode_pipeline_queries.rs
Normal file
File diff suppressed because it is too large
Load Diff
77
kb-store/src/postgres/query/health_queries.rs
Normal file
77
kb-store/src/postgres/query/health_queries.rs
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
// file: kb-store/src/postgres/query/health_queries.rs
|
||||||
|
// version: 1
|
||||||
|
|
||||||
|
//! PostgreSQL health and diagnostic SQL queries.
|
||||||
|
|
||||||
|
pub(in crate::postgres) async fn run_health_check(pool: &sqlx::PgPool) -> kb_core::Result<()> {
|
||||||
|
let query_result = sqlx::query_scalar::<sqlx::Postgres, i32>("SELECT 1").fetch_one(pool).await;
|
||||||
|
return match query_result {
|
||||||
|
std::result::Result::Ok(_value) => std::result::Result::Ok(()),
|
||||||
|
std::result::Result::Err(error) => std::result::Result::Err(kb_core::Error::db(format!(
|
||||||
|
"postgres healthcheck failed: {error}"
|
||||||
|
))),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(in crate::postgres) async fn load_current_schema(
|
||||||
|
pool: &sqlx::PgPool,
|
||||||
|
) -> kb_core::Result<std::string::String> {
|
||||||
|
let query_result =
|
||||||
|
sqlx::query_scalar::<sqlx::Postgres, std::string::String>("SELECT current_schema()")
|
||||||
|
.fetch_one(pool)
|
||||||
|
.await;
|
||||||
|
return match query_result {
|
||||||
|
std::result::Result::Ok(schema) => std::result::Result::Ok(schema),
|
||||||
|
std::result::Result::Err(error) => std::result::Result::Err(kb_core::Error::db(format!(
|
||||||
|
"postgres current schema query failed: {error}"
|
||||||
|
))),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(in crate::postgres) async fn load_server_version(
|
||||||
|
pool: &sqlx::PgPool,
|
||||||
|
) -> kb_core::Result<std::string::String> {
|
||||||
|
let query_result =
|
||||||
|
sqlx::query_scalar::<sqlx::Postgres, std::string::String>("SELECT version()")
|
||||||
|
.fetch_one(pool)
|
||||||
|
.await;
|
||||||
|
return match query_result {
|
||||||
|
std::result::Result::Ok(version) => std::result::Result::Ok(version),
|
||||||
|
std::result::Result::Err(error) => std::result::Result::Err(kb_core::Error::db(format!(
|
||||||
|
"postgres version query failed: {error}"
|
||||||
|
))),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(in crate::postgres) async fn load_migration_table_name(
|
||||||
|
pool: &sqlx::PgPool,
|
||||||
|
) -> kb_core::Result<std::option::Option<std::string::String>> {
|
||||||
|
let query_result =
|
||||||
|
sqlx::query_scalar::<sqlx::Postgres, std::option::Option<std::string::String>>(
|
||||||
|
"SELECT to_regclass('_sqlx_migrations')::text",
|
||||||
|
)
|
||||||
|
.fetch_one(pool)
|
||||||
|
.await;
|
||||||
|
return match query_result {
|
||||||
|
std::result::Result::Ok(table_name) => std::result::Result::Ok(table_name),
|
||||||
|
std::result::Result::Err(error) => std::result::Result::Err(kb_core::Error::db(format!(
|
||||||
|
"postgres migration table query failed: {error}"
|
||||||
|
))),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(in crate::postgres) async fn load_latest_migration_version(
|
||||||
|
pool: &sqlx::PgPool,
|
||||||
|
) -> kb_core::Result<std::option::Option<std::string::String>> {
|
||||||
|
let query_result = sqlx::query_scalar::<sqlx::Postgres, std::option::Option<std::string::String>>(
|
||||||
|
"SELECT version::text FROM _sqlx_migrations WHERE success = true ORDER BY version DESC LIMIT 1",
|
||||||
|
)
|
||||||
|
.fetch_optional(pool)
|
||||||
|
.await;
|
||||||
|
return match query_result {
|
||||||
|
std::result::Result::Ok(version) => std::result::Result::Ok(version.flatten()),
|
||||||
|
std::result::Result::Err(error) => std::result::Result::Err(kb_core::Error::db(format!(
|
||||||
|
"postgres latest migration query failed: {error}"
|
||||||
|
))),
|
||||||
|
};
|
||||||
|
}
|
||||||
506
kb-store/src/postgres/query/raw_queries.rs
Normal file
506
kb-store/src/postgres/query/raw_queries.rs
Normal file
@@ -0,0 +1,506 @@
|
|||||||
|
// file: kb-store/src/postgres/query/raw_queries.rs
|
||||||
|
// version: 2
|
||||||
|
|
||||||
|
//! PostgreSQL canonical transaction and acquisition observation SQL queries.
|
||||||
|
|
||||||
|
pub(in crate::postgres) async fn apply_raw_store_schema(
|
||||||
|
pool: &sqlx::PgPool,
|
||||||
|
) -> kb_core::Result<()> {
|
||||||
|
let validation_result = crate::validate_raw_store_table_names();
|
||||||
|
if let std::result::Result::Err(error) = validation_result {
|
||||||
|
return std::result::Result::Err(error);
|
||||||
|
}
|
||||||
|
let transaction_result = pool.begin().await;
|
||||||
|
let mut transaction = match transaction_result {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => {
|
||||||
|
return std::result::Result::Err(kb_core::Error::db(format!(
|
||||||
|
"postgres canonical raw store schema transaction failed: {error}"
|
||||||
|
)));
|
||||||
|
},
|
||||||
|
};
|
||||||
|
let lock_result = sqlx::query("SELECT pg_advisory_xact_lock($1)")
|
||||||
|
.bind(crate::STORE_SCHEMA_ADVISORY_LOCK_ID)
|
||||||
|
.execute(&mut *transaction)
|
||||||
|
.await;
|
||||||
|
if let std::result::Result::Err(error) = lock_result {
|
||||||
|
return std::result::Result::Err(kb_core::Error::db(format!(
|
||||||
|
"postgres canonical raw store schema advisory lock failed: {error}"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
for statement in crate::raw_store_schema_statements() {
|
||||||
|
let execution_result = sqlx::query(statement).execute(&mut *transaction).await;
|
||||||
|
if let std::result::Result::Err(error) = execution_result {
|
||||||
|
return std::result::Result::Err(kb_core::Error::db(format!(
|
||||||
|
"postgres canonical raw store schema statement failed: {error}"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let commit_result = transaction.commit().await;
|
||||||
|
if let std::result::Result::Err(error) = commit_result {
|
||||||
|
return std::result::Result::Err(kb_core::Error::db(format!(
|
||||||
|
"postgres canonical raw store schema commit failed: {error}"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
return std::result::Result::Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(in crate::postgres) async fn has_raw_transaction_signature(
|
||||||
|
pool: &sqlx::PgPool,
|
||||||
|
signature: &str,
|
||||||
|
) -> kb_core::Result<bool> {
|
||||||
|
let validation_result = crate::postgres::query::raw_queries::validate_required_text(
|
||||||
|
signature,
|
||||||
|
"canonical raw transaction signature must not be empty",
|
||||||
|
);
|
||||||
|
if let std::result::Result::Err(error) = validation_result {
|
||||||
|
return std::result::Result::Err(error);
|
||||||
|
}
|
||||||
|
let query_result = sqlx::query_scalar::<sqlx::Postgres, bool>(
|
||||||
|
"SELECT EXISTS(SELECT 1 FROM kb_sol_raw_transactions WHERE signature = $1)",
|
||||||
|
)
|
||||||
|
.bind(signature)
|
||||||
|
.fetch_one(pool)
|
||||||
|
.await;
|
||||||
|
return match query_result {
|
||||||
|
std::result::Result::Ok(value) => std::result::Result::Ok(value),
|
||||||
|
std::result::Result::Err(error) => std::result::Result::Err(kb_core::Error::db(format!(
|
||||||
|
"postgres canonical transaction signature lookup failed: {error}"
|
||||||
|
))),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(in crate::postgres) async fn has_transaction_observation_key(
|
||||||
|
pool: &sqlx::PgPool,
|
||||||
|
observation_key: &str,
|
||||||
|
) -> kb_core::Result<bool> {
|
||||||
|
let validation_result = crate::postgres::query::raw_queries::validate_required_text(
|
||||||
|
observation_key,
|
||||||
|
"transaction observation key must not be empty",
|
||||||
|
);
|
||||||
|
if let std::result::Result::Err(error) = validation_result {
|
||||||
|
return std::result::Result::Err(error);
|
||||||
|
}
|
||||||
|
let query_result = sqlx::query_scalar::<sqlx::Postgres, bool>(
|
||||||
|
"SELECT EXISTS(SELECT 1 FROM kb_sol_obs_transaction_observations WHERE observation_key = $1)",
|
||||||
|
)
|
||||||
|
.bind(observation_key)
|
||||||
|
.fetch_one(pool)
|
||||||
|
.await;
|
||||||
|
return match query_result {
|
||||||
|
std::result::Result::Ok(value) => std::result::Result::Ok(value),
|
||||||
|
std::result::Result::Err(error) => std::result::Result::Err(kb_core::Error::db(format!(
|
||||||
|
"postgres transaction observation lookup failed: {error}"
|
||||||
|
))),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(in crate::postgres) async fn insert_raw_transaction(
|
||||||
|
pool: &sqlx::PgPool,
|
||||||
|
input: &crate::RawTransactionInsert,
|
||||||
|
) -> kb_core::Result<crate::InsertOutcome> {
|
||||||
|
let slot_result = crate::postgres::query::raw_queries::sql_slot_from_u64(input.slot);
|
||||||
|
let slot = match slot_result {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
let version_result = i32::try_from(input.canonical_format_version);
|
||||||
|
let canonical_format_version = match version_result {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => {
|
||||||
|
return std::result::Result::Err(kb_core::Error::db(format!(
|
||||||
|
"canonical transaction format version does not fit into SQL INTEGER: {error}"
|
||||||
|
)));
|
||||||
|
},
|
||||||
|
};
|
||||||
|
let query_result = sqlx::query_scalar::<sqlx::Postgres, i64>(
|
||||||
|
"INSERT INTO kb_sol_raw_transactions (signature, slot, canonical_json, canonical_json_hash, canonical_format_version, retention_state, processing_state) VALUES ($1, $2, $3, $4, $5, 'full', 'received') ON CONFLICT (signature) DO NOTHING RETURNING id",
|
||||||
|
)
|
||||||
|
.bind(input.signature.as_str())
|
||||||
|
.bind(slot)
|
||||||
|
.bind(&input.canonical_json)
|
||||||
|
.bind(input.canonical_json_hash.as_deref())
|
||||||
|
.bind(canonical_format_version)
|
||||||
|
.fetch_optional(pool)
|
||||||
|
.await;
|
||||||
|
return match query_result {
|
||||||
|
std::result::Result::Ok(std::option::Option::Some(_id)) => {
|
||||||
|
std::result::Result::Ok(crate::InsertOutcome::new(1, 0, 0))
|
||||||
|
},
|
||||||
|
std::result::Result::Ok(std::option::Option::None) => {
|
||||||
|
std::result::Result::Ok(crate::InsertOutcome::new(0, 0, 1))
|
||||||
|
},
|
||||||
|
std::result::Result::Err(error) => std::result::Result::Err(kb_core::Error::db(format!(
|
||||||
|
"postgres canonical transaction insert failed: {error}"
|
||||||
|
))),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(in crate::postgres) async fn insert_transaction_observation(
|
||||||
|
pool: &sqlx::PgPool,
|
||||||
|
input: &crate::TransactionObservationInsert,
|
||||||
|
) -> kb_core::Result<crate::InsertOutcome> {
|
||||||
|
let slot_result = crate::postgres::query::raw_queries::optional_sql_slot_from_u64(input.slot);
|
||||||
|
let slot = match slot_result {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
let payload_size_result =
|
||||||
|
crate::postgres::query::raw_queries::optional_sql_bigint_from_u64(input.payload_size_bytes);
|
||||||
|
let payload_size_bytes = match payload_size_result {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
let origin =
|
||||||
|
crate::postgres::query::raw_queries::transaction_observation_origin_to_sql(input.origin);
|
||||||
|
let status =
|
||||||
|
crate::postgres::query::raw_queries::transaction_observation_status_to_sql(input.status);
|
||||||
|
let query_result = sqlx::query_scalar::<sqlx::Postgres, i64>(
|
||||||
|
"INSERT INTO kb_sol_obs_transaction_observations (raw_transaction_id, observation_key, signature, slot, provider, endpoint_code, protocol, acquisition_method, origin, commitment, capture_session_id, filter_code, detected_at, received_at, normalized_at, payload_size_bytes, source_payload_hash, status, error_code, error_message) VALUES (COALESCE($1, (SELECT id FROM kb_sol_raw_transactions WHERE signature = $3 LIMIT 1)), $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20) ON CONFLICT (observation_key) DO NOTHING RETURNING id",
|
||||||
|
)
|
||||||
|
.bind(input.raw_transaction_id)
|
||||||
|
.bind(input.observation_key.as_str())
|
||||||
|
.bind(input.signature.as_deref())
|
||||||
|
.bind(slot)
|
||||||
|
.bind(input.provider.as_str())
|
||||||
|
.bind(input.endpoint_code.as_deref())
|
||||||
|
.bind(input.protocol.as_str())
|
||||||
|
.bind(input.acquisition_method.as_str())
|
||||||
|
.bind(origin)
|
||||||
|
.bind(input.commitment.as_deref())
|
||||||
|
.bind(input.capture_session_id.as_deref())
|
||||||
|
.bind(input.filter_code.as_deref())
|
||||||
|
.bind(input.detected_at.as_ref())
|
||||||
|
.bind(input.received_at)
|
||||||
|
.bind(input.normalized_at.as_ref())
|
||||||
|
.bind(payload_size_bytes)
|
||||||
|
.bind(input.source_payload_hash.as_deref())
|
||||||
|
.bind(status)
|
||||||
|
.bind(input.error_code.as_deref())
|
||||||
|
.bind(input.error_message.as_deref())
|
||||||
|
.fetch_optional(pool)
|
||||||
|
.await;
|
||||||
|
return match query_result {
|
||||||
|
std::result::Result::Ok(std::option::Option::Some(_id)) => {
|
||||||
|
std::result::Result::Ok(crate::InsertOutcome::new(1, 0, 0))
|
||||||
|
},
|
||||||
|
std::result::Result::Ok(std::option::Option::None) => {
|
||||||
|
std::result::Result::Ok(crate::InsertOutcome::new(0, 0, 1))
|
||||||
|
},
|
||||||
|
std::result::Result::Err(error) => std::result::Result::Err(kb_core::Error::db(format!(
|
||||||
|
"postgres transaction observation insert failed: {error}"
|
||||||
|
))),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(in crate::postgres) async fn update_raw_payload_lifecycle(
|
||||||
|
pool: &sqlx::PgPool,
|
||||||
|
mark: &crate::RawPayloadLifecycleMark,
|
||||||
|
) -> kb_core::Result<crate::InsertOutcome> {
|
||||||
|
if mark.raw_table_name != crate::RAW_TRANSACTIONS_TABLE_NAME {
|
||||||
|
return std::result::Result::Err(kb_core::Error::db(
|
||||||
|
"raw lifecycle table name is not supported by the PostgreSQL canonical raw store",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
return crate::postgres::query::raw_queries::update_raw_transaction_lifecycle(pool, mark).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
fn raw_retention_state_to_sql(state: crate::RawPayloadRetentionState) -> &'static str {
|
||||||
|
return match state {
|
||||||
|
crate::RawPayloadRetentionState::Full => "full",
|
||||||
|
crate::RawPayloadRetentionState::Compacted => "compacted",
|
||||||
|
crate::RawPayloadRetentionState::Archived => "archived",
|
||||||
|
crate::RawPayloadRetentionState::Purged => "purged",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
fn raw_processing_state_to_sql(state: crate::RawPayloadProcessingState) -> &'static str {
|
||||||
|
return match state {
|
||||||
|
crate::RawPayloadProcessingState::Received => "received",
|
||||||
|
crate::RawPayloadProcessingState::CoreExtracted => "core_extracted",
|
||||||
|
crate::RawPayloadProcessingState::Decoded => "decoded",
|
||||||
|
crate::RawPayloadProcessingState::Materialized => "materialized",
|
||||||
|
crate::RawPayloadProcessingState::Failed => "failed",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
fn transaction_observation_origin_to_sql(
|
||||||
|
origin: crate::TransactionObservationOrigin,
|
||||||
|
) -> &'static str {
|
||||||
|
return match origin {
|
||||||
|
crate::TransactionObservationOrigin::Live => "live",
|
||||||
|
crate::TransactionObservationOrigin::Backfill => "backfill",
|
||||||
|
crate::TransactionObservationOrigin::Replay => "replay",
|
||||||
|
crate::TransactionObservationOrigin::Repair => "repair",
|
||||||
|
crate::TransactionObservationOrigin::Migration => "migration",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
fn transaction_observation_status_to_sql(
|
||||||
|
status: crate::TransactionObservationStatus,
|
||||||
|
) -> &'static str {
|
||||||
|
return match status {
|
||||||
|
crate::TransactionObservationStatus::Detected => "detected",
|
||||||
|
crate::TransactionObservationStatus::Received => "received",
|
||||||
|
crate::TransactionObservationStatus::Normalized => "normalized",
|
||||||
|
crate::TransactionObservationStatus::Persisted => "persisted",
|
||||||
|
crate::TransactionObservationStatus::Failed => "failed",
|
||||||
|
crate::TransactionObservationStatus::Missing => "missing",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
fn sql_slot_from_u64(slot: u64) -> kb_core::Result<i64> {
|
||||||
|
let conversion_result = i64::try_from(slot);
|
||||||
|
return match conversion_result {
|
||||||
|
std::result::Result::Ok(value) => std::result::Result::Ok(value),
|
||||||
|
std::result::Result::Err(error) => std::result::Result::Err(kb_core::Error::db(format!(
|
||||||
|
"Solana slot does not fit into PostgreSQL BIGINT: {error}"
|
||||||
|
))),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
fn optional_sql_slot_from_u64(
|
||||||
|
slot: std::option::Option<u64>,
|
||||||
|
) -> kb_core::Result<std::option::Option<i64>> {
|
||||||
|
return crate::postgres::query::raw_queries::optional_sql_bigint_from_u64(slot);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn optional_sql_bigint_from_u64(
|
||||||
|
value: std::option::Option<u64>,
|
||||||
|
) -> kb_core::Result<std::option::Option<i64>> {
|
||||||
|
return match value {
|
||||||
|
std::option::Option::Some(raw_value) => {
|
||||||
|
let conversion_result = i64::try_from(raw_value);
|
||||||
|
match conversion_result {
|
||||||
|
std::result::Result::Ok(converted) => {
|
||||||
|
std::result::Result::Ok(std::option::Option::Some(converted))
|
||||||
|
},
|
||||||
|
std::result::Result::Err(error) => std::result::Result::Err(kb_core::Error::db(
|
||||||
|
format!("unsigned value does not fit into PostgreSQL BIGINT: {error}"),
|
||||||
|
)),
|
||||||
|
}
|
||||||
|
},
|
||||||
|
std::option::Option::None => std::result::Result::Ok(std::option::Option::None),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
fn validate_required_text(value: &str, message: &str) -> kb_core::Result<()> {
|
||||||
|
if value.trim().is_empty() {
|
||||||
|
return std::result::Result::Err(kb_core::Error::db(message));
|
||||||
|
}
|
||||||
|
return std::result::Result::Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn update_raw_transaction_lifecycle(
|
||||||
|
pool: &sqlx::PgPool,
|
||||||
|
mark: &crate::RawPayloadLifecycleMark,
|
||||||
|
) -> kb_core::Result<crate::InsertOutcome> {
|
||||||
|
let retention_state =
|
||||||
|
crate::postgres::query::raw_queries::raw_retention_state_to_sql(mark.retention_state);
|
||||||
|
let processing_state =
|
||||||
|
crate::postgres::query::raw_queries::raw_processing_state_to_sql(mark.processing_state);
|
||||||
|
let query_result = sqlx::query(
|
||||||
|
"UPDATE kb_sol_raw_transactions SET retention_state = $1, processing_state = $2, lifecycle_reason = $3, updated_at = NOW() WHERE signature = $4",
|
||||||
|
)
|
||||||
|
.bind(retention_state)
|
||||||
|
.bind(processing_state)
|
||||||
|
.bind(mark.reason.as_deref())
|
||||||
|
.bind(mark.raw_row_key.as_str())
|
||||||
|
.execute(pool)
|
||||||
|
.await;
|
||||||
|
return crate::postgres::query::raw_queries::outcome_from_update_result(
|
||||||
|
query_result,
|
||||||
|
"postgres canonical raw transaction lifecycle update failed",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn outcome_from_update_result(
|
||||||
|
query_result: std::result::Result<sqlx::postgres::PgQueryResult, sqlx::Error>,
|
||||||
|
error_prefix: &str,
|
||||||
|
) -> kb_core::Result<crate::InsertOutcome> {
|
||||||
|
return match query_result {
|
||||||
|
std::result::Result::Ok(result) => {
|
||||||
|
let rows_affected = result.rows_affected();
|
||||||
|
if rows_affected == 0 {
|
||||||
|
return std::result::Result::Ok(crate::InsertOutcome::new(0, 0, 1));
|
||||||
|
}
|
||||||
|
std::result::Result::Ok(crate::InsertOutcome::new(0, rows_affected, 0))
|
||||||
|
},
|
||||||
|
std::result::Result::Err(error) => {
|
||||||
|
std::result::Result::Err(kb_core::Error::db(format!("{error_prefix}: {error}")))
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
#[test]
|
||||||
|
fn retention_state_serializes_to_lower_snake_case() {
|
||||||
|
let value = crate::postgres::query::raw_queries::raw_retention_state_to_sql(
|
||||||
|
crate::RawPayloadRetentionState::Compacted,
|
||||||
|
);
|
||||||
|
assert_eq!(value, "compacted");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn processing_state_serializes_to_lower_snake_case() {
|
||||||
|
let value = crate::postgres::query::raw_queries::raw_processing_state_to_sql(
|
||||||
|
crate::RawPayloadProcessingState::CoreExtracted,
|
||||||
|
);
|
||||||
|
assert_eq!(value, "core_extracted");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn observation_origin_serializes_to_lower_snake_case() {
|
||||||
|
let value = crate::postgres::query::raw_queries::transaction_observation_origin_to_sql(
|
||||||
|
crate::TransactionObservationOrigin::Backfill,
|
||||||
|
);
|
||||||
|
assert_eq!(value, "backfill");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn observation_status_serializes_to_lower_snake_case() {
|
||||||
|
let value = crate::postgres::query::raw_queries::transaction_observation_status_to_sql(
|
||||||
|
crate::TransactionObservationStatus::Normalized,
|
||||||
|
);
|
||||||
|
assert_eq!(value, "normalized");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn sql_slot_rejects_values_above_bigint() {
|
||||||
|
let result = crate::postgres::query::raw_queries::sql_slot_from_u64(u64::MAX);
|
||||||
|
assert!(result.is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn optional_postgres_canonical_store_roundtrip_from_env() {
|
||||||
|
let database_url = match std::env::var("KB_POSTGRES_TEST_URL") {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(_error) => return,
|
||||||
|
};
|
||||||
|
let _postgres_guard = crate::postgres::test_serial::postgres_test_guard().await;
|
||||||
|
let options_result = crate::PostgresStoreOptions::new(database_url, 1, 5000, false);
|
||||||
|
let options = match options_result {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => panic!("unexpected options error: {error}"),
|
||||||
|
};
|
||||||
|
let store_result = crate::PostgresStore::connect(options).await;
|
||||||
|
let store = match store_result {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => panic!("unexpected connect error: {error}"),
|
||||||
|
};
|
||||||
|
let schema_result = store.initialize_raw_store_schema().await;
|
||||||
|
if let std::result::Result::Err(error) = schema_result {
|
||||||
|
panic!("unexpected schema error: {error}");
|
||||||
|
}
|
||||||
|
let signature = test_signature();
|
||||||
|
let raw_input_result = crate::RawTransactionInsert::new(
|
||||||
|
signature.clone(),
|
||||||
|
1,
|
||||||
|
serde_json::json!({"source": "test"}),
|
||||||
|
1,
|
||||||
|
);
|
||||||
|
let raw_input = match raw_input_result {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => panic!("unexpected raw input error: {error}"),
|
||||||
|
};
|
||||||
|
let first_result =
|
||||||
|
crate::RawTransactionStore::insert_raw_transaction(&store, &raw_input).await;
|
||||||
|
let first_outcome = match first_result {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => panic!("unexpected first insert error: {error}"),
|
||||||
|
};
|
||||||
|
assert_eq!(first_outcome.inserted_count, 1);
|
||||||
|
let duplicate_result =
|
||||||
|
crate::RawTransactionStore::insert_raw_transaction(&store, &raw_input).await;
|
||||||
|
let duplicate_outcome = match duplicate_result {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => panic!("unexpected duplicate insert error: {error}"),
|
||||||
|
};
|
||||||
|
assert_eq!(duplicate_outcome.skipped_count, 1);
|
||||||
|
let has_result = crate::RawTransactionStore::has_raw_transaction_signature(
|
||||||
|
&store,
|
||||||
|
&kb_lib::Signature(signature.clone()),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
let has_signature = match has_result {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => panic!("unexpected signature lookup error: {error}"),
|
||||||
|
};
|
||||||
|
assert!(has_signature);
|
||||||
|
let observation_key = std::string::String::from("test:http:") + signature.as_str();
|
||||||
|
let observation_result = crate::TransactionObservationInsert::new(
|
||||||
|
observation_key.clone(),
|
||||||
|
"test_provider",
|
||||||
|
"solana_http",
|
||||||
|
"getTransaction",
|
||||||
|
crate::TransactionObservationOrigin::Backfill,
|
||||||
|
chrono::Utc::now(),
|
||||||
|
);
|
||||||
|
let observation = match observation_result {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => panic!("unexpected observation error: {error}"),
|
||||||
|
};
|
||||||
|
let identity_result =
|
||||||
|
observation.with_transaction_identity(signature.clone(), std::option::Option::Some(1));
|
||||||
|
let observation_with_identity = match identity_result {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => panic!("unexpected identity error: {error}"),
|
||||||
|
};
|
||||||
|
let observation_insert_result = crate::RawTransactionStore::insert_transaction_observation(
|
||||||
|
&store,
|
||||||
|
&observation_with_identity,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
let observation_outcome = match observation_insert_result {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => {
|
||||||
|
panic!("unexpected observation insert error: {error}")
|
||||||
|
},
|
||||||
|
};
|
||||||
|
assert_eq!(observation_outcome.inserted_count, 1);
|
||||||
|
let observation_lookup_result =
|
||||||
|
crate::RawTransactionStore::has_transaction_observation_key(
|
||||||
|
&store,
|
||||||
|
observation_key.as_str(),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
let has_observation = match observation_lookup_result {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => {
|
||||||
|
panic!("unexpected observation lookup error: {error}")
|
||||||
|
},
|
||||||
|
};
|
||||||
|
assert!(has_observation);
|
||||||
|
let mark_result = crate::RawPayloadLifecycleMark::new(
|
||||||
|
crate::RAW_TRANSACTIONS_TABLE_NAME,
|
||||||
|
signature,
|
||||||
|
crate::RawPayloadRetentionState::Full,
|
||||||
|
crate::RawPayloadProcessingState::CoreExtracted,
|
||||||
|
std::option::Option::Some(std::string::String::from("test extraction")),
|
||||||
|
);
|
||||||
|
let mark = match mark_result {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => panic!("unexpected mark error: {error}"),
|
||||||
|
};
|
||||||
|
let mark_outcome_result =
|
||||||
|
crate::RawTransactionStore::mark_raw_payload_lifecycle(&store, &mark).await;
|
||||||
|
let mark_outcome = match mark_outcome_result {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => panic!("unexpected lifecycle update error: {error}"),
|
||||||
|
};
|
||||||
|
assert_eq!(mark_outcome.updated_count, 1);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
fn test_signature() -> std::string::String {
|
||||||
|
let now_result = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH);
|
||||||
|
let duration = match now_result {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(_error) => {
|
||||||
|
return std::string::String::from("test_signature_fallback");
|
||||||
|
},
|
||||||
|
};
|
||||||
|
return format!("test_signature_{}", duration.as_nanos());
|
||||||
|
}
|
||||||
|
}
|
||||||
474
kb-store/src/postgres/query/replay_candidate_queries.rs
Normal file
474
kb-store/src/postgres/query/replay_candidate_queries.rs
Normal file
@@ -0,0 +1,474 @@
|
|||||||
|
// file: kb-store/src/postgres/query/replay_candidate_queries.rs
|
||||||
|
// version: 2
|
||||||
|
|
||||||
|
//! Read-only PostgreSQL queries for replay candidate discovery.
|
||||||
|
|
||||||
|
#[derive(sqlx::FromRow)]
|
||||||
|
struct ReplayTransactionCandidateRow {
|
||||||
|
signature: std::string::String,
|
||||||
|
slot: i64,
|
||||||
|
raw_processing_state: std::string::String,
|
||||||
|
retention_state: std::string::String,
|
||||||
|
has_core_transaction: bool,
|
||||||
|
transaction_failed: std::option::Option<bool>,
|
||||||
|
ledger_status: std::string::String,
|
||||||
|
processor_version: std::option::Option<std::string::String>,
|
||||||
|
attempt_count: i32,
|
||||||
|
outer_instruction_count: i64,
|
||||||
|
inner_instruction_count: i64,
|
||||||
|
outer_program_count: i64,
|
||||||
|
inner_program_count: i64,
|
||||||
|
updated_at: std::string::String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(sqlx::FromRow)]
|
||||||
|
struct ReplayProgramSummaryRow {
|
||||||
|
program_id: std::string::String,
|
||||||
|
transaction_count: i64,
|
||||||
|
outer_instruction_count: i64,
|
||||||
|
inner_instruction_count: i64,
|
||||||
|
log_count: i64,
|
||||||
|
min_slot: i64,
|
||||||
|
max_slot: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(sqlx::FromRow)]
|
||||||
|
struct ReplayEntitySummaryRow {
|
||||||
|
entity_kind: std::string::String,
|
||||||
|
entity_value: std::string::String,
|
||||||
|
transaction_count: i64,
|
||||||
|
occurrence_count: i64,
|
||||||
|
min_slot: i64,
|
||||||
|
max_slot: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(in crate::postgres) async fn list_replay_transaction_candidates(
|
||||||
|
pool: &sqlx::PgPool,
|
||||||
|
filter: &crate::PostgresReplayTransactionFilter,
|
||||||
|
) -> kb_core::Result<std::vec::Vec<crate::PostgresReplayTransactionCandidate>> {
|
||||||
|
let min_slot_result =
|
||||||
|
crate::postgres::query::replay_candidate_queries::optional_sql_bigint(filter.min_slot);
|
||||||
|
let min_slot = match min_slot_result {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
let max_slot_result =
|
||||||
|
crate::postgres::query::replay_candidate_queries::optional_sql_bigint(filter.max_slot);
|
||||||
|
let max_slot = match max_slot_result {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
let entity_kind = match filter.entity_kind {
|
||||||
|
std::option::Option::Some(value) => std::option::Option::Some(value.as_sql()),
|
||||||
|
std::option::Option::None => std::option::Option::None,
|
||||||
|
};
|
||||||
|
let sql = if filter.newest_first {
|
||||||
|
crate::postgres::query::replay_candidate_queries::transaction_candidate_sql_desc()
|
||||||
|
} else {
|
||||||
|
crate::postgres::query::replay_candidate_queries::transaction_candidate_sql_asc()
|
||||||
|
};
|
||||||
|
let query_result = sqlx::query_as::<
|
||||||
|
sqlx::Postgres,
|
||||||
|
crate::postgres::query::replay_candidate_queries::ReplayTransactionCandidateRow,
|
||||||
|
>(sql)
|
||||||
|
.bind(filter.signature_contains.as_deref())
|
||||||
|
.bind(min_slot)
|
||||||
|
.bind(max_slot)
|
||||||
|
.bind(filter.raw_processing_state.as_deref())
|
||||||
|
.bind(filter.ledger_status.as_deref())
|
||||||
|
.bind(filter.program_id.as_deref())
|
||||||
|
.bind(filter.program_scope.as_sql())
|
||||||
|
.bind(entity_kind)
|
||||||
|
.bind(filter.entity_value.as_deref())
|
||||||
|
.bind(i64::from(filter.limit))
|
||||||
|
.fetch_all(pool)
|
||||||
|
.await;
|
||||||
|
let rows = match query_result {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => {
|
||||||
|
return std::result::Result::Err(kb_core::Error::db(format!(
|
||||||
|
"postgres replay transaction candidate query failed: {error}"
|
||||||
|
)));
|
||||||
|
},
|
||||||
|
};
|
||||||
|
let mut output = std::vec::Vec::with_capacity(rows.len());
|
||||||
|
for row in rows {
|
||||||
|
output.push(crate::PostgresReplayTransactionCandidate {
|
||||||
|
signature: row.signature,
|
||||||
|
slot: row.slot,
|
||||||
|
raw_processing_state: row.raw_processing_state,
|
||||||
|
retention_state: row.retention_state,
|
||||||
|
has_core_transaction: row.has_core_transaction,
|
||||||
|
transaction_failed: row.transaction_failed,
|
||||||
|
ledger_status: row.ledger_status,
|
||||||
|
processor_version: row.processor_version,
|
||||||
|
attempt_count: row.attempt_count,
|
||||||
|
outer_instruction_count: row.outer_instruction_count,
|
||||||
|
inner_instruction_count: row.inner_instruction_count,
|
||||||
|
outer_program_count: row.outer_program_count,
|
||||||
|
inner_program_count: row.inner_program_count,
|
||||||
|
updated_at: row.updated_at,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return std::result::Result::Ok(output);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(in crate::postgres) async fn list_replay_program_summaries(
|
||||||
|
pool: &sqlx::PgPool,
|
||||||
|
filter: &crate::PostgresReplayProgramFilter,
|
||||||
|
) -> kb_core::Result<std::vec::Vec<crate::PostgresReplayProgramSummary>> {
|
||||||
|
let query_result = sqlx::query_as::<sqlx::Postgres, crate::postgres::query::replay_candidate_queries::ReplayProgramSummaryRow>(
|
||||||
|
r#"WITH occurrences AS (
|
||||||
|
SELECT program_id, signature, slot, 'outer'::TEXT AS scope FROM kb_sol_core_instructions
|
||||||
|
UNION ALL
|
||||||
|
SELECT program_id, signature, slot, 'inner'::TEXT AS scope FROM kb_sol_core_inner_instructions
|
||||||
|
UNION ALL
|
||||||
|
SELECT program_id, signature, slot, 'logs'::TEXT AS scope FROM kb_sol_core_logs WHERE program_id IS NOT NULL
|
||||||
|
)
|
||||||
|
SELECT program_id,
|
||||||
|
COUNT(DISTINCT signature)::BIGINT AS transaction_count,
|
||||||
|
COUNT(*) FILTER (WHERE scope = 'outer')::BIGINT AS outer_instruction_count,
|
||||||
|
COUNT(*) FILTER (WHERE scope = 'inner')::BIGINT AS inner_instruction_count,
|
||||||
|
COUNT(*) FILTER (WHERE scope = 'logs')::BIGINT AS log_count,
|
||||||
|
MIN(slot)::BIGINT AS min_slot,
|
||||||
|
MAX(slot)::BIGINT AS max_slot
|
||||||
|
FROM occurrences
|
||||||
|
WHERE ($1::TEXT IS NULL OR program_id ILIKE '%' || $1 || '%')
|
||||||
|
GROUP BY program_id
|
||||||
|
ORDER BY transaction_count DESC, program_id ASC
|
||||||
|
LIMIT $2"#,
|
||||||
|
)
|
||||||
|
.bind(filter.program_id_contains.as_deref())
|
||||||
|
.bind(i64::from(filter.limit))
|
||||||
|
.fetch_all(pool)
|
||||||
|
.await;
|
||||||
|
let rows = match query_result {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => {
|
||||||
|
return std::result::Result::Err(kb_core::Error::db(format!(
|
||||||
|
"postgres replay program summary query failed: {error}"
|
||||||
|
)));
|
||||||
|
},
|
||||||
|
};
|
||||||
|
let mut output = std::vec::Vec::with_capacity(rows.len());
|
||||||
|
for row in rows {
|
||||||
|
output.push(crate::PostgresReplayProgramSummary {
|
||||||
|
program_id: row.program_id,
|
||||||
|
transaction_count: row.transaction_count,
|
||||||
|
outer_instruction_count: row.outer_instruction_count,
|
||||||
|
inner_instruction_count: row.inner_instruction_count,
|
||||||
|
log_count: row.log_count,
|
||||||
|
min_slot: row.min_slot,
|
||||||
|
max_slot: row.max_slot,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return std::result::Result::Ok(output);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(in crate::postgres) async fn list_replay_entity_summaries(
|
||||||
|
pool: &sqlx::PgPool,
|
||||||
|
filter: &crate::PostgresReplayEntityFilter,
|
||||||
|
) -> kb_core::Result<std::vec::Vec<crate::PostgresReplayEntitySummary>> {
|
||||||
|
let entity_kind = filter.entity_kind.as_sql();
|
||||||
|
let query_result = sqlx::query_as::<
|
||||||
|
sqlx::Postgres,
|
||||||
|
crate::postgres::query::replay_candidate_queries::ReplayEntitySummaryRow,
|
||||||
|
>(
|
||||||
|
r#"WITH entities AS (
|
||||||
|
SELECT 'mint'::TEXT AS entity_kind, mint AS entity_value, signature, slot
|
||||||
|
FROM kb_sol_core_balance_changes
|
||||||
|
WHERE mint IS NOT NULL
|
||||||
|
UNION ALL
|
||||||
|
SELECT 'owner'::TEXT AS entity_kind, owner AS entity_value, signature, slot
|
||||||
|
FROM kb_sol_core_balance_changes
|
||||||
|
WHERE owner IS NOT NULL
|
||||||
|
UNION ALL
|
||||||
|
SELECT 'account_key'::TEXT AS entity_kind, account_key AS entity_value, signature, slot
|
||||||
|
FROM kb_sol_core_account_keys
|
||||||
|
)
|
||||||
|
SELECT entity_kind,
|
||||||
|
entity_value,
|
||||||
|
COUNT(DISTINCT signature)::BIGINT AS transaction_count,
|
||||||
|
COUNT(*)::BIGINT AS occurrence_count,
|
||||||
|
MIN(slot)::BIGINT AS min_slot,
|
||||||
|
MAX(slot)::BIGINT AS max_slot
|
||||||
|
FROM entities
|
||||||
|
WHERE entity_kind = $1
|
||||||
|
AND ($2::TEXT IS NULL OR entity_value ILIKE '%' || $2 || '%')
|
||||||
|
GROUP BY entity_kind, entity_value
|
||||||
|
ORDER BY transaction_count DESC, entity_value ASC
|
||||||
|
LIMIT $3"#,
|
||||||
|
)
|
||||||
|
.bind(entity_kind)
|
||||||
|
.bind(filter.entity_value_contains.as_deref())
|
||||||
|
.bind(i64::from(filter.limit))
|
||||||
|
.fetch_all(pool)
|
||||||
|
.await;
|
||||||
|
let rows = match query_result {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => {
|
||||||
|
return std::result::Result::Err(kb_core::Error::db(format!(
|
||||||
|
"postgres replay entity summary query failed: {error}"
|
||||||
|
)));
|
||||||
|
},
|
||||||
|
};
|
||||||
|
let mut output = std::vec::Vec::with_capacity(rows.len());
|
||||||
|
for row in rows {
|
||||||
|
output.push(crate::PostgresReplayEntitySummary {
|
||||||
|
entity_kind: row.entity_kind,
|
||||||
|
entity_value: row.entity_value,
|
||||||
|
transaction_count: row.transaction_count,
|
||||||
|
occurrence_count: row.occurrence_count,
|
||||||
|
min_slot: row.min_slot,
|
||||||
|
max_slot: row.max_slot,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return std::result::Result::Ok(output);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn transaction_candidate_sql_desc() -> &'static str {
|
||||||
|
return crate::postgres::query::replay_candidate_queries::transaction_candidate_sql("DESC");
|
||||||
|
}
|
||||||
|
|
||||||
|
fn transaction_candidate_sql_asc() -> &'static str {
|
||||||
|
return crate::postgres::query::replay_candidate_queries::transaction_candidate_sql("ASC");
|
||||||
|
}
|
||||||
|
|
||||||
|
fn transaction_candidate_sql(order: &str) -> &'static str {
|
||||||
|
if order == "DESC" {
|
||||||
|
return r#"SELECT raw.signature,
|
||||||
|
raw.slot,
|
||||||
|
raw.processing_state AS raw_processing_state,
|
||||||
|
raw.retention_state,
|
||||||
|
(core.id IS NOT NULL) AS has_core_transaction,
|
||||||
|
core.failed AS transaction_failed,
|
||||||
|
COALESCE(ledger.status, 'not_started') AS ledger_status,
|
||||||
|
ledger.processor_version,
|
||||||
|
COALESCE(ledger.attempt_count, 0)::INTEGER AS attempt_count,
|
||||||
|
COALESCE(outer_stats.instruction_count, 0)::BIGINT AS outer_instruction_count,
|
||||||
|
COALESCE(inner_stats.instruction_count, 0)::BIGINT AS inner_instruction_count,
|
||||||
|
COALESCE(outer_stats.program_count, 0)::BIGINT AS outer_program_count,
|
||||||
|
COALESCE(inner_stats.program_count, 0)::BIGINT AS inner_program_count,
|
||||||
|
raw.updated_at::TEXT AS updated_at
|
||||||
|
FROM kb_sol_raw_transactions raw
|
||||||
|
LEFT JOIN kb_sol_core_transactions core ON core.signature = raw.signature
|
||||||
|
LEFT JOIN LATERAL (
|
||||||
|
SELECT status, processor_version, attempt_count
|
||||||
|
FROM kb_sol_ops_processing_ledger
|
||||||
|
WHERE stage = 'core_extraction'
|
||||||
|
AND processor_name = 'canonical_to_core'
|
||||||
|
AND input_key = raw.signature
|
||||||
|
ORDER BY updated_at DESC, id DESC
|
||||||
|
LIMIT 1
|
||||||
|
) ledger ON TRUE
|
||||||
|
LEFT JOIN LATERAL (
|
||||||
|
SELECT COUNT(*)::BIGINT AS instruction_count, COUNT(DISTINCT program_id)::BIGINT AS program_count
|
||||||
|
FROM kb_sol_core_instructions
|
||||||
|
WHERE signature = raw.signature
|
||||||
|
) outer_stats ON TRUE
|
||||||
|
LEFT JOIN LATERAL (
|
||||||
|
SELECT COUNT(*)::BIGINT AS instruction_count, COUNT(DISTINCT program_id)::BIGINT AS program_count
|
||||||
|
FROM kb_sol_core_inner_instructions
|
||||||
|
WHERE signature = raw.signature
|
||||||
|
) inner_stats ON TRUE
|
||||||
|
WHERE ($1::TEXT IS NULL OR raw.signature ILIKE '%' || $1 || '%')
|
||||||
|
AND ($2::BIGINT IS NULL OR raw.slot >= $2)
|
||||||
|
AND ($3::BIGINT IS NULL OR raw.slot <= $3)
|
||||||
|
AND ($4::TEXT IS NULL OR raw.processing_state = $4)
|
||||||
|
AND ($5::TEXT IS NULL OR ($5 = 'not_started' AND ledger.status IS NULL) OR ledger.status = $5)
|
||||||
|
AND ($6::TEXT IS NULL OR
|
||||||
|
($7 = 'any' AND (
|
||||||
|
EXISTS (SELECT 1 FROM kb_sol_core_instructions candidate_outer WHERE candidate_outer.signature = raw.signature AND candidate_outer.program_id = $6) OR
|
||||||
|
EXISTS (SELECT 1 FROM kb_sol_core_inner_instructions candidate_inner WHERE candidate_inner.signature = raw.signature AND candidate_inner.program_id = $6) OR
|
||||||
|
EXISTS (SELECT 1 FROM kb_sol_core_logs candidate_log WHERE candidate_log.signature = raw.signature AND candidate_log.program_id = $6)
|
||||||
|
)) OR
|
||||||
|
($7 = 'outer' AND EXISTS (SELECT 1 FROM kb_sol_core_instructions candidate_outer WHERE candidate_outer.signature = raw.signature AND candidate_outer.program_id = $6)) OR
|
||||||
|
($7 = 'inner' AND EXISTS (SELECT 1 FROM kb_sol_core_inner_instructions candidate_inner WHERE candidate_inner.signature = raw.signature AND candidate_inner.program_id = $6)) OR
|
||||||
|
($7 = 'logs' AND EXISTS (SELECT 1 FROM kb_sol_core_logs candidate_log WHERE candidate_log.signature = raw.signature AND candidate_log.program_id = $6)))
|
||||||
|
AND ($8::TEXT IS NULL OR
|
||||||
|
($8 = 'mint' AND EXISTS (
|
||||||
|
SELECT 1 FROM kb_sol_core_balance_changes candidate_balance
|
||||||
|
WHERE candidate_balance.signature = raw.signature
|
||||||
|
AND candidate_balance.mint = $9
|
||||||
|
)) OR
|
||||||
|
($8 = 'owner' AND EXISTS (
|
||||||
|
SELECT 1 FROM kb_sol_core_balance_changes candidate_balance
|
||||||
|
WHERE candidate_balance.signature = raw.signature
|
||||||
|
AND candidate_balance.owner = $9
|
||||||
|
)) OR
|
||||||
|
($8 = 'account_key' AND EXISTS (
|
||||||
|
SELECT 1 FROM kb_sol_core_account_keys candidate_account
|
||||||
|
WHERE candidate_account.signature = raw.signature
|
||||||
|
AND candidate_account.account_key = $9
|
||||||
|
)))
|
||||||
|
ORDER BY raw.slot DESC, raw.signature DESC
|
||||||
|
LIMIT $10"#;
|
||||||
|
}
|
||||||
|
return r#"SELECT raw.signature,
|
||||||
|
raw.slot,
|
||||||
|
raw.processing_state AS raw_processing_state,
|
||||||
|
raw.retention_state,
|
||||||
|
(core.id IS NOT NULL) AS has_core_transaction,
|
||||||
|
core.failed AS transaction_failed,
|
||||||
|
COALESCE(ledger.status, 'not_started') AS ledger_status,
|
||||||
|
ledger.processor_version,
|
||||||
|
COALESCE(ledger.attempt_count, 0)::INTEGER AS attempt_count,
|
||||||
|
COALESCE(outer_stats.instruction_count, 0)::BIGINT AS outer_instruction_count,
|
||||||
|
COALESCE(inner_stats.instruction_count, 0)::BIGINT AS inner_instruction_count,
|
||||||
|
COALESCE(outer_stats.program_count, 0)::BIGINT AS outer_program_count,
|
||||||
|
COALESCE(inner_stats.program_count, 0)::BIGINT AS inner_program_count,
|
||||||
|
raw.updated_at::TEXT AS updated_at
|
||||||
|
FROM kb_sol_raw_transactions raw
|
||||||
|
LEFT JOIN kb_sol_core_transactions core ON core.signature = raw.signature
|
||||||
|
LEFT JOIN LATERAL (
|
||||||
|
SELECT status, processor_version, attempt_count
|
||||||
|
FROM kb_sol_ops_processing_ledger
|
||||||
|
WHERE stage = 'core_extraction'
|
||||||
|
AND processor_name = 'canonical_to_core'
|
||||||
|
AND input_key = raw.signature
|
||||||
|
ORDER BY updated_at DESC, id DESC
|
||||||
|
LIMIT 1
|
||||||
|
) ledger ON TRUE
|
||||||
|
LEFT JOIN LATERAL (
|
||||||
|
SELECT COUNT(*)::BIGINT AS instruction_count, COUNT(DISTINCT program_id)::BIGINT AS program_count
|
||||||
|
FROM kb_sol_core_instructions
|
||||||
|
WHERE signature = raw.signature
|
||||||
|
) outer_stats ON TRUE
|
||||||
|
LEFT JOIN LATERAL (
|
||||||
|
SELECT COUNT(*)::BIGINT AS instruction_count, COUNT(DISTINCT program_id)::BIGINT AS program_count
|
||||||
|
FROM kb_sol_core_inner_instructions
|
||||||
|
WHERE signature = raw.signature
|
||||||
|
) inner_stats ON TRUE
|
||||||
|
WHERE ($1::TEXT IS NULL OR raw.signature ILIKE '%' || $1 || '%')
|
||||||
|
AND ($2::BIGINT IS NULL OR raw.slot >= $2)
|
||||||
|
AND ($3::BIGINT IS NULL OR raw.slot <= $3)
|
||||||
|
AND ($4::TEXT IS NULL OR raw.processing_state = $4)
|
||||||
|
AND ($5::TEXT IS NULL OR ($5 = 'not_started' AND ledger.status IS NULL) OR ledger.status = $5)
|
||||||
|
AND ($6::TEXT IS NULL OR
|
||||||
|
($7 = 'any' AND (
|
||||||
|
EXISTS (SELECT 1 FROM kb_sol_core_instructions candidate_outer WHERE candidate_outer.signature = raw.signature AND candidate_outer.program_id = $6) OR
|
||||||
|
EXISTS (SELECT 1 FROM kb_sol_core_inner_instructions candidate_inner WHERE candidate_inner.signature = raw.signature AND candidate_inner.program_id = $6) OR
|
||||||
|
EXISTS (SELECT 1 FROM kb_sol_core_logs candidate_log WHERE candidate_log.signature = raw.signature AND candidate_log.program_id = $6)
|
||||||
|
)) OR
|
||||||
|
($7 = 'outer' AND EXISTS (SELECT 1 FROM kb_sol_core_instructions candidate_outer WHERE candidate_outer.signature = raw.signature AND candidate_outer.program_id = $6)) OR
|
||||||
|
($7 = 'inner' AND EXISTS (SELECT 1 FROM kb_sol_core_inner_instructions candidate_inner WHERE candidate_inner.signature = raw.signature AND candidate_inner.program_id = $6)) OR
|
||||||
|
($7 = 'logs' AND EXISTS (SELECT 1 FROM kb_sol_core_logs candidate_log WHERE candidate_log.signature = raw.signature AND candidate_log.program_id = $6)))
|
||||||
|
AND ($8::TEXT IS NULL OR
|
||||||
|
($8 = 'mint' AND EXISTS (
|
||||||
|
SELECT 1 FROM kb_sol_core_balance_changes candidate_balance
|
||||||
|
WHERE candidate_balance.signature = raw.signature
|
||||||
|
AND candidate_balance.mint = $9
|
||||||
|
)) OR
|
||||||
|
($8 = 'owner' AND EXISTS (
|
||||||
|
SELECT 1 FROM kb_sol_core_balance_changes candidate_balance
|
||||||
|
WHERE candidate_balance.signature = raw.signature
|
||||||
|
AND candidate_balance.owner = $9
|
||||||
|
)) OR
|
||||||
|
($8 = 'account_key' AND EXISTS (
|
||||||
|
SELECT 1 FROM kb_sol_core_account_keys candidate_account
|
||||||
|
WHERE candidate_account.signature = raw.signature
|
||||||
|
AND candidate_account.account_key = $9
|
||||||
|
)))
|
||||||
|
ORDER BY raw.slot ASC, raw.signature ASC
|
||||||
|
LIMIT $10"#;
|
||||||
|
}
|
||||||
|
|
||||||
|
fn optional_sql_bigint(
|
||||||
|
value: std::option::Option<u64>,
|
||||||
|
) -> kb_core::Result<std::option::Option<i64>> {
|
||||||
|
return match value {
|
||||||
|
std::option::Option::Some(raw_value) => {
|
||||||
|
let conversion_result = i64::try_from(raw_value);
|
||||||
|
match conversion_result {
|
||||||
|
std::result::Result::Ok(converted) => {
|
||||||
|
std::result::Result::Ok(std::option::Option::Some(converted))
|
||||||
|
},
|
||||||
|
std::result::Result::Err(error) => std::result::Result::Err(kb_core::Error::db(
|
||||||
|
format!("replay candidate slot does not fit into PostgreSQL BIGINT: {error}"),
|
||||||
|
)),
|
||||||
|
}
|
||||||
|
},
|
||||||
|
std::option::Option::None => std::result::Result::Ok(std::option::Option::None),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
#[tokio::test]
|
||||||
|
async fn optional_postgres_replay_candidate_queries_from_env() {
|
||||||
|
let database_url = match std::env::var("KB_POSTGRES_TEST_URL") {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(_error) => return,
|
||||||
|
};
|
||||||
|
let _postgres_guard = crate::postgres::test_serial::postgres_test_guard().await;
|
||||||
|
let options_result = crate::PostgresStoreOptions::new(database_url, 1, 5000, false);
|
||||||
|
let options = match options_result {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => panic!("unexpected options error: {error}"),
|
||||||
|
};
|
||||||
|
let store_result = crate::PostgresStore::connect(options).await;
|
||||||
|
let store = match store_result {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => panic!("unexpected connect error: {error}"),
|
||||||
|
};
|
||||||
|
let raw_schema_result = store.initialize_raw_store_schema().await;
|
||||||
|
if let std::result::Result::Err(error) = raw_schema_result {
|
||||||
|
panic!("unexpected raw schema error: {error}");
|
||||||
|
}
|
||||||
|
let core_schema_result = store.initialize_core_store_schema().await;
|
||||||
|
if let std::result::Result::Err(error) = core_schema_result {
|
||||||
|
panic!("unexpected core schema error: {error}");
|
||||||
|
}
|
||||||
|
let transaction_filter_result = crate::PostgresReplayTransactionFilter::new(
|
||||||
|
std::option::Option::None,
|
||||||
|
std::option::Option::None,
|
||||||
|
std::option::Option::None,
|
||||||
|
std::option::Option::None,
|
||||||
|
std::option::Option::None,
|
||||||
|
std::option::Option::None,
|
||||||
|
crate::PostgresReplayProgramScope::Any,
|
||||||
|
std::option::Option::None,
|
||||||
|
std::option::Option::None,
|
||||||
|
10,
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
let transaction_filter = match transaction_filter_result {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => {
|
||||||
|
panic!("unexpected transaction filter error: {error}")
|
||||||
|
},
|
||||||
|
};
|
||||||
|
let transaction_result = store.replay_transaction_candidates(&transaction_filter).await;
|
||||||
|
if let std::result::Result::Err(error) = transaction_result {
|
||||||
|
panic!("unexpected transaction candidate query error: {error}");
|
||||||
|
}
|
||||||
|
let program_filter_result =
|
||||||
|
crate::PostgresReplayProgramFilter::new(std::option::Option::None, 10);
|
||||||
|
let program_filter = match program_filter_result {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => panic!("unexpected program filter error: {error}"),
|
||||||
|
};
|
||||||
|
let program_result = store.replay_program_summaries(&program_filter).await;
|
||||||
|
if let std::result::Result::Err(error) = program_result {
|
||||||
|
panic!("unexpected program summary query error: {error}");
|
||||||
|
}
|
||||||
|
for entity_kind in [
|
||||||
|
crate::PostgresReplayEntityKind::Mint,
|
||||||
|
crate::PostgresReplayEntityKind::Owner,
|
||||||
|
crate::PostgresReplayEntityKind::AccountKey,
|
||||||
|
] {
|
||||||
|
let entity_filter_result =
|
||||||
|
crate::PostgresReplayEntityFilter::new(entity_kind, std::option::Option::None, 10);
|
||||||
|
let entity_filter = match entity_filter_result {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => {
|
||||||
|
panic!("unexpected entity filter error: {error}")
|
||||||
|
},
|
||||||
|
};
|
||||||
|
let entity_result = store.replay_entity_summaries(&entity_filter).await;
|
||||||
|
if let std::result::Result::Err(error) = entity_result {
|
||||||
|
panic!("unexpected entity summary query error: {error}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
197
kb-store/src/postgres/query/table_diagnostics_queries.rs
Normal file
197
kb-store/src/postgres/query/table_diagnostics_queries.rs
Normal file
@@ -0,0 +1,197 @@
|
|||||||
|
// file: kb-store/src/postgres/query/table_diagnostics_queries.rs
|
||||||
|
// version: 2
|
||||||
|
|
||||||
|
//! Read-only PostgreSQL diagnostics for known Solana store tables.
|
||||||
|
|
||||||
|
use sqlx::Row; // rust-rules: trait-import
|
||||||
|
|
||||||
|
pub(in crate::postgres) async fn table_exists(
|
||||||
|
pool: &sqlx::PgPool,
|
||||||
|
table_name: &str,
|
||||||
|
) -> kb_core::Result<bool> {
|
||||||
|
let query_result =
|
||||||
|
sqlx::query_scalar::<sqlx::Postgres, bool>("SELECT to_regclass($1)::text IS NOT NULL")
|
||||||
|
.bind(table_name)
|
||||||
|
.fetch_one(pool)
|
||||||
|
.await;
|
||||||
|
return match query_result {
|
||||||
|
std::result::Result::Ok(value) => std::result::Result::Ok(value),
|
||||||
|
std::result::Result::Err(error) => std::result::Result::Err(kb_core::Error::db(format!(
|
||||||
|
"postgres table existence diagnostic failed for '{table_name}': {error}"
|
||||||
|
))),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(in crate::postgres) async fn load_table_statistics(
|
||||||
|
pool: &sqlx::PgPool,
|
||||||
|
table_name: &str,
|
||||||
|
) -> kb_core::Result<crate::PostgresTableStatistics> {
|
||||||
|
return match table_name {
|
||||||
|
crate::RAW_TRANSACTIONS_TABLE_NAME => {
|
||||||
|
crate::postgres::query::table_diagnostics_queries::load_table_statistics_from_sql(
|
||||||
|
pool,
|
||||||
|
crate::postgres::migrations::table_stats_kb_sol_raw_transactions_sql(),
|
||||||
|
table_name,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
},
|
||||||
|
crate::TRANSACTION_OBSERVATIONS_TABLE_NAME => {
|
||||||
|
crate::postgres::query::table_diagnostics_queries::load_table_statistics_from_sql(
|
||||||
|
pool,
|
||||||
|
crate::postgres::migrations::table_stats_kb_sol_obs_transaction_observations_sql(),
|
||||||
|
table_name,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
},
|
||||||
|
crate::CORE_TRANSACTIONS_TABLE_NAME => {
|
||||||
|
crate::postgres::query::table_diagnostics_queries::load_table_statistics_from_sql(
|
||||||
|
pool,
|
||||||
|
crate::postgres::migrations::table_stats_kb_sol_core_transactions_sql(),
|
||||||
|
table_name,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
},
|
||||||
|
crate::CORE_ACCOUNT_KEYS_TABLE_NAME => {
|
||||||
|
crate::postgres::query::table_diagnostics_queries::load_table_statistics_from_sql(
|
||||||
|
pool,
|
||||||
|
crate::postgres::migrations::table_stats_kb_sol_core_account_keys_sql(),
|
||||||
|
table_name,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
},
|
||||||
|
crate::CORE_INSTRUCTIONS_TABLE_NAME => {
|
||||||
|
crate::postgres::query::table_diagnostics_queries::load_table_statistics_from_sql(
|
||||||
|
pool,
|
||||||
|
crate::postgres::migrations::table_stats_kb_sol_core_instructions_sql(),
|
||||||
|
table_name,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
},
|
||||||
|
crate::CORE_INNER_INSTRUCTIONS_TABLE_NAME => {
|
||||||
|
crate::postgres::query::table_diagnostics_queries::load_table_statistics_from_sql(
|
||||||
|
pool,
|
||||||
|
crate::postgres::migrations::table_stats_kb_sol_core_inner_instructions_sql(),
|
||||||
|
table_name,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
},
|
||||||
|
crate::CORE_LOGS_TABLE_NAME => {
|
||||||
|
crate::postgres::query::table_diagnostics_queries::load_table_statistics_from_sql(
|
||||||
|
pool,
|
||||||
|
crate::postgres::migrations::table_stats_kb_sol_core_logs_sql(),
|
||||||
|
table_name,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
},
|
||||||
|
crate::CORE_BALANCE_CHANGES_TABLE_NAME => {
|
||||||
|
crate::postgres::query::table_diagnostics_queries::load_table_statistics_from_sql(
|
||||||
|
pool,
|
||||||
|
crate::postgres::migrations::table_stats_kb_sol_core_balance_changes_sql(),
|
||||||
|
table_name,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
},
|
||||||
|
crate::PROCESSING_LEDGER_TABLE_NAME => {
|
||||||
|
crate::postgres::query::table_diagnostics_queries::load_table_statistics_from_sql(
|
||||||
|
pool,
|
||||||
|
crate::postgres::migrations::table_stats_kb_sol_ops_processing_ledger_sql(),
|
||||||
|
table_name,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
},
|
||||||
|
crate::DECODE_EVENTS_TABLE_NAME => {
|
||||||
|
crate::postgres::query::table_diagnostics_queries::load_table_statistics_from_sql(
|
||||||
|
pool,
|
||||||
|
crate::postgres::migrations::table_stats_kb_sol_decode_events_sql(),
|
||||||
|
table_name,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
},
|
||||||
|
crate::DECODE_COVERAGE_DECLARATIONS_TABLE_NAME => {
|
||||||
|
crate::postgres::query::table_diagnostics_queries::load_table_statistics_from_sql(
|
||||||
|
pool,
|
||||||
|
crate::postgres::migrations::table_stats_kb_sol_decode_coverage_declarations_sql(),
|
||||||
|
table_name,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
},
|
||||||
|
crate::DECODE_COVERAGE_OBSERVATIONS_TABLE_NAME => {
|
||||||
|
crate::postgres::query::table_diagnostics_queries::load_table_statistics_from_sql(
|
||||||
|
pool,
|
||||||
|
crate::postgres::migrations::table_stats_kb_sol_decode_coverage_observations_sql(),
|
||||||
|
table_name,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
},
|
||||||
|
crate::MATERIALIZED_EVENTS_TABLE_NAME => {
|
||||||
|
crate::postgres::query::table_diagnostics_queries::load_table_statistics_from_sql(
|
||||||
|
pool,
|
||||||
|
crate::postgres::migrations::table_stats_kb_sol_mat_events_sql(),
|
||||||
|
table_name,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
},
|
||||||
|
_ => std::result::Result::Err(kb_core::Error::db(format!(
|
||||||
|
"postgres table statistics are not supported for '{table_name}'"
|
||||||
|
))),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn load_table_statistics_from_sql(
|
||||||
|
pool: &sqlx::PgPool,
|
||||||
|
sql: &'static str,
|
||||||
|
table_name: &str,
|
||||||
|
) -> kb_core::Result<crate::PostgresTableStatistics> {
|
||||||
|
let query_result = sqlx::query(sql).fetch_one(pool).await;
|
||||||
|
let row = match query_result {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => {
|
||||||
|
return std::result::Result::Err(kb_core::Error::db(format!(
|
||||||
|
"postgres table statistics failed for '{table_name}': {error}"
|
||||||
|
)));
|
||||||
|
},
|
||||||
|
};
|
||||||
|
let row_count_result = row.try_get::<i64, _>("row_count");
|
||||||
|
let row_count = match row_count_result {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => {
|
||||||
|
return std::result::Result::Err(kb_core::Error::db(format!(
|
||||||
|
"postgres table row_count mapping failed for '{table_name}': {error}"
|
||||||
|
)));
|
||||||
|
},
|
||||||
|
};
|
||||||
|
let min_slot_result = row.try_get::<std::option::Option<i64>, _>("min_slot");
|
||||||
|
let min_slot = match min_slot_result {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => {
|
||||||
|
return std::result::Result::Err(kb_core::Error::db(format!(
|
||||||
|
"postgres table min_slot mapping failed for '{table_name}': {error}"
|
||||||
|
)));
|
||||||
|
},
|
||||||
|
};
|
||||||
|
let max_slot_result = row.try_get::<std::option::Option<i64>, _>("max_slot");
|
||||||
|
let max_slot = match max_slot_result {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => {
|
||||||
|
return std::result::Result::Err(kb_core::Error::db(format!(
|
||||||
|
"postgres table max_slot mapping failed for '{table_name}': {error}"
|
||||||
|
)));
|
||||||
|
},
|
||||||
|
};
|
||||||
|
let latest_created_at_result =
|
||||||
|
row.try_get::<std::option::Option<std::string::String>, _>("latest_created_at");
|
||||||
|
let latest_created_at = match latest_created_at_result {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => {
|
||||||
|
return std::result::Result::Err(kb_core::Error::db(format!(
|
||||||
|
"postgres table latest_created_at mapping failed for '{table_name}': {error}"
|
||||||
|
)));
|
||||||
|
},
|
||||||
|
};
|
||||||
|
return std::result::Result::Ok(crate::PostgresTableStatistics {
|
||||||
|
row_count,
|
||||||
|
min_slot,
|
||||||
|
max_slot,
|
||||||
|
latest_created_at,
|
||||||
|
});
|
||||||
|
}
|
||||||
380
kb-store/src/postgres/replay_candidates.rs
Normal file
380
kb-store/src/postgres/replay_candidates.rs
Normal file
@@ -0,0 +1,380 @@
|
|||||||
|
// file: kb-store/src/postgres/replay_candidates.rs
|
||||||
|
// version: 1
|
||||||
|
|
||||||
|
//! Read-only replay candidate filters and PostgreSQL result rows.
|
||||||
|
|
||||||
|
/// Maximum number of rows returned by one replay candidate query.
|
||||||
|
pub const MAX_REPLAY_CANDIDATE_ROWS: u32 = 5_000;
|
||||||
|
|
||||||
|
/// Program occurrence scope used while filtering replay candidates.
|
||||||
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||||
|
pub enum PostgresReplayProgramScope {
|
||||||
|
/// Match outer instructions, inner instructions or reliably linked logs.
|
||||||
|
Any,
|
||||||
|
/// Match only top-level instructions.
|
||||||
|
Outer,
|
||||||
|
/// Match only inner instructions.
|
||||||
|
Inner,
|
||||||
|
/// Match only logs with a reliably linked program id.
|
||||||
|
Logs,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PostgresReplayProgramScope {
|
||||||
|
/// Returns the stable SQL code for this scope.
|
||||||
|
pub fn as_sql(self) -> &'static str {
|
||||||
|
return match self {
|
||||||
|
Self::Any => "any",
|
||||||
|
Self::Outer => "outer",
|
||||||
|
Self::Inner => "inner",
|
||||||
|
Self::Logs => "logs",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Core entity kind used while filtering replay candidates.
|
||||||
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||||
|
pub enum PostgresReplayEntityKind {
|
||||||
|
/// SPL or Token-2022 mint address.
|
||||||
|
Mint,
|
||||||
|
/// Token account owner address.
|
||||||
|
Owner,
|
||||||
|
/// Native or token account address.
|
||||||
|
AccountKey,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PostgresReplayEntityKind {
|
||||||
|
/// Returns the stable SQL code for this entity kind.
|
||||||
|
pub fn as_sql(self) -> &'static str {
|
||||||
|
return match self {
|
||||||
|
Self::Mint => "mint",
|
||||||
|
Self::Owner => "owner",
|
||||||
|
Self::AccountKey => "account_key",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Bounded read-only filter for transaction replay candidates.
|
||||||
|
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||||
|
pub struct PostgresReplayTransactionFilter {
|
||||||
|
/// Optional partial signature search.
|
||||||
|
pub signature_contains: std::option::Option<std::string::String>,
|
||||||
|
/// Optional inclusive minimum slot.
|
||||||
|
pub min_slot: std::option::Option<u64>,
|
||||||
|
/// Optional inclusive maximum slot.
|
||||||
|
pub max_slot: std::option::Option<u64>,
|
||||||
|
/// Optional raw processing state.
|
||||||
|
pub raw_processing_state: std::option::Option<std::string::String>,
|
||||||
|
/// Optional latest core extraction ledger status, including `not_started`.
|
||||||
|
pub ledger_status: std::option::Option<std::string::String>,
|
||||||
|
/// Optional exact program id.
|
||||||
|
pub program_id: std::option::Option<std::string::String>,
|
||||||
|
/// Program occurrence scope.
|
||||||
|
pub program_scope: crate::PostgresReplayProgramScope,
|
||||||
|
/// Optional core entity kind.
|
||||||
|
pub entity_kind: std::option::Option<crate::PostgresReplayEntityKind>,
|
||||||
|
/// Optional exact entity value.
|
||||||
|
pub entity_value: std::option::Option<std::string::String>,
|
||||||
|
/// Maximum returned rows.
|
||||||
|
pub limit: u32,
|
||||||
|
/// Orders newest slots first when true.
|
||||||
|
pub newest_first: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PostgresReplayTransactionFilter {
|
||||||
|
/// Creates and validates a bounded transaction candidate filter.
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
|
pub fn new(
|
||||||
|
signature_contains: std::option::Option<std::string::String>,
|
||||||
|
min_slot: std::option::Option<u64>,
|
||||||
|
max_slot: std::option::Option<u64>,
|
||||||
|
raw_processing_state: std::option::Option<std::string::String>,
|
||||||
|
ledger_status: std::option::Option<std::string::String>,
|
||||||
|
program_id: std::option::Option<std::string::String>,
|
||||||
|
program_scope: crate::PostgresReplayProgramScope,
|
||||||
|
entity_kind: std::option::Option<crate::PostgresReplayEntityKind>,
|
||||||
|
entity_value: std::option::Option<std::string::String>,
|
||||||
|
limit: u32,
|
||||||
|
newest_first: bool,
|
||||||
|
) -> kb_core::Result<Self> {
|
||||||
|
let signature_contains_value = trim_optional_text(signature_contains);
|
||||||
|
let raw_processing_state_value = trim_optional_text(raw_processing_state);
|
||||||
|
let ledger_status_value = trim_optional_text(ledger_status);
|
||||||
|
let program_id_value = trim_optional_text(program_id);
|
||||||
|
let entity_value_value = trim_optional_text(entity_value);
|
||||||
|
let slot_result = validate_slot_range(min_slot, max_slot);
|
||||||
|
if let std::result::Result::Err(error) = slot_result {
|
||||||
|
return std::result::Result::Err(error);
|
||||||
|
}
|
||||||
|
let limit_result = validate_limit(limit);
|
||||||
|
if let std::result::Result::Err(error) = limit_result {
|
||||||
|
return std::result::Result::Err(error);
|
||||||
|
}
|
||||||
|
let raw_state_result = validate_optional_code(
|
||||||
|
raw_processing_state_value.as_deref(),
|
||||||
|
&["received", "core_extracted", "decoded", "materialized", "failed"],
|
||||||
|
"raw processing state",
|
||||||
|
);
|
||||||
|
if let std::result::Result::Err(error) = raw_state_result {
|
||||||
|
return std::result::Result::Err(error);
|
||||||
|
}
|
||||||
|
let ledger_status_result = validate_optional_code(
|
||||||
|
ledger_status_value.as_deref(),
|
||||||
|
&["not_started", "running", "succeeded", "failed"],
|
||||||
|
"ledger status",
|
||||||
|
);
|
||||||
|
if let std::result::Result::Err(error) = ledger_status_result {
|
||||||
|
return std::result::Result::Err(error);
|
||||||
|
}
|
||||||
|
if entity_kind.is_some() != entity_value_value.is_some() {
|
||||||
|
return std::result::Result::Err(kb_core::Error::db(
|
||||||
|
"replay entity kind and value must either both be present or both be absent",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
return std::result::Result::Ok(Self {
|
||||||
|
signature_contains: signature_contains_value,
|
||||||
|
min_slot,
|
||||||
|
max_slot,
|
||||||
|
raw_processing_state: raw_processing_state_value,
|
||||||
|
ledger_status: ledger_status_value,
|
||||||
|
program_id: program_id_value,
|
||||||
|
program_scope,
|
||||||
|
entity_kind,
|
||||||
|
entity_value: entity_value_value,
|
||||||
|
limit,
|
||||||
|
newest_first,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One raw transaction candidate enriched with core and ledger diagnostics.
|
||||||
|
#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
|
||||||
|
pub struct PostgresReplayTransactionCandidate {
|
||||||
|
/// Canonical transaction signature.
|
||||||
|
pub signature: std::string::String,
|
||||||
|
/// Transaction slot.
|
||||||
|
pub slot: i64,
|
||||||
|
/// Current raw processing state.
|
||||||
|
pub raw_processing_state: std::string::String,
|
||||||
|
/// Current raw retention state.
|
||||||
|
pub retention_state: std::string::String,
|
||||||
|
/// Whether a core transaction row exists.
|
||||||
|
pub has_core_transaction: bool,
|
||||||
|
/// Core transaction failure flag when a core row exists.
|
||||||
|
pub transaction_failed: std::option::Option<bool>,
|
||||||
|
/// Latest core extraction ledger status or `not_started`.
|
||||||
|
pub ledger_status: std::string::String,
|
||||||
|
/// Latest core extraction processor version when available.
|
||||||
|
pub processor_version: std::option::Option<std::string::String>,
|
||||||
|
/// Latest core extraction attempt count.
|
||||||
|
pub attempt_count: i32,
|
||||||
|
/// Number of top-level instructions.
|
||||||
|
pub outer_instruction_count: i64,
|
||||||
|
/// Number of inner instructions.
|
||||||
|
pub inner_instruction_count: i64,
|
||||||
|
/// Number of distinct top-level programs.
|
||||||
|
pub outer_program_count: i64,
|
||||||
|
/// Number of distinct inner programs.
|
||||||
|
pub inner_program_count: i64,
|
||||||
|
/// Raw row update timestamp rendered by PostgreSQL.
|
||||||
|
pub updated_at: std::string::String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Bounded read-only filter for program summaries.
|
||||||
|
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||||
|
pub struct PostgresReplayProgramFilter {
|
||||||
|
/// Optional partial program id search.
|
||||||
|
pub program_id_contains: std::option::Option<std::string::String>,
|
||||||
|
/// Maximum returned rows.
|
||||||
|
pub limit: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PostgresReplayProgramFilter {
|
||||||
|
/// Creates and validates a bounded program summary filter.
|
||||||
|
pub fn new(
|
||||||
|
program_id_contains: std::option::Option<std::string::String>,
|
||||||
|
limit: u32,
|
||||||
|
) -> kb_core::Result<Self> {
|
||||||
|
let limit_result = validate_limit(limit);
|
||||||
|
if let std::result::Result::Err(error) = limit_result {
|
||||||
|
return std::result::Result::Err(error);
|
||||||
|
}
|
||||||
|
return std::result::Result::Ok(Self {
|
||||||
|
program_id_contains: trim_optional_text(program_id_contains),
|
||||||
|
limit,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Aggregated program occurrences across outer, inner and linked logs.
|
||||||
|
#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
|
||||||
|
pub struct PostgresReplayProgramSummary {
|
||||||
|
/// Program id.
|
||||||
|
pub program_id: std::string::String,
|
||||||
|
/// Number of distinct transactions containing the program.
|
||||||
|
pub transaction_count: i64,
|
||||||
|
/// Number of top-level instruction occurrences.
|
||||||
|
pub outer_instruction_count: i64,
|
||||||
|
/// Number of inner instruction occurrences.
|
||||||
|
pub inner_instruction_count: i64,
|
||||||
|
/// Number of reliably linked log occurrences.
|
||||||
|
pub log_count: i64,
|
||||||
|
/// Lowest observed slot.
|
||||||
|
pub min_slot: i64,
|
||||||
|
/// Highest observed slot.
|
||||||
|
pub max_slot: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Bounded read-only filter for core entity summaries.
|
||||||
|
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||||
|
pub struct PostgresReplayEntityFilter {
|
||||||
|
/// Entity kind to aggregate.
|
||||||
|
pub entity_kind: crate::PostgresReplayEntityKind,
|
||||||
|
/// Optional partial entity value search.
|
||||||
|
pub entity_value_contains: std::option::Option<std::string::String>,
|
||||||
|
/// Maximum returned rows.
|
||||||
|
pub limit: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PostgresReplayEntityFilter {
|
||||||
|
/// Creates and validates a bounded entity summary filter.
|
||||||
|
pub fn new(
|
||||||
|
entity_kind: crate::PostgresReplayEntityKind,
|
||||||
|
entity_value_contains: std::option::Option<std::string::String>,
|
||||||
|
limit: u32,
|
||||||
|
) -> kb_core::Result<Self> {
|
||||||
|
let limit_result = validate_limit(limit);
|
||||||
|
if let std::result::Result::Err(error) = limit_result {
|
||||||
|
return std::result::Result::Err(error);
|
||||||
|
}
|
||||||
|
return std::result::Result::Ok(Self {
|
||||||
|
entity_kind,
|
||||||
|
entity_value_contains: trim_optional_text(entity_value_contains),
|
||||||
|
limit,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Aggregated mint, owner or account-key occurrences from core tables.
|
||||||
|
#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
|
||||||
|
pub struct PostgresReplayEntitySummary {
|
||||||
|
/// Stable entity kind code.
|
||||||
|
pub entity_kind: std::string::String,
|
||||||
|
/// Mint, owner or account-key address.
|
||||||
|
pub entity_value: std::string::String,
|
||||||
|
/// Number of distinct transactions containing the entity.
|
||||||
|
pub transaction_count: i64,
|
||||||
|
/// Total number of core-table occurrences.
|
||||||
|
pub occurrence_count: i64,
|
||||||
|
/// Lowest observed slot.
|
||||||
|
pub min_slot: i64,
|
||||||
|
/// Highest observed slot.
|
||||||
|
pub max_slot: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn trim_optional_text(
|
||||||
|
value: std::option::Option<std::string::String>,
|
||||||
|
) -> std::option::Option<std::string::String> {
|
||||||
|
return match value {
|
||||||
|
std::option::Option::Some(text) => {
|
||||||
|
let trimmed = text.trim();
|
||||||
|
if trimmed.is_empty() {
|
||||||
|
return std::option::Option::None;
|
||||||
|
}
|
||||||
|
std::option::Option::Some(trimmed.to_string())
|
||||||
|
},
|
||||||
|
std::option::Option::None => std::option::Option::None,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
fn validate_slot_range(
|
||||||
|
min_slot: std::option::Option<u64>,
|
||||||
|
max_slot: std::option::Option<u64>,
|
||||||
|
) -> kb_core::Result<()> {
|
||||||
|
if let (std::option::Option::Some(minimum), std::option::Option::Some(maximum)) =
|
||||||
|
(min_slot, max_slot)
|
||||||
|
{
|
||||||
|
if minimum > maximum {
|
||||||
|
return std::result::Result::Err(kb_core::Error::db(
|
||||||
|
"replay candidate minimum slot must not exceed maximum slot",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return std::result::Result::Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
fn validate_limit(limit: u32) -> kb_core::Result<()> {
|
||||||
|
if limit == 0 || limit > crate::MAX_REPLAY_CANDIDATE_ROWS {
|
||||||
|
return std::result::Result::Err(kb_core::Error::db(format!(
|
||||||
|
"replay candidate limit must be between 1 and {}",
|
||||||
|
crate::MAX_REPLAY_CANDIDATE_ROWS
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
return std::result::Result::Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
fn validate_optional_code(
|
||||||
|
value: std::option::Option<&str>,
|
||||||
|
allowed: &[&str],
|
||||||
|
label: &str,
|
||||||
|
) -> kb_core::Result<()> {
|
||||||
|
let selected = match value {
|
||||||
|
std::option::Option::Some(code) => code,
|
||||||
|
std::option::Option::None => return std::result::Result::Ok(()),
|
||||||
|
};
|
||||||
|
for allowed_code in allowed {
|
||||||
|
if selected == *allowed_code {
|
||||||
|
return std::result::Result::Ok(());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return std::result::Result::Err(kb_core::Error::db(format!(
|
||||||
|
"unsupported {label}: {selected}"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
#[test]
|
||||||
|
fn transaction_filter_rejects_inverted_slots() {
|
||||||
|
let result = crate::PostgresReplayTransactionFilter::new(
|
||||||
|
std::option::Option::None,
|
||||||
|
std::option::Option::Some(20),
|
||||||
|
std::option::Option::Some(10),
|
||||||
|
std::option::Option::None,
|
||||||
|
std::option::Option::None,
|
||||||
|
std::option::Option::None,
|
||||||
|
crate::PostgresReplayProgramScope::Any,
|
||||||
|
std::option::Option::None,
|
||||||
|
std::option::Option::None,
|
||||||
|
100,
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
assert!(result.is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn transaction_filter_requires_complete_entity_pair() {
|
||||||
|
let result = crate::PostgresReplayTransactionFilter::new(
|
||||||
|
std::option::Option::None,
|
||||||
|
std::option::Option::None,
|
||||||
|
std::option::Option::None,
|
||||||
|
std::option::Option::None,
|
||||||
|
std::option::Option::None,
|
||||||
|
std::option::Option::None,
|
||||||
|
crate::PostgresReplayProgramScope::Any,
|
||||||
|
std::option::Option::Some(crate::PostgresReplayEntityKind::Mint),
|
||||||
|
std::option::Option::None,
|
||||||
|
100,
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
assert!(result.is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn program_filter_rejects_limit_above_maximum() {
|
||||||
|
let result = crate::PostgresReplayProgramFilter::new(
|
||||||
|
std::option::Option::None,
|
||||||
|
crate::MAX_REPLAY_CANDIDATE_ROWS + 1,
|
||||||
|
);
|
||||||
|
assert!(result.is_err());
|
||||||
|
}
|
||||||
|
}
|
||||||
10
kb-store/src/postgres/repository.rs
Normal file
10
kb-store/src/postgres/repository.rs
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
// file: kb-store/src/postgres/repository.rs
|
||||||
|
// version: 1
|
||||||
|
|
||||||
|
//! PostgreSQL repository implementations.
|
||||||
|
|
||||||
|
mod core_extraction_repository;
|
||||||
|
mod core_transaction_repository;
|
||||||
|
mod decode_pipeline_repository;
|
||||||
|
mod raw_transaction_repository;
|
||||||
|
mod store_health_repository;
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
// file: kb-store/src/postgres/repository/core_extraction_repository.rs
|
||||||
|
// version: 1
|
||||||
|
|
||||||
|
//! PostgreSQL atomic canonical transaction to core extraction repository.
|
||||||
|
|
||||||
|
#[async_trait::async_trait]
|
||||||
|
impl crate::CoreExtractionStore for crate::PostgresStore {
|
||||||
|
#[expect(
|
||||||
|
clippy::implicit_return,
|
||||||
|
reason = "async_trait expansion triggers implicit_return on generated async trait methods."
|
||||||
|
)]
|
||||||
|
async fn list_raw_transactions_for_core_extraction(
|
||||||
|
&self,
|
||||||
|
filter: &crate::CoreExtractionSelectionFilter,
|
||||||
|
) -> kb_core::Result<std::vec::Vec<crate::RawTransactionRow>> {
|
||||||
|
return crate::postgres::query::list_raw_transactions_for_core_extraction(
|
||||||
|
self.pool(),
|
||||||
|
filter,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[expect(
|
||||||
|
clippy::implicit_return,
|
||||||
|
reason = "async_trait expansion triggers implicit_return on generated async trait methods."
|
||||||
|
)]
|
||||||
|
async fn is_core_extraction_current(
|
||||||
|
&self,
|
||||||
|
identity: &crate::ProcessingLedgerIdentity,
|
||||||
|
) -> kb_core::Result<bool> {
|
||||||
|
return crate::postgres::query::is_core_extraction_current(self.pool(), identity).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[expect(
|
||||||
|
clippy::implicit_return,
|
||||||
|
reason = "async_trait expansion triggers implicit_return on generated async trait methods."
|
||||||
|
)]
|
||||||
|
async fn persist_core_extraction(
|
||||||
|
&self,
|
||||||
|
bundle: &crate::CoreExtractionBundle,
|
||||||
|
force_replay: bool,
|
||||||
|
) -> kb_core::Result<crate::InsertOutcome> {
|
||||||
|
return crate::postgres::query::persist_core_extraction(self.pool(), bundle, force_replay)
|
||||||
|
.await;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[expect(
|
||||||
|
clippy::implicit_return,
|
||||||
|
reason = "async_trait expansion triggers implicit_return on generated async trait methods."
|
||||||
|
)]
|
||||||
|
async fn mark_core_extraction_failed(
|
||||||
|
&self,
|
||||||
|
failure: &crate::CoreExtractionFailure,
|
||||||
|
) -> kb_core::Result<crate::InsertOutcome> {
|
||||||
|
return crate::postgres::query::mark_core_extraction_failed(self.pool(), failure).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
118
kb-store/src/postgres/repository/core_transaction_repository.rs
Normal file
118
kb-store/src/postgres/repository/core_transaction_repository.rs
Normal file
@@ -0,0 +1,118 @@
|
|||||||
|
// file: kb-store/src/postgres/repository/core_transaction_repository.rs
|
||||||
|
// version: 1
|
||||||
|
|
||||||
|
//! PostgreSQL core Solana repository implementation.
|
||||||
|
|
||||||
|
#[async_trait::async_trait]
|
||||||
|
impl crate::CoreTransactionStore for crate::PostgresStore {
|
||||||
|
#[expect(
|
||||||
|
clippy::implicit_return,
|
||||||
|
reason = "async_trait expansion triggers implicit_return on generated async trait methods."
|
||||||
|
)]
|
||||||
|
async fn insert_core_transaction(
|
||||||
|
&self,
|
||||||
|
input: &crate::CoreTransactionInsert,
|
||||||
|
) -> kb_core::Result<crate::InsertOutcome> {
|
||||||
|
return crate::postgres::query::insert_core_transaction(self.pool(), input).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[expect(
|
||||||
|
clippy::implicit_return,
|
||||||
|
reason = "async_trait expansion triggers implicit_return on generated async trait methods."
|
||||||
|
)]
|
||||||
|
async fn insert_core_account_keys(
|
||||||
|
&self,
|
||||||
|
inputs: &[crate::CoreAccountKeyInsert],
|
||||||
|
) -> kb_core::Result<crate::InsertOutcome> {
|
||||||
|
return crate::postgres::query::insert_core_account_keys(self.pool(), inputs).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[expect(
|
||||||
|
clippy::implicit_return,
|
||||||
|
reason = "async_trait expansion triggers implicit_return on generated async trait methods."
|
||||||
|
)]
|
||||||
|
async fn insert_core_instructions(
|
||||||
|
&self,
|
||||||
|
inputs: &[crate::CoreInstructionInsert],
|
||||||
|
) -> kb_core::Result<crate::InsertOutcome> {
|
||||||
|
return crate::postgres::query::insert_core_instructions(self.pool(), inputs).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[expect(
|
||||||
|
clippy::implicit_return,
|
||||||
|
reason = "async_trait expansion triggers implicit_return on generated async trait methods."
|
||||||
|
)]
|
||||||
|
async fn insert_core_inner_instructions(
|
||||||
|
&self,
|
||||||
|
inputs: &[crate::CoreInnerInstructionInsert],
|
||||||
|
) -> kb_core::Result<crate::InsertOutcome> {
|
||||||
|
return crate::postgres::query::insert_core_inner_instructions(self.pool(), inputs).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[expect(
|
||||||
|
clippy::implicit_return,
|
||||||
|
reason = "async_trait expansion triggers implicit_return on generated async trait methods."
|
||||||
|
)]
|
||||||
|
async fn insert_core_logs(
|
||||||
|
&self,
|
||||||
|
inputs: &[crate::CoreLogInsert],
|
||||||
|
) -> kb_core::Result<crate::InsertOutcome> {
|
||||||
|
return crate::postgres::query::insert_core_logs(self.pool(), inputs).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[expect(
|
||||||
|
clippy::implicit_return,
|
||||||
|
reason = "async_trait expansion triggers implicit_return on generated async trait methods."
|
||||||
|
)]
|
||||||
|
async fn insert_core_balance_changes(
|
||||||
|
&self,
|
||||||
|
inputs: &[crate::CoreBalanceChangeInsert],
|
||||||
|
) -> kb_core::Result<crate::InsertOutcome> {
|
||||||
|
return crate::postgres::query::insert_core_balance_changes(self.pool(), inputs).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[expect(
|
||||||
|
clippy::implicit_return,
|
||||||
|
reason = "async_trait expansion triggers implicit_return on generated async trait methods."
|
||||||
|
)]
|
||||||
|
async fn list_core_instructions_for_replay(
|
||||||
|
&self,
|
||||||
|
filter: &crate::CoreInstructionReplayFilter,
|
||||||
|
page_request: &crate::PageRequest,
|
||||||
|
) -> kb_core::Result<std::vec::Vec<crate::CoreInstructionRow>> {
|
||||||
|
return crate::postgres::query::list_core_instructions_for_replay(
|
||||||
|
self.pool(),
|
||||||
|
filter,
|
||||||
|
page_request,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[expect(
|
||||||
|
clippy::implicit_return,
|
||||||
|
reason = "async_trait expansion triggers implicit_return on generated async trait methods."
|
||||||
|
)]
|
||||||
|
async fn list_core_instruction_replay_inputs(
|
||||||
|
&self,
|
||||||
|
filter: &crate::CoreInstructionReplayFilter,
|
||||||
|
page_request: &crate::PageRequest,
|
||||||
|
) -> kb_core::Result<std::vec::Vec<crate::CoreInstructionReplayInput>> {
|
||||||
|
return crate::postgres::query::list_core_instruction_replay_inputs(
|
||||||
|
self.pool(),
|
||||||
|
filter,
|
||||||
|
page_request,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[expect(
|
||||||
|
clippy::implicit_return,
|
||||||
|
reason = "async_trait expansion triggers implicit_return on generated async trait methods."
|
||||||
|
)]
|
||||||
|
async fn mark_core_instruction_lifecycle(
|
||||||
|
&self,
|
||||||
|
mark: &crate::CoreInstructionLifecycleMark,
|
||||||
|
) -> kb_core::Result<crate::InsertOutcome> {
|
||||||
|
return crate::postgres::query::update_core_instruction_lifecycle(self.pool(), mark).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
115
kb-store/src/postgres/repository/decode_pipeline_repository.rs
Normal file
115
kb-store/src/postgres/repository/decode_pipeline_repository.rs
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
// file: kb-store/src/postgres/repository/decode_pipeline_repository.rs
|
||||||
|
// version: 1
|
||||||
|
|
||||||
|
//! PostgreSQL contextual decode and materialization repository.
|
||||||
|
|
||||||
|
#[async_trait::async_trait]
|
||||||
|
impl crate::DecodePipelineStore for crate::PostgresStore {
|
||||||
|
#[expect(
|
||||||
|
clippy::implicit_return,
|
||||||
|
reason = "async_trait expansion triggers implicit_return on generated async trait methods."
|
||||||
|
)]
|
||||||
|
async fn list_decode_inputs(
|
||||||
|
&self,
|
||||||
|
filter: &crate::DecodeSelectionFilter,
|
||||||
|
) -> kb_core::Result<std::vec::Vec<crate::CoreInstructionReplayInput>> {
|
||||||
|
return crate::postgres::query::list_decode_inputs(self.pool(), filter).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[expect(
|
||||||
|
clippy::implicit_return,
|
||||||
|
reason = "async_trait expansion triggers implicit_return on generated async trait methods."
|
||||||
|
)]
|
||||||
|
async fn is_decode_current(
|
||||||
|
&self,
|
||||||
|
identity: &crate::ProcessingLedgerIdentity,
|
||||||
|
) -> kb_core::Result<bool> {
|
||||||
|
return crate::postgres::query::is_decode_current(self.pool(), identity).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[expect(
|
||||||
|
clippy::implicit_return,
|
||||||
|
reason = "async_trait expansion triggers implicit_return on generated async trait methods."
|
||||||
|
)]
|
||||||
|
async fn persist_decode_coverage_declarations(
|
||||||
|
&self,
|
||||||
|
declarations: &[crate::DecodeCoverageDeclarationInsert],
|
||||||
|
) -> kb_core::Result<crate::InsertOutcome> {
|
||||||
|
return crate::postgres::query::persist_decode_coverage_declarations(
|
||||||
|
self.pool(),
|
||||||
|
declarations,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[expect(
|
||||||
|
clippy::implicit_return,
|
||||||
|
reason = "async_trait expansion triggers implicit_return on generated async trait methods."
|
||||||
|
)]
|
||||||
|
async fn persist_decode_result(
|
||||||
|
&self,
|
||||||
|
bundle: &crate::DecodePersistenceBundle,
|
||||||
|
force_replay: bool,
|
||||||
|
) -> kb_core::Result<crate::InsertOutcome> {
|
||||||
|
return crate::postgres::query::persist_decode_result(self.pool(), bundle, force_replay)
|
||||||
|
.await;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[expect(
|
||||||
|
clippy::implicit_return,
|
||||||
|
reason = "async_trait expansion triggers implicit_return on generated async trait methods."
|
||||||
|
)]
|
||||||
|
async fn mark_decode_failed(
|
||||||
|
&self,
|
||||||
|
failure: &crate::DecodeFailure,
|
||||||
|
) -> kb_core::Result<crate::InsertOutcome> {
|
||||||
|
return crate::postgres::query::mark_decode_failed(self.pool(), failure).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[expect(
|
||||||
|
clippy::implicit_return,
|
||||||
|
reason = "async_trait expansion triggers implicit_return on generated async trait methods."
|
||||||
|
)]
|
||||||
|
async fn persist_materialization_result(
|
||||||
|
&self,
|
||||||
|
bundle: &crate::MaterializationPersistenceBundle,
|
||||||
|
force_replay: bool,
|
||||||
|
) -> kb_core::Result<crate::InsertOutcome> {
|
||||||
|
return crate::postgres::query::persist_materialization_result(
|
||||||
|
self.pool(),
|
||||||
|
bundle,
|
||||||
|
force_replay,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[expect(
|
||||||
|
clippy::implicit_return,
|
||||||
|
reason = "async_trait expansion triggers implicit_return on generated async trait methods."
|
||||||
|
)]
|
||||||
|
async fn list_decode_coverage_summary(
|
||||||
|
&self,
|
||||||
|
processor_name: std::option::Option<&str>,
|
||||||
|
processor_version: std::option::Option<&str>,
|
||||||
|
limit: u32,
|
||||||
|
) -> kb_core::Result<std::vec::Vec<crate::DecodeCoverageSummaryRow>> {
|
||||||
|
return crate::postgres::query::list_decode_coverage_summary(
|
||||||
|
self.pool(),
|
||||||
|
processor_name,
|
||||||
|
processor_version,
|
||||||
|
limit,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[expect(
|
||||||
|
clippy::implicit_return,
|
||||||
|
reason = "async_trait expansion triggers implicit_return on generated async trait methods."
|
||||||
|
)]
|
||||||
|
async fn list_materialized_events(
|
||||||
|
&self,
|
||||||
|
filter: &crate::MaterializedEventFilter,
|
||||||
|
) -> kb_core::Result<std::vec::Vec<crate::MaterializedEventQueryRow>> {
|
||||||
|
return crate::postgres::query::list_materialized_events(self.pool(), filter).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
// file: kb-store/src/postgres/repository/raw_transaction_repository.rs
|
||||||
|
// version: 1
|
||||||
|
|
||||||
|
//! PostgreSQL canonical transaction and acquisition observation repository implementation.
|
||||||
|
|
||||||
|
#[async_trait::async_trait]
|
||||||
|
impl crate::RawTransactionStore for crate::PostgresStore {
|
||||||
|
#[expect(
|
||||||
|
clippy::implicit_return,
|
||||||
|
reason = "async_trait expansion triggers implicit_return on generated async trait methods."
|
||||||
|
)]
|
||||||
|
async fn has_raw_transaction_signature(
|
||||||
|
&self,
|
||||||
|
signature: &kb_lib::Signature,
|
||||||
|
) -> kb_core::Result<bool> {
|
||||||
|
return crate::postgres::query::has_raw_transaction_signature(
|
||||||
|
self.pool(),
|
||||||
|
signature.0.as_str(),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[expect(
|
||||||
|
clippy::implicit_return,
|
||||||
|
reason = "async_trait expansion triggers implicit_return on generated async trait methods."
|
||||||
|
)]
|
||||||
|
async fn has_transaction_observation_key(
|
||||||
|
&self,
|
||||||
|
observation_key: &str,
|
||||||
|
) -> kb_core::Result<bool> {
|
||||||
|
return crate::postgres::query::has_transaction_observation_key(
|
||||||
|
self.pool(),
|
||||||
|
observation_key,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[expect(
|
||||||
|
clippy::implicit_return,
|
||||||
|
reason = "async_trait expansion triggers implicit_return on generated async trait methods."
|
||||||
|
)]
|
||||||
|
async fn insert_raw_transaction(
|
||||||
|
&self,
|
||||||
|
input: &crate::RawTransactionInsert,
|
||||||
|
) -> kb_core::Result<crate::InsertOutcome> {
|
||||||
|
return crate::postgres::query::insert_raw_transaction(self.pool(), input).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[expect(
|
||||||
|
clippy::implicit_return,
|
||||||
|
reason = "async_trait expansion triggers implicit_return on generated async trait methods."
|
||||||
|
)]
|
||||||
|
async fn insert_transaction_observation(
|
||||||
|
&self,
|
||||||
|
input: &crate::TransactionObservationInsert,
|
||||||
|
) -> kb_core::Result<crate::InsertOutcome> {
|
||||||
|
return crate::postgres::query::insert_transaction_observation(self.pool(), input).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[expect(
|
||||||
|
clippy::implicit_return,
|
||||||
|
reason = "async_trait expansion triggers implicit_return on generated async trait methods."
|
||||||
|
)]
|
||||||
|
async fn mark_raw_payload_lifecycle(
|
||||||
|
&self,
|
||||||
|
mark: &crate::RawPayloadLifecycleMark,
|
||||||
|
) -> kb_core::Result<crate::InsertOutcome> {
|
||||||
|
return crate::postgres::query::update_raw_payload_lifecycle(self.pool(), mark).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
31
kb-store/src/postgres/repository/store_health_repository.rs
Normal file
31
kb-store/src/postgres/repository/store_health_repository.rs
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
// file: kb-store/src/postgres/repository/store_health_repository.rs
|
||||||
|
// version: 1
|
||||||
|
|
||||||
|
//! PostgreSQL store health repository implementation.
|
||||||
|
|
||||||
|
#[async_trait::async_trait]
|
||||||
|
impl crate::StoreHealthStore for crate::PostgresStore {
|
||||||
|
#[expect(
|
||||||
|
clippy::implicit_return,
|
||||||
|
reason = "async_trait expansion triggers implicit_return on generated async trait methods."
|
||||||
|
)]
|
||||||
|
async fn backend_descriptor(&self) -> kb_core::Result<crate::StoreBackendDescriptor> {
|
||||||
|
return crate::PostgresStore::backend_descriptor(self).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[expect(
|
||||||
|
clippy::implicit_return,
|
||||||
|
reason = "async_trait expansion triggers implicit_return on generated async trait methods."
|
||||||
|
)]
|
||||||
|
async fn health_snapshot(&self) -> kb_core::Result<crate::StoreHealthSnapshot> {
|
||||||
|
return crate::PostgresStore::health_snapshot(self).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[expect(
|
||||||
|
clippy::implicit_return,
|
||||||
|
reason = "async_trait expansion triggers implicit_return on generated async trait methods."
|
||||||
|
)]
|
||||||
|
async fn migration_snapshot(&self) -> kb_core::Result<crate::StoreMigrationSnapshot> {
|
||||||
|
return crate::PostgresStore::migration_snapshot(self).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
512
kb-store/src/postgres/store.rs
Normal file
512
kb-store/src/postgres/store.rs
Normal file
@@ -0,0 +1,512 @@
|
|||||||
|
// file: kb-store/src/postgres/store.rs
|
||||||
|
// version: 2
|
||||||
|
|
||||||
|
//! Store implementation scaffold for the `kb-store` crate.
|
||||||
|
|
||||||
|
/// PostgreSQL store connection options.
|
||||||
|
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||||
|
pub struct PostgresStoreOptions {
|
||||||
|
/// Database URL or DSN.
|
||||||
|
pub database_url: std::string::String,
|
||||||
|
/// Maximum connection count.
|
||||||
|
pub max_connections: u32,
|
||||||
|
/// Connection timeout in milliseconds.
|
||||||
|
pub connect_timeout_ms: u64,
|
||||||
|
/// Enables idempotent raw schema initialization at startup.
|
||||||
|
pub auto_initialize_schema: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PostgresStoreOptions {
|
||||||
|
/// Creates validated PostgreSQL store options.
|
||||||
|
pub fn new(
|
||||||
|
database_url: impl std::convert::Into<std::string::String>,
|
||||||
|
max_connections: u32,
|
||||||
|
connect_timeout_ms: u64,
|
||||||
|
auto_initialize_schema: bool,
|
||||||
|
) -> kb_core::Result<Self> {
|
||||||
|
let database_url_value = database_url.into();
|
||||||
|
if database_url_value.trim().is_empty() {
|
||||||
|
return std::result::Result::Err(kb_core::Error::db(
|
||||||
|
"postgres database url must not be empty",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if max_connections == 0 {
|
||||||
|
return std::result::Result::Err(kb_core::Error::db(
|
||||||
|
"postgres max_connections must be greater than zero",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if connect_timeout_ms == 0 {
|
||||||
|
return std::result::Result::Err(kb_core::Error::db(
|
||||||
|
"postgres connect_timeout_ms must be greater than zero",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
return std::result::Result::Ok(Self {
|
||||||
|
database_url: database_url_value,
|
||||||
|
max_connections,
|
||||||
|
connect_timeout_ms,
|
||||||
|
auto_initialize_schema,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns a DSN masked for diagnostics.
|
||||||
|
pub fn masked_dsn(&self) -> std::string::String {
|
||||||
|
return crate::mask_postgres_dsn(self.database_url.as_str());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// PostgreSQL diagnostic snapshot.
|
||||||
|
#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
|
||||||
|
pub struct PostgresBackendDiagnostics {
|
||||||
|
/// Backend descriptor safe for UI display.
|
||||||
|
pub descriptor: crate::StoreBackendDescriptor,
|
||||||
|
/// Backend health snapshot.
|
||||||
|
pub health: crate::StoreHealthSnapshot,
|
||||||
|
/// Migration status snapshot.
|
||||||
|
pub migrations: crate::StoreMigrationSnapshot,
|
||||||
|
/// Full PostgreSQL server version string when available.
|
||||||
|
pub server_version: std::option::Option<std::string::String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Read-only statistics for one PostgreSQL table.
|
||||||
|
#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
|
||||||
|
pub struct PostgresTableStatistics {
|
||||||
|
/// Number of rows currently stored in the table.
|
||||||
|
pub row_count: i64,
|
||||||
|
/// Lowest observed Solana slot when the table contains a slot column and rows.
|
||||||
|
pub min_slot: std::option::Option<i64>,
|
||||||
|
/// Highest observed Solana slot when the table contains a slot column and rows.
|
||||||
|
pub max_slot: std::option::Option<i64>,
|
||||||
|
/// Latest insertion timestamp rendered by PostgreSQL for UI diagnostics.
|
||||||
|
pub latest_created_at: std::option::Option<std::string::String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Read-only diagnostics for one expected PostgreSQL table.
|
||||||
|
#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
|
||||||
|
pub struct PostgresTableDiagnostics {
|
||||||
|
/// Expected table name.
|
||||||
|
pub table_name: std::string::String,
|
||||||
|
/// Logical Solana domain encoded in the table name.
|
||||||
|
pub domain: std::string::String,
|
||||||
|
/// Human-readable role of the table.
|
||||||
|
pub role: std::string::String,
|
||||||
|
/// Whether the table exists in the current PostgreSQL search path.
|
||||||
|
pub exists: bool,
|
||||||
|
/// Table statistics when the table exists.
|
||||||
|
pub statistics: std::option::Option<crate::PostgresTableStatistics>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// PostgreSQL store handle.
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
pub struct PostgresStore {
|
||||||
|
options: crate::PostgresStoreOptions,
|
||||||
|
pool: sqlx::PgPool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PostgresStore {
|
||||||
|
/// Connects to PostgreSQL from typed store options.
|
||||||
|
pub async fn connect(options: crate::PostgresStoreOptions) -> kb_core::Result<Self> {
|
||||||
|
let pool_options = sqlx::postgres::PgPoolOptions::new()
|
||||||
|
.max_connections(options.max_connections)
|
||||||
|
.acquire_timeout(std::time::Duration::from_millis(options.connect_timeout_ms));
|
||||||
|
let connect_result = pool_options.connect(options.database_url.as_str()).await;
|
||||||
|
return match connect_result {
|
||||||
|
std::result::Result::Ok(pool) => {
|
||||||
|
let store = Self { options, pool };
|
||||||
|
if store.options.auto_initialize_schema {
|
||||||
|
let schema_result = store.initialize_store_schema().await;
|
||||||
|
if let std::result::Result::Err(error) = schema_result {
|
||||||
|
return std::result::Result::Err(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
std::result::Result::Ok(store)
|
||||||
|
},
|
||||||
|
std::result::Result::Err(error) => std::result::Result::Err(kb_core::Error::db(
|
||||||
|
format!("postgres connection failed: {error}"),
|
||||||
|
)),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Creates a store handle from an existing PostgreSQL pool.
|
||||||
|
pub fn from_pool(options: crate::PostgresStoreOptions, pool: sqlx::PgPool) -> Self {
|
||||||
|
return Self { options, pool };
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the underlying PostgreSQL pool.
|
||||||
|
pub fn pool(&self) -> &sqlx::PgPool {
|
||||||
|
return &self.pool;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the connection options used to create this store.
|
||||||
|
pub fn options(&self) -> &crate::PostgresStoreOptions {
|
||||||
|
return &self.options;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Applies each idempotent store schema once per invocation in dependency order.
|
||||||
|
pub async fn initialize_store_schema(&self) -> kb_core::Result<()> {
|
||||||
|
let raw_result = crate::postgres::query::apply_raw_store_schema(&self.pool).await;
|
||||||
|
if let std::result::Result::Err(error) = raw_result {
|
||||||
|
return std::result::Result::Err(error);
|
||||||
|
}
|
||||||
|
let core_result = crate::postgres::query::apply_core_store_schema(&self.pool).await;
|
||||||
|
if let std::result::Result::Err(error) = core_result {
|
||||||
|
return std::result::Result::Err(error);
|
||||||
|
}
|
||||||
|
return crate::postgres::query::apply_decode_store_schema(&self.pool).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Applies the idempotent minimal raw Solana store schema.
|
||||||
|
pub async fn initialize_raw_store_schema(&self) -> kb_core::Result<()> {
|
||||||
|
return crate::postgres::query::apply_raw_store_schema(&self.pool).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Applies the idempotent minimal core Solana store schema.
|
||||||
|
pub async fn initialize_core_store_schema(&self) -> kb_core::Result<()> {
|
||||||
|
let raw_result = self.initialize_raw_store_schema().await;
|
||||||
|
if let std::result::Result::Err(error) = raw_result {
|
||||||
|
return std::result::Result::Err(error);
|
||||||
|
}
|
||||||
|
return crate::postgres::query::apply_core_store_schema(&self.pool).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Applies the idempotent common decode and materialization store schema.
|
||||||
|
pub async fn initialize_decode_store_schema(&self) -> kb_core::Result<()> {
|
||||||
|
let core_result = self.initialize_core_store_schema().await;
|
||||||
|
if let std::result::Result::Err(error) = core_result {
|
||||||
|
return std::result::Result::Err(error);
|
||||||
|
}
|
||||||
|
return crate::postgres::query::apply_decode_store_schema(&self.pool).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reads a UI-safe backend descriptor.
|
||||||
|
pub async fn backend_descriptor(&self) -> kb_core::Result<crate::StoreBackendDescriptor> {
|
||||||
|
let schema_result = crate::postgres::query::load_current_schema(&self.pool).await;
|
||||||
|
return match schema_result {
|
||||||
|
std::result::Result::Ok(schema) => crate::StoreBackendDescriptor::new(
|
||||||
|
crate::StoreBackendKind::Postgres,
|
||||||
|
"postgres",
|
||||||
|
std::option::Option::Some(self.options.masked_dsn()),
|
||||||
|
std::option::Option::Some(schema),
|
||||||
|
),
|
||||||
|
std::result::Result::Err(error) => std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reads a PostgreSQL health snapshot.
|
||||||
|
pub async fn health_snapshot(&self) -> kb_core::Result<crate::StoreHealthSnapshot> {
|
||||||
|
let health_result = crate::postgres::query::run_health_check(&self.pool).await;
|
||||||
|
return match health_result {
|
||||||
|
std::result::Result::Ok(()) => crate::StoreHealthSnapshot::new(
|
||||||
|
"postgres",
|
||||||
|
crate::StoreHealthStatus::Healthy,
|
||||||
|
std::option::Option::Some(std::string::String::from("SELECT 1 succeeded")),
|
||||||
|
),
|
||||||
|
std::result::Result::Err(error) => crate::StoreHealthSnapshot::new(
|
||||||
|
"postgres",
|
||||||
|
crate::StoreHealthStatus::Unhealthy,
|
||||||
|
std::option::Option::Some(error.to_string()),
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reads a non-destructive migration snapshot.
|
||||||
|
pub async fn migration_snapshot(&self) -> kb_core::Result<crate::StoreMigrationSnapshot> {
|
||||||
|
let migration_table_result =
|
||||||
|
crate::postgres::query::load_migration_table_name(&self.pool).await;
|
||||||
|
return match migration_table_result {
|
||||||
|
std::result::Result::Ok(std::option::Option::None) => {
|
||||||
|
std::result::Result::Ok(crate::StoreMigrationSnapshot::new(
|
||||||
|
crate::StoreMigrationStatus::NotInitialized,
|
||||||
|
std::option::Option::None,
|
||||||
|
std::vec::Vec::new(),
|
||||||
|
std::option::Option::Some(std::string::String::from(
|
||||||
|
"no sqlx migration table detected; 0.3.1 canonical acquisition/core schemas use idempotent crate-managed DDL",
|
||||||
|
)),
|
||||||
|
))
|
||||||
|
},
|
||||||
|
std::result::Result::Ok(std::option::Option::Some(_table_name)) => {
|
||||||
|
self.migration_snapshot_from_existing_table().await
|
||||||
|
},
|
||||||
|
std::result::Result::Err(error) => std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reads a complete PostgreSQL diagnostic snapshot.
|
||||||
|
pub async fn backend_diagnostics(&self) -> kb_core::Result<crate::PostgresBackendDiagnostics> {
|
||||||
|
let descriptor_result = self.backend_descriptor().await;
|
||||||
|
let descriptor = match descriptor_result {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
let health_result = self.health_snapshot().await;
|
||||||
|
let health = match health_result {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
let migrations_result = self.migration_snapshot().await;
|
||||||
|
let migrations = match migrations_result {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
let server_version = match crate::postgres::query::load_server_version(&self.pool).await {
|
||||||
|
std::result::Result::Ok(value) => std::option::Option::Some(value),
|
||||||
|
std::result::Result::Err(_error) => std::option::Option::None,
|
||||||
|
};
|
||||||
|
return std::result::Result::Ok(crate::PostgresBackendDiagnostics {
|
||||||
|
descriptor,
|
||||||
|
health,
|
||||||
|
migrations,
|
||||||
|
server_version,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Lists bounded raw transaction candidates enriched with core and ledger diagnostics.
|
||||||
|
pub async fn replay_transaction_candidates(
|
||||||
|
&self,
|
||||||
|
filter: &crate::PostgresReplayTransactionFilter,
|
||||||
|
) -> kb_core::Result<std::vec::Vec<crate::PostgresReplayTransactionCandidate>> {
|
||||||
|
return crate::postgres::query::list_replay_transaction_candidates(&self.pool, filter)
|
||||||
|
.await;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Lists bounded program summaries across outer, inner and reliably linked logs.
|
||||||
|
pub async fn replay_program_summaries(
|
||||||
|
&self,
|
||||||
|
filter: &crate::PostgresReplayProgramFilter,
|
||||||
|
) -> kb_core::Result<std::vec::Vec<crate::PostgresReplayProgramSummary>> {
|
||||||
|
return crate::postgres::query::list_replay_program_summaries(&self.pool, filter).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Lists bounded mint, owner or account-key summaries from core tables.
|
||||||
|
pub async fn replay_entity_summaries(
|
||||||
|
&self,
|
||||||
|
filter: &crate::PostgresReplayEntityFilter,
|
||||||
|
) -> kb_core::Result<std::vec::Vec<crate::PostgresReplayEntitySummary>> {
|
||||||
|
return crate::postgres::query::list_replay_entity_summaries(&self.pool, filter).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reads diagnostics for raw Solana store tables without changing the schema.
|
||||||
|
pub async fn raw_table_diagnostics(
|
||||||
|
&self,
|
||||||
|
) -> kb_core::Result<std::vec::Vec<crate::PostgresTableDiagnostics>> {
|
||||||
|
let specs = crate::raw_store_table_diagnostic_specs();
|
||||||
|
return self.table_diagnostics(&specs).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reads diagnostics for core Solana store tables without changing the schema.
|
||||||
|
pub async fn core_table_diagnostics(
|
||||||
|
&self,
|
||||||
|
) -> kb_core::Result<std::vec::Vec<crate::PostgresTableDiagnostics>> {
|
||||||
|
let specs = crate::core_store_table_diagnostic_specs();
|
||||||
|
return self.table_diagnostics(&specs).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reads diagnostics for decode and materialization store tables without changing the schema.
|
||||||
|
pub async fn decode_table_diagnostics(
|
||||||
|
&self,
|
||||||
|
) -> kb_core::Result<std::vec::Vec<crate::PostgresTableDiagnostics>> {
|
||||||
|
let specs = crate::decode_store_table_diagnostic_specs();
|
||||||
|
return self.table_diagnostics(&specs).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reads diagnostics for every known raw/core/decode Solana store table.
|
||||||
|
pub async fn known_table_diagnostics(
|
||||||
|
&self,
|
||||||
|
) -> kb_core::Result<std::vec::Vec<crate::PostgresTableDiagnostics>> {
|
||||||
|
let mut diagnostics = std::vec::Vec::new();
|
||||||
|
let raw_result = self.raw_table_diagnostics().await;
|
||||||
|
let raw_tables = match raw_result {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
for table in raw_tables {
|
||||||
|
diagnostics.push(table);
|
||||||
|
}
|
||||||
|
let core_result = self.core_table_diagnostics().await;
|
||||||
|
let core_tables = match core_result {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
for table in core_tables {
|
||||||
|
diagnostics.push(table);
|
||||||
|
}
|
||||||
|
let decode_result = self.decode_table_diagnostics().await;
|
||||||
|
let decode_tables = match decode_result {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
for table in decode_tables {
|
||||||
|
diagnostics.push(table);
|
||||||
|
}
|
||||||
|
return std::result::Result::Ok(diagnostics);
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn table_diagnostics(
|
||||||
|
&self,
|
||||||
|
specs: &[crate::PostgresTableDiagnosticSpec],
|
||||||
|
) -> kb_core::Result<std::vec::Vec<crate::PostgresTableDiagnostics>> {
|
||||||
|
let mut diagnostics = std::vec::Vec::new();
|
||||||
|
for spec in specs {
|
||||||
|
let exists_result =
|
||||||
|
crate::postgres::query::table_exists(&self.pool, spec.table_name).await;
|
||||||
|
let exists = match exists_result {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
let statistics = if exists {
|
||||||
|
let statistics_result =
|
||||||
|
crate::postgres::query::load_table_statistics(&self.pool, spec.table_name)
|
||||||
|
.await;
|
||||||
|
match statistics_result {
|
||||||
|
std::result::Result::Ok(value) => std::option::Option::Some(value),
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
std::option::Option::None
|
||||||
|
};
|
||||||
|
diagnostics.push(crate::PostgresTableDiagnostics {
|
||||||
|
table_name: spec.table_name.to_string(),
|
||||||
|
domain: spec.domain.to_string(),
|
||||||
|
role: spec.role.to_string(),
|
||||||
|
exists,
|
||||||
|
statistics,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return std::result::Result::Ok(diagnostics);
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn migration_snapshot_from_existing_table(
|
||||||
|
&self,
|
||||||
|
) -> kb_core::Result<crate::StoreMigrationSnapshot> {
|
||||||
|
let version_result =
|
||||||
|
crate::postgres::query::load_latest_migration_version(&self.pool).await;
|
||||||
|
return match version_result {
|
||||||
|
std::result::Result::Ok(current_version) => {
|
||||||
|
std::result::Result::Ok(crate::StoreMigrationSnapshot::new(
|
||||||
|
crate::StoreMigrationStatus::Current,
|
||||||
|
current_version,
|
||||||
|
std::vec::Vec::new(),
|
||||||
|
std::option::Option::Some(std::string::String::from(
|
||||||
|
"sqlx migration table detected; canonical acquisition/core schema remains idempotent and crate-managed in 0.3.1",
|
||||||
|
)),
|
||||||
|
))
|
||||||
|
},
|
||||||
|
std::result::Result::Err(error) => std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns a DSN masked for logs and UI diagnostics.
|
||||||
|
pub fn mask_postgres_dsn(dsn: &str) -> std::string::String {
|
||||||
|
let trimmed_dsn = dsn.trim();
|
||||||
|
if trimmed_dsn.is_empty() {
|
||||||
|
return std::string::String::from("");
|
||||||
|
}
|
||||||
|
let queryless = crate::postgres::store::strip_query(trimmed_dsn);
|
||||||
|
return match queryless.split_once("://") {
|
||||||
|
std::option::Option::Some((scheme, remainder)) => {
|
||||||
|
crate::postgres::store::mask_scheme_remainder(
|
||||||
|
scheme,
|
||||||
|
remainder,
|
||||||
|
trimmed_dsn.contains('?'),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
std::option::Option::None => crate::postgres::store::mask_plain_dsn(queryless.as_str()),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
fn strip_query(dsn: &str) -> std::string::String {
|
||||||
|
return match dsn.split_once('?') {
|
||||||
|
std::option::Option::Some((prefix, _query)) => std::string::String::from(prefix),
|
||||||
|
std::option::Option::None => std::string::String::from(dsn),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
fn mask_scheme_remainder(scheme: &str, remainder: &str, had_query: bool) -> std::string::String {
|
||||||
|
let suffix = crate::postgres::store::query_suffix(had_query);
|
||||||
|
return match remainder.rsplit_once('@') {
|
||||||
|
std::option::Option::Some((_userinfo, host_path)) => {
|
||||||
|
format!("{scheme}://***:***@{host_path}{suffix}")
|
||||||
|
},
|
||||||
|
std::option::Option::None => format!("{scheme}://{remainder}{suffix}"),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
fn mask_plain_dsn(dsn: &str) -> std::string::String {
|
||||||
|
if dsn.contains("password=") {
|
||||||
|
return std::string::String::from("<postgres-dsn-redacted>");
|
||||||
|
}
|
||||||
|
return std::string::String::from(dsn);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn query_suffix(had_query: bool) -> std::string::String {
|
||||||
|
if had_query {
|
||||||
|
return std::string::String::from("?<redacted>");
|
||||||
|
}
|
||||||
|
return std::string::String::from("");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
#[test]
|
||||||
|
fn options_reject_empty_database_url() {
|
||||||
|
let result = crate::PostgresStoreOptions::new(" ", 1, 1000, false);
|
||||||
|
assert!(result.is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn options_reject_zero_max_connections() {
|
||||||
|
let result = crate::PostgresStoreOptions::new("postgres://localhost/db", 0, 1000, false);
|
||||||
|
assert!(result.is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn options_reject_zero_connect_timeout() {
|
||||||
|
let result = crate::PostgresStoreOptions::new("postgres://localhost/db", 1, 0, false);
|
||||||
|
assert!(result.is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn optional_postgres_healthcheck_from_env() {
|
||||||
|
let database_url = match std::env::var("KB_POSTGRES_TEST_URL") {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(_error) => return,
|
||||||
|
};
|
||||||
|
let _postgres_guard = crate::postgres::test_serial::postgres_test_guard().await;
|
||||||
|
let options_result = crate::PostgresStoreOptions::new(database_url, 1, 5000, false);
|
||||||
|
let options = match options_result {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => panic!("unexpected options error: {error}"),
|
||||||
|
};
|
||||||
|
let store_result = crate::PostgresStore::connect(options).await;
|
||||||
|
let store = match store_result {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => panic!("unexpected connect error: {error}"),
|
||||||
|
};
|
||||||
|
let health_result = store.health_snapshot().await;
|
||||||
|
let health = match health_result {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(error) => panic!("unexpected health error: {error}"),
|
||||||
|
};
|
||||||
|
assert_eq!(health.status, crate::StoreHealthStatus::Healthy);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn mask_postgres_dsn_masks_userinfo() {
|
||||||
|
let masked = crate::mask_postgres_dsn("postgres://user:secret@localhost:5432/db");
|
||||||
|
assert_eq!(masked, "postgres://***:***@localhost:5432/db");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn mask_postgres_dsn_masks_query_string() {
|
||||||
|
let masked =
|
||||||
|
crate::mask_postgres_dsn("postgres://localhost/db?sslmode=require&password=secret");
|
||||||
|
assert_eq!(masked, "postgres://localhost/db?<redacted>");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn mask_postgres_dsn_masks_plain_password_dsn() {
|
||||||
|
let masked = crate::mask_postgres_dsn("host=localhost password=secret dbname=khadhroony");
|
||||||
|
assert_eq!(masked, "<postgres-dsn-redacted>");
|
||||||
|
}
|
||||||
|
}
|
||||||
15
kb-store/src/postgres/test_serial.rs
Normal file
15
kb-store/src/postgres/test_serial.rs
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
// file: kb-store/src/postgres/test_serial.rs
|
||||||
|
// version: 1
|
||||||
|
|
||||||
|
//! Test-only serialization helpers for optional real PostgreSQL tests.
|
||||||
|
|
||||||
|
static POSTGRES_TEST_MUTEX: std::sync::OnceLock<std::sync::Arc<tokio::sync::Mutex<()>>> =
|
||||||
|
std::sync::OnceLock::new();
|
||||||
|
|
||||||
|
/// Acquires the process-local guard shared by optional real PostgreSQL tests.
|
||||||
|
pub(in crate::postgres) async fn postgres_test_guard() -> tokio::sync::OwnedMutexGuard<()> {
|
||||||
|
let mutex = POSTGRES_TEST_MUTEX
|
||||||
|
.get_or_init(|| return std::sync::Arc::new(tokio::sync::Mutex::new(())))
|
||||||
|
.clone();
|
||||||
|
return mutex.lock_owned().await;
|
||||||
|
}
|
||||||
184
scripts/audit_khadhroony_workspace_rules.py
Executable file
184
scripts/audit_khadhroony_workspace_rules.py
Executable file
@@ -0,0 +1,184 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
# file: scripts/audit_khadhroony_workspace_rules.py
|
||||||
|
# version: 2
|
||||||
|
|
||||||
|
"""Audit mechanically verifiable rules specific to khadhroony-bot3."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import dataclasses
|
||||||
|
import pathlib
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
import tomllib
|
||||||
|
|
||||||
|
|
||||||
|
@dataclasses.dataclass(frozen=True)
|
||||||
|
class Violation:
|
||||||
|
"""One project-specific workspace rule violation."""
|
||||||
|
|
||||||
|
code: str
|
||||||
|
path: str
|
||||||
|
line: int
|
||||||
|
message: str
|
||||||
|
|
||||||
|
|
||||||
|
def package_name(crate_dir: pathlib.Path) -> str | None:
|
||||||
|
"""Return a crate package name."""
|
||||||
|
|
||||||
|
cargo = crate_dir / "Cargo.toml"
|
||||||
|
if not cargo.exists():
|
||||||
|
return None
|
||||||
|
value = tomllib.loads(cargo.read_text(encoding="utf-8")).get("package", {}).get("name")
|
||||||
|
return value if isinstance(value, str) else None
|
||||||
|
|
||||||
|
|
||||||
|
def relevant_tracing_crate(crate_dir: pathlib.Path) -> bool:
|
||||||
|
"""Return whether the crate owns a tracing target contract."""
|
||||||
|
|
||||||
|
cargo = crate_dir / "Cargo.toml"
|
||||||
|
data = tomllib.loads(cargo.read_text(encoding="utf-8"))
|
||||||
|
if "tracing" in data.get("dependencies", {}):
|
||||||
|
return True
|
||||||
|
return any("TRACING_TARGET" in path.read_text(encoding="utf-8") for path in crate_dir.glob("src/**/*.rs"))
|
||||||
|
|
||||||
|
|
||||||
|
def audit_tracing(root: pathlib.Path) -> list[Violation]:
|
||||||
|
"""Audit the project tracing target contract."""
|
||||||
|
|
||||||
|
violations: list[Violation] = []
|
||||||
|
for cargo in sorted(root.glob("*/Cargo.toml")):
|
||||||
|
crate = cargo.parent
|
||||||
|
if crate.name == "kb-lib":
|
||||||
|
continue
|
||||||
|
if not relevant_tracing_crate(crate):
|
||||||
|
continue
|
||||||
|
name = package_name(crate)
|
||||||
|
if name is None:
|
||||||
|
continue
|
||||||
|
constants = crate / "src/constants.rs"
|
||||||
|
expected = f'pub(crate) const TRACING_TARGET: &str = "{name}";'
|
||||||
|
relative_constants = constants.relative_to(root).as_posix()
|
||||||
|
if not constants.exists() or expected not in constants.read_text(encoding="utf-8"):
|
||||||
|
violations.append(Violation("KH_TRACE001", relative_constants, 1, f"expected `{expected}`"))
|
||||||
|
crate_root = crate / "src/lib.rs"
|
||||||
|
if not crate_root.exists():
|
||||||
|
crate_root = crate / "src/main.rs"
|
||||||
|
relative_root = crate_root.relative_to(root).as_posix()
|
||||||
|
if not crate_root.exists() or "pub(crate) use crate::constants::TRACING_TARGET;" not in crate_root.read_text(encoding="utf-8"):
|
||||||
|
violations.append(Violation("KH_TRACE002", relative_root, 1, "missing crate-root TRACING_TARGET re-export"))
|
||||||
|
for path in crate.glob("src/**/*.rs"):
|
||||||
|
relative = path.relative_to(root).as_posix()
|
||||||
|
for index, line in enumerate(path.read_text(encoding="utf-8").splitlines(), 1):
|
||||||
|
if "khbot." in line:
|
||||||
|
violations.append(Violation("KH_TRACE003", relative, index, "legacy khbot tracing target is forbidden"))
|
||||||
|
if "crate::constants::TRACING_TARGET" in line and path.name not in {"lib.rs", "main.rs"}:
|
||||||
|
violations.append(Violation("KH_TRACE004", relative, index, "use crate::TRACING_TARGET outside crate root"))
|
||||||
|
if re.search(r"(?:crate::[A-Za-z0-9_]+::|(?<!crate::))TRACING_TARGET", line) and "const TRACING_TARGET" not in line and "use crate::constants::TRACING_TARGET" not in line:
|
||||||
|
if "crate::TRACING_TARGET" not in line:
|
||||||
|
violations.append(Violation("KH_TRACE005", relative, index, "non-canonical TRACING_TARGET path"))
|
||||||
|
return violations
|
||||||
|
|
||||||
|
|
||||||
|
def audit_kb_lib_tracing(root: pathlib.Path) -> list[Violation]:
|
||||||
|
"""Audit hierarchical tracing targets owned by operational kb-lib components."""
|
||||||
|
|
||||||
|
violations: list[Violation] = []
|
||||||
|
crate = root / "kb-lib"
|
||||||
|
crate_root = crate / "src/lib.rs"
|
||||||
|
if not crate_root.exists():
|
||||||
|
return violations
|
||||||
|
root_text = crate_root.read_text(encoding="utf-8")
|
||||||
|
aliases = set(
|
||||||
|
re.findall(
|
||||||
|
r"^pub\(crate\) use crate::[A-Za-z0-9_:]+::([A-Z][A-Z0-9_]+_TRACING_TARGET);$",
|
||||||
|
root_text,
|
||||||
|
re.MULTILINE,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
target_pattern = re.compile(
|
||||||
|
r'^pub\(crate\) const TRACING_TARGET: &str = "([^"]+)";$',
|
||||||
|
re.MULTILINE,
|
||||||
|
)
|
||||||
|
for constants in sorted((crate / "src").rglob("constants.rs")):
|
||||||
|
text = constants.read_text(encoding="utf-8")
|
||||||
|
match = target_pattern.search(text)
|
||||||
|
if match is None:
|
||||||
|
continue
|
||||||
|
component = constants.parent.relative_to(crate / "src")
|
||||||
|
expected = "kb-lib." + ".".join(component.parts)
|
||||||
|
relative = constants.relative_to(root).as_posix()
|
||||||
|
if match.group(1) != expected:
|
||||||
|
violations.append(
|
||||||
|
Violation(
|
||||||
|
"KH_TRACE101",
|
||||||
|
relative,
|
||||||
|
text[: match.start()].count("\n") + 1,
|
||||||
|
f"expected hierarchical target `{expected}`",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
macro_pattern = re.compile(
|
||||||
|
r"tracing::(?:debug|error|info|trace|warn)!\(\s*target:\s*crate::([A-Z][A-Z0-9_]+_TRACING_TARGET)"
|
||||||
|
)
|
||||||
|
for path in sorted((crate / "src").rglob("*.rs")):
|
||||||
|
relative = path.relative_to(root).as_posix()
|
||||||
|
text = path.read_text(encoding="utf-8")
|
||||||
|
for index, line in enumerate(text.splitlines(), 1):
|
||||||
|
if "let _target" in line:
|
||||||
|
violations.append(
|
||||||
|
Violation(
|
||||||
|
"KH_TRACE102",
|
||||||
|
relative,
|
||||||
|
index,
|
||||||
|
"fake tracing target consumption is forbidden",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
for match in macro_pattern.finditer(text):
|
||||||
|
if match.group(1) not in aliases:
|
||||||
|
violations.append(
|
||||||
|
Violation(
|
||||||
|
"KH_TRACE103",
|
||||||
|
relative,
|
||||||
|
text[: match.start()].count("\n") + 1,
|
||||||
|
f"missing crate-root re-export for `{match.group(1)}`",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return violations
|
||||||
|
|
||||||
|
|
||||||
|
def audit_solana_types(root: pathlib.Path) -> list[Violation]:
|
||||||
|
"""Reject non-canonical Solana address types in workspace Rust code."""
|
||||||
|
|
||||||
|
violations: list[Violation] = []
|
||||||
|
for path in sorted(root.rglob("*.rs")):
|
||||||
|
if "target" in path.parts or ".git" in path.parts:
|
||||||
|
continue
|
||||||
|
relative = path.relative_to(root).as_posix()
|
||||||
|
for index, line in enumerate(path.read_text(encoding="utf-8").splitlines(), 1):
|
||||||
|
if "solana_address::Address" in line:
|
||||||
|
violations.append(Violation("KH_SOL001", relative, index, "use solana_pubkey::Pubkey instead of solana_address::Address"))
|
||||||
|
return violations
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
"""Run the khadhroony-specific audit."""
|
||||||
|
|
||||||
|
parser = argparse.ArgumentParser()
|
||||||
|
parser.add_argument("--root", default=".")
|
||||||
|
parser.add_argument("--report-only", action="store_true")
|
||||||
|
arguments = parser.parse_args()
|
||||||
|
root = pathlib.Path(arguments.root).resolve()
|
||||||
|
violations = audit_tracing(root) + audit_kb_lib_tracing(root) + audit_solana_types(root)
|
||||||
|
violations.sort(key=lambda item: (item.code, item.path, item.line, item.message))
|
||||||
|
if not violations:
|
||||||
|
sys.stdout.write("Khadhroony workspace rule audit: clean\n")
|
||||||
|
return 0
|
||||||
|
sys.stdout.write(f"Khadhroony workspace rule audit: {len(violations)} violation(s)\n")
|
||||||
|
for item in violations:
|
||||||
|
sys.stdout.write(f"{item.code} {item.path}:{item.line}: {item.message}\n")
|
||||||
|
return 0 if arguments.report_only else 1
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
174
scripts/audit_rust_export_completeness.py
Executable file
174
scripts/audit_rust_export_completeness.py
Executable file
@@ -0,0 +1,174 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
# file: scripts/audit_rust_export_completeness.py
|
||||||
|
# version: 2
|
||||||
|
|
||||||
|
"""Report missing crate-root exports and replaceable long internal paths.
|
||||||
|
|
||||||
|
The audit distinguishes declarations from their crate-root aliases. Crate-root
|
||||||
|
files are excluded from long-path findings because re-export declarations must
|
||||||
|
name their source module. Findings remain advisory until the affected crate is
|
||||||
|
compiled.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import dataclasses
|
||||||
|
import pathlib
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
|
||||||
|
|
||||||
|
@dataclasses.dataclass(frozen=True)
|
||||||
|
class Candidate:
|
||||||
|
"""One export-completeness candidate."""
|
||||||
|
|
||||||
|
code: str
|
||||||
|
path: str
|
||||||
|
line: int
|
||||||
|
message: str
|
||||||
|
|
||||||
|
|
||||||
|
@dataclasses.dataclass(frozen=True)
|
||||||
|
class Declaration:
|
||||||
|
"""One module-level public or crate-public declaration."""
|
||||||
|
|
||||||
|
module: str
|
||||||
|
name: str
|
||||||
|
visibility: str
|
||||||
|
path: pathlib.Path
|
||||||
|
line: int
|
||||||
|
|
||||||
|
|
||||||
|
def crate_roots(root: pathlib.Path) -> list[pathlib.Path]:
|
||||||
|
"""Return workspace crate directories that contain a Rust crate root."""
|
||||||
|
|
||||||
|
crates: list[pathlib.Path] = []
|
||||||
|
for manifest in root.glob("*/Cargo.toml"):
|
||||||
|
crate = manifest.parent
|
||||||
|
if (crate / "src/lib.rs").is_file() or (crate / "src/main.rs").is_file():
|
||||||
|
crates.append(crate)
|
||||||
|
return sorted(crates)
|
||||||
|
|
||||||
|
|
||||||
|
def module_path(crate: pathlib.Path, path: pathlib.Path) -> str:
|
||||||
|
"""Return the Rust module path represented by one source file."""
|
||||||
|
|
||||||
|
relative = path.relative_to(crate / "src").with_suffix("")
|
||||||
|
return "::".join(relative.parts)
|
||||||
|
|
||||||
|
|
||||||
|
def declaration_candidates(crate: pathlib.Path, path: pathlib.Path) -> list[Declaration]:
|
||||||
|
"""Return module-level public declarations from one Rust module."""
|
||||||
|
|
||||||
|
text = path.read_text(encoding="utf-8")
|
||||||
|
depth = 0
|
||||||
|
found: list[Declaration] = []
|
||||||
|
pattern = re.compile(
|
||||||
|
r"^\s*(pub(?:\(crate\))?)\s+(?:(?:async|unsafe|const)\s+)*(?:const|static|type|struct|enum|trait|union|fn)\s+([A-Za-z_][A-Za-z0-9_]*)"
|
||||||
|
)
|
||||||
|
for number, line in enumerate(text.splitlines(), 1):
|
||||||
|
if depth == 0:
|
||||||
|
match = pattern.match(line)
|
||||||
|
if match is not None:
|
||||||
|
found.append(
|
||||||
|
Declaration(
|
||||||
|
module_path(crate, path),
|
||||||
|
match.group(2),
|
||||||
|
match.group(1),
|
||||||
|
path,
|
||||||
|
number,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
depth += line.count("{") - line.count("}")
|
||||||
|
if depth < 0:
|
||||||
|
depth = 0
|
||||||
|
return found
|
||||||
|
|
||||||
|
|
||||||
|
def root_exports(crate_root: pathlib.Path) -> dict[tuple[str, str], str]:
|
||||||
|
"""Return source module/name pairs and their crate-root aliases."""
|
||||||
|
|
||||||
|
exports: dict[tuple[str, str], str] = {}
|
||||||
|
pattern = re.compile(
|
||||||
|
r"^\s*pub(?:\(crate\))?\s+use\s+crate::([A-Za-z_][A-Za-z0-9_:]*)::([A-Za-z_][A-Za-z0-9_]*)(?:\s+as\s+([A-Za-z_][A-Za-z0-9_]*))?\s*;",
|
||||||
|
re.MULTILINE,
|
||||||
|
)
|
||||||
|
for match in pattern.finditer(crate_root.read_text(encoding="utf-8")):
|
||||||
|
exports[(match.group(1), match.group(2))] = match.group(3) or match.group(2)
|
||||||
|
return exports
|
||||||
|
|
||||||
|
|
||||||
|
def audit_crate(workspace: pathlib.Path, crate: pathlib.Path) -> list[Candidate]:
|
||||||
|
"""Audit one crate and return advisory candidates."""
|
||||||
|
|
||||||
|
crate_root = crate / "src/lib.rs"
|
||||||
|
if not crate_root.is_file():
|
||||||
|
crate_root = crate / "src/main.rs"
|
||||||
|
exports = root_exports(crate_root)
|
||||||
|
candidates: list[Candidate] = []
|
||||||
|
declarations: dict[tuple[str, str], Declaration] = {}
|
||||||
|
for path in sorted((crate / "src").rglob("*.rs")):
|
||||||
|
if path == crate_root:
|
||||||
|
continue
|
||||||
|
for declaration in declaration_candidates(crate, path):
|
||||||
|
key = (declaration.module, declaration.name)
|
||||||
|
declarations[key] = declaration
|
||||||
|
if key not in exports:
|
||||||
|
relative = path.relative_to(workspace).as_posix()
|
||||||
|
candidates.append(
|
||||||
|
Candidate(
|
||||||
|
"EXPORT001",
|
||||||
|
relative,
|
||||||
|
declaration.line,
|
||||||
|
f"{declaration.visibility} `{declaration.name}` has no crate-root re-export",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
long_path = re.compile(
|
||||||
|
r"\bcrate::([A-Za-z_][A-Za-z0-9_:]*)::([A-Za-z_][A-Za-z0-9_]*)"
|
||||||
|
)
|
||||||
|
for path in sorted((crate / "src").rglob("*.rs")):
|
||||||
|
if path == crate_root:
|
||||||
|
continue
|
||||||
|
for number, line in enumerate(path.read_text(encoding="utf-8").splitlines(), 1):
|
||||||
|
for match in long_path.finditer(line):
|
||||||
|
key = (match.group(1), match.group(2))
|
||||||
|
alias = exports.get(key)
|
||||||
|
if alias is None:
|
||||||
|
continue
|
||||||
|
relative = path.relative_to(workspace).as_posix()
|
||||||
|
candidates.append(
|
||||||
|
Candidate(
|
||||||
|
"EXPORT002",
|
||||||
|
relative,
|
||||||
|
number,
|
||||||
|
f"long internal path `crate::{match.group(1)}::{match.group(2)}` must use `crate::{alias}`",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return candidates
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
"""Run the advisory export-completeness audit."""
|
||||||
|
|
||||||
|
parser = argparse.ArgumentParser()
|
||||||
|
parser.add_argument("--root", default=".")
|
||||||
|
parser.add_argument("--summary-only", action="store_true")
|
||||||
|
arguments = parser.parse_args()
|
||||||
|
workspace = pathlib.Path(arguments.root).resolve()
|
||||||
|
candidates = [item for crate in crate_roots(workspace) for item in audit_crate(workspace, crate)]
|
||||||
|
candidates.sort(key=lambda item: (item.code, item.path, item.line, item.message))
|
||||||
|
counts: dict[str, int] = {}
|
||||||
|
for item in candidates:
|
||||||
|
counts[item.code] = counts.get(item.code, 0) + 1
|
||||||
|
sys.stdout.write(f"Rust export completeness audit: {len(candidates)} candidate(s)\n")
|
||||||
|
for code in sorted(counts):
|
||||||
|
sys.stdout.write(f"{code}: {counts[code]}\n")
|
||||||
|
if not arguments.summary_only:
|
||||||
|
for item in candidates:
|
||||||
|
sys.stdout.write(f"{item.code} {item.path}:{item.line}: {item.message}\n")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
102
scripts/audit_rust_general_rules.py
Executable file
102
scripts/audit_rust_general_rules.py
Executable file
@@ -0,0 +1,102 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
# file: scripts/audit_rust_general_rules.py
|
||||||
|
# version: 3
|
||||||
|
|
||||||
|
"""Audit mechanically verifiable Rust rules shared by all Rust projects."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import dataclasses
|
||||||
|
import pathlib
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
|
||||||
|
|
||||||
|
@dataclasses.dataclass(frozen=True)
|
||||||
|
class Violation:
|
||||||
|
"""One mechanically detected general Rust rule violation."""
|
||||||
|
|
||||||
|
code: str
|
||||||
|
path: str
|
||||||
|
line: int
|
||||||
|
message: str
|
||||||
|
|
||||||
|
|
||||||
|
def rust_files(root: pathlib.Path) -> list[pathlib.Path]:
|
||||||
|
"""Return Rust sources outside generated build directories."""
|
||||||
|
|
||||||
|
return sorted(
|
||||||
|
path
|
||||||
|
for path in root.rglob("*.rs")
|
||||||
|
if "target" not in path.parts
|
||||||
|
and ".git" not in path.parts
|
||||||
|
and "mnt" not in path.relative_to(root).parts
|
||||||
|
and path.relative_to(root).parts[:2]
|
||||||
|
!= ("migration", "khadhroony-bot2-reference")
|
||||||
|
and not any(part.startswith("pre035_") for part in path.relative_to(root).parts)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def audit_file(root: pathlib.Path, path: pathlib.Path) -> list[Violation]:
|
||||||
|
"""Audit one Rust source against general rules."""
|
||||||
|
|
||||||
|
relative = path.relative_to(root).as_posix()
|
||||||
|
text = path.read_text(encoding="utf-8")
|
||||||
|
lines = text.splitlines()
|
||||||
|
violations: list[Violation] = []
|
||||||
|
expected_header = f"// file: {relative}"
|
||||||
|
if not lines or lines[0] != expected_header:
|
||||||
|
violations.append(Violation("RUST001", relative, 1, f"expected `{expected_header}`"))
|
||||||
|
if len(lines) < 2 or re.fullmatch(r"// version: [1-9][0-9]*", lines[1]) is None:
|
||||||
|
violations.append(Violation("RUST002", relative, 2, "missing positive file version"))
|
||||||
|
if not text.endswith("\n") or text.endswith("\n\n"):
|
||||||
|
violations.append(Violation("RUST003", relative, max(len(lines), 1), "file must end with exactly one newline"))
|
||||||
|
for index, line in enumerate(lines, 1):
|
||||||
|
stripped = line.strip()
|
||||||
|
if re.match(r"^(?:pub(?:\(crate\))?\s+)?use\s+", stripped) is None:
|
||||||
|
continue
|
||||||
|
is_export = stripped.startswith("pub use ") or stripped.startswith("pub(crate) use ")
|
||||||
|
justified_trait = "rust-rules: trait-import" in stripped
|
||||||
|
justified_derive = "rust-rules: derive-import" in stripped
|
||||||
|
if not is_export and not justified_trait and not justified_derive:
|
||||||
|
violations.append(Violation("RUST010", relative, index, "ordinary use requires a trait or derive import justification"))
|
||||||
|
if "::*" in stripped:
|
||||||
|
violations.append(Violation("RUST012", relative, index, "glob imports are forbidden"))
|
||||||
|
if "{" in stripped or "}" in stripped:
|
||||||
|
code = "RUST011" if is_export else "RUST013"
|
||||||
|
violations.append(Violation(code, relative, index, "grouped use declarations are forbidden"))
|
||||||
|
if path.name in {"lib.rs", "main.rs"}:
|
||||||
|
for attribute in ("#![warn(missing_docs)]", "#![deny(unreachable_pub)]", "#![forbid(unsafe_code)]"):
|
||||||
|
if attribute not in lines[:20]:
|
||||||
|
violations.append(Violation("RUST020", relative, 1, f"missing `{attribute}`"))
|
||||||
|
for index, line in enumerate(lines, 1):
|
||||||
|
if not (line.startswith("pub use ") or line.startswith("pub(crate) use ")):
|
||||||
|
continue
|
||||||
|
previous = lines[index - 2].strip() if index >= 2 else ""
|
||||||
|
if not previous.startswith("///"):
|
||||||
|
violations.append(Violation("RUST021", relative, index, "crate-root re-export requires adjacent rustdoc"))
|
||||||
|
return violations
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
"""Run the general Rust audit."""
|
||||||
|
|
||||||
|
parser = argparse.ArgumentParser()
|
||||||
|
parser.add_argument("--root", default=".")
|
||||||
|
parser.add_argument("--report-only", action="store_true")
|
||||||
|
arguments = parser.parse_args()
|
||||||
|
root = pathlib.Path(arguments.root).resolve()
|
||||||
|
violations = [item for path in rust_files(root) for item in audit_file(root, path)]
|
||||||
|
violations.sort(key=lambda item: (item.code, item.path, item.line, item.message))
|
||||||
|
if not violations:
|
||||||
|
sys.stdout.write("General Rust rule audit: clean\n")
|
||||||
|
return 0
|
||||||
|
sys.stdout.write(f"General Rust rule audit: {len(violations)} violation(s)\n")
|
||||||
|
for item in violations:
|
||||||
|
sys.stdout.write(f"{item.code} {item.path}:{item.line}: {item.message}\n")
|
||||||
|
return 0 if arguments.report_only else 1
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
33
scripts/audit_rust_workspace_rules.py
Executable file
33
scripts/audit_rust_workspace_rules.py
Executable file
@@ -0,0 +1,33 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
# file: scripts/audit_rust_workspace_rules.py
|
||||||
|
# version: 2
|
||||||
|
|
||||||
|
"""Run both the general Rust and khadhroony-specific workspace audits."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import pathlib
|
||||||
|
import subprocess
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
"""Run both independent audit scripts."""
|
||||||
|
|
||||||
|
parser = argparse.ArgumentParser()
|
||||||
|
parser.add_argument("--root", default=".")
|
||||||
|
parser.add_argument("--report-only", action="store_true")
|
||||||
|
arguments = parser.parse_args()
|
||||||
|
script_dir = pathlib.Path(__file__).resolve().parent
|
||||||
|
commands = [
|
||||||
|
["python3", str(script_dir / "audit_rust_general_rules.py"), "--root", arguments.root],
|
||||||
|
["python3", str(script_dir / "audit_khadhroony_workspace_rules.py"), "--root", arguments.root],
|
||||||
|
]
|
||||||
|
if arguments.report_only:
|
||||||
|
for command in commands:
|
||||||
|
command.append("--report-only")
|
||||||
|
return max(subprocess.run(command, check=False).returncode for command in commands)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
Reference in New Issue
Block a user