v0.4.8-pre.010
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
# file: Cargo.toml
|
||||
# version: 31
|
||||
# version: 32
|
||||
|
||||
[workspace]
|
||||
resolver = "3"
|
||||
@@ -18,7 +18,7 @@ members = [
|
||||
]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.4.8-pre.9"
|
||||
version = "0.4.8-pre.10"
|
||||
edition = "2024"
|
||||
license = "MIT"
|
||||
repository = "https://git.sasedev.com/Sasedev/khadhroony-bot3"
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<!-- file: config/README.md -->
|
||||
<!-- version: 16 -->
|
||||
<!-- version: 17 -->
|
||||
|
||||
# Configuration locale
|
||||
|
||||
@@ -77,3 +77,28 @@ logs/<profil>/kb-lib/materializer/transaction/annotations/
|
||||
```
|
||||
|
||||
Le test de configuration découvre dynamiquement toutes les crates déclarant `tracing.workspace = true` et vérifie leurs trois routes dans chaque profil. Les logs ne doivent contenir ni secret, ni DSN non masqué, ni payload Config ou bytecode complet.
|
||||
|
||||
## Limites du profil public Devnet
|
||||
|
||||
`api.devnet.solana.com` est un endpoint public partagé. Les limites configurées par rôle ne sont pas des quotas indépendants fournis par le serveur : leur débit cumulé doit rester sous la limite globale de l’endpoint, et chaque méthode RPC doit également rester sous sa propre limite.
|
||||
|
||||
L’exemple `local_devnet` utilise donc volontairement des valeurs conservatrices :
|
||||
|
||||
```text
|
||||
http_queries:
|
||||
requests_per_second: 3
|
||||
burst_capacity: 3
|
||||
max_concurrent_requests: 2
|
||||
pause_after_rate_limit_ms: 10000
|
||||
|
||||
http_transactions:
|
||||
requests_per_second: 1
|
||||
burst_capacity: 1
|
||||
max_concurrent_requests: 1
|
||||
pause_after_rate_limit_ms: 10000
|
||||
```
|
||||
|
||||
Un fichier de configuration local déjà créé n’est pas modifié automatiquement lorsque `example.config.json` évolue. L’opérateur doit reporter explicitement ces valeurs dans son profil `local_devnet`.
|
||||
|
||||
Un warning `retry_http_json_rpc_after_rate_limit` reste possible sur un service public partagé. Il indique que le cooldown et le retry borné ont été activés ; il ne constitue un échec que si les tentatives finissent par être épuisées.
|
||||
|
||||
|
||||
@@ -1277,11 +1277,11 @@
|
||||
"request_airdrop"
|
||||
],
|
||||
"priority": 10,
|
||||
"requests_per_second": 8,
|
||||
"burst_capacity": 16,
|
||||
"max_concurrent_requests": 8,
|
||||
"requests_per_second": 3,
|
||||
"burst_capacity": 3,
|
||||
"max_concurrent_requests": 2,
|
||||
"max_subscriptions": 0,
|
||||
"pause_after_rate_limit_ms": 1500
|
||||
"pause_after_rate_limit_ms": 10000
|
||||
},
|
||||
{
|
||||
"role": "http_transactions",
|
||||
@@ -1292,11 +1292,11 @@
|
||||
"get_signature_statuses"
|
||||
],
|
||||
"priority": 20,
|
||||
"requests_per_second": 2,
|
||||
"burst_capacity": 2,
|
||||
"max_concurrent_requests": 2,
|
||||
"requests_per_second": 1,
|
||||
"burst_capacity": 1,
|
||||
"max_concurrent_requests": 1,
|
||||
"max_subscriptions": 0,
|
||||
"pause_after_rate_limit_ms": 3000
|
||||
"pause_after_rate_limit_ms": 10000
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
<!-- file: docs/audits/V0_4_8_PRE_010_METADATA_SOLANA_PROGRAM_DESKTOP_INTEGRATION_AUDIT.md -->
|
||||
<!-- version: 4 -->
|
||||
|
||||
# Audit `0.4.8-pre.010` — structure Metadata et exécution Devnet
|
||||
|
||||
## Périmètre
|
||||
|
||||
La prerelease raccorde `demo_execution_metadata` à la campagne Devnet Solana Program Metadata réutilisable. Le correctif `pre.010-fix-001` traite trois écarts révélés pendant la validation réelle :
|
||||
|
||||
- la structure desktop ne distinguait pas explicitement les contrats communs, Metaplex Token Metadata et Solana Program Metadata ;
|
||||
- le pipeline stateful demandait une lecture complète de `10 MiB`, alors que l’adaptateur `getAccountInfo` borne une réponse complète à `65536` octets ;
|
||||
- l’endpoint public Devnet a renvoyé `429 Too Many Requests` pendant une seconde campagne rapprochée.
|
||||
|
||||
## Structure desktop normalisée
|
||||
|
||||
La surface Rust est séparée en trois modules :
|
||||
|
||||
```text
|
||||
kb-app-demo-desktop/src/demo_execution_metadata.rs
|
||||
kb-app-demo-desktop/src/demo_execution_metadata_metaplex_token_metadata.rs
|
||||
kb-app-demo-desktop/src/demo_execution_metadata_solana_program.rs
|
||||
```
|
||||
|
||||
`demo_execution_metadata.rs` contient uniquement les contrats partagés du panneau :
|
||||
|
||||
- options de profils Devnet ;
|
||||
- payload de progression Metadata ;
|
||||
- observer Metadata commun aux workflows Metaplex et Solana Program Metadata.
|
||||
|
||||
Le module Metaplex contient les contrats, préparateurs et commandes propres à `metadata.metaplex_token_metadata`. Le module Solana Program Metadata contient l’inventaire et l’adaptation de la campagne `metadata.solana_program_metadata`.
|
||||
|
||||
Les types et commandes Tauri Metaplex portent désormais explicitement le segment `metaplex_token_metadata`.
|
||||
|
||||
## Progression et verrou d’exécution
|
||||
|
||||
Le workflow Metaplex réutilisait auparavant `DemoExecutionSolanaCoreObserver`, ce qui ciblait la fenêtre `demo_execution_solana_core`. Les deux workflows Metadata utilisent désormais `DemoExecutionMetadataObserver`, qui émet exclusivement vers :
|
||||
|
||||
```text
|
||||
window = demo_execution_metadata
|
||||
event = demo-execution-metadata-progress
|
||||
```
|
||||
|
||||
Le garde RAII partagé est déplacé dans `demo_devnet_common.rs` sous le nom `DemoExecutionRunGuard`. Son identité ne dépend plus de Solana Core alors qu’il protège également les exécutions SPL et Metadata.
|
||||
|
||||
## Borne des lectures stateful
|
||||
|
||||
Deux limites distinctes sont conservées :
|
||||
|
||||
- `kb_lib::DC_METADATA_SPM_MAX_ACCOUNT_BYTES = 10 MiB` : capacité défensive du décodeur pour des octets déjà disponibles, notamment offline ou via un futur lecteur par tranches ;
|
||||
- `kb_onchain_transport::MAX_COMPLETE_ACCOUNT_DATA_BYTES = 65536` : maximum actuel d’une lecture complète via l’adaptateur d’exécution `getAccountInfo`.
|
||||
|
||||
`kb_pipeline::MAX_SOLANA_PROGRAM_METADATA_STATEFUL_ACCOUNT_BYTES` est alignée sur la seconde limite. Une lecture complète ne peut donc plus être construite avec une valeur que le transport refusera avant l’appel RPC.
|
||||
|
||||
## Résilience aux limites RPC
|
||||
|
||||
`kb-onchain-transport` réessaie désormais les réponses suivantes :
|
||||
|
||||
- statut HTTP `429` ;
|
||||
- erreur JSON-RPC de code `429`.
|
||||
|
||||
Le retry est borné à quatre tentatives supplémentaires. Le délai :
|
||||
|
||||
1. utilise `Retry-After` lorsqu’il contient un nombre de secondes ;
|
||||
2. utilise sinon `pause_after_rate_limit_ms` du rôle compatible ;
|
||||
3. applique un backoff exponentiel plafonné à `60000 ms`.
|
||||
|
||||
Le même objet JSON-RPC est réutilisé. Pour `sendTransaction`, la transaction signée et sa signature restent donc identiques pendant les retries.
|
||||
|
||||
## Validation attendue
|
||||
|
||||
Après application du correctif :
|
||||
|
||||
```bash
|
||||
cargo fmt --all
|
||||
cargo check --workspace
|
||||
cargo clippy --all-targets
|
||||
python3 scripts/audit_rust_workspace_rules.py
|
||||
cargo test -p kb-onchain-transport
|
||||
cargo test -p kb-pipeline
|
||||
cargo test -p kb-app-demo-desktop
|
||||
```
|
||||
|
||||
La campagne Devnet doit ensuite être relancée une seule fois. Les résultats attendus sont :
|
||||
|
||||
- aucun rejet local sur `max_data_bytes` ;
|
||||
- retry visible et borné en cas de `429` ;
|
||||
- deux préfinancements confirmés ;
|
||||
- neuf opérations confirmées ;
|
||||
- snapshots stateful présents, sauf preuve d’absence après `Close`.
|
||||
|
||||
## 8. Correctif de débit HTTP et cible de logging
|
||||
|
||||
La campagne Devnet a montré que les limites de rôle existaient dans la configuration, mais n’étaient pas appliquées par le client HTTP générique utilisé par les RPC d’exécution. Le backfill disposait de son propre pacer ; cette protection ne couvrait pas les lectures stateful, simulations, soumissions et confirmations du desktop.
|
||||
|
||||
Le client HTTP applique désormais avant chaque tentative :
|
||||
|
||||
1. un token bucket partagé fondé sur `requests_per_second` et `burst_capacity` ;
|
||||
2. un sémaphore partagé fondé sur `max_concurrent_requests` ;
|
||||
3. le rôle exact retenu par `HttpEndpointPool` lorsque plusieurs rôles acceptent le même type de requête ;
|
||||
4. un cooldown partagé après une réponse HTTP ou JSON-RPC `429`.
|
||||
|
||||
Le retry borné reste nécessaire : un endpoint public peut imposer un quota dynamique ou partagé avec d’autres processus, adresses IP ou utilisateurs. Un warning `retry_http_json_rpc_after_rate_limit` signifie que la seconde ligne de défense a été activée ; il ne signifie pas que la requête a été perdue.
|
||||
|
||||
La fixture Solana Program Metadata réutilise en outre la cible racine `kb-pipeline-demo-scenarios`. La crate ne déclare donc plus deux constantes de tracing lorsqu’elle possède une cible racine canonique.
|
||||
|
||||
## 9. Diagnostic Devnet du correctif suivant
|
||||
|
||||
La campagne réelle postérieure à l’activation des limiteurs confirme que les réponses `429` sont récupérées : chaque warning observé est suivi du même `request_id` avec `retry_index = 1` et d’une réponse `200 OK`. Elles ralentissent la campagne mais ne constituent pas sa cause terminale.
|
||||
|
||||
L’échec reproductible intervient sur `SetAuthority` après `Initialize` et `SetData` :
|
||||
|
||||
```text
|
||||
InstructionError[0] = InvalidAccountData
|
||||
```
|
||||
|
||||
La fixture initialisait un compte Metadata non canonique puis tentait de réaffirmer son autorité. Le processeur officiel refuse explicitement `SetAuthority` sur un compte Metadata dont le drapeau `canonical` est faux. La même opération est en revanche autorisée sur un Buffer non canonique dès lors qu’une nouvelle autorité non nulle est fournie.
|
||||
|
||||
La campagne corrigée couvre donc toujours les neuf opérations, mais selon l’ordre suivant :
|
||||
|
||||
```text
|
||||
Buffer:
|
||||
Allocate -> Extend -> Write -> SetAuthority -> Trim -> Close
|
||||
|
||||
Metadata non canonique:
|
||||
Initialize -> SetData -> SetImmutable
|
||||
```
|
||||
|
||||
Le profil public Devnet de l’exemple est également abaissé à `3 r/s` pour les lectures et `1 r/s` pour les transactions. Les anciens fichiers de configuration locaux doivent être ajustés manuellement.
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<!-- file: docs/guides/CONFIGURATION.md -->
|
||||
<!-- version: 1 -->
|
||||
<!-- version: 2 -->
|
||||
|
||||
# Guide de configuration
|
||||
|
||||
@@ -59,6 +59,17 @@ println!("active profile={}", profile.name);
|
||||
- une sérialisation publique doit être précédée d’une validation ;
|
||||
- les noms de profils et rôles d’endpoints doivent rester cohérents avec leurs consommateurs.
|
||||
|
||||
## Quotas HTTP cumulés
|
||||
|
||||
Les limites HTTP sont déclarées par rôle, mais les quotas d’un endpoint public s’appliquent généralement à l’endpoint ou à l’adresse IP entière. Pour un endpoint unique, il faut donc considérer au minimum :
|
||||
|
||||
- la somme des `requests_per_second` de tous les rôles actifs ;
|
||||
- la somme des bursts pouvant partir dans la même fenêtre ;
|
||||
- la limite propre à une méthode RPC répétée ;
|
||||
- les autres processus utilisant la même IP ou le même fournisseur.
|
||||
|
||||
Le profil public Devnet de l’exemple utilise `3 r/s` pour `http_queries` et `1 r/s` pour `http_transactions`. Un profil privé ou payant peut utiliser d’autres valeurs, mais elles doivent suivre le contrat réel du fournisseur.
|
||||
|
||||
## Diagnostic
|
||||
|
||||
Pour isoler une erreur :
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
<!-- file: docs/plans/V0_4_8_SOLANA_PROGRAM_METADATA_AND_TOKEN_2022_COMPLETENESS_PLAN.md -->
|
||||
<!-- version: 14 -->
|
||||
<!-- version: 17 -->
|
||||
|
||||
# Plan `0.4.8` — Solana Program Metadata et complétude Token-2022
|
||||
|
||||
## 1. Statut et rôle
|
||||
|
||||
Ce document est le livrable principal de `0.4.8-pre.001`, réconcilié après l’audit contractuel de `0.4.8-pre.002`, les modèles de `pre.003`, les décodeurs de comptes de `pre.004`, le décodeur d’instructions de `pre.005`, la matérialisation de `pre.006`, l’exécuteur de `pre.007`, l’orchestration généraliste de `pre.008` et les scénarios réutilisables de `pre.009`. Il constitue le plan vivant de la version et reste modifiable lorsque l’audit du code, des interfaces officielles, de l’IDL ou des validations Devnet impose un ajustement.
|
||||
Ce document est le livrable principal de `0.4.8-pre.001`, réconcilié après l’audit contractuel de `0.4.8-pre.002`, les modèles de `pre.003`, les décodeurs de comptes de `pre.004`, le décodeur d’instructions de `pre.005`, la matérialisation de `pre.006`, l’exécuteur de `pre.007`, l’orchestration généraliste de `pre.008`, les scénarios réutilisables de `pre.009` et leur intégration desktop de `pre.010`. Il constitue le plan vivant de la version et reste modifiable lorsque l’audit du code, des interfaces officielles, de l’IDL ou des validations Devnet impose un ajustement.
|
||||
|
||||
Il doit être maintenu pendant chaque prerelease, puis archivé pendant la dernière prerelease après transfert des décisions durables vers le ROADMAP, les matrices, les guides, les rapports, les TODO et les changelogs concernés.
|
||||
|
||||
@@ -296,8 +296,8 @@ Travaux parallèles intégrés : les fonctions de matérialisation Metaplex exis
|
||||
|
||||
- séparation stricte : `kb-pipeline` reste généraliste et inchangé, tandis que les fixtures et campagnes réseau appartiennent à `kb-pipeline-demo-scenarios` ;
|
||||
- deux parcours ordonnés couvrent exactement les neuf opérations stables ;
|
||||
- parcours Buffer : `Allocate → Extend → Write → Trim → Close` ;
|
||||
- parcours Metadata : `Initialize → SetData → SetAuthority → SetImmutable` ;
|
||||
- parcours Buffer : `Allocate → Extend → Write → SetAuthority → Trim → Close` ;
|
||||
- parcours Metadata non canonique : `Initialize → SetData → SetImmutable` ;
|
||||
- création de deux PDA non canoniques uniques et préfinancés avec le wallet persistant du profil Devnet ;
|
||||
- calcul RPC du rent pour les tailles initiales et finales ;
|
||||
- runner unitaire simulation-first avec `ExSafetyChecker`, soumission contrôlée, confirmation, lectures avant/après et postconditions ;
|
||||
@@ -306,15 +306,21 @@ Travaux parallèles intégrés : les fonctions de matérialisation Metaplex exis
|
||||
- matrice Devnet conservative à `not_run` avant exécution réelle ;
|
||||
- aucune modification de `demo_execution_metadata` dans cette prerelease.
|
||||
|
||||
### `0.4.8-pre.010` — intégration desktop et campagne Devnet Solana Program Metadata
|
||||
### `0.4.8-pre.010` — intégration desktop et campagne Devnet Solana Program Metadata — terminé côté delta
|
||||
|
||||
- adapter le sous-panneau `ProgM6…` de `demo_execution_metadata` pour consommer exclusivement les runners de `kb-pipeline-demo-scenarios` ;
|
||||
- préparer la fixture, présenter les deux parcours et exiger la confirmation explicite avant les transferts ou mutations ;
|
||||
- exécuter les neuf opérations sur Devnet, avec signatures, simulations, confirmations et postconditions ;
|
||||
- hydrater les signatures confirmées, lancer Core extraction, decode replay et matérialisation canonique ;
|
||||
- mettre à jour la matrice de validation sans convertir une simulation ou une construction synthétique en preuve confirmée ;
|
||||
- confirmer particulièrement `Trim` et `Extend`, absentes de l’échantillon mainnet ;
|
||||
- conserver des résultats JSON dans le JsonViewer commun.
|
||||
- sous-panneau `ProgM6…` séparé dans `demo_execution_metadata`, consommant exclusivement les runners de `kb-pipeline-demo-scenarios` ;
|
||||
- profil Devnet synchronisé avec la section Metaplex et préparation PostgreSQL réutilisée ;
|
||||
- présentation des deux parcours Buffer et Metadata, avec leurs neuf étapes ordonnées ;
|
||||
- confirmation explicite unique avant les deux préfinancements et les neuf mutations ;
|
||||
- exécution de la campagne complète, avec simulations, signatures, confirmations, lectures avant/après, postconditions et snapshots matérialisés ;
|
||||
- progression réseau dirigée vers la fenêtre Metadata ;
|
||||
- résultats intégralement JSON affichés avec le JsonViewer commun ;
|
||||
- aucun doublon d’orchestration dans le desktop et aucune modification fonctionnelle de `kb-pipeline-demo-scenarios` ;
|
||||
- séparation physique entre le module commun Metadata, Metaplex Token Metadata et Solana Program Metadata ;
|
||||
- borne des lectures stateful complètes alignée sur les `65536` octets acceptés par le transport, sans réduire la capacité offline du décodeur ;
|
||||
- retry borné des réponses HTTP ou JSON-RPC `429` afin que la campagne puisse respecter les limites de l’endpoint public Devnet ;
|
||||
- la validation historique `backfill → extraction → replay → matérialisation` reste la preuve de décodage de `pre.008` ; la campagne Devnet fournit séparément les preuves d’exécution et les snapshots stateful confirmés ;
|
||||
- la matrice Devnet reste `not_run` jusqu’à l’exécution opérateur réelle, particulièrement pour `Trim` et `Extend`.
|
||||
|
||||
### `0.4.8-pre.011` — complétude Token-2022 Token Metadata
|
||||
|
||||
|
||||
@@ -1,8 +1,23 @@
|
||||
<!-- file: kb-app-demo-desktop/CHANGELOG.md -->
|
||||
<!-- version: 27 -->
|
||||
<!-- version: 29 -->
|
||||
|
||||
# CHANGELOG — kb-app-demo-desktop
|
||||
|
||||
## 0.4.8-pre.010
|
||||
|
||||
- scinde le panneau Metadata en un module commun, un adaptateur `metadata_metaplex_token_metadata` et un adaptateur `metadata_solana_program` ;
|
||||
- renomme les contrats et commandes Tauri Metaplex afin de rendre leur domaine explicite ;
|
||||
- remplace la réutilisation erronée de l’observer Solana Core par un observer Metadata ciblant exclusivement `demo_execution_metadata` ;
|
||||
- déplace le garde de l’exécution Devnet partagée dans `demo_devnet_common` sous le nom générique `DemoExecutionRunGuard` ;
|
||||
- consomme la borne RPC complète de `65536` octets exposée par `kb-onchain-transport` pour les lectures Solana Program Metadata ;
|
||||
- ajoute un adaptateur desktop séparé pour les deux parcours Solana Program Metadata ;
|
||||
- expose l’inventaire exact des neuf opérations et leurs états attendus ;
|
||||
- synchronise le profil Devnet entre les sections Metaplex et Solana Program Metadata ;
|
||||
- appelle directement la campagne réutilisable de `kb-pipeline-demo-scenarios` sans dupliquer la logique réseau ;
|
||||
- affiche la fixture, les deux signatures de préfinancement, les neuf simulations, confirmations, postconditions et snapshots matérialisés ;
|
||||
- exige une confirmation explicite unique couvrant les deux préfinancements et les neuf mutations ;
|
||||
- conserve les parcours Metaplex existants inchangés.
|
||||
|
||||
## 0.4.8-pre.008
|
||||
|
||||
- enregistre `metadata.solana_program_metadata` dans le replay générique afin que les signatures hydratées par `demo_backfill` puissent être décodées ;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<!-- file: kb-app-demo-desktop/README.md -->
|
||||
<!-- version: 8 -->
|
||||
<!-- version: 9 -->
|
||||
|
||||
# kb-app-demo-desktop
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
- diagnostics PostgreSQL ;
|
||||
- backfill, extraction Core et replay, avec enregistrement générique de Solana Program Metadata ;
|
||||
- démonstrations Solana Core, SPL Memo, SPL Token, ATA et Token-2022 ;
|
||||
- panneau Metadata avec scénarios synthétiques, préparation du profil et de la base, intents typés, simulation RPC Devnet réelle et soumission explicitement confirmée ;
|
||||
- panneau Metadata avec parcours séparés Metaplex Token Metadata et Solana Program Metadata, préparation du profil et de la base, simulation RPC réelle, soumission explicitement confirmée et affichage des postconditions ;
|
||||
- progression, annulation et restauration de l’état des fenêtres.
|
||||
|
||||
## Frontière architecturale
|
||||
@@ -25,6 +25,10 @@ Les scénarios UI Devnet/Testnet restent dans le desktop. Des tests automatisés
|
||||
|
||||
Le panneau conserve les scénarios synthétiques et appelle directement le runner réutilisable de `kb-pipeline-demo-scenarios` pour les opérations Metaplex courantes. La simulation RPC est réelle ; la soumission exige deux autorisations distinctes dans la requête desktop.
|
||||
|
||||
## Exécution Solana Program Metadata
|
||||
|
||||
Le même panneau expose séparément les deux parcours `Buffer` et `Metadata` de `ProgM6…`. La campagne complète crée deux PDA frais, confirme leurs préfinancements, exécute les neuf opérations stables, vérifie les lectures avant/après et affiche les snapshots matérialisés. La logique réseau reste entièrement dans `kb-pipeline-demo-scenarios` ; le desktop ne fait que sélectionner le profil, demander la confirmation et présenter les preuves.
|
||||
|
||||
## Documentation
|
||||
|
||||
- [Guide d’utilisation](USAGE.md)
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<!-- file: kb-app-demo-desktop/TODO.md -->
|
||||
<!-- version: 9 -->
|
||||
<!-- version: 10 -->
|
||||
|
||||
# TODO — kb-app-demo-desktop
|
||||
|
||||
@@ -8,6 +8,11 @@
|
||||
- [ ] UX — empêcher les doubles déclenchements pendant une préparation, simulation ou soumission active.
|
||||
- [ ] Observabilité — afficher explicitement les comptes relus et les projections matérialisées.
|
||||
|
||||
## Validation Devnet Solana Program Metadata
|
||||
|
||||
- [ ] Exécuter la campagne desktop complète sur Devnet et archiver les signatures, confirmations, postconditions et snapshots dans la preuve `0.4.8`.
|
||||
- [ ] Renseigner la matrice réseau uniquement après validation des neuf étapes.
|
||||
|
||||
## Validation Devnet complémentaire Metaplex Token Metadata
|
||||
|
||||
- [ ] Ajouter les parcours spécialisés Print/Burn, collection avancée et pNFT uniquement lorsque leurs fixtures réutilisables sont disponibles.
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<!-- file: kb-app-demo-desktop/USAGE.md -->
|
||||
<!-- version: 6 -->
|
||||
<!-- version: 9 -->
|
||||
|
||||
# Utilisation de kb-app-demo-desktop
|
||||
|
||||
@@ -207,16 +207,18 @@ La simulation et l’envoi doivent rester explicitement distingués dans les req
|
||||
|
||||
### Exécution Metadata Devnet
|
||||
|
||||
Le panneau Metadata charge les opérations courantes depuis `demo_execution_metadata_current_operations` et peut demander un modèle avec `demo_execution_metadata_operation_template`.
|
||||
Le backend distingue le module commun Metadata, Metaplex Token Metadata et Solana Program Metadata.
|
||||
|
||||
Le panneau Metadata charge les opérations courantes depuis `demo_execution_metadata_metaplex_token_metadata_current_operations` et peut demander un modèle avec `demo_execution_metadata_metaplex_token_metadata_operation_template`.
|
||||
|
||||
```typescript
|
||||
const operationJson = await invoke(
|
||||
"demo_execution_metadata_operation_template",
|
||||
"demo_execution_metadata_metaplex_token_metadata_operation_template",
|
||||
{ operation: "verify" }
|
||||
);
|
||||
|
||||
const summary = await invoke(
|
||||
"demo_execution_metadata_execute",
|
||||
"demo_execution_metadata_metaplex_token_metadata_execute",
|
||||
{
|
||||
request: {
|
||||
profileName: "devnet",
|
||||
@@ -303,3 +305,23 @@ Le panneau distingue préparation de l’étape, simulation exacte et soumission
|
||||
### Préparation de la fixture Metaplex `Create`
|
||||
|
||||
Le panneau desktop peut créer ou réutiliser un mint SPL classique Devnet à zéro décimale. Le préparateur dérive ensuite automatiquement les PDA `metadata` et `master edition` et génère un intent `Create` typé. Les valeurs entre chevrons des modèles génériques restent des placeholders non exécutables.
|
||||
|
||||
### Campagne Solana Program Metadata
|
||||
|
||||
Ouvrir l’accordéon **Solana Program Metadata**, sélectionner un profil Devnet persistant et préparer sa base. L’inventaire affiche deux parcours totalisant neuf opérations :
|
||||
|
||||
```text
|
||||
Buffer : Allocate → Extend → Write → SetAuthority → Trim → Close
|
||||
Metadata : Initialize → SetData → SetImmutable
|
||||
```
|
||||
|
||||
Après confirmation explicite, le bouton **Exécuter la campagne complète** appelle le runner réutilisable. Celui-ci réalise deux transferts de préfinancement, puis les neuf simulations et soumissions dans l’ordre. Le panneau affiche :
|
||||
|
||||
- la fixture et les opérations préparées ;
|
||||
- les signatures de préfinancement et les PDA frais ;
|
||||
- les neuf simulations et confirmations ;
|
||||
- les lectures stateful avant/après ;
|
||||
- les rapports de postcondition ;
|
||||
- les snapshots matérialisés, sauf pour `Close` qui exige l’absence du compte.
|
||||
|
||||
La campagne s’arrête au premier échec. Elle ne reprend pas un parcours partiellement exécuté et ne réutilise pas les PDA d’une campagne précédente.
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<!-- file: kb-app-demo-desktop/frontend/demo_execution_metadata.html -->
|
||||
<!-- version: 9 -->
|
||||
<!-- version: 10 -->
|
||||
<!DOCTYPE html>
|
||||
<html lang="fr">
|
||||
|
||||
@@ -23,20 +23,27 @@
|
||||
<main class="app-main">
|
||||
<div class="osb-scrollable pt-1 pb-4" data-simplebar>
|
||||
<div class="container-fluid px-4 py-4">
|
||||
<div class="alert alert-info">Cette fenêtre accueille les familles d’exécution metadata. Metaplex Token Metadata est la première famille ; SPL Token Metadata sera ajouté ultérieurement sans changer la frontière de la fenêtre.</div>
|
||||
<div class="alert alert-info">Cette fenêtre sépare les familles metadata. Metaplex Token Metadata conserve ses scénarios propres ; Solana Program Metadata exécute les neuf opérations stables de <code>ProgM6…</code> via deux parcours Devnet. Token-2022 Token Metadata sera ajouté dans une étape ultérieure.</div>
|
||||
<div class="accordion shadow-sm mb-4" id="metadataGuideAccordion">
|
||||
<div class="accordion-item">
|
||||
<h2 class="accordion-header"><button class="accordion-button collapsed" data-bs-target="#metadataGuideCollapse" data-bs-toggle="collapse" type="button">Guide d’exécution Metaplex Devnet</button></h2>
|
||||
<h2 class="accordion-header"><button class="accordion-button collapsed" data-bs-target="#metadataGuideCollapse" data-bs-toggle="collapse" type="button">Guide d’exécution Metadata Devnet</button></h2>
|
||||
<div class="accordion-collapse collapse" id="metadataGuideCollapse" data-bs-parent="#metadataGuideAccordion">
|
||||
<div class="accordion-body">
|
||||
<ol class="mb-2">
|
||||
<h3 class="h6">Metaplex Token Metadata</h3>
|
||||
<ol class="mb-3">
|
||||
<li>Choisir un profil Devnet puis préparer sa base PostgreSQL.</li>
|
||||
<li>Choisir le parcours Devnet puis son étape. Le parcours actuellement exécutable couvre <code>create</code> puis <code>update</code> sur un NFT classique.</li>
|
||||
<li>Cliquer sur <strong>Préparer l’étape courante</strong> : l’intent, les lectures stateful et les postconditions sont générés ensemble.</li>
|
||||
<li>Lancer d’abord une simulation. La soumission exige les deux confirmations explicites.</li>
|
||||
<li>Contrôler le journal, le plan exact, la simulation RPC, la signature éventuelle et les états avant/après.</li>
|
||||
<li>Choisir le parcours Devnet puis son étape.</li>
|
||||
<li>Préparer l’étape afin de générer ensemble l’intent, les lectures stateful et les postconditions.</li>
|
||||
<li>Lancer d’abord une simulation. La soumission exige une confirmation explicite.</li>
|
||||
</ol>
|
||||
<div class="small text-muted">Les chaînes entre chevrons sont des placeholders non exécutables. Ne jamais les soumettre telles quelles.</div>
|
||||
<h3 class="h6">Solana Program Metadata</h3>
|
||||
<ol class="mb-2">
|
||||
<li>Choisir et préparer un profil Devnet persistant, autorisé à soumettre.</li>
|
||||
<li>Contrôler les deux parcours et leurs neuf étapes dans l’inventaire.</li>
|
||||
<li>Confirmer la campagne complète : deux transferts de préfinancement puis neuf transactions <code>ProgM6…</code>.</li>
|
||||
<li>Contrôler les simulations, signatures, confirmations, lectures avant/après, postconditions et snapshots matérialisés.</li>
|
||||
</ol>
|
||||
<div class="small text-muted">La campagne Solana Program Metadata est indivisible : elle crée des PDA frais et arrête immédiatement le parcours au premier échec ou à la première preuve manquante.</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -80,7 +87,7 @@
|
||||
<button class="btn btn-outline-secondary flex-fill" id="nextMetadataStepButton" type="button">Étape suivante</button>
|
||||
</div>
|
||||
<div class="d-grid mt-2"><button class="btn btn-outline-success" id="prepareMetadataScenarioStepButton" type="button">Préparer l’étape courante</button></div>
|
||||
<div class="form-text">Le scénario remplit ensemble l’intent, les lectures avant/après et les cibles de matérialisation. Les étapes non encore préparables sont refusées explicitement.</div>
|
||||
<div class="form-text">Le scénario remplit ensemble l’intent, les lectures avant/après et les cibles de matérialisation.</div>
|
||||
<label class="form-label mt-3" for="metadataOperationJson">Intent Metaplex typé</label>
|
||||
<textarea class="form-control font-monospace" id="metadataOperationJson" rows="12" spellcheck="false"></textarea>
|
||||
<label class="form-label mt-3" for="metadataPreflightReadsJson">Lectures stateful avant simulation</label>
|
||||
@@ -106,11 +113,36 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="d-grid mt-3">
|
||||
<button class="btn btn-primary" id="refreshMetadataScenariosButton" type="button">Recharger les contrats</button>
|
||||
<button class="btn btn-primary" id="refreshMetadataScenariosButton" type="button">Recharger les contrats Metaplex</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="accordion-item">
|
||||
<h2 class="accordion-header">
|
||||
<button class="accordion-button collapsed" data-bs-target="#solanaProgramMetadataCollapse" data-bs-toggle="collapse" type="button">Solana Program Metadata</button>
|
||||
</h2>
|
||||
<div class="accordion-collapse collapse" id="solanaProgramMetadataCollapse" data-bs-parent="#metadataExecutionAccordion">
|
||||
<div class="accordion-body">
|
||||
<label class="form-label" for="solanaProgramMetadataProfileSelect">Profil Devnet</label>
|
||||
<select class="form-select" id="solanaProgramMetadataProfileSelect"></select>
|
||||
<div class="form-text mb-3" id="solanaProgramMetadataProfileHelp">Chargement du profil Devnet.</div>
|
||||
<div class="d-grid"><button class="btn btn-outline-primary" id="prepareSolanaProgramMetadataProfileButton" type="button">Charger le profil et préparer la base</button></div>
|
||||
<div class="d-flex justify-content-between align-items-center mt-3"><span class="small text-muted">État du profil</span><span class="badge text-bg-secondary" id="solanaProgramMetadataProfileStatusBadge">Non chargé</span></div>
|
||||
<div class="mt-3" id="solanaProgramMetadataProfileOutput"></div>
|
||||
<label class="form-label mt-3" for="solanaProgramMetadataScenarioSelect">Parcours</label>
|
||||
<select class="form-select" id="solanaProgramMetadataScenarioSelect"></select>
|
||||
<div class="mt-3" id="solanaProgramMetadataScenarioOutput"></div>
|
||||
<div class="alert alert-warning mt-3">Cette campagne soumet onze transactions : deux transferts de préfinancement et les neuf opérations stables. Elle utilise des PDA frais et ne peut pas reprendre au milieu d’un parcours.</div>
|
||||
<div class="form-check mt-3">
|
||||
<input class="form-check-input" id="solanaProgramMetadataOperatorConfirmed" type="checkbox">
|
||||
<label class="form-check-label" for="solanaProgramMetadataOperatorConfirmed">Je confirme le préfinancement et les neuf mutations Devnet</label>
|
||||
</div>
|
||||
<div class="d-grid mt-3"><button class="btn btn-danger" id="executeSolanaProgramMetadataCampaignButton" type="button">Exécuter la campagne complète</button></div>
|
||||
<div class="form-text">Le runner arrête la campagne dès qu’une simulation, une confirmation, une postcondition ou une matérialisation requise échoue.</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card shadow-sm border-0 mt-4">
|
||||
<div class="card-body">
|
||||
@@ -152,4 +184,4 @@
|
||||
<script type="module" src="ts/demo_execution_metadata.ts"></script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
</html>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// file: kb-app-demo-desktop/frontend/ts/demo_execution_metadata.ts
|
||||
// version: 10
|
||||
// version: 12
|
||||
|
||||
import * as bootstrap from "bootstrap";
|
||||
import "simplebar";
|
||||
@@ -7,18 +7,24 @@ import { invoke } from "@tauri-apps/api/core";
|
||||
import { listen } from "@tauri-apps/api/event";
|
||||
import { frontendDebug, frontendError, installFrontendConsoleBridge } from "./frontend_log.ts";
|
||||
import { renderJsonViewer } from "./json_viewer.ts";
|
||||
import type { DemoExecutionMetadataScenarioPayload } from "./bindings/kb_app_demo_desktop/demo_execution_metadata/DemoExecutionMetadataScenarioPayload.ts";
|
||||
import type { DemoExecutionMetadataPreparedStepPayload } from "./bindings/kb_app_demo_desktop/demo_execution_metadata/DemoExecutionMetadataPreparedStepPayload.ts";
|
||||
import type { DemoExecutionMetadataRequest } from "./bindings/kb_app_demo_desktop/demo_execution_metadata/DemoExecutionMetadataRequest.ts";
|
||||
import type { DemoExecutionMetadataSummaryPayload } from "./bindings/kb_app_demo_desktop/demo_execution_metadata/DemoExecutionMetadataSummaryPayload.ts";
|
||||
import type { DemoExecutionMetadataMetaplexTokenMetadataScenarioPayload } from "./bindings/kb_app_demo_desktop/demo_execution_metadata/DemoExecutionMetadataMetaplexTokenMetadataScenarioPayload.ts";
|
||||
import type { DemoExecutionMetadataProgressPayload } from "./bindings/kb_app_demo_desktop/demo_execution_metadata/DemoExecutionMetadataProgressPayload.ts";
|
||||
import type { DemoExecutionMetadataMetaplexTokenMetadataPreparedStepPayload } from "./bindings/kb_app_demo_desktop/demo_execution_metadata/DemoExecutionMetadataMetaplexTokenMetadataPreparedStepPayload.ts";
|
||||
import type { DemoExecutionMetadataMetaplexTokenMetadataRequest } from "./bindings/kb_app_demo_desktop/demo_execution_metadata/DemoExecutionMetadataMetaplexTokenMetadataRequest.ts";
|
||||
import type { DemoExecutionMetadataMetaplexTokenMetadataSummaryPayload } from "./bindings/kb_app_demo_desktop/demo_execution_metadata/DemoExecutionMetadataMetaplexTokenMetadataSummaryPayload.ts";
|
||||
import type { DemoExecutionMetadataSolanaProgramCampaignRequest } from "./bindings/kb_app_demo_desktop/demo_execution_metadata/DemoExecutionMetadataSolanaProgramCampaignRequest.ts";
|
||||
import type { DemoExecutionMetadataSolanaProgramCampaignSummaryPayload } from "./bindings/kb_app_demo_desktop/demo_execution_metadata/DemoExecutionMetadataSolanaProgramCampaignSummaryPayload.ts";
|
||||
import type { DemoExecutionMetadataSolanaProgramScenarioPayload } from "./bindings/kb_app_demo_desktop/demo_execution_metadata/DemoExecutionMetadataSolanaProgramScenarioPayload.ts";
|
||||
import type { DemoExecutionDevnetStoreReadinessPayload } from "./bindings/kb_app_demo_desktop/demo_execution_solana_core/DemoExecutionDevnetStoreReadinessPayload.ts";
|
||||
import type { DemoExecutionSolanaCoreOptionsPayload } from "./bindings/kb_app_demo_desktop/demo_execution_solana_core/DemoExecutionSolanaCoreOptionsPayload.ts";
|
||||
import type { DemoExecutionSolanaCoreProfileOption } from "./bindings/kb_app_demo_desktop/demo_execution_solana_core/DemoExecutionSolanaCoreProfileOption.ts";
|
||||
|
||||
const storageKey = "kb-app-demo-desktop.metadata.selected-scenario";
|
||||
const solanaProgramMetadataStorageKey = "kb-app-demo-desktop.metadata.solana-program.selected-scenario";
|
||||
const profileStorageKey = "kb-app-demo-desktop.metadata.selected-profile";
|
||||
(window as Window & typeof globalThis & { bootstrap?: typeof bootstrap }).bootstrap = bootstrap;
|
||||
let scenarios: DemoExecutionMetadataScenarioPayload[] = [];
|
||||
let scenarios: DemoExecutionMetadataMetaplexTokenMetadataScenarioPayload[] = [];
|
||||
let solanaProgramMetadataScenarios: DemoExecutionMetadataSolanaProgramScenarioPayload[] = [];
|
||||
let selectedProfileName: string | null = null;
|
||||
let executionRunning = false;
|
||||
let profileStoreReady = false;
|
||||
@@ -28,9 +34,9 @@ let preparedStepIndex: number | null = null;
|
||||
const metadataLogLines: string[] = [];
|
||||
const maximumMetadataLogLines = 1200;
|
||||
|
||||
type MetadataProgressPayload = { timestamp: string; level: string; stage: string; message: string; signature?: string | null };
|
||||
type MetadataProgressInput = Omit<DemoExecutionMetadataProgressPayload, "signature"> & { signature?: string | null };
|
||||
|
||||
function appendMetadataLog(payload: MetadataProgressPayload): void {
|
||||
function appendMetadataLog(payload: MetadataProgressInput): void {
|
||||
const signature = payload.signature ? ` signature=${payload.signature}` : "";
|
||||
metadataLogLines.push(`${payload.timestamp} ${payload.level.toUpperCase()} [${payload.stage}] ${payload.message}${signature}`);
|
||||
if (metadataLogLines.length > maximumMetadataLogLines) {
|
||||
@@ -51,11 +57,34 @@ function element<T extends HTMLElement>(selector: string): T {
|
||||
return value;
|
||||
}
|
||||
|
||||
const profileSelectSelectors = ["#metadataProfileSelect", "#solanaProgramMetadataProfileSelect"] as const;
|
||||
const profileHelpSelectors = ["#metadataProfileHelp", "#solanaProgramMetadataProfileHelp"] as const;
|
||||
const profileStatusSelectors = ["#metadataProfileStatusBadge", "#solanaProgramMetadataProfileStatusBadge"] as const;
|
||||
const profileOutputSelectors = ["#metadataProfileOutput", "#solanaProgramMetadataProfileOutput"] as const;
|
||||
|
||||
function synchronizeProfileSelections(profileName: string): void {
|
||||
for (const selector of profileSelectSelectors) {
|
||||
const select = element<HTMLSelectElement>(selector);
|
||||
if (Array.from(select.options).some(option => option.value === profileName)) {
|
||||
select.value = profileName;
|
||||
}
|
||||
}
|
||||
selectedProfileName = profileName.length === 0 ? null : profileName;
|
||||
}
|
||||
|
||||
function updateProfileStatus(text: string, className: string): void {
|
||||
for (const selector of profileStatusSelectors) {
|
||||
const badge = element<HTMLElement>(selector);
|
||||
badge.textContent = text;
|
||||
badge.className = className;
|
||||
}
|
||||
}
|
||||
|
||||
function activeSelect(): HTMLSelectElement {
|
||||
return document.querySelector("#metaplexDevnetCollapse.show") !== null ? element<HTMLSelectElement>("#metadataDevnetScenarioSelect") : element<HTMLSelectElement>("#metadataSyntheticScenarioSelect");
|
||||
}
|
||||
|
||||
function selectedScenario(): DemoExecutionMetadataScenarioPayload | null {
|
||||
function selectedScenario(): DemoExecutionMetadataMetaplexTokenMetadataScenarioPayload | null {
|
||||
const id = activeSelect().value;
|
||||
return scenarios.find(scenario => scenario.id === id) ?? null;
|
||||
}
|
||||
@@ -76,7 +105,7 @@ function operationName(operationCode: string): string {
|
||||
return parts.length === 0 ? operationCode : parts[parts.length - 1] ?? operationCode;
|
||||
}
|
||||
|
||||
function renderScenarioSteps(scenario: DemoExecutionMetadataScenarioPayload): void {
|
||||
function renderScenarioSteps(scenario: DemoExecutionMetadataMetaplexTokenMetadataScenarioPayload): void {
|
||||
const select = element<HTMLSelectElement>("#metadataStepSelect");
|
||||
const previousValue = Number.parseInt(select.value, 10);
|
||||
select.replaceChildren();
|
||||
@@ -145,7 +174,7 @@ async function prepareScenarioStep(): Promise<void> {
|
||||
if (scenario === null || scenario.mode === "synthetic") { throw new Error("Sélectionnez un scénario Devnet."); }
|
||||
if (!Number.isInteger(stepIndex) || stepIndex < 0) { throw new Error("Sélectionnez une étape valide."); }
|
||||
appendMetadataLog({ timestamp: new Date().toISOString(), level: "info", stage: "fixture", message: `Préparation de ${scenario.id} étape ${stepIndex + 1}` });
|
||||
const prepared = await invoke<DemoExecutionMetadataPreparedStepPayload>("demo_execution_metadata_prepare_step", {
|
||||
const prepared = await invoke<DemoExecutionMetadataMetaplexTokenMetadataPreparedStepPayload>("demo_execution_metadata_metaplex_token_metadata_prepare_step", {
|
||||
profileName,
|
||||
scenarioId: scenario.id,
|
||||
stepIndex,
|
||||
@@ -193,18 +222,20 @@ function parseJsonText(value: string, label: string): unknown {
|
||||
}
|
||||
|
||||
function selectedProfile(): DemoExecutionSolanaCoreProfileOption | null {
|
||||
const name = element<HTMLSelectElement>("#metadataProfileSelect").value;
|
||||
return profileOptions.find(profile => profile.name === name) ?? null;
|
||||
if (selectedProfileName === null) {
|
||||
return null;
|
||||
}
|
||||
return profileOptions.find(profile => profile.name === selectedProfileName) ?? null;
|
||||
}
|
||||
|
||||
function updateProfileHelp(): void {
|
||||
const profile = selectedProfile();
|
||||
const help = element<HTMLElement>("#metadataProfileHelp");
|
||||
if (profile === null) {
|
||||
help.textContent = "Aucun profil Devnet compatible.";
|
||||
return;
|
||||
const text = profile === null
|
||||
? "Aucun profil Devnet compatible."
|
||||
: `wallet=${profile.walletAlias}, spendMax=${profile.maxSpendLamports}, airdropMax=${profile.maxAirdropLamports}, send=${profile.sendEnabled ? "enabled" : "disabled"}`;
|
||||
for (const selector of profileHelpSelectors) {
|
||||
element<HTMLElement>(selector).textContent = text;
|
||||
}
|
||||
help.textContent = `wallet=${profile.walletAlias}, spendMax=${profile.maxSpendLamports}, airdropMax=${profile.maxAirdropLamports}, send=${profile.sendEnabled ? "enabled" : "disabled"}`;
|
||||
}
|
||||
|
||||
function setExecutionRunning(value: boolean): void {
|
||||
@@ -215,6 +246,8 @@ function setExecutionRunning(value: boolean): void {
|
||||
element<HTMLButtonElement>("#simulateMetadataButton").disabled = unavailable;
|
||||
element<HTMLButtonElement>("#submitMetadataButton").disabled = unavailable || !profile?.sendEnabled;
|
||||
element<HTMLButtonElement>("#prepareMetadataScenarioStepButton").disabled = value || profile === null || !profileStoreReady;
|
||||
const solanaProgramConfirmed = element<HTMLInputElement>("#solanaProgramMetadataOperatorConfirmed").checked;
|
||||
element<HTMLButtonElement>("#executeSolanaProgramMetadataCampaignButton").disabled = value || profile === null || !profileStoreReady || !profile.sendEnabled || !solanaProgramConfirmed;
|
||||
}
|
||||
|
||||
async function executeMetadata(submit: boolean): Promise<void> {
|
||||
@@ -243,7 +276,7 @@ async function executeMetadata(submit: boolean): Promise<void> {
|
||||
if (submit && !operatorConfirmed) {
|
||||
throw new Error("La soumission exige la confirmation explicite de l’opérateur.");
|
||||
}
|
||||
const request: DemoExecutionMetadataRequest = {
|
||||
const request: DemoExecutionMetadataMetaplexTokenMetadataRequest = {
|
||||
profileName,
|
||||
intentId: `desktop-metaplex-${Date.now()}`,
|
||||
operationJson,
|
||||
@@ -259,7 +292,7 @@ async function executeMetadata(submit: boolean): Promise<void> {
|
||||
badge.className = "badge text-bg-warning";
|
||||
appendMetadataLog({ timestamp: new Date().toISOString(), level: "info", stage: submit ? "submit" : "simulation", message: submit ? "Démarrage du parcours Devnet complet." : "Démarrage de la simulation RPC exacte." });
|
||||
try {
|
||||
const summary = await invoke<DemoExecutionMetadataSummaryPayload>("demo_execution_metadata_execute", { request });
|
||||
const summary = await invoke<DemoExecutionMetadataMetaplexTokenMetadataSummaryPayload>("demo_execution_metadata_metaplex_token_metadata_execute", { request });
|
||||
renderJsonViewer("#metadataPreflightOutput", titled("Préflight et opérations", JSON.parse(summary.preflightJson)));
|
||||
renderJsonViewer("#metadataSimulationOutput", titled("Simulation et confirmation", {
|
||||
profileName: summary.profileName,
|
||||
@@ -283,65 +316,160 @@ async function executeMetadata(submit: boolean): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
function selectedSolanaProgramMetadataScenario(): DemoExecutionMetadataSolanaProgramScenarioPayload | null {
|
||||
const id = element<HTMLSelectElement>("#solanaProgramMetadataScenarioSelect").value;
|
||||
return solanaProgramMetadataScenarios.find(scenario => scenario.id === id) ?? null;
|
||||
}
|
||||
|
||||
function renderActiveMetadataSelection(): void {
|
||||
if (document.querySelector("#solanaProgramMetadataCollapse.show") !== null) {
|
||||
renderSolanaProgramMetadataScenario();
|
||||
return;
|
||||
}
|
||||
renderSelectedScenario();
|
||||
}
|
||||
|
||||
function renderSolanaProgramMetadataScenario(): void {
|
||||
const scenario = selectedSolanaProgramMetadataScenario();
|
||||
if (scenario === null) {
|
||||
return;
|
||||
}
|
||||
window.localStorage.setItem(solanaProgramMetadataStorageKey, scenario.id);
|
||||
renderJsonViewer("#solanaProgramMetadataScenarioOutput", titled("Parcours Solana Program Metadata", {
|
||||
scenarioId: scenario.id,
|
||||
label: scenario.label,
|
||||
fixtureAccount: scenario.fixtureAccount,
|
||||
steps: scenario.steps,
|
||||
}));
|
||||
const allSteps = solanaProgramMetadataScenarios.flatMap(candidate => candidate.steps);
|
||||
element<HTMLElement>("#metadataRequirements").innerHTML = `<dt class="col-6">Famille</dt><dd class="col-6"><code>metadata.solana_program_metadata</code></dd><dt class="col-6">Program ID</dt><dd class="col-6"><code>ProgM6JCCvbYkfKqJYHePx4xxSUSqJp7rh8Lyv7nk7S</code></dd><dt class="col-6">Parcours affiché</dt><dd class="col-6">${scenario.label}</dd><dt class="col-6">Étapes totales</dt><dd class="col-6">${allSteps.length}</dd><dt class="col-6">Transactions</dt><dd class="col-6">11 minimum</dd><dt class="col-6">Profil</dt><dd class="col-6"><code>${selectedProfileName ?? "non chargé"}</code></dd>`;
|
||||
renderJsonViewer("#metadataPreflightOutput", titled("Préflight et opérations", {
|
||||
family: "metadata.solana_program_metadata",
|
||||
journeys: solanaProgramMetadataScenarios,
|
||||
operationCount: allSteps.length,
|
||||
fixtureAccounts: ["buffer", "metadata"],
|
||||
prefundingTransactionCount: 2,
|
||||
}));
|
||||
renderJsonViewer("#metadataSimulationOutput", titled("Simulation et confirmation", {
|
||||
policy: "simulation_first",
|
||||
submission: "required",
|
||||
operatorConfirmationRequired: true,
|
||||
stopOnFirstFailure: true,
|
||||
networkExecutionPerformed: false,
|
||||
}));
|
||||
renderJsonViewer("#metadataPostconditionsOutput", titled("Postconditions et preuves", {
|
||||
requiredEvidence: ["stateful_preflight", "rpc_simulation", "confirmed_signature", "stateful_postcondition", "materialized_snapshot_or_account_absence"],
|
||||
currentStatus: "not_run",
|
||||
networkEvidenceCollected: false,
|
||||
}));
|
||||
}
|
||||
|
||||
async function executeSolanaProgramMetadataCampaign(): Promise<void> {
|
||||
if (executionRunning) {
|
||||
return;
|
||||
}
|
||||
const profile = selectedProfile();
|
||||
if (profile === null || selectedProfileName === null || !profileStoreReady) {
|
||||
throw new Error("Sélectionnez et préparez un profil Devnet.");
|
||||
}
|
||||
if (!profile.sendEnabled) {
|
||||
throw new Error("Le profil sélectionné n’autorise pas les soumissions Devnet.");
|
||||
}
|
||||
const operatorConfirmed = element<HTMLInputElement>("#solanaProgramMetadataOperatorConfirmed").checked;
|
||||
if (!operatorConfirmed) {
|
||||
throw new Error("La campagne exige la confirmation explicite du préfinancement et des neuf mutations.");
|
||||
}
|
||||
const request: DemoExecutionMetadataSolanaProgramCampaignRequest = {
|
||||
profileName: selectedProfileName,
|
||||
operatorConfirmed,
|
||||
};
|
||||
setExecutionRunning(true);
|
||||
const badge = element<HTMLElement>("#metadataStatusBadge");
|
||||
badge.textContent = "Campagne ProgM6";
|
||||
badge.className = "badge text-bg-warning";
|
||||
appendMetadataLog({ timestamp: new Date().toISOString(), level: "info", stage: "spm_campaign", message: "Démarrage de la campagne complète : 2 préfinancements et 9 opérations." });
|
||||
try {
|
||||
const summary = await invoke<DemoExecutionMetadataSolanaProgramCampaignSummaryPayload>("demo_execution_metadata_solana_program_execute_campaign", { request });
|
||||
renderJsonViewer("#metadataPreflightOutput", titled("Fixture et opérations préparées", JSON.parse(summary.fixtureJson)));
|
||||
renderJsonViewer("#metadataSimulationOutput", titled("Synthèse de campagne", {
|
||||
profileName: summary.profileName,
|
||||
authority: summary.authority,
|
||||
describedProgram: summary.describedProgram,
|
||||
buffer: summary.buffer,
|
||||
bufferPrefundSignature: summary.bufferPrefundSignature,
|
||||
metadata: summary.metadata,
|
||||
metadataPrefundSignature: summary.metadataPrefundSignature,
|
||||
stepCount: summary.stepCount,
|
||||
confirmedStepCount: summary.confirmedStepCount,
|
||||
materializedSnapshotCount: summary.materializedSnapshotCount,
|
||||
completed: summary.completed,
|
||||
}));
|
||||
renderJsonViewer("#metadataPostconditionsOutput", titled("Preuves ordonnées des neuf opérations", JSON.parse(summary.stepsJson)));
|
||||
element<HTMLElement>("#metadataRequirements").innerHTML = `<dt class="col-6">Famille</dt><dd class="col-6"><code>metadata.solana_program_metadata</code></dd><dt class="col-6">Profil</dt><dd class="col-6"><code>${summary.profileName}</code></dd><dt class="col-6">Buffer</dt><dd class="col-6"><code>${summary.buffer}</code></dd><dt class="col-6">Metadata</dt><dd class="col-6"><code>${summary.metadata}</code></dd><dt class="col-6">Confirmées</dt><dd class="col-6">${summary.confirmedStepCount}/${summary.stepCount}</dd><dt class="col-6">Snapshots</dt><dd class="col-6">${summary.materializedSnapshotCount}</dd>`;
|
||||
badge.textContent = summary.completed ? "Campagne confirmée" : "Campagne incomplète";
|
||||
badge.className = summary.completed ? "badge text-bg-success" : "badge text-bg-danger";
|
||||
appendMetadataLog({ timestamp: new Date().toISOString(), level: summary.completed ? "info" : "error", stage: "spm_campaign", message: `Campagne terminée : confirmed=${summary.confirmedStepCount}/${summary.stepCount}, snapshots=${summary.materializedSnapshotCount}` });
|
||||
} finally {
|
||||
setExecutionRunning(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function loadProfiles(): Promise<void> {
|
||||
const options = await invoke<DemoExecutionSolanaCoreOptionsPayload>("demo_execution_metadata_options");
|
||||
profileOptions = options.profiles;
|
||||
const select = element<HTMLSelectElement>("#metadataProfileSelect");
|
||||
select.replaceChildren();
|
||||
for (const profile of options.profiles) {
|
||||
const option = document.createElement("option");
|
||||
option.value = profile.name;
|
||||
option.textContent = `${profile.name} — ${profile.walletAlias}`;
|
||||
select.append(option);
|
||||
for (const selector of profileSelectSelectors) {
|
||||
const select = element<HTMLSelectElement>(selector);
|
||||
select.replaceChildren();
|
||||
for (const profile of options.profiles) {
|
||||
const option = document.createElement("option");
|
||||
option.value = profile.name;
|
||||
option.textContent = `${profile.name} — ${profile.walletAlias}`;
|
||||
select.append(option);
|
||||
}
|
||||
}
|
||||
const restored = window.localStorage.getItem(profileStorageKey);
|
||||
if (restored !== null && Array.from(select.options).some(option => option.value === restored)) {
|
||||
select.value = restored;
|
||||
} else if (options.defaultProfileName !== null) {
|
||||
select.value = options.defaultProfileName;
|
||||
}
|
||||
selectedProfileName = select.value.length === 0 ? null : select.value;
|
||||
const availableNames = options.profiles.map(profile => profile.name);
|
||||
const selected = restored !== null && availableNames.includes(restored)
|
||||
? restored
|
||||
: options.defaultProfileName ?? availableNames[0] ?? "";
|
||||
synchronizeProfileSelections(selected);
|
||||
updateProfileHelp();
|
||||
if (options.profiles.length === 0) {
|
||||
const message = "Aucun profil Devnet compatible n’a été retourné par la configuration active.";
|
||||
appendMetadataLog({ timestamp: new Date().toISOString(), level: "error", stage: "profile_options", message });
|
||||
element<HTMLElement>("#metadataProfileStatusBadge").textContent = "Aucun profil";
|
||||
element<HTMLElement>("#metadataProfileStatusBadge").className = "badge text-bg-danger";
|
||||
renderSelectedScenario();
|
||||
updateProfileStatus("Aucun profil", "badge text-bg-danger");
|
||||
renderActiveMetadataSelection();
|
||||
return;
|
||||
}
|
||||
appendMetadataLog({ timestamp: new Date().toISOString(), level: "info", stage: "profile_options", message: `${options.profiles.length} profil(s) Devnet chargé(s).` });
|
||||
renderSelectedScenario();
|
||||
renderActiveMetadataSelection();
|
||||
await prepareProfile();
|
||||
}
|
||||
|
||||
async function prepareProfile(): Promise<void> {
|
||||
const badge = element<HTMLElement>("#metadataProfileStatusBadge");
|
||||
const profileName = element<HTMLSelectElement>("#metadataProfileSelect").value;
|
||||
const profileName = selectedProfileName ?? "";
|
||||
if (profileName.length === 0) {
|
||||
badge.textContent = "Aucun profil";
|
||||
badge.className = "badge text-bg-danger";
|
||||
updateProfileStatus("Aucun profil", "badge text-bg-danger");
|
||||
return;
|
||||
}
|
||||
profileStoreReady = false;
|
||||
setExecutionRunning(executionRunning);
|
||||
badge.textContent = "Préparation";
|
||||
badge.className = "badge text-bg-warning";
|
||||
updateProfileStatus("Préparation", "badge text-bg-warning");
|
||||
try {
|
||||
const readiness = await invoke<DemoExecutionDevnetStoreReadinessPayload>("demo_execution_devnet_prepare_profile", { profileName });
|
||||
selectedProfileName = profileName;
|
||||
synchronizeProfileSelections(profileName);
|
||||
window.localStorage.setItem(profileStorageKey, profileName);
|
||||
renderJsonViewer("#metadataProfileOutput", readiness);
|
||||
for (const selector of profileOutputSelectors) {
|
||||
renderJsonViewer(selector, readiness);
|
||||
}
|
||||
profileStoreReady = true;
|
||||
badge.textContent = "Base prête";
|
||||
badge.className = "badge text-bg-success";
|
||||
updateProfileStatus("Base prête", "badge text-bg-success");
|
||||
setExecutionRunning(executionRunning);
|
||||
renderSelectedScenario();
|
||||
renderActiveMetadataSelection();
|
||||
} catch (caught) {
|
||||
const message = caught instanceof Error ? caught.message : String(caught);
|
||||
profileStoreReady = false;
|
||||
badge.textContent = "Erreur";
|
||||
badge.className = "badge text-bg-danger";
|
||||
updateProfileStatus("Erreur", "badge text-bg-danger");
|
||||
setExecutionRunning(executionRunning);
|
||||
frontendError("kb-app-demo-desktop.frontend.execution_metadata", message);
|
||||
}
|
||||
@@ -352,7 +480,8 @@ async function loadScenarios(): Promise<void> {
|
||||
badge.textContent = "Chargement";
|
||||
badge.className = "badge text-bg-warning";
|
||||
try {
|
||||
scenarios = await invoke<DemoExecutionMetadataScenarioPayload[]>("demo_execution_metadata_scenarios");
|
||||
scenarios = await invoke<DemoExecutionMetadataMetaplexTokenMetadataScenarioPayload[]>("demo_execution_metadata_metaplex_token_metadata_scenarios");
|
||||
solanaProgramMetadataScenarios = await invoke<DemoExecutionMetadataSolanaProgramScenarioPayload[]>("demo_execution_metadata_solana_program_scenarios");
|
||||
fillSelect("#metadataSyntheticScenarioSelect", "synthetic");
|
||||
fillSelect("#metadataDevnetScenarioSelect", "network_simulation");
|
||||
const restored = window.localStorage.getItem(storageKey);
|
||||
@@ -362,8 +491,20 @@ async function loadScenarios(): Promise<void> {
|
||||
select.value = restored;
|
||||
}
|
||||
}
|
||||
renderSelectedScenario();
|
||||
badge.textContent = `${scenarios.length} contrats`;
|
||||
const solanaSelect = element<HTMLSelectElement>("#solanaProgramMetadataScenarioSelect");
|
||||
solanaSelect.replaceChildren();
|
||||
for (const scenario of solanaProgramMetadataScenarios) {
|
||||
const option = document.createElement("option");
|
||||
option.value = scenario.id;
|
||||
option.textContent = `${scenario.fixtureAccount} — ${scenario.label}`;
|
||||
solanaSelect.append(option);
|
||||
}
|
||||
const restoredSolana = window.localStorage.getItem(solanaProgramMetadataStorageKey);
|
||||
if (restoredSolana !== null && Array.from(solanaSelect.options).some(option => option.value === restoredSolana)) {
|
||||
solanaSelect.value = restoredSolana;
|
||||
}
|
||||
renderActiveMetadataSelection();
|
||||
badge.textContent = `${scenarios.length + solanaProgramMetadataScenarios.length} contrats`;
|
||||
badge.className = "badge text-bg-success";
|
||||
} catch (caught) {
|
||||
const message = caught instanceof Error ? caught.message : String(caught);
|
||||
@@ -383,16 +524,21 @@ document.addEventListener("DOMContentLoaded", () => {
|
||||
for (const id of ["metaplexSyntheticCollapse", "metaplexDevnetCollapse"]) {
|
||||
element<HTMLElement>(`#${id}`).addEventListener("shown.bs.collapse", renderSelectedScenario);
|
||||
}
|
||||
element<HTMLSelectElement>("#metadataProfileSelect").addEventListener("change", event => {
|
||||
const target = event.currentTarget as HTMLSelectElement;
|
||||
selectedProfileName = target.value.length === 0 ? null : target.value;
|
||||
profileStoreReady = false;
|
||||
updateProfileHelp();
|
||||
setExecutionRunning(executionRunning);
|
||||
renderSelectedScenario();
|
||||
void prepareProfile();
|
||||
});
|
||||
element<HTMLElement>("#solanaProgramMetadataCollapse").addEventListener("shown.bs.collapse", renderSolanaProgramMetadataScenario);
|
||||
element<HTMLSelectElement>("#solanaProgramMetadataScenarioSelect").addEventListener("change", renderSolanaProgramMetadataScenario);
|
||||
for (const selector of profileSelectSelectors) {
|
||||
element<HTMLSelectElement>(selector).addEventListener("change", event => {
|
||||
const target = event.currentTarget as HTMLSelectElement;
|
||||
synchronizeProfileSelections(target.value);
|
||||
profileStoreReady = false;
|
||||
updateProfileHelp();
|
||||
setExecutionRunning(executionRunning);
|
||||
renderActiveMetadataSelection();
|
||||
void prepareProfile();
|
||||
});
|
||||
}
|
||||
element<HTMLButtonElement>("#prepareMetadataProfileButton").addEventListener("click", () => void prepareProfile());
|
||||
element<HTMLButtonElement>("#prepareSolanaProgramMetadataProfileButton").addEventListener("click", () => void prepareProfile());
|
||||
element<HTMLButtonElement>("#refreshMetadataScenariosButton").addEventListener("click", () => void loadScenarios());
|
||||
element<HTMLSelectElement>("#metadataStepSelect").addEventListener("change", () => {
|
||||
const scenario = selectedScenario();
|
||||
@@ -407,13 +553,17 @@ document.addEventListener("DOMContentLoaded", () => {
|
||||
});
|
||||
element<HTMLButtonElement>("#prepareMetadataScenarioStepButton").addEventListener("click", () => { void prepareScenarioStep().catch(caught => frontendError("kb-app-demo-desktop.frontend.execution_metadata", caught instanceof Error ? caught.message : String(caught))); });
|
||||
element<HTMLTextAreaElement>("#metadataExecutionLogOutput").addEventListener("app-log-clear", () => { metadataLogLines.length = 0; });
|
||||
void listen<MetadataProgressPayload>("demo-execution-solana-core-progress", event => appendMetadataLog(event.payload));
|
||||
void listen<DemoExecutionMetadataProgressPayload>("demo-execution-metadata-progress", event => appendMetadataLog(event.payload));
|
||||
element<HTMLButtonElement>("#simulateMetadataButton").addEventListener("click", () => {
|
||||
void executeMetadata(false).catch(caught => frontendError("kb-app-demo-desktop.frontend.execution_metadata", caught instanceof Error ? caught.message : String(caught)));
|
||||
});
|
||||
element<HTMLButtonElement>("#submitMetadataButton").addEventListener("click", () => {
|
||||
void executeMetadata(true).catch(caught => frontendError("kb-app-demo-desktop.frontend.execution_metadata", caught instanceof Error ? caught.message : String(caught)));
|
||||
});
|
||||
element<HTMLInputElement>("#solanaProgramMetadataOperatorConfirmed").addEventListener("change", () => setExecutionRunning(executionRunning));
|
||||
element<HTMLButtonElement>("#executeSolanaProgramMetadataCampaignButton").addEventListener("click", () => {
|
||||
void executeSolanaProgramMetadataCampaign().catch(caught => frontendError("kb-app-demo-desktop.frontend.execution_metadata", caught instanceof Error ? caught.message : String(caught)));
|
||||
});
|
||||
setExecutionRunning(false);
|
||||
frontendDebug("kb-app-demo-desktop.frontend.execution_metadata", "Metadata execution window loaded");
|
||||
void loadProfiles().catch(caught => frontendError("kb-app-demo-desktop.frontend.execution_metadata", caught instanceof Error ? caught.message : String(caught)));
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// file: kb-app-demo-desktop/src/demo_devnet_common.rs
|
||||
// version: 2
|
||||
// version: 3
|
||||
|
||||
//! Shared Devnet demo UI contracts.
|
||||
|
||||
@@ -41,6 +41,18 @@ pub(crate) fn devnet_store_readiness_payload(
|
||||
};
|
||||
}
|
||||
|
||||
/// Releases the shared Devnet execution slot when one workflow terminates.
|
||||
pub(crate) struct DemoExecutionRunGuard<'a> {
|
||||
/// Shared running flag reset on drop.
|
||||
pub(crate) running: &'a std::sync::atomic::AtomicBool,
|
||||
}
|
||||
|
||||
impl std::ops::Drop for crate::DemoExecutionRunGuard<'_> {
|
||||
fn drop(&mut self) {
|
||||
self.running.store(false, std::sync::atomic::Ordering::Release);
|
||||
}
|
||||
}
|
||||
|
||||
/// Serializes a diagnostic value to indented JSON without panicking.
|
||||
pub(crate) fn pretty_json<T>(value: &T) -> std::string::String
|
||||
where
|
||||
|
||||
@@ -1,840 +1,158 @@
|
||||
// file: kb-app-demo-desktop/src/demo_execution_metadata.rs
|
||||
// version: 14
|
||||
// version: 15
|
||||
|
||||
//! Thin desktop adapters for metadata execution demo scenarios.
|
||||
//! Shared desktop contracts for metadata execution demos.
|
||||
|
||||
/// UI-safe Metaplex scenario row.
|
||||
use tauri::Emitter; // rust-rules: trait-import
|
||||
|
||||
/// UI-safe progress event shared by Metadata execution workflows.
|
||||
#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize, ts_rs::TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(
|
||||
export,
|
||||
export_to = "../frontend/ts/bindings/kb_app_demo_desktop/demo_execution_metadata/DemoExecutionMetadataScenarioPayload.ts"
|
||||
export_to = "../frontend/ts/bindings/kb_app_demo_desktop/demo_execution_metadata/DemoExecutionMetadataProgressPayload.ts"
|
||||
)]
|
||||
pub(crate) struct DemoExecutionMetadataScenarioPayload {
|
||||
/// Stable scenario identifier.
|
||||
pub id: std::string::String,
|
||||
/// Operator-visible scenario label.
|
||||
pub label: std::string::String,
|
||||
/// Stable asset-family code.
|
||||
pub asset_family: std::string::String,
|
||||
/// Stable fixture graph code.
|
||||
pub fixture_kind: std::string::String,
|
||||
/// Required initial lifecycle state.
|
||||
pub initial_state: std::string::String,
|
||||
/// Expected terminal lifecycle state.
|
||||
pub resulting_state: std::string::String,
|
||||
/// Stable execution-mode code.
|
||||
pub mode: std::string::String,
|
||||
/// Ordered operation codes.
|
||||
pub operation_codes: std::vec::Vec<std::string::String>,
|
||||
/// Whether a collection account is required.
|
||||
pub requires_collection: bool,
|
||||
/// Whether programmable authorization rules are required.
|
||||
pub requires_programmable_rules: bool,
|
||||
/// Whether postconditions are mandatory.
|
||||
pub requires_postcondition: bool,
|
||||
/// Current validation status from the canonical matrix.
|
||||
pub validation_status: std::string::String,
|
||||
/// Required evidence kinds.
|
||||
pub required_evidence: std::vec::Vec<std::string::String>,
|
||||
/// Canonical account projections expected when materialization is enabled.
|
||||
pub materialization_targets: std::vec::Vec<std::string::String>,
|
||||
pub(crate) struct DemoExecutionMetadataProgressPayload {
|
||||
/// RFC3339 event timestamp.
|
||||
pub timestamp: std::string::String,
|
||||
/// Stable lowercase log level.
|
||||
pub level: std::string::String,
|
||||
/// Stable execution stage.
|
||||
pub stage: std::string::String,
|
||||
/// Operator-visible message.
|
||||
pub message: std::string::String,
|
||||
/// Optional transaction signature.
|
||||
pub signature: std::option::Option<std::string::String>,
|
||||
}
|
||||
|
||||
/// UI-safe result of one prepared Metaplex Create fixture.
|
||||
#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize, ts_rs::TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(
|
||||
export,
|
||||
export_to = "../frontend/ts/bindings/kb_app_demo_desktop/demo_execution_metadata/DemoExecutionMetadataCreateFixturePayload.ts"
|
||||
)]
|
||||
pub(crate) struct DemoExecutionMetadataCreateFixturePayload {
|
||||
/// Fixture mint address.
|
||||
pub mint: std::string::String,
|
||||
/// Canonical metadata PDA.
|
||||
pub metadata: std::string::String,
|
||||
/// Canonical master-edition PDA.
|
||||
pub master_edition: std::string::String,
|
||||
/// Wallet authority.
|
||||
pub authority: std::string::String,
|
||||
/// Generated typed Create operation JSON.
|
||||
pub operation_json: std::string::String,
|
||||
/// Whether the mint account was newly created.
|
||||
pub mint_created: bool,
|
||||
/// Metadata-window observer shared by Metaplex and Solana Program Metadata workflows.
|
||||
pub(crate) struct DemoExecutionMetadataObserver<'a> {
|
||||
/// Tauri application handle used for targeted progress events.
|
||||
pub(crate) app_handle: tauri::AppHandle,
|
||||
/// Shared cooperative cancellation flag.
|
||||
pub(crate) cancel_requested: &'a std::sync::atomic::AtomicBool,
|
||||
}
|
||||
|
||||
/// UI-safe prepared step for one coherent Metaplex Devnet scenario.
|
||||
#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize, ts_rs::TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(
|
||||
export,
|
||||
export_to = "../frontend/ts/bindings/kb_app_demo_desktop/demo_execution_metadata/DemoExecutionMetadataPreparedStepPayload.ts"
|
||||
)]
|
||||
pub(crate) struct DemoExecutionMetadataPreparedStepPayload {
|
||||
/// Stable scenario identifier.
|
||||
pub scenario_id: std::string::String,
|
||||
/// Zero-based step index.
|
||||
pub step_index: usize,
|
||||
/// Stable operation name accepted by the executor JSON contract.
|
||||
pub operation: std::string::String,
|
||||
/// Operator-visible step label.
|
||||
pub label: std::string::String,
|
||||
/// Prepared typed operation JSON.
|
||||
pub operation_json: std::string::String,
|
||||
/// Automatically generated bounded preflight reads.
|
||||
pub preflight_reads_json: std::string::String,
|
||||
/// Automatically generated bounded postcondition reads.
|
||||
pub postcondition_reads_json: std::string::String,
|
||||
/// Whether this step can be simulated immediately.
|
||||
pub simulation_ready: bool,
|
||||
/// Whether this step can be submitted immediately.
|
||||
pub submission_ready: bool,
|
||||
/// Fixture state used to prepare the step.
|
||||
pub fixture_state: std::string::String,
|
||||
impl crate::DemoExecutionMetadataObserver<'_> {
|
||||
fn emit(
|
||||
&self,
|
||||
timestamp: std::string::String,
|
||||
level: &str,
|
||||
stage: &str,
|
||||
message: std::string::String,
|
||||
signature: std::option::Option<std::string::String>,
|
||||
) {
|
||||
let payload = crate::DemoExecutionMetadataProgressPayload {
|
||||
timestamp,
|
||||
level: level.to_string(),
|
||||
stage: stage.to_string(),
|
||||
message,
|
||||
signature,
|
||||
};
|
||||
let emit_result = self.app_handle.emit_to(
|
||||
"demo_execution_metadata",
|
||||
"demo-execution-metadata-progress",
|
||||
payload,
|
||||
);
|
||||
if let std::result::Result::Err(error) = emit_result {
|
||||
tracing::warn!(
|
||||
target: crate::TRACING_TARGET,
|
||||
action = "emit_metadata_execution_progress",
|
||||
error = %error,
|
||||
"cannot emit Metadata execution progress"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn cancelled(&self) -> bool {
|
||||
return self.cancel_requested.load(std::sync::atomic::Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
|
||||
/// Request for one real Devnet Metaplex execution from the desktop panel.
|
||||
#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize, ts_rs::TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(
|
||||
export,
|
||||
export_to = "../frontend/ts/bindings/kb_app_demo_desktop/demo_execution_metadata/DemoExecutionMetadataRequest.ts"
|
||||
)]
|
||||
pub(crate) struct DemoExecutionMetadataRequest {
|
||||
/// Devnet profile selected by the operator.
|
||||
pub profile_name: std::string::String,
|
||||
/// Stable execution identifier shown in diagnostics.
|
||||
pub intent_id: std::string::String,
|
||||
/// Serialized typed current Metaplex operation.
|
||||
pub operation_json: std::string::String,
|
||||
/// Serialized bounded preflight read requests.
|
||||
pub preflight_reads_json: std::string::String,
|
||||
/// Serialized bounded postcondition read requests.
|
||||
pub postcondition_reads_json: std::string::String,
|
||||
/// Whether signing and submission are explicitly requested.
|
||||
pub submit: bool,
|
||||
/// Explicit operator confirmation for submission.
|
||||
pub operator_confirmed: bool,
|
||||
/// Materializes confirmed Metaplex account snapshots after submission.
|
||||
pub materialize_after_confirmation: bool,
|
||||
impl kb_pipeline::BackfillObserver for crate::DemoExecutionMetadataObserver<'_> {
|
||||
fn on_progress(&self, event: &kb_pipeline::BackfillProgressEvent) {
|
||||
self.emit(
|
||||
event.timestamp.clone(),
|
||||
event.level.code(),
|
||||
"canonical_insert",
|
||||
event.message.clone(),
|
||||
std::option::Option::None,
|
||||
);
|
||||
}
|
||||
|
||||
fn is_cancelled(&self) -> bool {
|
||||
return self.cancelled();
|
||||
}
|
||||
}
|
||||
|
||||
/// UI-safe result of one real Devnet Metaplex execution.
|
||||
#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize, ts_rs::TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(
|
||||
export,
|
||||
export_to = "../frontend/ts/bindings/kb_app_demo_desktop/demo_execution_metadata/DemoExecutionMetadataSummaryPayload.ts"
|
||||
)]
|
||||
pub(crate) struct DemoExecutionMetadataSummaryPayload {
|
||||
/// Selected profile name.
|
||||
pub profile_name: std::string::String,
|
||||
/// Exact cluster code.
|
||||
pub cluster: std::string::String,
|
||||
/// Genesis hash observed from RPC.
|
||||
pub genesis_hash: std::string::String,
|
||||
/// Wallet public key used by the runner.
|
||||
pub wallet_public_key: std::string::String,
|
||||
/// Wallet balance before execution.
|
||||
pub balance_lamports: std::string::String,
|
||||
/// Whether the real RPC simulation succeeded.
|
||||
pub simulation_success: bool,
|
||||
/// Optional submitted signature.
|
||||
pub transaction_signature: std::option::Option<std::string::String>,
|
||||
/// Optional terminal confirmation status.
|
||||
pub confirmation_status: std::option::Option<std::string::String>,
|
||||
/// Exact prepared plan as formatted JSON.
|
||||
pub plan_json: std::string::String,
|
||||
/// Stateful preflight as formatted JSON.
|
||||
pub preflight_json: std::string::String,
|
||||
/// Real RPC simulation as formatted JSON.
|
||||
pub simulation_json: std::string::String,
|
||||
/// Before/after state and readiness diagnostics as formatted JSON.
|
||||
pub evidence_json: std::string::String,
|
||||
/// Whether post-confirmation account materialization was requested.
|
||||
pub materialization_requested: bool,
|
||||
/// Number of canonical post-confirmation snapshots materialized.
|
||||
pub materialized_snapshot_count: usize,
|
||||
impl kb_pipeline::CoreExtractionObserver for crate::DemoExecutionMetadataObserver<'_> {
|
||||
fn on_progress(&self, event: &kb_pipeline::CoreExtractionProgressEvent) {
|
||||
self.emit(
|
||||
event.timestamp.clone(),
|
||||
event.level.code(),
|
||||
"core_extraction",
|
||||
event.message.clone(),
|
||||
std::option::Option::None,
|
||||
);
|
||||
}
|
||||
|
||||
fn is_cancelled(&self) -> bool {
|
||||
return self.cancelled();
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns Devnet profile options dedicated to the Metadata execution panel.
|
||||
impl kb_pipeline::DecodeReplayObserver for crate::DemoExecutionMetadataObserver<'_> {
|
||||
fn on_progress(&self, event: &kb_pipeline::DecodeReplayProgressEvent) {
|
||||
self.emit(
|
||||
event.timestamp.clone(),
|
||||
event.level.code(),
|
||||
"decode_replay",
|
||||
event.message.clone(),
|
||||
std::option::Option::None,
|
||||
);
|
||||
}
|
||||
|
||||
fn is_cancelled(&self) -> bool {
|
||||
return self.cancelled();
|
||||
}
|
||||
}
|
||||
|
||||
impl kb_pipeline_demo_scenarios::SolanaExecutionObserver
|
||||
for crate::DemoExecutionMetadataObserver<'_>
|
||||
{
|
||||
fn on_execution_progress(
|
||||
&self,
|
||||
event: &kb_pipeline_demo_scenarios::SolanaExecutionProgressEvent,
|
||||
) {
|
||||
self.emit(
|
||||
event.timestamp.clone(),
|
||||
event.level.code(),
|
||||
event.stage.as_str(),
|
||||
event.message.clone(),
|
||||
event.signature.clone(),
|
||||
);
|
||||
}
|
||||
|
||||
fn is_execution_cancelled(&self) -> bool {
|
||||
return self.cancelled();
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns Devnet profile options shared by the Metadata execution panel.
|
||||
pub(crate) fn demo_execution_metadata_options(
|
||||
state: tauri::State<'_, crate::AppState>,
|
||||
) -> crate::DemoExecutionSolanaCoreOptionsPayload {
|
||||
return crate::demo_execution_solana_core_options(state);
|
||||
}
|
||||
|
||||
/// Executes one real Devnet Metaplex simulation or explicitly confirmed submission.
|
||||
pub(crate) async fn demo_execution_metadata_execute(
|
||||
app_handle: tauri::AppHandle,
|
||||
state: tauri::State<'_, crate::AppState>,
|
||||
request: crate::DemoExecutionMetadataRequest,
|
||||
) -> std::result::Result<crate::DemoExecutionMetadataSummaryPayload, std::string::String> {
|
||||
let acquire_result = state.demo_execution_solana_core_running().compare_exchange(
|
||||
false,
|
||||
true,
|
||||
std::sync::atomic::Ordering::AcqRel,
|
||||
std::sync::atomic::Ordering::Acquire,
|
||||
);
|
||||
if acquire_result.is_err() {
|
||||
return std::result::Result::Err("a Devnet execution is already running".to_string());
|
||||
}
|
||||
let _run_guard = crate::DemoExecutionSolanaCoreRunGuard {
|
||||
running: state.demo_execution_solana_core_running(),
|
||||
};
|
||||
state
|
||||
.demo_execution_solana_core_cancel_requested()
|
||||
.store(false, std::sync::atomic::Ordering::Release);
|
||||
let profile =
|
||||
match crate::select_devnet_profile(state.app_config(), request.profile_name.as_str()) {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let http_pool = match kb_onchain_transport::HttpEndpointPool::from_profile(&profile) {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error.to_string()),
|
||||
};
|
||||
let mut execution_request = match kb_pipeline_demo_scenarios::DevnetMetaplexTokenMetadataExecutionRequest::from_operation_json(
|
||||
request.intent_id,
|
||||
request.operation_json.as_str(),
|
||||
) {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error.to_string()),
|
||||
};
|
||||
execution_request.preflight_reads =
|
||||
match parse_stateful_reads(request.preflight_reads_json.as_str()) {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
execution_request.postcondition_reads =
|
||||
match parse_stateful_reads(request.postcondition_reads_json.as_str()) {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
execution_request.submit = request.submit;
|
||||
execution_request.operator_confirmed = request.operator_confirmed;
|
||||
execution_request.materialize_after_confirmation = request.materialize_after_confirmation;
|
||||
if let std::result::Result::Err(error) = execution_request.validate() {
|
||||
return std::result::Result::Err(error.to_string());
|
||||
}
|
||||
let observer = crate::DemoExecutionSolanaCoreObserver {
|
||||
app_handle,
|
||||
cancel_requested: state.demo_execution_solana_core_cancel_requested(),
|
||||
};
|
||||
let workspace_root = crate::workspace_root_dir();
|
||||
let summary = match kb_pipeline_demo_scenarios::execute_devnet_metaplex_token_metadata(
|
||||
&http_pool,
|
||||
&profile,
|
||||
workspace_root.as_path(),
|
||||
&execution_request,
|
||||
&observer,
|
||||
)
|
||||
.await
|
||||
{
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error.to_string()),
|
||||
};
|
||||
let transaction_signature =
|
||||
summary.send_result.as_ref().map(|value| return value.signature.0.clone());
|
||||
let confirmation_status =
|
||||
summary.confirmation.as_ref().map(|value| return format!("{:?}", value.status));
|
||||
let evidence_json = crate::pretty_json(&serde_json::json!({
|
||||
"before": &summary.before,
|
||||
"readiness": &summary.readiness,
|
||||
"sendResult": &summary.send_result,
|
||||
"confirmation": &summary.confirmation,
|
||||
"after": &summary.after,
|
||||
"materializationRequested": summary.materialization_requested,
|
||||
"materializedSnapshots": &summary.materialized_snapshots
|
||||
}));
|
||||
return std::result::Result::Ok(crate::DemoExecutionMetadataSummaryPayload {
|
||||
profile_name: summary.profile_name,
|
||||
cluster: format!("{:?}", summary.cluster).to_lowercase(),
|
||||
genesis_hash: summary.genesis_hash,
|
||||
wallet_public_key: summary.wallet.public_key,
|
||||
balance_lamports: summary.balance_lamports.to_string(),
|
||||
simulation_success: summary.simulation.success,
|
||||
transaction_signature,
|
||||
confirmation_status,
|
||||
plan_json: crate::pretty_json(&summary.plan),
|
||||
preflight_json: crate::pretty_json(&summary.stateful_preflight),
|
||||
simulation_json: crate::pretty_json(&summary.simulation),
|
||||
evidence_json,
|
||||
materialization_requested: summary.materialization_requested,
|
||||
materialized_snapshot_count: summary.materialized_snapshots.len(),
|
||||
});
|
||||
}
|
||||
|
||||
fn parse_stateful_reads(
|
||||
value: &str,
|
||||
) -> std::result::Result<
|
||||
std::vec::Vec<kb_pipeline::MetaplexTokenMetadataStatefulReadRequest>,
|
||||
std::string::String,
|
||||
> {
|
||||
if value.trim().is_empty() {
|
||||
return std::result::Result::Ok(std::vec::Vec::new());
|
||||
}
|
||||
return match serde_json::from_str(value) {
|
||||
std::result::Result::Ok(parsed) => std::result::Result::Ok(parsed),
|
||||
std::result::Result::Err(error) => {
|
||||
std::result::Result::Err(format!("invalid Metaplex stateful reads JSON: {error}"))
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/// Creates or reuses the Devnet mint fixture used by the named Create scenario.
|
||||
pub(crate) async fn demo_execution_metadata_prepare_create_fixture(
|
||||
state: tauri::State<'_, crate::AppState>,
|
||||
profile_name: std::string::String,
|
||||
) -> std::result::Result<crate::DemoExecutionMetadataCreateFixturePayload, std::string::String> {
|
||||
let profile = match crate::select_devnet_profile(state.app_config(), profile_name.as_str()) {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let configured = std::path::PathBuf::from(profile.wallet.wallet_dir.as_str());
|
||||
let wallet_dir = if configured.is_absolute() {
|
||||
configured
|
||||
} else {
|
||||
crate::workspace_root_dir().join(configured)
|
||||
};
|
||||
let http_pool = match kb_onchain_transport::HttpEndpointPool::from_profile(&profile) {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error.to_string()),
|
||||
};
|
||||
let workspace_root = crate::workspace_root_dir();
|
||||
let summary = match kb_pipeline_demo_scenarios::prepare_metaplex_create_fixture(
|
||||
&http_pool,
|
||||
&profile,
|
||||
workspace_root.as_path(),
|
||||
&kb_pipeline_demo_scenarios::MetaplexCreateFixturePreparationOptions::new(wallet_dir),
|
||||
)
|
||||
.await
|
||||
{
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error.to_string()),
|
||||
};
|
||||
return std::result::Result::Ok(crate::DemoExecutionMetadataCreateFixturePayload {
|
||||
mint: summary.mint,
|
||||
metadata: summary.metadata,
|
||||
master_edition: summary.master_edition,
|
||||
authority: summary.authority,
|
||||
operation_json: summary.operation_json,
|
||||
mint_created: summary.mint_created,
|
||||
});
|
||||
}
|
||||
|
||||
fn adapt_create_operation_json(
|
||||
operation_json: &str,
|
||||
asset_family: kb_pipeline_demo_scenarios::MetaplexTokenMetadataAssetFamily,
|
||||
) -> std::result::Result<std::string::String, std::string::String> {
|
||||
let mut value: serde_json::Value = match serde_json::from_str(operation_json) {
|
||||
std::result::Result::Ok(parsed) => parsed,
|
||||
std::result::Result::Err(error) => {
|
||||
return std::result::Result::Err(format!(
|
||||
"invalid generated Metaplex Create JSON: {error}"
|
||||
));
|
||||
},
|
||||
};
|
||||
if matches!(
|
||||
asset_family,
|
||||
kb_pipeline_demo_scenarios::MetaplexTokenMetadataAssetFamily::Sft
|
||||
| kb_pipeline_demo_scenarios::MetaplexTokenMetadataAssetFamily::Fungible
|
||||
) {
|
||||
value["master_edition"] = serde_json::Value::Null;
|
||||
}
|
||||
let create = match value
|
||||
.get_mut("create_args")
|
||||
.and_then(|args| return args.get_mut("V1"))
|
||||
.and_then(serde_json::Value::as_object_mut)
|
||||
{
|
||||
std::option::Option::Some(found) => found,
|
||||
std::option::Option::None => {
|
||||
return std::result::Result::Err(
|
||||
"generated Metaplex Create JSON has no create_args.V1 object".to_string(),
|
||||
);
|
||||
},
|
||||
};
|
||||
match asset_family {
|
||||
kb_pipeline_demo_scenarios::MetaplexTokenMetadataAssetFamily::Nft => {
|
||||
create.insert("name".to_string(), serde_json::json!("Khadhroony Devnet NFT"));
|
||||
create.insert("symbol".to_string(), serde_json::json!("KHNFT"));
|
||||
create.insert("token_standard".to_string(), serde_json::json!("NonFungible"));
|
||||
create.insert("collection_details".to_string(), serde_json::Value::Null);
|
||||
create.insert("decimals".to_string(), serde_json::Value::Null);
|
||||
create.insert("print_supply".to_string(), serde_json::json!("Zero"));
|
||||
},
|
||||
kb_pipeline_demo_scenarios::MetaplexTokenMetadataAssetFamily::Sft => {
|
||||
create.insert("name".to_string(), serde_json::json!("Khadhroony Devnet SFT"));
|
||||
create.insert("symbol".to_string(), serde_json::json!("KHSFT"));
|
||||
create.insert("token_standard".to_string(), serde_json::json!("FungibleAsset"));
|
||||
create.insert("collection_details".to_string(), serde_json::Value::Null);
|
||||
create.insert("decimals".to_string(), serde_json::json!(0));
|
||||
create.insert("print_supply".to_string(), serde_json::Value::Null);
|
||||
},
|
||||
kb_pipeline_demo_scenarios::MetaplexTokenMetadataAssetFamily::Fungible => {
|
||||
create.insert("name".to_string(), serde_json::json!("Khadhroony Devnet Fungible"));
|
||||
create.insert("symbol".to_string(), serde_json::json!("KHFUN"));
|
||||
create.insert("token_standard".to_string(), serde_json::json!("Fungible"));
|
||||
create.insert("collection_details".to_string(), serde_json::Value::Null);
|
||||
create.insert("decimals".to_string(), serde_json::json!(0));
|
||||
create.insert("print_supply".to_string(), serde_json::Value::Null);
|
||||
},
|
||||
kb_pipeline_demo_scenarios::MetaplexTokenMetadataAssetFamily::Collection => {
|
||||
create.insert("name".to_string(), serde_json::json!("Khadhroony Devnet Collection"));
|
||||
create.insert("symbol".to_string(), serde_json::json!("KHCOL"));
|
||||
create.insert("token_standard".to_string(), serde_json::json!("NonFungible"));
|
||||
create.insert("collection_details".to_string(), serde_json::json!({"V1": {"size": 0}}));
|
||||
create.insert("decimals".to_string(), serde_json::Value::Null);
|
||||
create.insert("print_supply".to_string(), serde_json::json!("Zero"));
|
||||
},
|
||||
kb_pipeline_demo_scenarios::MetaplexTokenMetadataAssetFamily::ProgrammableNft => {
|
||||
create.insert("name".to_string(), serde_json::json!("Khadhroony Devnet pNFT"));
|
||||
create.insert("symbol".to_string(), serde_json::json!("KHPNFT"));
|
||||
create
|
||||
.insert("token_standard".to_string(), serde_json::json!("ProgrammableNonFungible"));
|
||||
create.insert("collection_details".to_string(), serde_json::Value::Null);
|
||||
create.insert("rule_set".to_string(), serde_json::Value::Null);
|
||||
create.insert("decimals".to_string(), serde_json::Value::Null);
|
||||
create.insert("print_supply".to_string(), serde_json::json!("Zero"));
|
||||
},
|
||||
}
|
||||
return std::result::Result::Ok(crate::pretty_json(&value));
|
||||
}
|
||||
|
||||
/// Prepares one coherent scenario step and all of its stateful reads.
|
||||
pub(crate) async fn demo_execution_metadata_prepare_step(
|
||||
state: tauri::State<'_, crate::AppState>,
|
||||
profile_name: std::string::String,
|
||||
scenario_id: std::string::String,
|
||||
step_index: usize,
|
||||
previous_operation_json: std::option::Option<std::string::String>,
|
||||
) -> std::result::Result<crate::DemoExecutionMetadataPreparedStepPayload, std::string::String> {
|
||||
let scenario = kb_pipeline_demo_scenarios::metaplex_token_metadata_devnet_scenarios()
|
||||
.into_iter()
|
||||
.find(|candidate| return candidate.id == scenario_id);
|
||||
let scenario = match scenario {
|
||||
std::option::Option::Some(value) => value,
|
||||
std::option::Option::None => {
|
||||
return std::result::Result::Err(format!(
|
||||
"unknown Metaplex Devnet scenario `{scenario_id}`"
|
||||
));
|
||||
},
|
||||
};
|
||||
let operation_code = match scenario.operation_codes.get(step_index) {
|
||||
std::option::Option::Some(value) => value.clone(),
|
||||
std::option::Option::None => {
|
||||
return std::result::Result::Err(format!(
|
||||
"scenario `{}` has no step at index {step_index}",
|
||||
scenario.id
|
||||
));
|
||||
},
|
||||
};
|
||||
let operation_suffix = match operation_code.rsplit('.').next() {
|
||||
std::option::Option::Some(value) => value,
|
||||
std::option::Option::None => operation_code.as_str(),
|
||||
};
|
||||
let operation = if operation_suffix == "update" {
|
||||
"update_as_update_authority_v2".to_string()
|
||||
} else {
|
||||
operation_suffix.to_string()
|
||||
};
|
||||
if operation != "create" && operation != "update_as_update_authority_v2" {
|
||||
return std::result::Result::Err(format!(
|
||||
"scenario step `{operation}` is declared but its Devnet fixture builder is not implemented yet"
|
||||
));
|
||||
}
|
||||
let (fixture_mint, fixture_metadata, fixture_master_edition, operation_json, fixture_state) =
|
||||
if operation == "create" {
|
||||
let fixture =
|
||||
match crate::demo_execution_metadata_prepare_create_fixture(state, profile_name)
|
||||
.await
|
||||
{
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let operation_json = match adapt_create_operation_json(
|
||||
fixture.operation_json.as_str(),
|
||||
scenario.asset_family,
|
||||
) {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
(
|
||||
fixture.mint,
|
||||
fixture.metadata,
|
||||
fixture.master_edition,
|
||||
operation_json,
|
||||
if fixture.mint_created {
|
||||
"mint_prepared".to_string()
|
||||
} else {
|
||||
"mint_reused".to_string()
|
||||
},
|
||||
)
|
||||
} else {
|
||||
let previous = match previous_operation_json {
|
||||
std::option::Option::Some(value) if !value.trim().is_empty() => value,
|
||||
_ => {
|
||||
return std::result::Result::Err(
|
||||
"prepare and submit the previous NFT lifecycle step before continuing"
|
||||
.to_string(),
|
||||
);
|
||||
},
|
||||
};
|
||||
let previous_value: serde_json::Value = match serde_json::from_str(previous.as_str()) {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => {
|
||||
return std::result::Result::Err(format!(
|
||||
"invalid previous scenario operation JSON: {error}"
|
||||
));
|
||||
},
|
||||
};
|
||||
let string_field = |name: &str| -> std::result::Result<
|
||||
std::string::String,
|
||||
std::string::String,
|
||||
> {
|
||||
return match previous_value.get(name).and_then(serde_json::Value::as_str) {
|
||||
std::option::Option::Some(value) => std::result::Result::Ok(value.to_string()),
|
||||
std::option::Option::None => std::result::Result::Err(format!(
|
||||
"previous scenario step does not contain `{name}`"
|
||||
)),
|
||||
};
|
||||
};
|
||||
let mint = match string_field("mint") {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let metadata = match string_field("metadata") {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let authority = match string_field("authority") {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let master_edition =
|
||||
match previous_value.get("master_edition").and_then(serde_json::Value::as_str) {
|
||||
std::option::Option::Some(value) => value.to_string(),
|
||||
std::option::Option::None => std::string::String::new(),
|
||||
};
|
||||
let immutable_transition = scenario.asset_family
|
||||
== kb_pipeline_demo_scenarios::MetaplexTokenMetadataAssetFamily::Nft
|
||||
&& step_index == 2;
|
||||
let value = serde_json::json!({
|
||||
"operation": "update_as_update_authority_v2",
|
||||
"authority": authority.clone(),
|
||||
"mint": mint.clone(),
|
||||
"metadata": metadata.clone(),
|
||||
"edition": null,
|
||||
"token": null,
|
||||
"authorization_rules_program": null,
|
||||
"authorization_rules": null,
|
||||
"update_args": {
|
||||
"AsUpdateAuthorityV2": {
|
||||
"new_update_authority": null,
|
||||
"data": null,
|
||||
"primary_sale_happened": if immutable_transition {
|
||||
serde_json::Value::Null
|
||||
} else {
|
||||
serde_json::json!(true)
|
||||
},
|
||||
"is_mutable": if immutable_transition {
|
||||
serde_json::json!(false)
|
||||
} else {
|
||||
serde_json::Value::Null
|
||||
},
|
||||
"collection": "None",
|
||||
"collection_details": "None",
|
||||
"uses": "None",
|
||||
"rule_set": "None",
|
||||
"token_standard": null,
|
||||
"authorization_data": null
|
||||
}
|
||||
}
|
||||
});
|
||||
(
|
||||
mint,
|
||||
metadata,
|
||||
master_edition,
|
||||
crate::pretty_json(&value),
|
||||
if immutable_transition {
|
||||
"metadata_immutable".to_string()
|
||||
} else {
|
||||
"metadata_created".to_string()
|
||||
},
|
||||
)
|
||||
};
|
||||
let metadata_read = serde_json::json!({
|
||||
"query_role": "http_queries",
|
||||
"account": fixture_metadata.clone(),
|
||||
"kind": { "kind": "metadata" },
|
||||
"min_context_slot": null,
|
||||
"max_data_bytes": 65536
|
||||
});
|
||||
let edition_read = serde_json::json!({
|
||||
"query_role": "http_queries",
|
||||
"account": fixture_master_edition.clone(),
|
||||
"kind": { "kind": "edition", "mint": fixture_mint.clone() },
|
||||
"min_context_slot": null,
|
||||
"max_data_bytes": 65536
|
||||
});
|
||||
let preflight_reads = if operation == "create" {
|
||||
serde_json::json!([])
|
||||
} else {
|
||||
serde_json::json!([metadata_read.clone()])
|
||||
};
|
||||
let create_reads_edition = matches!(
|
||||
scenario.asset_family,
|
||||
kb_pipeline_demo_scenarios::MetaplexTokenMetadataAssetFamily::Nft
|
||||
| kb_pipeline_demo_scenarios::MetaplexTokenMetadataAssetFamily::Collection
|
||||
| kb_pipeline_demo_scenarios::MetaplexTokenMetadataAssetFamily::ProgrammableNft
|
||||
);
|
||||
let postcondition_reads = if operation == "create" && create_reads_edition {
|
||||
serde_json::json!([metadata_read, edition_read])
|
||||
} else {
|
||||
serde_json::json!([metadata_read])
|
||||
};
|
||||
return std::result::Result::Ok(crate::DemoExecutionMetadataPreparedStepPayload {
|
||||
scenario_id: scenario.id,
|
||||
step_index,
|
||||
operation: operation.clone(),
|
||||
label: if scenario.asset_family
|
||||
== kb_pipeline_demo_scenarios::MetaplexTokenMetadataAssetFamily::Nft
|
||||
&& step_index == 2
|
||||
{
|
||||
"Étape 3 — rendre les metadata immutables".to_string()
|
||||
} else {
|
||||
format!("Étape {} — {}", step_index + 1, operation)
|
||||
},
|
||||
operation_json,
|
||||
preflight_reads_json: crate::pretty_json(&preflight_reads),
|
||||
postcondition_reads_json: crate::pretty_json(&postcondition_reads),
|
||||
simulation_ready: true,
|
||||
submission_ready: true,
|
||||
fixture_state,
|
||||
});
|
||||
}
|
||||
|
||||
/// Returns current operation names accepted by automatic Devnet campaigns.
|
||||
pub(crate) fn demo_execution_metadata_current_operations() -> std::vec::Vec<std::string::String> {
|
||||
return kb_pipeline_demo_scenarios::metaplex_token_metadata_current_operation_names()
|
||||
.iter()
|
||||
.map(|value| return (*value).to_string())
|
||||
.collect();
|
||||
}
|
||||
|
||||
/// Returns one generated typed JSON template for a named current operation.
|
||||
pub(crate) fn demo_execution_metadata_operation_template(
|
||||
operation: std::string::String,
|
||||
) -> std::result::Result<std::string::String, std::string::String> {
|
||||
return match kb_pipeline_demo_scenarios::metaplex_token_metadata_current_operation_json_template(
|
||||
operation.as_str(),
|
||||
) {
|
||||
std::result::Result::Ok(value) => std::result::Result::Ok(value),
|
||||
std::result::Result::Err(error) => std::result::Result::Err(error.to_string()),
|
||||
};
|
||||
}
|
||||
|
||||
/// Returns the canonical Metaplex scenario inventory for the desktop panel.
|
||||
pub(crate) fn demo_execution_metadata_scenarios() -> std::result::Result<
|
||||
std::vec::Vec<crate::DemoExecutionMetadataScenarioPayload>,
|
||||
std::string::String,
|
||||
> {
|
||||
let matrix = match kb_pipeline_demo_scenarios::load_metaplex_token_metadata_validation_matrix()
|
||||
{
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error.to_string()),
|
||||
};
|
||||
let statuses = matrix
|
||||
.scenarios
|
||||
.into_iter()
|
||||
.map(|scenario| return (scenario.id, (scenario.status, scenario.required_evidence)))
|
||||
.collect::<std::collections::BTreeMap<
|
||||
std::string::String,
|
||||
(
|
||||
kb_pipeline_demo_scenarios::MetaplexTokenMetadataValidationStatus,
|
||||
std::vec::Vec<std::string::String>,
|
||||
),
|
||||
>>();
|
||||
let mut payloads = std::vec::Vec::new();
|
||||
for scenario in kb_pipeline_demo_scenarios::metaplex_token_metadata_synthetic_scenarios()
|
||||
.into_iter()
|
||||
.chain(kb_pipeline_demo_scenarios::metaplex_token_metadata_devnet_scenarios())
|
||||
{
|
||||
let status = statuses.get(scenario.id.as_str());
|
||||
let validation_status = match status {
|
||||
std::option::Option::Some((value, _)) => validation_status_code(*value),
|
||||
std::option::Option::None => "not_run".to_string(),
|
||||
};
|
||||
let required_evidence = match status {
|
||||
std::option::Option::Some((_, evidence)) => evidence.clone(),
|
||||
std::option::Option::None => std::vec::Vec::new(),
|
||||
};
|
||||
payloads.push(crate::DemoExecutionMetadataScenarioPayload {
|
||||
id: scenario.id,
|
||||
label: scenario.label,
|
||||
asset_family: asset_family_code(scenario.asset_family),
|
||||
fixture_kind: fixture_kind_code(scenario.fixture_kind),
|
||||
initial_state: fixture_state_code(scenario.initial_state),
|
||||
resulting_state: fixture_state_code(scenario.resulting_state),
|
||||
mode: scenario_mode_code(scenario.mode),
|
||||
operation_codes: scenario.operation_codes,
|
||||
requires_collection: scenario.requires_collection,
|
||||
requires_programmable_rules: scenario.requires_programmable_rules,
|
||||
requires_postcondition: scenario.requires_postcondition,
|
||||
validation_status,
|
||||
required_evidence,
|
||||
materialization_targets: scenario.materialization_targets,
|
||||
});
|
||||
}
|
||||
return std::result::Result::Ok(payloads);
|
||||
}
|
||||
|
||||
fn asset_family_code(
|
||||
value: kb_pipeline_demo_scenarios::MetaplexTokenMetadataAssetFamily,
|
||||
) -> std::string::String {
|
||||
return match value {
|
||||
kb_pipeline_demo_scenarios::MetaplexTokenMetadataAssetFamily::Nft => "nft".to_string(),
|
||||
kb_pipeline_demo_scenarios::MetaplexTokenMetadataAssetFamily::Sft => "sft".to_string(),
|
||||
kb_pipeline_demo_scenarios::MetaplexTokenMetadataAssetFamily::Fungible => {
|
||||
"fungible".to_string()
|
||||
},
|
||||
kb_pipeline_demo_scenarios::MetaplexTokenMetadataAssetFamily::Collection => {
|
||||
"collection".to_string()
|
||||
},
|
||||
kb_pipeline_demo_scenarios::MetaplexTokenMetadataAssetFamily::ProgrammableNft => {
|
||||
"programmable_nft".to_string()
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
fn fixture_kind_code(
|
||||
value: kb_pipeline_demo_scenarios::MetaplexTokenMetadataFixtureKind,
|
||||
) -> std::string::String {
|
||||
return format!("{value:?}").to_lowercase();
|
||||
}
|
||||
|
||||
fn fixture_state_code(
|
||||
value: kb_pipeline_demo_scenarios::MetaplexTokenMetadataFixtureState,
|
||||
) -> std::string::String {
|
||||
return format!("{value:?}").to_lowercase();
|
||||
}
|
||||
|
||||
fn scenario_mode_code(
|
||||
value: kb_pipeline_demo_scenarios::MetaplexTokenMetadataScenarioMode,
|
||||
) -> std::string::String {
|
||||
return match value {
|
||||
kb_pipeline_demo_scenarios::MetaplexTokenMetadataScenarioMode::Synthetic => {
|
||||
"synthetic".to_string()
|
||||
},
|
||||
kb_pipeline_demo_scenarios::MetaplexTokenMetadataScenarioMode::NetworkSimulation => {
|
||||
"network_simulation".to_string()
|
||||
},
|
||||
kb_pipeline_demo_scenarios::MetaplexTokenMetadataScenarioMode::NetworkSubmission => {
|
||||
"network_submission".to_string()
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
fn validation_status_code(
|
||||
value: kb_pipeline_demo_scenarios::MetaplexTokenMetadataValidationStatus,
|
||||
) -> std::string::String {
|
||||
return match value {
|
||||
kb_pipeline_demo_scenarios::MetaplexTokenMetadataValidationStatus::NotRun => {
|
||||
"not_run".to_string()
|
||||
},
|
||||
kb_pipeline_demo_scenarios::MetaplexTokenMetadataValidationStatus::SyntheticValidated => {
|
||||
"synthetic_validated".to_string()
|
||||
},
|
||||
kb_pipeline_demo_scenarios::MetaplexTokenMetadataValidationStatus::Simulated => {
|
||||
"simulated".to_string()
|
||||
},
|
||||
kb_pipeline_demo_scenarios::MetaplexTokenMetadataValidationStatus::Submitted => {
|
||||
"submitted".to_string()
|
||||
},
|
||||
kb_pipeline_demo_scenarios::MetaplexTokenMetadataValidationStatus::Confirmed => {
|
||||
"confirmed".to_string()
|
||||
},
|
||||
kb_pipeline_demo_scenarios::MetaplexTokenMetadataValidationStatus::Unavailable => {
|
||||
"unavailable".to_string()
|
||||
},
|
||||
kb_pipeline_demo_scenarios::MetaplexTokenMetadataValidationStatus::Failed => {
|
||||
"failed".to_string()
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#[test]
|
||||
fn desktop_inventory_matches_reusable_scenarios_and_matrix() {
|
||||
let payloads = match crate::demo_execution_metadata_scenarios() {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => {
|
||||
assert_eq!(std::option::Option::Some(error), std::option::Option::None);
|
||||
return;
|
||||
},
|
||||
};
|
||||
let expected_count =
|
||||
kb_pipeline_demo_scenarios::metaplex_token_metadata_synthetic_scenarios().len()
|
||||
+ kb_pipeline_demo_scenarios::metaplex_token_metadata_devnet_scenarios().len();
|
||||
assert_eq!(payloads.len(), expected_count);
|
||||
assert!(payloads.iter().all(|payload| return !payload.operation_codes.is_empty()));
|
||||
assert!(payloads.iter().all(|payload| return payload.requires_postcondition));
|
||||
assert!(
|
||||
payloads
|
||||
.iter()
|
||||
.all(|payload| return payload.validation_status != "missing_from_matrix")
|
||||
);
|
||||
assert_eq!(
|
||||
payloads
|
||||
.iter()
|
||||
.filter(|payload| return payload.mode == "network_simulation")
|
||||
.count(),
|
||||
5
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_stateful_reads_are_accepted() {
|
||||
let parsed = super::parse_stateful_reads(" ");
|
||||
assert!(parsed.is_ok());
|
||||
let values = match parsed {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => {
|
||||
assert_eq!(std::option::Option::Some(error), std::option::Option::None);
|
||||
return;
|
||||
},
|
||||
};
|
||||
assert_eq!(values.len(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn metadata_frontend_contains_every_required_runtime_control() {
|
||||
let source = std::include_str!("../frontend/demo_execution_metadata.html");
|
||||
for id in [
|
||||
"metadataProfileSelect",
|
||||
"metadataDevnetScenarioSelect",
|
||||
"metadataStepSelect",
|
||||
"prepareMetadataScenarioStepButton",
|
||||
"metadataExecutionLogOutput",
|
||||
"metadataProfileHelp",
|
||||
"simulateMetadataButton",
|
||||
"submitMetadataButton",
|
||||
] {
|
||||
assert!(source.contains(format!("id=\"{id}\"").as_str()));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn programmable_and_collection_requirements_remain_explicit() {
|
||||
let payloads = match crate::demo_execution_metadata_scenarios() {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => {
|
||||
assert_eq!(std::option::Option::Some(error), std::option::Option::None);
|
||||
return;
|
||||
},
|
||||
};
|
||||
assert!(payloads.iter().any(|payload| return payload.requires_collection));
|
||||
assert!(payloads.iter().any(|payload| return payload.requires_programmable_rules));
|
||||
fn metadata_frontend_listens_to_the_metadata_specific_progress_event() {
|
||||
let source = std::include_str!("../frontend/ts/demo_execution_metadata.ts");
|
||||
assert!(source.contains("demo-execution-metadata-progress"));
|
||||
assert!(!source.contains(
|
||||
"listen<DemoExecutionMetadataProgressPayload>(\"demo-execution-solana-core-progress\""
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,852 @@
|
||||
// file: kb-app-demo-desktop/src/demo_execution_metadata_metaplex_token_metadata.rs
|
||||
// version: 1
|
||||
|
||||
//! Desktop adapters for Metaplex Token Metadata execution demo scenarios.
|
||||
|
||||
/// UI-safe Metaplex scenario row.
|
||||
#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize, ts_rs::TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(
|
||||
export,
|
||||
export_to = "../frontend/ts/bindings/kb_app_demo_desktop/demo_execution_metadata/DemoExecutionMetadataMetaplexTokenMetadataScenarioPayload.ts"
|
||||
)]
|
||||
pub(crate) struct DemoExecutionMetadataMetaplexTokenMetadataScenarioPayload {
|
||||
/// Stable scenario identifier.
|
||||
pub id: std::string::String,
|
||||
/// Operator-visible scenario label.
|
||||
pub label: std::string::String,
|
||||
/// Stable asset-family code.
|
||||
pub asset_family: std::string::String,
|
||||
/// Stable fixture graph code.
|
||||
pub fixture_kind: std::string::String,
|
||||
/// Required initial lifecycle state.
|
||||
pub initial_state: std::string::String,
|
||||
/// Expected terminal lifecycle state.
|
||||
pub resulting_state: std::string::String,
|
||||
/// Stable execution-mode code.
|
||||
pub mode: std::string::String,
|
||||
/// Ordered operation codes.
|
||||
pub operation_codes: std::vec::Vec<std::string::String>,
|
||||
/// Whether a collection account is required.
|
||||
pub requires_collection: bool,
|
||||
/// Whether programmable authorization rules are required.
|
||||
pub requires_programmable_rules: bool,
|
||||
/// Whether postconditions are mandatory.
|
||||
pub requires_postcondition: bool,
|
||||
/// Current validation status from the canonical matrix.
|
||||
pub validation_status: std::string::String,
|
||||
/// Required evidence kinds.
|
||||
pub required_evidence: std::vec::Vec<std::string::String>,
|
||||
/// Canonical account projections expected when materialization is enabled.
|
||||
pub materialization_targets: std::vec::Vec<std::string::String>,
|
||||
}
|
||||
|
||||
/// UI-safe result of one prepared Metaplex Create fixture.
|
||||
#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize, ts_rs::TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(
|
||||
export,
|
||||
export_to = "../frontend/ts/bindings/kb_app_demo_desktop/demo_execution_metadata/DemoExecutionMetadataMetaplexTokenMetadataCreateFixturePayload.ts"
|
||||
)]
|
||||
pub(crate) struct DemoExecutionMetadataMetaplexTokenMetadataCreateFixturePayload {
|
||||
/// Fixture mint address.
|
||||
pub mint: std::string::String,
|
||||
/// Canonical metadata PDA.
|
||||
pub metadata: std::string::String,
|
||||
/// Canonical master-edition PDA.
|
||||
pub master_edition: std::string::String,
|
||||
/// Wallet authority.
|
||||
pub authority: std::string::String,
|
||||
/// Generated typed Create operation JSON.
|
||||
pub operation_json: std::string::String,
|
||||
/// Whether the mint account was newly created.
|
||||
pub mint_created: bool,
|
||||
}
|
||||
|
||||
/// UI-safe prepared step for one coherent Metaplex Devnet scenario.
|
||||
#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize, ts_rs::TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(
|
||||
export,
|
||||
export_to = "../frontend/ts/bindings/kb_app_demo_desktop/demo_execution_metadata/DemoExecutionMetadataMetaplexTokenMetadataPreparedStepPayload.ts"
|
||||
)]
|
||||
pub(crate) struct DemoExecutionMetadataMetaplexTokenMetadataPreparedStepPayload {
|
||||
/// Stable scenario identifier.
|
||||
pub scenario_id: std::string::String,
|
||||
/// Zero-based step index.
|
||||
pub step_index: usize,
|
||||
/// Stable operation name accepted by the executor JSON contract.
|
||||
pub operation: std::string::String,
|
||||
/// Operator-visible step label.
|
||||
pub label: std::string::String,
|
||||
/// Prepared typed operation JSON.
|
||||
pub operation_json: std::string::String,
|
||||
/// Automatically generated bounded preflight reads.
|
||||
pub preflight_reads_json: std::string::String,
|
||||
/// Automatically generated bounded postcondition reads.
|
||||
pub postcondition_reads_json: std::string::String,
|
||||
/// Whether this step can be simulated immediately.
|
||||
pub simulation_ready: bool,
|
||||
/// Whether this step can be submitted immediately.
|
||||
pub submission_ready: bool,
|
||||
/// Fixture state used to prepare the step.
|
||||
pub fixture_state: std::string::String,
|
||||
}
|
||||
|
||||
/// Request for one real Devnet Metaplex execution from the desktop panel.
|
||||
#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize, ts_rs::TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(
|
||||
export,
|
||||
export_to = "../frontend/ts/bindings/kb_app_demo_desktop/demo_execution_metadata/DemoExecutionMetadataMetaplexTokenMetadataRequest.ts"
|
||||
)]
|
||||
pub(crate) struct DemoExecutionMetadataMetaplexTokenMetadataRequest {
|
||||
/// Devnet profile selected by the operator.
|
||||
pub profile_name: std::string::String,
|
||||
/// Stable execution identifier shown in diagnostics.
|
||||
pub intent_id: std::string::String,
|
||||
/// Serialized typed current Metaplex operation.
|
||||
pub operation_json: std::string::String,
|
||||
/// Serialized bounded preflight read requests.
|
||||
pub preflight_reads_json: std::string::String,
|
||||
/// Serialized bounded postcondition read requests.
|
||||
pub postcondition_reads_json: std::string::String,
|
||||
/// Whether signing and submission are explicitly requested.
|
||||
pub submit: bool,
|
||||
/// Explicit operator confirmation for submission.
|
||||
pub operator_confirmed: bool,
|
||||
/// Materializes confirmed Metaplex account snapshots after submission.
|
||||
pub materialize_after_confirmation: bool,
|
||||
}
|
||||
|
||||
/// UI-safe result of one real Devnet Metaplex execution.
|
||||
#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize, ts_rs::TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(
|
||||
export,
|
||||
export_to = "../frontend/ts/bindings/kb_app_demo_desktop/demo_execution_metadata/DemoExecutionMetadataMetaplexTokenMetadataSummaryPayload.ts"
|
||||
)]
|
||||
pub(crate) struct DemoExecutionMetadataMetaplexTokenMetadataSummaryPayload {
|
||||
/// Selected profile name.
|
||||
pub profile_name: std::string::String,
|
||||
/// Exact cluster code.
|
||||
pub cluster: std::string::String,
|
||||
/// Genesis hash observed from RPC.
|
||||
pub genesis_hash: std::string::String,
|
||||
/// Wallet public key used by the runner.
|
||||
pub wallet_public_key: std::string::String,
|
||||
/// Wallet balance before execution.
|
||||
pub balance_lamports: std::string::String,
|
||||
/// Whether the real RPC simulation succeeded.
|
||||
pub simulation_success: bool,
|
||||
/// Optional submitted signature.
|
||||
pub transaction_signature: std::option::Option<std::string::String>,
|
||||
/// Optional terminal confirmation status.
|
||||
pub confirmation_status: std::option::Option<std::string::String>,
|
||||
/// Exact prepared plan as formatted JSON.
|
||||
pub plan_json: std::string::String,
|
||||
/// Stateful preflight as formatted JSON.
|
||||
pub preflight_json: std::string::String,
|
||||
/// Real RPC simulation as formatted JSON.
|
||||
pub simulation_json: std::string::String,
|
||||
/// Before/after state and readiness diagnostics as formatted JSON.
|
||||
pub evidence_json: std::string::String,
|
||||
/// Whether post-confirmation account materialization was requested.
|
||||
pub materialization_requested: bool,
|
||||
/// Number of canonical post-confirmation snapshots materialized.
|
||||
pub materialized_snapshot_count: usize,
|
||||
}
|
||||
|
||||
/// Executes one real Devnet Metaplex simulation or explicitly confirmed submission.
|
||||
pub(crate) async fn demo_execution_metadata_metaplex_token_metadata_execute(
|
||||
app_handle: tauri::AppHandle,
|
||||
state: tauri::State<'_, crate::AppState>,
|
||||
request: crate::DemoExecutionMetadataMetaplexTokenMetadataRequest,
|
||||
) -> std::result::Result<
|
||||
crate::DemoExecutionMetadataMetaplexTokenMetadataSummaryPayload,
|
||||
std::string::String,
|
||||
> {
|
||||
let acquire_result = state.demo_execution_solana_core_running().compare_exchange(
|
||||
false,
|
||||
true,
|
||||
std::sync::atomic::Ordering::AcqRel,
|
||||
std::sync::atomic::Ordering::Acquire,
|
||||
);
|
||||
if acquire_result.is_err() {
|
||||
return std::result::Result::Err("a Devnet execution is already running".to_string());
|
||||
}
|
||||
let _run_guard = crate::DemoExecutionRunGuard {
|
||||
running: state.demo_execution_solana_core_running(),
|
||||
};
|
||||
state
|
||||
.demo_execution_solana_core_cancel_requested()
|
||||
.store(false, std::sync::atomic::Ordering::Release);
|
||||
let profile =
|
||||
match crate::select_devnet_profile(state.app_config(), request.profile_name.as_str()) {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let http_pool = match kb_onchain_transport::HttpEndpointPool::from_profile(&profile) {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error.to_string()),
|
||||
};
|
||||
let mut execution_request = match kb_pipeline_demo_scenarios::DevnetMetaplexTokenMetadataExecutionRequest::from_operation_json(
|
||||
request.intent_id,
|
||||
request.operation_json.as_str(),
|
||||
) {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error.to_string()),
|
||||
};
|
||||
execution_request.preflight_reads =
|
||||
match parse_stateful_reads(request.preflight_reads_json.as_str()) {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
execution_request.postcondition_reads =
|
||||
match parse_stateful_reads(request.postcondition_reads_json.as_str()) {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
execution_request.submit = request.submit;
|
||||
execution_request.operator_confirmed = request.operator_confirmed;
|
||||
execution_request.materialize_after_confirmation = request.materialize_after_confirmation;
|
||||
if let std::result::Result::Err(error) = execution_request.validate() {
|
||||
return std::result::Result::Err(error.to_string());
|
||||
}
|
||||
let observer = crate::DemoExecutionMetadataObserver {
|
||||
app_handle,
|
||||
cancel_requested: state.demo_execution_solana_core_cancel_requested(),
|
||||
};
|
||||
let workspace_root = crate::workspace_root_dir();
|
||||
let summary = match kb_pipeline_demo_scenarios::execute_devnet_metaplex_token_metadata(
|
||||
&http_pool,
|
||||
&profile,
|
||||
workspace_root.as_path(),
|
||||
&execution_request,
|
||||
&observer,
|
||||
)
|
||||
.await
|
||||
{
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error.to_string()),
|
||||
};
|
||||
let transaction_signature =
|
||||
summary.send_result.as_ref().map(|value| return value.signature.0.clone());
|
||||
let confirmation_status =
|
||||
summary.confirmation.as_ref().map(|value| return format!("{:?}", value.status));
|
||||
let evidence_json = crate::pretty_json(&serde_json::json!({
|
||||
"before": &summary.before,
|
||||
"readiness": &summary.readiness,
|
||||
"sendResult": &summary.send_result,
|
||||
"confirmation": &summary.confirmation,
|
||||
"after": &summary.after,
|
||||
"materializationRequested": summary.materialization_requested,
|
||||
"materializedSnapshots": &summary.materialized_snapshots
|
||||
}));
|
||||
return std::result::Result::Ok(
|
||||
crate::DemoExecutionMetadataMetaplexTokenMetadataSummaryPayload {
|
||||
profile_name: summary.profile_name,
|
||||
cluster: format!("{:?}", summary.cluster).to_lowercase(),
|
||||
genesis_hash: summary.genesis_hash,
|
||||
wallet_public_key: summary.wallet.public_key,
|
||||
balance_lamports: summary.balance_lamports.to_string(),
|
||||
simulation_success: summary.simulation.success,
|
||||
transaction_signature,
|
||||
confirmation_status,
|
||||
plan_json: crate::pretty_json(&summary.plan),
|
||||
preflight_json: crate::pretty_json(&summary.stateful_preflight),
|
||||
simulation_json: crate::pretty_json(&summary.simulation),
|
||||
evidence_json,
|
||||
materialization_requested: summary.materialization_requested,
|
||||
materialized_snapshot_count: summary.materialized_snapshots.len(),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
fn parse_stateful_reads(
|
||||
value: &str,
|
||||
) -> std::result::Result<
|
||||
std::vec::Vec<kb_pipeline::MetaplexTokenMetadataStatefulReadRequest>,
|
||||
std::string::String,
|
||||
> {
|
||||
if value.trim().is_empty() {
|
||||
return std::result::Result::Ok(std::vec::Vec::new());
|
||||
}
|
||||
return match serde_json::from_str(value) {
|
||||
std::result::Result::Ok(parsed) => std::result::Result::Ok(parsed),
|
||||
std::result::Result::Err(error) => {
|
||||
std::result::Result::Err(format!("invalid Metaplex stateful reads JSON: {error}"))
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/// Creates or reuses the Devnet mint fixture used by the named Create scenario.
|
||||
pub(crate) async fn demo_execution_metadata_metaplex_token_metadata_prepare_create_fixture(
|
||||
state: tauri::State<'_, crate::AppState>,
|
||||
profile_name: std::string::String,
|
||||
) -> std::result::Result<
|
||||
crate::DemoExecutionMetadataMetaplexTokenMetadataCreateFixturePayload,
|
||||
std::string::String,
|
||||
> {
|
||||
let profile = match crate::select_devnet_profile(state.app_config(), profile_name.as_str()) {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let configured = std::path::PathBuf::from(profile.wallet.wallet_dir.as_str());
|
||||
let wallet_dir = if configured.is_absolute() {
|
||||
configured
|
||||
} else {
|
||||
crate::workspace_root_dir().join(configured)
|
||||
};
|
||||
let http_pool = match kb_onchain_transport::HttpEndpointPool::from_profile(&profile) {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error.to_string()),
|
||||
};
|
||||
let workspace_root = crate::workspace_root_dir();
|
||||
let summary = match kb_pipeline_demo_scenarios::prepare_metaplex_create_fixture(
|
||||
&http_pool,
|
||||
&profile,
|
||||
workspace_root.as_path(),
|
||||
&kb_pipeline_demo_scenarios::MetaplexCreateFixturePreparationOptions::new(wallet_dir),
|
||||
)
|
||||
.await
|
||||
{
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error.to_string()),
|
||||
};
|
||||
return std::result::Result::Ok(
|
||||
crate::DemoExecutionMetadataMetaplexTokenMetadataCreateFixturePayload {
|
||||
mint: summary.mint,
|
||||
metadata: summary.metadata,
|
||||
master_edition: summary.master_edition,
|
||||
authority: summary.authority,
|
||||
operation_json: summary.operation_json,
|
||||
mint_created: summary.mint_created,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
fn adapt_create_operation_json(
|
||||
operation_json: &str,
|
||||
asset_family: kb_pipeline_demo_scenarios::MetaplexTokenMetadataAssetFamily,
|
||||
) -> std::result::Result<std::string::String, std::string::String> {
|
||||
let mut value: serde_json::Value = match serde_json::from_str(operation_json) {
|
||||
std::result::Result::Ok(parsed) => parsed,
|
||||
std::result::Result::Err(error) => {
|
||||
return std::result::Result::Err(format!(
|
||||
"invalid generated Metaplex Create JSON: {error}"
|
||||
));
|
||||
},
|
||||
};
|
||||
if matches!(
|
||||
asset_family,
|
||||
kb_pipeline_demo_scenarios::MetaplexTokenMetadataAssetFamily::Sft
|
||||
| kb_pipeline_demo_scenarios::MetaplexTokenMetadataAssetFamily::Fungible
|
||||
) {
|
||||
value["master_edition"] = serde_json::Value::Null;
|
||||
}
|
||||
let create = match value
|
||||
.get_mut("create_args")
|
||||
.and_then(|args| return args.get_mut("V1"))
|
||||
.and_then(serde_json::Value::as_object_mut)
|
||||
{
|
||||
std::option::Option::Some(found) => found,
|
||||
std::option::Option::None => {
|
||||
return std::result::Result::Err(
|
||||
"generated Metaplex Create JSON has no create_args.V1 object".to_string(),
|
||||
);
|
||||
},
|
||||
};
|
||||
match asset_family {
|
||||
kb_pipeline_demo_scenarios::MetaplexTokenMetadataAssetFamily::Nft => {
|
||||
create.insert("name".to_string(), serde_json::json!("Khadhroony Devnet NFT"));
|
||||
create.insert("symbol".to_string(), serde_json::json!("KHNFT"));
|
||||
create.insert("token_standard".to_string(), serde_json::json!("NonFungible"));
|
||||
create.insert("collection_details".to_string(), serde_json::Value::Null);
|
||||
create.insert("decimals".to_string(), serde_json::Value::Null);
|
||||
create.insert("print_supply".to_string(), serde_json::json!("Zero"));
|
||||
},
|
||||
kb_pipeline_demo_scenarios::MetaplexTokenMetadataAssetFamily::Sft => {
|
||||
create.insert("name".to_string(), serde_json::json!("Khadhroony Devnet SFT"));
|
||||
create.insert("symbol".to_string(), serde_json::json!("KHSFT"));
|
||||
create.insert("token_standard".to_string(), serde_json::json!("FungibleAsset"));
|
||||
create.insert("collection_details".to_string(), serde_json::Value::Null);
|
||||
create.insert("decimals".to_string(), serde_json::json!(0));
|
||||
create.insert("print_supply".to_string(), serde_json::Value::Null);
|
||||
},
|
||||
kb_pipeline_demo_scenarios::MetaplexTokenMetadataAssetFamily::Fungible => {
|
||||
create.insert("name".to_string(), serde_json::json!("Khadhroony Devnet Fungible"));
|
||||
create.insert("symbol".to_string(), serde_json::json!("KHFUN"));
|
||||
create.insert("token_standard".to_string(), serde_json::json!("Fungible"));
|
||||
create.insert("collection_details".to_string(), serde_json::Value::Null);
|
||||
create.insert("decimals".to_string(), serde_json::json!(0));
|
||||
create.insert("print_supply".to_string(), serde_json::Value::Null);
|
||||
},
|
||||
kb_pipeline_demo_scenarios::MetaplexTokenMetadataAssetFamily::Collection => {
|
||||
create.insert("name".to_string(), serde_json::json!("Khadhroony Devnet Collection"));
|
||||
create.insert("symbol".to_string(), serde_json::json!("KHCOL"));
|
||||
create.insert("token_standard".to_string(), serde_json::json!("NonFungible"));
|
||||
create.insert("collection_details".to_string(), serde_json::json!({"V1": {"size": 0}}));
|
||||
create.insert("decimals".to_string(), serde_json::Value::Null);
|
||||
create.insert("print_supply".to_string(), serde_json::json!("Zero"));
|
||||
},
|
||||
kb_pipeline_demo_scenarios::MetaplexTokenMetadataAssetFamily::ProgrammableNft => {
|
||||
create.insert("name".to_string(), serde_json::json!("Khadhroony Devnet pNFT"));
|
||||
create.insert("symbol".to_string(), serde_json::json!("KHPNFT"));
|
||||
create
|
||||
.insert("token_standard".to_string(), serde_json::json!("ProgrammableNonFungible"));
|
||||
create.insert("collection_details".to_string(), serde_json::Value::Null);
|
||||
create.insert("rule_set".to_string(), serde_json::Value::Null);
|
||||
create.insert("decimals".to_string(), serde_json::Value::Null);
|
||||
create.insert("print_supply".to_string(), serde_json::json!("Zero"));
|
||||
},
|
||||
}
|
||||
return std::result::Result::Ok(crate::pretty_json(&value));
|
||||
}
|
||||
|
||||
/// Prepares one coherent scenario step and all of its stateful reads.
|
||||
pub(crate) async fn demo_execution_metadata_metaplex_token_metadata_prepare_step(
|
||||
state: tauri::State<'_, crate::AppState>,
|
||||
profile_name: std::string::String,
|
||||
scenario_id: std::string::String,
|
||||
step_index: usize,
|
||||
previous_operation_json: std::option::Option<std::string::String>,
|
||||
) -> std::result::Result<
|
||||
crate::DemoExecutionMetadataMetaplexTokenMetadataPreparedStepPayload,
|
||||
std::string::String,
|
||||
> {
|
||||
let scenario = kb_pipeline_demo_scenarios::metaplex_token_metadata_devnet_scenarios()
|
||||
.into_iter()
|
||||
.find(|candidate| return candidate.id == scenario_id);
|
||||
let scenario = match scenario {
|
||||
std::option::Option::Some(value) => value,
|
||||
std::option::Option::None => {
|
||||
return std::result::Result::Err(format!(
|
||||
"unknown Metaplex Devnet scenario `{scenario_id}`"
|
||||
));
|
||||
},
|
||||
};
|
||||
let operation_code = match scenario.operation_codes.get(step_index) {
|
||||
std::option::Option::Some(value) => value.clone(),
|
||||
std::option::Option::None => {
|
||||
return std::result::Result::Err(format!(
|
||||
"scenario `{}` has no step at index {step_index}",
|
||||
scenario.id
|
||||
));
|
||||
},
|
||||
};
|
||||
let operation_suffix = match operation_code.rsplit('.').next() {
|
||||
std::option::Option::Some(value) => value,
|
||||
std::option::Option::None => operation_code.as_str(),
|
||||
};
|
||||
let operation = if operation_suffix == "update" {
|
||||
"update_as_update_authority_v2".to_string()
|
||||
} else {
|
||||
operation_suffix.to_string()
|
||||
};
|
||||
if operation != "create" && operation != "update_as_update_authority_v2" {
|
||||
return std::result::Result::Err(format!(
|
||||
"scenario step `{operation}` is declared but its Devnet fixture builder is not implemented yet"
|
||||
));
|
||||
}
|
||||
let (fixture_mint, fixture_metadata, fixture_master_edition, operation_json, fixture_state) =
|
||||
if operation == "create" {
|
||||
let fixture =
|
||||
match crate::demo_execution_metadata_metaplex_token_metadata_prepare_create_fixture(
|
||||
state,
|
||||
profile_name,
|
||||
)
|
||||
.await
|
||||
{
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let operation_json = match adapt_create_operation_json(
|
||||
fixture.operation_json.as_str(),
|
||||
scenario.asset_family,
|
||||
) {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
(
|
||||
fixture.mint,
|
||||
fixture.metadata,
|
||||
fixture.master_edition,
|
||||
operation_json,
|
||||
if fixture.mint_created {
|
||||
"mint_prepared".to_string()
|
||||
} else {
|
||||
"mint_reused".to_string()
|
||||
},
|
||||
)
|
||||
} else {
|
||||
let previous = match previous_operation_json {
|
||||
std::option::Option::Some(value) if !value.trim().is_empty() => value,
|
||||
_ => {
|
||||
return std::result::Result::Err(
|
||||
"prepare and submit the previous NFT lifecycle step before continuing"
|
||||
.to_string(),
|
||||
);
|
||||
},
|
||||
};
|
||||
let previous_value: serde_json::Value = match serde_json::from_str(previous.as_str()) {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => {
|
||||
return std::result::Result::Err(format!(
|
||||
"invalid previous scenario operation JSON: {error}"
|
||||
));
|
||||
},
|
||||
};
|
||||
let string_field = |name: &str| -> std::result::Result<
|
||||
std::string::String,
|
||||
std::string::String,
|
||||
> {
|
||||
return match previous_value.get(name).and_then(serde_json::Value::as_str) {
|
||||
std::option::Option::Some(value) => std::result::Result::Ok(value.to_string()),
|
||||
std::option::Option::None => std::result::Result::Err(format!(
|
||||
"previous scenario step does not contain `{name}`"
|
||||
)),
|
||||
};
|
||||
};
|
||||
let mint = match string_field("mint") {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let metadata = match string_field("metadata") {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let authority = match string_field("authority") {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let master_edition =
|
||||
match previous_value.get("master_edition").and_then(serde_json::Value::as_str) {
|
||||
std::option::Option::Some(value) => value.to_string(),
|
||||
std::option::Option::None => std::string::String::new(),
|
||||
};
|
||||
let immutable_transition = scenario.asset_family
|
||||
== kb_pipeline_demo_scenarios::MetaplexTokenMetadataAssetFamily::Nft
|
||||
&& step_index == 2;
|
||||
let value = serde_json::json!({
|
||||
"operation": "update_as_update_authority_v2",
|
||||
"authority": authority.clone(),
|
||||
"mint": mint.clone(),
|
||||
"metadata": metadata.clone(),
|
||||
"edition": null,
|
||||
"token": null,
|
||||
"authorization_rules_program": null,
|
||||
"authorization_rules": null,
|
||||
"update_args": {
|
||||
"AsUpdateAuthorityV2": {
|
||||
"new_update_authority": null,
|
||||
"data": null,
|
||||
"primary_sale_happened": if immutable_transition {
|
||||
serde_json::Value::Null
|
||||
} else {
|
||||
serde_json::json!(true)
|
||||
},
|
||||
"is_mutable": if immutable_transition {
|
||||
serde_json::json!(false)
|
||||
} else {
|
||||
serde_json::Value::Null
|
||||
},
|
||||
"collection": "None",
|
||||
"collection_details": "None",
|
||||
"uses": "None",
|
||||
"rule_set": "None",
|
||||
"token_standard": null,
|
||||
"authorization_data": null
|
||||
}
|
||||
}
|
||||
});
|
||||
(
|
||||
mint,
|
||||
metadata,
|
||||
master_edition,
|
||||
crate::pretty_json(&value),
|
||||
if immutable_transition {
|
||||
"metadata_immutable".to_string()
|
||||
} else {
|
||||
"metadata_created".to_string()
|
||||
},
|
||||
)
|
||||
};
|
||||
let metadata_read = serde_json::json!({
|
||||
"query_role": "http_queries",
|
||||
"account": fixture_metadata.clone(),
|
||||
"kind": { "kind": "metadata" },
|
||||
"min_context_slot": null,
|
||||
"max_data_bytes": 65536
|
||||
});
|
||||
let edition_read = serde_json::json!({
|
||||
"query_role": "http_queries",
|
||||
"account": fixture_master_edition.clone(),
|
||||
"kind": { "kind": "edition", "mint": fixture_mint.clone() },
|
||||
"min_context_slot": null,
|
||||
"max_data_bytes": 65536
|
||||
});
|
||||
let preflight_reads = if operation == "create" {
|
||||
serde_json::json!([])
|
||||
} else {
|
||||
serde_json::json!([metadata_read.clone()])
|
||||
};
|
||||
let create_reads_edition = matches!(
|
||||
scenario.asset_family,
|
||||
kb_pipeline_demo_scenarios::MetaplexTokenMetadataAssetFamily::Nft
|
||||
| kb_pipeline_demo_scenarios::MetaplexTokenMetadataAssetFamily::Collection
|
||||
| kb_pipeline_demo_scenarios::MetaplexTokenMetadataAssetFamily::ProgrammableNft
|
||||
);
|
||||
let postcondition_reads = if operation == "create" && create_reads_edition {
|
||||
serde_json::json!([metadata_read, edition_read])
|
||||
} else {
|
||||
serde_json::json!([metadata_read])
|
||||
};
|
||||
return std::result::Result::Ok(
|
||||
crate::DemoExecutionMetadataMetaplexTokenMetadataPreparedStepPayload {
|
||||
scenario_id: scenario.id,
|
||||
step_index,
|
||||
operation: operation.clone(),
|
||||
label: if scenario.asset_family
|
||||
== kb_pipeline_demo_scenarios::MetaplexTokenMetadataAssetFamily::Nft
|
||||
&& step_index == 2
|
||||
{
|
||||
"Étape 3 — rendre les metadata immutables".to_string()
|
||||
} else {
|
||||
format!("Étape {} — {}", step_index + 1, operation)
|
||||
},
|
||||
operation_json,
|
||||
preflight_reads_json: crate::pretty_json(&preflight_reads),
|
||||
postcondition_reads_json: crate::pretty_json(&postcondition_reads),
|
||||
simulation_ready: true,
|
||||
submission_ready: true,
|
||||
fixture_state,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// Returns current operation names accepted by automatic Devnet campaigns.
|
||||
pub(crate) fn demo_execution_metadata_metaplex_token_metadata_current_operations()
|
||||
-> std::vec::Vec<std::string::String> {
|
||||
return kb_pipeline_demo_scenarios::metaplex_token_metadata_current_operation_names()
|
||||
.iter()
|
||||
.map(|value| return (*value).to_string())
|
||||
.collect();
|
||||
}
|
||||
|
||||
/// Returns one generated typed JSON template for a named current operation.
|
||||
pub(crate) fn demo_execution_metadata_metaplex_token_metadata_operation_template(
|
||||
operation: std::string::String,
|
||||
) -> std::result::Result<std::string::String, std::string::String> {
|
||||
return match kb_pipeline_demo_scenarios::metaplex_token_metadata_current_operation_json_template(
|
||||
operation.as_str(),
|
||||
) {
|
||||
std::result::Result::Ok(value) => std::result::Result::Ok(value),
|
||||
std::result::Result::Err(error) => std::result::Result::Err(error.to_string()),
|
||||
};
|
||||
}
|
||||
|
||||
/// Returns the canonical Metaplex scenario inventory for the desktop panel.
|
||||
pub(crate) fn demo_execution_metadata_metaplex_token_metadata_scenarios() -> std::result::Result<
|
||||
std::vec::Vec<crate::DemoExecutionMetadataMetaplexTokenMetadataScenarioPayload>,
|
||||
std::string::String,
|
||||
> {
|
||||
let matrix = match kb_pipeline_demo_scenarios::load_metaplex_token_metadata_validation_matrix()
|
||||
{
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error.to_string()),
|
||||
};
|
||||
let statuses = matrix
|
||||
.scenarios
|
||||
.into_iter()
|
||||
.map(|scenario| return (scenario.id, (scenario.status, scenario.required_evidence)))
|
||||
.collect::<std::collections::BTreeMap<
|
||||
std::string::String,
|
||||
(
|
||||
kb_pipeline_demo_scenarios::MetaplexTokenMetadataValidationStatus,
|
||||
std::vec::Vec<std::string::String>,
|
||||
),
|
||||
>>();
|
||||
let mut payloads = std::vec::Vec::new();
|
||||
for scenario in kb_pipeline_demo_scenarios::metaplex_token_metadata_synthetic_scenarios()
|
||||
.into_iter()
|
||||
.chain(kb_pipeline_demo_scenarios::metaplex_token_metadata_devnet_scenarios())
|
||||
{
|
||||
let status = statuses.get(scenario.id.as_str());
|
||||
let validation_status = match status {
|
||||
std::option::Option::Some((value, _)) => validation_status_code(*value),
|
||||
std::option::Option::None => "not_run".to_string(),
|
||||
};
|
||||
let required_evidence = match status {
|
||||
std::option::Option::Some((_, evidence)) => evidence.clone(),
|
||||
std::option::Option::None => std::vec::Vec::new(),
|
||||
};
|
||||
payloads.push(crate::DemoExecutionMetadataMetaplexTokenMetadataScenarioPayload {
|
||||
id: scenario.id,
|
||||
label: scenario.label,
|
||||
asset_family: asset_family_code(scenario.asset_family),
|
||||
fixture_kind: fixture_kind_code(scenario.fixture_kind),
|
||||
initial_state: fixture_state_code(scenario.initial_state),
|
||||
resulting_state: fixture_state_code(scenario.resulting_state),
|
||||
mode: scenario_mode_code(scenario.mode),
|
||||
operation_codes: scenario.operation_codes,
|
||||
requires_collection: scenario.requires_collection,
|
||||
requires_programmable_rules: scenario.requires_programmable_rules,
|
||||
requires_postcondition: scenario.requires_postcondition,
|
||||
validation_status,
|
||||
required_evidence,
|
||||
materialization_targets: scenario.materialization_targets,
|
||||
});
|
||||
}
|
||||
return std::result::Result::Ok(payloads);
|
||||
}
|
||||
|
||||
fn asset_family_code(
|
||||
value: kb_pipeline_demo_scenarios::MetaplexTokenMetadataAssetFamily,
|
||||
) -> std::string::String {
|
||||
return match value {
|
||||
kb_pipeline_demo_scenarios::MetaplexTokenMetadataAssetFamily::Nft => "nft".to_string(),
|
||||
kb_pipeline_demo_scenarios::MetaplexTokenMetadataAssetFamily::Sft => "sft".to_string(),
|
||||
kb_pipeline_demo_scenarios::MetaplexTokenMetadataAssetFamily::Fungible => {
|
||||
"fungible".to_string()
|
||||
},
|
||||
kb_pipeline_demo_scenarios::MetaplexTokenMetadataAssetFamily::Collection => {
|
||||
"collection".to_string()
|
||||
},
|
||||
kb_pipeline_demo_scenarios::MetaplexTokenMetadataAssetFamily::ProgrammableNft => {
|
||||
"programmable_nft".to_string()
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
fn fixture_kind_code(
|
||||
value: kb_pipeline_demo_scenarios::MetaplexTokenMetadataFixtureKind,
|
||||
) -> std::string::String {
|
||||
return format!("{value:?}").to_lowercase();
|
||||
}
|
||||
|
||||
fn fixture_state_code(
|
||||
value: kb_pipeline_demo_scenarios::MetaplexTokenMetadataFixtureState,
|
||||
) -> std::string::String {
|
||||
return format!("{value:?}").to_lowercase();
|
||||
}
|
||||
|
||||
fn scenario_mode_code(
|
||||
value: kb_pipeline_demo_scenarios::MetaplexTokenMetadataScenarioMode,
|
||||
) -> std::string::String {
|
||||
return match value {
|
||||
kb_pipeline_demo_scenarios::MetaplexTokenMetadataScenarioMode::Synthetic => {
|
||||
"synthetic".to_string()
|
||||
},
|
||||
kb_pipeline_demo_scenarios::MetaplexTokenMetadataScenarioMode::NetworkSimulation => {
|
||||
"network_simulation".to_string()
|
||||
},
|
||||
kb_pipeline_demo_scenarios::MetaplexTokenMetadataScenarioMode::NetworkSubmission => {
|
||||
"network_submission".to_string()
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
fn validation_status_code(
|
||||
value: kb_pipeline_demo_scenarios::MetaplexTokenMetadataValidationStatus,
|
||||
) -> std::string::String {
|
||||
return match value {
|
||||
kb_pipeline_demo_scenarios::MetaplexTokenMetadataValidationStatus::NotRun => {
|
||||
"not_run".to_string()
|
||||
},
|
||||
kb_pipeline_demo_scenarios::MetaplexTokenMetadataValidationStatus::SyntheticValidated => {
|
||||
"synthetic_validated".to_string()
|
||||
},
|
||||
kb_pipeline_demo_scenarios::MetaplexTokenMetadataValidationStatus::Simulated => {
|
||||
"simulated".to_string()
|
||||
},
|
||||
kb_pipeline_demo_scenarios::MetaplexTokenMetadataValidationStatus::Submitted => {
|
||||
"submitted".to_string()
|
||||
},
|
||||
kb_pipeline_demo_scenarios::MetaplexTokenMetadataValidationStatus::Confirmed => {
|
||||
"confirmed".to_string()
|
||||
},
|
||||
kb_pipeline_demo_scenarios::MetaplexTokenMetadataValidationStatus::Unavailable => {
|
||||
"unavailable".to_string()
|
||||
},
|
||||
kb_pipeline_demo_scenarios::MetaplexTokenMetadataValidationStatus::Failed => {
|
||||
"failed".to_string()
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#[test]
|
||||
fn desktop_inventory_matches_reusable_scenarios_and_matrix() {
|
||||
let payloads = match crate::demo_execution_metadata_metaplex_token_metadata_scenarios() {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => {
|
||||
assert_eq!(std::option::Option::Some(error), std::option::Option::None);
|
||||
return;
|
||||
},
|
||||
};
|
||||
let expected_count =
|
||||
kb_pipeline_demo_scenarios::metaplex_token_metadata_synthetic_scenarios().len()
|
||||
+ kb_pipeline_demo_scenarios::metaplex_token_metadata_devnet_scenarios().len();
|
||||
assert_eq!(payloads.len(), expected_count);
|
||||
assert!(payloads.iter().all(|payload| return !payload.operation_codes.is_empty()));
|
||||
assert!(payloads.iter().all(|payload| return payload.requires_postcondition));
|
||||
assert!(
|
||||
payloads
|
||||
.iter()
|
||||
.all(|payload| return payload.validation_status != "missing_from_matrix")
|
||||
);
|
||||
assert_eq!(
|
||||
payloads
|
||||
.iter()
|
||||
.filter(|payload| return payload.mode == "network_simulation")
|
||||
.count(),
|
||||
5
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_stateful_reads_are_accepted() {
|
||||
let parsed = super::parse_stateful_reads(" ");
|
||||
assert!(parsed.is_ok());
|
||||
let values = match parsed {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => {
|
||||
assert_eq!(std::option::Option::Some(error), std::option::Option::None);
|
||||
return;
|
||||
},
|
||||
};
|
||||
assert_eq!(values.len(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn metadata_frontend_contains_every_required_runtime_control() {
|
||||
let source = std::include_str!("../frontend/demo_execution_metadata.html");
|
||||
for id in [
|
||||
"metadataProfileSelect",
|
||||
"metadataDevnetScenarioSelect",
|
||||
"metadataStepSelect",
|
||||
"prepareMetadataScenarioStepButton",
|
||||
"metadataExecutionLogOutput",
|
||||
"metadataProfileHelp",
|
||||
"simulateMetadataButton",
|
||||
"submitMetadataButton",
|
||||
] {
|
||||
assert!(source.contains(format!("id=\"{id}\"").as_str()));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn programmable_and_collection_requirements_remain_explicit() {
|
||||
let payloads = match crate::demo_execution_metadata_metaplex_token_metadata_scenarios() {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => {
|
||||
assert_eq!(std::option::Option::Some(error), std::option::Option::None);
|
||||
return;
|
||||
},
|
||||
};
|
||||
assert!(payloads.iter().any(|payload| return payload.requires_collection));
|
||||
assert!(payloads.iter().any(|payload| return payload.requires_programmable_rules));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,338 @@
|
||||
// file: kb-app-demo-desktop/src/demo_execution_metadata_solana_program.rs
|
||||
// version: 2
|
||||
|
||||
//! Thin desktop adapter for complete Solana Program Metadata Devnet campaigns.
|
||||
|
||||
/// UI-safe step in one Solana Program Metadata Devnet journey.
|
||||
#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize, ts_rs::TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(
|
||||
export,
|
||||
export_to = "../frontend/ts/bindings/kb_app_demo_desktop/demo_execution_metadata/DemoExecutionMetadataSolanaProgramScenarioStepPayload.ts"
|
||||
)]
|
||||
pub(crate) struct DemoExecutionMetadataSolanaProgramScenarioStepPayload {
|
||||
/// Stable scenario-step identifier.
|
||||
pub id: std::string::String,
|
||||
/// Operator-visible label.
|
||||
pub label: std::string::String,
|
||||
/// Stable executor operation code.
|
||||
pub operation_code: std::string::String,
|
||||
/// Expected state before execution.
|
||||
pub initial_state: std::string::String,
|
||||
/// Expected state after confirmed execution.
|
||||
pub resulting_state: std::string::String,
|
||||
/// Whether explicit approval is required.
|
||||
pub requires_explicit_approval: bool,
|
||||
/// Evidence kinds required by the validation contract.
|
||||
pub required_evidence: std::vec::Vec<std::string::String>,
|
||||
}
|
||||
|
||||
/// UI-safe Solana Program Metadata Devnet journey.
|
||||
#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize, ts_rs::TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(
|
||||
export,
|
||||
export_to = "../frontend/ts/bindings/kb_app_demo_desktop/demo_execution_metadata/DemoExecutionMetadataSolanaProgramScenarioPayload.ts"
|
||||
)]
|
||||
pub(crate) struct DemoExecutionMetadataSolanaProgramScenarioPayload {
|
||||
/// Stable scenario identifier.
|
||||
pub id: std::string::String,
|
||||
/// Operator-visible label.
|
||||
pub label: std::string::String,
|
||||
/// Fixture account selected by the journey.
|
||||
pub fixture_account: std::string::String,
|
||||
/// Ordered steps.
|
||||
pub steps: std::vec::Vec<crate::DemoExecutionMetadataSolanaProgramScenarioStepPayload>,
|
||||
}
|
||||
|
||||
/// Request for one complete confirmed Solana Program Metadata Devnet campaign.
|
||||
#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize, ts_rs::TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(
|
||||
export,
|
||||
export_to = "../frontend/ts/bindings/kb_app_demo_desktop/demo_execution_metadata/DemoExecutionMetadataSolanaProgramCampaignRequest.ts"
|
||||
)]
|
||||
pub(crate) struct DemoExecutionMetadataSolanaProgramCampaignRequest {
|
||||
/// Selected Devnet profile.
|
||||
pub profile_name: std::string::String,
|
||||
/// Explicit operator confirmation for prefunding and all nine mutations.
|
||||
pub operator_confirmed: bool,
|
||||
}
|
||||
|
||||
/// UI-safe result of one complete Solana Program Metadata Devnet campaign.
|
||||
#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize, ts_rs::TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(
|
||||
export,
|
||||
export_to = "../frontend/ts/bindings/kb_app_demo_desktop/demo_execution_metadata/DemoExecutionMetadataSolanaProgramCampaignSummaryPayload.ts"
|
||||
)]
|
||||
pub(crate) struct DemoExecutionMetadataSolanaProgramCampaignSummaryPayload {
|
||||
/// Profile used by the campaign.
|
||||
pub profile_name: std::string::String,
|
||||
/// Profile-wallet authority.
|
||||
pub authority: std::string::String,
|
||||
/// Executable program described by both non-canonical PDAs.
|
||||
pub described_program: std::string::String,
|
||||
/// Fresh Buffer PDA.
|
||||
pub buffer: std::string::String,
|
||||
/// Confirmed Buffer prefunding signature.
|
||||
pub buffer_prefund_signature: std::string::String,
|
||||
/// Fresh Metadata PDA.
|
||||
pub metadata: std::string::String,
|
||||
/// Confirmed Metadata prefunding signature.
|
||||
pub metadata_prefund_signature: std::string::String,
|
||||
/// Total number of stable operations executed.
|
||||
pub step_count: usize,
|
||||
/// Number of operations reaching confirmed or finalized state.
|
||||
pub confirmed_step_count: usize,
|
||||
/// Total number of canonical account snapshots materialized.
|
||||
pub materialized_snapshot_count: usize,
|
||||
/// Whether all nine steps completed with required evidence.
|
||||
pub completed: bool,
|
||||
/// Complete fixture as formatted JSON.
|
||||
pub fixture_json: std::string::String,
|
||||
/// Ordered execution evidence as formatted JSON.
|
||||
pub steps_json: std::string::String,
|
||||
}
|
||||
|
||||
/// Returns the two canonical Solana Program Metadata Devnet journeys.
|
||||
pub(crate) fn demo_execution_metadata_solana_program_scenarios()
|
||||
-> std::vec::Vec<crate::DemoExecutionMetadataSolanaProgramScenarioPayload> {
|
||||
return kb_pipeline_demo_scenarios::solana_program_metadata_devnet_scenarios()
|
||||
.into_iter()
|
||||
.map(|scenario| {
|
||||
return crate::DemoExecutionMetadataSolanaProgramScenarioPayload {
|
||||
id: scenario.id,
|
||||
label: scenario.label,
|
||||
fixture_account: scenario.fixture_account,
|
||||
steps: scenario
|
||||
.steps
|
||||
.into_iter()
|
||||
.map(|step| {
|
||||
return crate::DemoExecutionMetadataSolanaProgramScenarioStepPayload {
|
||||
id: step.id,
|
||||
label: step.label,
|
||||
operation_code: step.operation_code,
|
||||
initial_state: fixture_state_code(step.initial_state),
|
||||
resulting_state: fixture_state_code(step.resulting_state),
|
||||
requires_explicit_approval: step.requires_explicit_approval,
|
||||
required_evidence: step.required_evidence,
|
||||
};
|
||||
})
|
||||
.collect(),
|
||||
};
|
||||
})
|
||||
.collect();
|
||||
}
|
||||
|
||||
/// Executes the complete eleven-transaction Solana Program Metadata Devnet campaign.
|
||||
pub(crate) async fn demo_execution_metadata_solana_program_execute_campaign(
|
||||
app_handle: tauri::AppHandle,
|
||||
state: tauri::State<'_, crate::AppState>,
|
||||
request: crate::DemoExecutionMetadataSolanaProgramCampaignRequest,
|
||||
) -> std::result::Result<
|
||||
crate::DemoExecutionMetadataSolanaProgramCampaignSummaryPayload,
|
||||
std::string::String,
|
||||
> {
|
||||
let acquire_result = state.demo_execution_solana_core_running().compare_exchange(
|
||||
false,
|
||||
true,
|
||||
std::sync::atomic::Ordering::AcqRel,
|
||||
std::sync::atomic::Ordering::Acquire,
|
||||
);
|
||||
if acquire_result.is_err() {
|
||||
return std::result::Result::Err("a Devnet execution is already running".to_string());
|
||||
}
|
||||
let _run_guard = crate::DemoExecutionRunGuard {
|
||||
running: state.demo_execution_solana_core_running(),
|
||||
};
|
||||
state
|
||||
.demo_execution_solana_core_cancel_requested()
|
||||
.store(false, std::sync::atomic::Ordering::Release);
|
||||
if !request.operator_confirmed {
|
||||
return std::result::Result::Err(
|
||||
"Solana Program Metadata campaign requires explicit operator confirmation".to_string(),
|
||||
);
|
||||
}
|
||||
let profile =
|
||||
match crate::select_devnet_profile(state.app_config(), request.profile_name.as_str()) {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let http_pool = match kb_onchain_transport::HttpEndpointPool::from_profile(&profile) {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error.to_string()),
|
||||
};
|
||||
let mut options =
|
||||
kb_pipeline_demo_scenarios::SolanaProgramMetadataFixturePreparationOptions::new();
|
||||
options.operator_confirmed = true;
|
||||
let observer = crate::DemoExecutionMetadataObserver {
|
||||
app_handle,
|
||||
cancel_requested: state.demo_execution_solana_core_cancel_requested(),
|
||||
};
|
||||
let workspace_root = crate::workspace_root_dir();
|
||||
let summary = match kb_pipeline_demo_scenarios::execute_devnet_solana_program_metadata_campaign(
|
||||
&http_pool,
|
||||
&profile,
|
||||
workspace_root.as_path(),
|
||||
&options,
|
||||
&observer,
|
||||
)
|
||||
.await
|
||||
{
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error.to_string()),
|
||||
};
|
||||
return std::result::Result::Ok(campaign_summary_payload(summary));
|
||||
}
|
||||
|
||||
fn campaign_summary_payload(
|
||||
summary: kb_pipeline_demo_scenarios::DevnetSolanaProgramMetadataCampaignSummary,
|
||||
) -> crate::DemoExecutionMetadataSolanaProgramCampaignSummaryPayload {
|
||||
let mut confirmed_step_count = 0_usize;
|
||||
let mut materialized_snapshot_count = 0_usize;
|
||||
let mut step_values = std::vec::Vec::with_capacity(summary.steps.len());
|
||||
for step in &summary.steps {
|
||||
let confirmation_status = step
|
||||
.execution
|
||||
.confirmation
|
||||
.as_ref()
|
||||
.map(|value| return format!("{:?}", value.status).to_lowercase());
|
||||
let confirmed = match step.execution.confirmation.as_ref() {
|
||||
std::option::Option::Some(value) => matches!(
|
||||
value.status,
|
||||
kb_lib::ExApiExecutionConfirmationStatus::Confirmed
|
||||
| kb_lib::ExApiExecutionConfirmationStatus::Finalized
|
||||
),
|
||||
std::option::Option::None => false,
|
||||
};
|
||||
if confirmed {
|
||||
confirmed_step_count = confirmed_step_count.saturating_add(1);
|
||||
}
|
||||
materialized_snapshot_count =
|
||||
materialized_snapshot_count.saturating_add(step.execution.materialized_snapshots.len());
|
||||
let operation_code = summary
|
||||
.fixture
|
||||
.prepared_steps
|
||||
.iter()
|
||||
.find(|prepared| return prepared.step_id == step.step_id)
|
||||
.map(|prepared| return prepared.operation.operation_code().to_string());
|
||||
step_values.push(serde_json::json!({
|
||||
"scenarioId": &step.scenario_id,
|
||||
"stepId": &step.step_id,
|
||||
"stepIndex": step.step_index,
|
||||
"operationCode": operation_code,
|
||||
"simulationSuccess": step.execution.simulation.success,
|
||||
"transactionSignature": step.execution.send_result.as_ref().map(|value| return value.signature.0.clone()),
|
||||
"confirmationStatus": confirmation_status,
|
||||
"before": &step.execution.before,
|
||||
"statefulPreflight": &step.execution.stateful_preflight,
|
||||
"plan": &step.execution.plan,
|
||||
"simulation": &step.execution.simulation,
|
||||
"readiness": &step.execution.readiness,
|
||||
"after": &step.execution.after,
|
||||
"postExecution": &step.execution.post_execution,
|
||||
"materializedSnapshots": &step.execution.materialized_snapshots
|
||||
}));
|
||||
}
|
||||
let expected_step_count = kb_lib::EX_METADATA_SPM_SUPPORTED_OPERATION_CODES.len();
|
||||
let completed = summary.steps.len() == expected_step_count
|
||||
&& confirmed_step_count == expected_step_count
|
||||
&& summary.steps.iter().all(|step| {
|
||||
let close_step = match summary
|
||||
.fixture
|
||||
.prepared_steps
|
||||
.iter()
|
||||
.find(|prepared| return prepared.step_id == step.step_id)
|
||||
{
|
||||
std::option::Option::Some(prepared) => {
|
||||
prepared.operation.operation_code() == kb_lib::EX_METADATA_SPM_CLOSE_OPERATION
|
||||
},
|
||||
std::option::Option::None => false,
|
||||
};
|
||||
return step.execution.post_execution.is_some()
|
||||
&& (!step.execution.materialized_snapshots.is_empty() || close_step);
|
||||
});
|
||||
let fixture_json = crate::pretty_json(&serde_json::json!({
|
||||
"profileName": &summary.fixture.profile_name,
|
||||
"authority": &summary.fixture.authority,
|
||||
"describedProgram": &summary.fixture.described_program,
|
||||
"bufferSeed": &summary.fixture.buffer_seed,
|
||||
"buffer": &summary.fixture.buffer,
|
||||
"bufferPrefundLamports": summary.fixture.buffer_prefund_lamports.to_string(),
|
||||
"bufferPrefundSignature": &summary.fixture.buffer_prefund_signature,
|
||||
"metadataSeed": &summary.fixture.metadata_seed,
|
||||
"metadata": &summary.fixture.metadata,
|
||||
"metadataPrefundLamports": summary.fixture.metadata_prefund_lamports.to_string(),
|
||||
"metadataPrefundSignature": &summary.fixture.metadata_prefund_signature,
|
||||
"preparedSteps": &summary.fixture.prepared_steps
|
||||
}));
|
||||
return crate::DemoExecutionMetadataSolanaProgramCampaignSummaryPayload {
|
||||
profile_name: summary.fixture.profile_name.clone(),
|
||||
authority: summary.fixture.authority.clone(),
|
||||
described_program: summary.fixture.described_program.clone(),
|
||||
buffer: summary.fixture.buffer.clone(),
|
||||
buffer_prefund_signature: summary.fixture.buffer_prefund_signature.clone(),
|
||||
metadata: summary.fixture.metadata.clone(),
|
||||
metadata_prefund_signature: summary.fixture.metadata_prefund_signature.clone(),
|
||||
step_count: summary.steps.len(),
|
||||
confirmed_step_count,
|
||||
materialized_snapshot_count,
|
||||
completed,
|
||||
fixture_json,
|
||||
steps_json: crate::pretty_json(&step_values),
|
||||
};
|
||||
}
|
||||
|
||||
fn fixture_state_code(
|
||||
value: kb_pipeline_demo_scenarios::SolanaProgramMetadataFixtureState,
|
||||
) -> std::string::String {
|
||||
return match value {
|
||||
kb_pipeline_demo_scenarios::SolanaProgramMetadataFixtureState::Prefunded => {
|
||||
"prefunded".to_string()
|
||||
},
|
||||
kb_pipeline_demo_scenarios::SolanaProgramMetadataFixtureState::Buffer => {
|
||||
"buffer".to_string()
|
||||
},
|
||||
kb_pipeline_demo_scenarios::SolanaProgramMetadataFixtureState::MutableMetadata => {
|
||||
"mutable_metadata".to_string()
|
||||
},
|
||||
kb_pipeline_demo_scenarios::SolanaProgramMetadataFixtureState::ImmutableMetadata => {
|
||||
"immutable_metadata".to_string()
|
||||
},
|
||||
kb_pipeline_demo_scenarios::SolanaProgramMetadataFixtureState::Closed => {
|
||||
"closed".to_string()
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#[test]
|
||||
fn desktop_inventory_matches_the_two_reusable_journeys() {
|
||||
let payloads = crate::demo_execution_metadata_solana_program_scenarios();
|
||||
assert_eq!(payloads.len(), 2);
|
||||
assert_eq!(
|
||||
payloads.iter().map(|scenario| return scenario.steps.len()).sum::<usize>(),
|
||||
kb_lib::EX_METADATA_SPM_SUPPORTED_OPERATION_CODES.len()
|
||||
);
|
||||
assert!(payloads.iter().flat_map(|scenario| return scenario.steps.iter()).all(|step| {
|
||||
return !step.required_evidence.is_empty()
|
||||
&& kb_lib::EX_METADATA_SPM_SUPPORTED_OPERATION_CODES
|
||||
.contains(&step.operation_code.as_str());
|
||||
}));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn metadata_frontend_contains_solana_program_campaign_controls() {
|
||||
let source = std::include_str!("../frontend/demo_execution_metadata.html");
|
||||
for id in [
|
||||
"solanaProgramMetadataScenarioSelect",
|
||||
"solanaProgramMetadataScenarioOutput",
|
||||
"solanaProgramMetadataOperatorConfirmed",
|
||||
"executeSolanaProgramMetadataCampaignButton",
|
||||
] {
|
||||
assert!(source.contains(format!("id=\"{id}\"").as_str()));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
// file: kb-app-demo-desktop/src/demo_execution_solana_core.rs
|
||||
// version: 12
|
||||
// version: 13
|
||||
|
||||
//! Tauri adapter for bounded Solana Core execution on Devnet.
|
||||
|
||||
@@ -359,15 +359,6 @@ impl kb_pipeline_demo_scenarios::SolanaExecutionObserver
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) struct DemoExecutionSolanaCoreRunGuard<'a> {
|
||||
pub(crate) running: &'a std::sync::atomic::AtomicBool,
|
||||
}
|
||||
impl std::ops::Drop for crate::DemoExecutionSolanaCoreRunGuard<'_> {
|
||||
fn drop(&mut self) {
|
||||
self.running.store(false, std::sync::atomic::Ordering::Release);
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn select_devnet_profile(
|
||||
config: &kb_config::AppConfig,
|
||||
profile_name: &str,
|
||||
@@ -467,7 +458,7 @@ pub(crate) async fn demo_execution_solana_core_execute(
|
||||
if acquire_result.is_err() {
|
||||
return std::result::Result::Err("a Solana Core execution is already running".to_string());
|
||||
}
|
||||
let _run_guard = crate::DemoExecutionSolanaCoreRunGuard {
|
||||
let _run_guard = crate::DemoExecutionRunGuard {
|
||||
running: state.demo_execution_solana_core_running(),
|
||||
};
|
||||
state
|
||||
@@ -552,7 +543,7 @@ pub(crate) async fn demo_execution_spl_memo_execute(
|
||||
if acquire_result.is_err() {
|
||||
return std::result::Result::Err("a Devnet execution is already running".to_string());
|
||||
}
|
||||
let _run_guard = crate::DemoExecutionSolanaCoreRunGuard {
|
||||
let _run_guard = crate::DemoExecutionRunGuard {
|
||||
running: state.demo_execution_solana_core_running(),
|
||||
};
|
||||
state
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// file: kb-app-demo-desktop/src/demo_spl_ata.rs
|
||||
// version: 11
|
||||
// version: 12
|
||||
|
||||
//! Thin Tauri adapter for ATA execution, derivation and lifecycle journal reads.
|
||||
|
||||
@@ -204,7 +204,7 @@ pub(crate) async fn demo_execution_spl_ata_execute(
|
||||
if acquired.is_err() {
|
||||
return std::result::Result::Err("a Devnet execution is already running".to_string());
|
||||
}
|
||||
let _run_guard = crate::DemoExecutionSolanaCoreRunGuard {
|
||||
let _run_guard = crate::DemoExecutionRunGuard {
|
||||
running: state.demo_execution_solana_core_running(),
|
||||
};
|
||||
state
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// file: kb-app-demo-desktop/src/demo_spl_token.rs
|
||||
// version: 8
|
||||
// version: 9
|
||||
|
||||
//! Thin Tauri adapter for classic SPL Token execution and materialized journals.
|
||||
|
||||
@@ -168,7 +168,7 @@ pub(crate) async fn demo_execution_spl_token_execute(
|
||||
if acquire_result.is_err() {
|
||||
return std::result::Result::Err("a Devnet execution is already running".to_string());
|
||||
}
|
||||
let _run_guard = crate::DemoExecutionSolanaCoreRunGuard {
|
||||
let _run_guard = crate::DemoExecutionRunGuard {
|
||||
running: state.demo_execution_solana_core_running(),
|
||||
};
|
||||
state
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// file: kb-app-demo-desktop/src/demo_spl_token_2022.rs
|
||||
// version: 11
|
||||
// version: 12
|
||||
|
||||
//! Thin Tauri adapter for independent public Token-2022 Devnet validation scenarios.
|
||||
|
||||
@@ -146,7 +146,7 @@ pub(crate) async fn demo_execution_spl_token_2022_execute(
|
||||
if acquire_result.is_err() {
|
||||
return std::result::Result::Err("a Devnet execution is already running".to_string());
|
||||
}
|
||||
let _run_guard = crate::DemoExecutionSolanaCoreRunGuard {
|
||||
let _run_guard = crate::DemoExecutionRunGuard {
|
||||
running: state.demo_execution_solana_core_running(),
|
||||
};
|
||||
state
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// file: kb-app-demo-desktop/src/lib.rs
|
||||
// version: 29
|
||||
// version: 31
|
||||
|
||||
//! Tauri desktop demo application for `khadhroony-bot3`.
|
||||
|
||||
@@ -15,6 +15,8 @@ mod demo_core_extraction;
|
||||
mod demo_decode_replay;
|
||||
mod demo_devnet_common;
|
||||
mod demo_execution_metadata;
|
||||
mod demo_execution_metadata_metaplex_token_metadata;
|
||||
mod demo_execution_metadata_solana_program;
|
||||
mod demo_execution_solana_core;
|
||||
mod demo_execution_spl;
|
||||
mod demo_http;
|
||||
@@ -123,34 +125,52 @@ pub(crate) use self::demo_decode_replay::demo_decode_replay_execute;
|
||||
pub(crate) use self::demo_decode_replay::demo_decode_replay_options;
|
||||
/// Readiness report for one Devnet profile PostgreSQL store.
|
||||
pub(crate) use self::demo_devnet_common::DemoExecutionDevnetStoreReadinessPayload;
|
||||
/// Restores the shared single-execution state flag when a Devnet run finishes.
|
||||
pub(crate) use self::demo_devnet_common::DemoExecutionRunGuard;
|
||||
/// Converts the reusable scenario readiness report to the desktop TS-RS payload.
|
||||
pub(crate) use self::demo_devnet_common::devnet_store_readiness_payload;
|
||||
/// Serializes a diagnostic value to indented JSON without panicking.
|
||||
pub(crate) use self::demo_devnet_common::pretty_json;
|
||||
/// UI-safe result of one prepared Metaplex Create fixture.
|
||||
pub(crate) use self::demo_execution_metadata::DemoExecutionMetadataCreateFixturePayload;
|
||||
/// Prepared coherent Metaplex scenario step.
|
||||
pub(crate) use self::demo_execution_metadata::DemoExecutionMetadataPreparedStepPayload;
|
||||
/// Request for one real Devnet Metaplex execution from the desktop panel.
|
||||
pub(crate) use self::demo_execution_metadata::DemoExecutionMetadataRequest;
|
||||
/// UI-safe Metaplex scenario row.
|
||||
pub(crate) use self::demo_execution_metadata::DemoExecutionMetadataScenarioPayload;
|
||||
/// UI-safe result of one real Devnet Metaplex execution.
|
||||
pub(crate) use self::demo_execution_metadata::DemoExecutionMetadataSummaryPayload;
|
||||
/// Returns current Metaplex operation names accepted by Devnet campaigns.
|
||||
pub(crate) use self::demo_execution_metadata::demo_execution_metadata_current_operations;
|
||||
/// Executes one real Devnet Metaplex simulation or submission.
|
||||
pub(crate) use self::demo_execution_metadata::demo_execution_metadata_execute;
|
||||
/// Returns one generated operation JSON template.
|
||||
pub(crate) use self::demo_execution_metadata::demo_execution_metadata_operation_template;
|
||||
/// Returns Devnet profile options dedicated to the Metadata execution panel.
|
||||
/// Bridges Metadata execution progress to the Metadata desktop window.
|
||||
pub(crate) use self::demo_execution_metadata::DemoExecutionMetadataObserver;
|
||||
/// Progress event emitted by Metadata execution workflows.
|
||||
pub(crate) use self::demo_execution_metadata::DemoExecutionMetadataProgressPayload;
|
||||
/// Returns Devnet profile options shared by the Metadata execution panel.
|
||||
pub(crate) use self::demo_execution_metadata::demo_execution_metadata_options;
|
||||
/// Creates or reuses the Devnet mint fixture used by the Create scenario.
|
||||
pub(crate) use self::demo_execution_metadata::demo_execution_metadata_prepare_create_fixture;
|
||||
/// Prepares one coherent scenario step and its automatic stateful reads.
|
||||
pub(crate) use self::demo_execution_metadata::demo_execution_metadata_prepare_step;
|
||||
/// Returns the canonical Metaplex scenario inventory for the desktop panel.
|
||||
pub(crate) use self::demo_execution_metadata::demo_execution_metadata_scenarios;
|
||||
/// UI-safe result of one prepared Metaplex Token Metadata Create fixture.
|
||||
pub(crate) use self::demo_execution_metadata_metaplex_token_metadata::DemoExecutionMetadataMetaplexTokenMetadataCreateFixturePayload;
|
||||
/// Prepared coherent Metaplex Token Metadata scenario step.
|
||||
pub(crate) use self::demo_execution_metadata_metaplex_token_metadata::DemoExecutionMetadataMetaplexTokenMetadataPreparedStepPayload;
|
||||
/// Request for one real Devnet Metaplex Token Metadata execution.
|
||||
pub(crate) use self::demo_execution_metadata_metaplex_token_metadata::DemoExecutionMetadataMetaplexTokenMetadataRequest;
|
||||
/// UI-safe Metaplex Token Metadata scenario row.
|
||||
pub(crate) use self::demo_execution_metadata_metaplex_token_metadata::DemoExecutionMetadataMetaplexTokenMetadataScenarioPayload;
|
||||
/// UI-safe result of one real Devnet Metaplex Token Metadata execution.
|
||||
pub(crate) use self::demo_execution_metadata_metaplex_token_metadata::DemoExecutionMetadataMetaplexTokenMetadataSummaryPayload;
|
||||
/// Returns current Metaplex Token Metadata operation names accepted by Devnet campaigns.
|
||||
pub(crate) use self::demo_execution_metadata_metaplex_token_metadata::demo_execution_metadata_metaplex_token_metadata_current_operations;
|
||||
/// Executes one real Devnet Metaplex Token Metadata simulation or submission.
|
||||
pub(crate) use self::demo_execution_metadata_metaplex_token_metadata::demo_execution_metadata_metaplex_token_metadata_execute;
|
||||
/// Returns one generated Metaplex Token Metadata operation JSON template.
|
||||
pub(crate) use self::demo_execution_metadata_metaplex_token_metadata::demo_execution_metadata_metaplex_token_metadata_operation_template;
|
||||
/// Creates or reuses the Devnet mint fixture used by a Metaplex Token Metadata Create scenario.
|
||||
pub(crate) use self::demo_execution_metadata_metaplex_token_metadata::demo_execution_metadata_metaplex_token_metadata_prepare_create_fixture;
|
||||
/// Prepares one coherent Metaplex Token Metadata scenario step and its stateful reads.
|
||||
pub(crate) use self::demo_execution_metadata_metaplex_token_metadata::demo_execution_metadata_metaplex_token_metadata_prepare_step;
|
||||
/// Returns the canonical Metaplex Token Metadata scenario inventory.
|
||||
pub(crate) use self::demo_execution_metadata_metaplex_token_metadata::demo_execution_metadata_metaplex_token_metadata_scenarios;
|
||||
/// Request for one complete Solana Program Metadata Devnet campaign.
|
||||
pub(crate) use self::demo_execution_metadata_solana_program::DemoExecutionMetadataSolanaProgramCampaignRequest;
|
||||
/// UI-safe result of one complete Solana Program Metadata Devnet campaign.
|
||||
pub(crate) use self::demo_execution_metadata_solana_program::DemoExecutionMetadataSolanaProgramCampaignSummaryPayload;
|
||||
/// UI-safe Solana Program Metadata Devnet journey.
|
||||
pub(crate) use self::demo_execution_metadata_solana_program::DemoExecutionMetadataSolanaProgramScenarioPayload;
|
||||
/// UI-safe step in one Solana Program Metadata Devnet journey.
|
||||
pub(crate) use self::demo_execution_metadata_solana_program::DemoExecutionMetadataSolanaProgramScenarioStepPayload;
|
||||
/// Executes the complete Solana Program Metadata Devnet campaign.
|
||||
pub(crate) use self::demo_execution_metadata_solana_program::demo_execution_metadata_solana_program_execute_campaign;
|
||||
/// Returns the two canonical Solana Program Metadata Devnet journeys.
|
||||
pub(crate) use self::demo_execution_metadata_solana_program::demo_execution_metadata_solana_program_scenarios;
|
||||
/// Request sent by the Memo v4 Devnet execution panel.
|
||||
pub(crate) use self::demo_execution_solana_core::DemoExecutionMemoRequest;
|
||||
/// UI-safe result of one Memo v4 Devnet execution orchestration.
|
||||
@@ -167,8 +187,6 @@ pub(crate) use self::demo_execution_solana_core::DemoExecutionSolanaCoreProfileO
|
||||
pub(crate) use self::demo_execution_solana_core::DemoExecutionSolanaCoreProgressPayload;
|
||||
/// Request sent by the execution demo.
|
||||
pub(crate) use self::demo_execution_solana_core::DemoExecutionSolanaCoreRequest;
|
||||
/// Restores the single-execution state flag when a run finishes.
|
||||
pub(crate) use self::demo_execution_solana_core::DemoExecutionSolanaCoreRunGuard;
|
||||
/// UI-safe result of one execution orchestration.
|
||||
pub(crate) use self::demo_execution_solana_core::DemoExecutionSolanaCoreSummaryPayload;
|
||||
/// Maps a confirmation status to its stable UI code.
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// file: kb-app-demo-desktop/src/tauri.rs
|
||||
// version: 30
|
||||
// version: 32
|
||||
|
||||
//! Tauri runtime assembly and private command wrappers.
|
||||
|
||||
@@ -89,13 +89,15 @@ pub fn run() -> kb_core::Result<()> {
|
||||
demo_execution_spl_token_2022_execute,
|
||||
demo_spl_token_2022_fixture,
|
||||
open_demo_execution_metadata_window,
|
||||
demo_execution_metadata_scenarios,
|
||||
demo_execution_metadata_metaplex_token_metadata_scenarios,
|
||||
demo_execution_metadata_options,
|
||||
demo_execution_metadata_current_operations,
|
||||
demo_execution_metadata_operation_template,
|
||||
demo_execution_metadata_prepare_create_fixture,
|
||||
demo_execution_metadata_prepare_step,
|
||||
demo_execution_metadata_execute,
|
||||
demo_execution_metadata_metaplex_token_metadata_current_operations,
|
||||
demo_execution_metadata_metaplex_token_metadata_operation_template,
|
||||
demo_execution_metadata_metaplex_token_metadata_prepare_create_fixture,
|
||||
demo_execution_metadata_metaplex_token_metadata_prepare_step,
|
||||
demo_execution_metadata_metaplex_token_metadata_execute,
|
||||
demo_execution_metadata_solana_program_scenarios,
|
||||
demo_execution_metadata_solana_program_execute_campaign,
|
||||
]);
|
||||
builder = builder.on_window_event(|window, event| {
|
||||
if window.label() != "main" || !matches!(event, tauri::WindowEvent::Destroyed) {
|
||||
@@ -629,42 +631,53 @@ fn demo_execution_metadata_options(
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn demo_execution_metadata_scenarios() -> std::result::Result<
|
||||
std::vec::Vec<crate::DemoExecutionMetadataScenarioPayload>,
|
||||
fn demo_execution_metadata_metaplex_token_metadata_scenarios() -> std::result::Result<
|
||||
std::vec::Vec<crate::DemoExecutionMetadataMetaplexTokenMetadataScenarioPayload>,
|
||||
std::string::String,
|
||||
> {
|
||||
return crate::demo_execution_metadata_scenarios();
|
||||
return crate::demo_execution_metadata_metaplex_token_metadata_scenarios();
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn demo_execution_metadata_current_operations() -> std::vec::Vec<std::string::String> {
|
||||
return crate::demo_execution_metadata_current_operations();
|
||||
fn demo_execution_metadata_metaplex_token_metadata_current_operations()
|
||||
-> std::vec::Vec<std::string::String> {
|
||||
return crate::demo_execution_metadata_metaplex_token_metadata_current_operations();
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn demo_execution_metadata_operation_template(
|
||||
fn demo_execution_metadata_metaplex_token_metadata_operation_template(
|
||||
operation: std::string::String,
|
||||
) -> std::result::Result<std::string::String, std::string::String> {
|
||||
return crate::demo_execution_metadata_operation_template(operation);
|
||||
return crate::demo_execution_metadata_metaplex_token_metadata_operation_template(operation);
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn demo_execution_metadata_prepare_create_fixture(
|
||||
async fn demo_execution_metadata_metaplex_token_metadata_prepare_create_fixture(
|
||||
state: tauri::State<'_, crate::AppState>,
|
||||
profile_name: std::string::String,
|
||||
) -> std::result::Result<crate::DemoExecutionMetadataCreateFixturePayload, std::string::String> {
|
||||
return crate::demo_execution_metadata_prepare_create_fixture(state, profile_name).await;
|
||||
) -> std::result::Result<
|
||||
crate::DemoExecutionMetadataMetaplexTokenMetadataCreateFixturePayload,
|
||||
std::string::String,
|
||||
> {
|
||||
return crate::demo_execution_metadata_metaplex_token_metadata_prepare_create_fixture(
|
||||
state,
|
||||
profile_name,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn demo_execution_metadata_prepare_step(
|
||||
async fn demo_execution_metadata_metaplex_token_metadata_prepare_step(
|
||||
state: tauri::State<'_, crate::AppState>,
|
||||
profile_name: std::string::String,
|
||||
scenario_id: std::string::String,
|
||||
step_index: usize,
|
||||
previous_operation_json: std::option::Option<std::string::String>,
|
||||
) -> std::result::Result<crate::DemoExecutionMetadataPreparedStepPayload, std::string::String> {
|
||||
return crate::demo_execution_metadata_prepare_step(
|
||||
) -> std::result::Result<
|
||||
crate::DemoExecutionMetadataMetaplexTokenMetadataPreparedStepPayload,
|
||||
std::string::String,
|
||||
> {
|
||||
return crate::demo_execution_metadata_metaplex_token_metadata_prepare_step(
|
||||
state,
|
||||
profile_name,
|
||||
scenario_id,
|
||||
@@ -675,12 +688,39 @@ async fn demo_execution_metadata_prepare_step(
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn demo_execution_metadata_execute(
|
||||
async fn demo_execution_metadata_metaplex_token_metadata_execute(
|
||||
app_handle: tauri::AppHandle,
|
||||
state: tauri::State<'_, crate::AppState>,
|
||||
request: crate::DemoExecutionMetadataRequest,
|
||||
) -> std::result::Result<crate::DemoExecutionMetadataSummaryPayload, std::string::String> {
|
||||
return crate::demo_execution_metadata_execute(app_handle, state, request).await;
|
||||
request: crate::DemoExecutionMetadataMetaplexTokenMetadataRequest,
|
||||
) -> std::result::Result<
|
||||
crate::DemoExecutionMetadataMetaplexTokenMetadataSummaryPayload,
|
||||
std::string::String,
|
||||
> {
|
||||
return crate::demo_execution_metadata_metaplex_token_metadata_execute(
|
||||
app_handle, state, request,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn demo_execution_metadata_solana_program_scenarios()
|
||||
-> std::vec::Vec<crate::DemoExecutionMetadataSolanaProgramScenarioPayload> {
|
||||
return crate::demo_execution_metadata_solana_program_scenarios();
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn demo_execution_metadata_solana_program_execute_campaign(
|
||||
app_handle: tauri::AppHandle,
|
||||
state: tauri::State<'_, crate::AppState>,
|
||||
request: crate::DemoExecutionMetadataSolanaProgramCampaignRequest,
|
||||
) -> std::result::Result<
|
||||
crate::DemoExecutionMetadataSolanaProgramCampaignSummaryPayload,
|
||||
std::string::String,
|
||||
> {
|
||||
return crate::demo_execution_metadata_solana_program_execute_campaign(
|
||||
app_handle, state, request,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
|
||||
@@ -1,8 +1,22 @@
|
||||
<!-- file: kb-onchain-transport/CHANGELOG.md -->
|
||||
<!-- version: 8 -->
|
||||
<!-- version: 11 -->
|
||||
|
||||
# CHANGELOG — kb-onchain-transport
|
||||
|
||||
## 0.4.8-pre.010
|
||||
|
||||
- documente que les limites de rôles doivent être additionnées pour respecter le quota global d’un endpoint public et que le quota par méthode peut être plus strict que le quota global ;
|
||||
- aligne l’exemple `local_devnet` sur un pacing conservateur pour `api.devnet.solana.com` : `3 r/s` pour les lectures et `1 r/s` pour les transactions, avec bursts bornés et cooldown de `10 s` ;
|
||||
|
||||
- applique réellement `requests_per_second`, `burst_capacity` et `max_concurrent_requests` au rôle HTTP sélectionné avant chaque tentative réseau ;
|
||||
- partage le token bucket, le sémaphore de concurrence et le cooldown distant entre tous les clones d’un même endpoint ;
|
||||
- conserve le retry réactif `429` comme seconde ligne de défense lorsque le quota distant est plus strict ou partagé avec d’autres clients ;
|
||||
- remplace les trois opérateurs `?` accidentels du parseur `Retry-After` par des branches explicites conformes aux règles du workspace ;
|
||||
- expose la borne publique `MAX_COMPLETE_ACCOUNT_DATA_BYTES = 65536` pour réconcilier les pipelines stateful avec le contrat réel de `getAccountInfo` ;
|
||||
- ajoute un retry borné avec backoff exponentiel sur les statuts HTTP `429` et les erreurs JSON-RPC `429` ;
|
||||
- respecte `Retry-After` lorsqu’il contient un délai en secondes et utilise sinon `pause_after_rate_limit_ms` des rôles configurés ;
|
||||
- conserve le même payload et la même transaction signée lors des retries, ce qui maintient l’idempotence de `sendTransaction` ;
|
||||
|
||||
## 0.4.6
|
||||
|
||||
- alignement de la crate sur la version fonctionnelle bot3 `0.4.6` ;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<!-- file: kb-onchain-transport/USAGE.md -->
|
||||
<!-- version: 2 -->
|
||||
<!-- version: 5 -->
|
||||
|
||||
# Utilisation de kb-onchain-transport
|
||||
|
||||
@@ -126,6 +126,18 @@ Les types suivants couvrent les opérations d’exécution :
|
||||
|
||||
La soumission ne remplace pas les contrôles de sécurité, préflights et confirmations opérateur réalisés par les couches supérieures.
|
||||
|
||||
## Lectures complètes et limites de débit
|
||||
|
||||
`MAX_COMPLETE_ACCOUNT_DATA_BYTES` expose la borne commune des données décodées retournées par une lecture complète `getAccountInfo`. Les couches supérieures doivent utiliser cette constante au lieu d’aligner une lecture RPC sur la capacité maximale d’un décodeur offline.
|
||||
|
||||
Le client applique d’abord les limites préventives du rôle sélectionné :
|
||||
|
||||
- `requests_per_second` alimente un token bucket partagé ;
|
||||
- `burst_capacity` borne le burst initial et les crédits accumulés ;
|
||||
- `max_concurrent_requests` borne les requêtes simultanément en vol.
|
||||
|
||||
Ces limiteurs sont partagés entre les clones d’un même endpoint. Les statuts HTTP `429` et erreurs JSON-RPC `429` restent réessayés avec un backoff borné, car un fournisseur peut appliquer un quota global, dynamique ou partagé que la configuration locale ne peut pas connaître. Le délai configuré par `pause_after_rate_limit_ms` est alors appliqué comme cooldown partagé, sauf lorsqu’un en-tête `Retry-After` impose une attente supérieure. Le nombre de retries reste limité et une erreur terminale conserve le nombre de tentatives.
|
||||
|
||||
## Erreurs et invariants
|
||||
|
||||
- les réponses sont validées et adaptées avant exposition aux couches supérieures ;
|
||||
@@ -145,3 +157,25 @@ La soumission ne remplace pas les contrôles de sécurité, préflights et confi
|
||||
- la crate traite les transports on-chain ; elle ne gère pas les métadonnées HTTP, IPFS ou Arweave ;
|
||||
- elle ne décode pas les instructions de programmes ;
|
||||
- elle ne décide pas seule de l’autorisation d’envoyer une transaction.
|
||||
|
||||
## Limitation préventive et réponses `429`
|
||||
|
||||
Le client applique les limites du rôle sélectionné avant chaque tentative :
|
||||
|
||||
- `requests_per_second` et `burst_capacity` alimentent un token bucket partagé ;
|
||||
- `max_concurrent_requests` borne les requêtes simultanées ;
|
||||
- `pause_after_rate_limit_ms` fournit le cooldown de repli ;
|
||||
- `Retry-After` est prioritaire lorsqu’il est fourni par l’endpoint.
|
||||
|
||||
Ces limites sont propres aux rôles du client. Elles ne créent pas de quotas indépendants côté fournisseur. Lorsque plusieurs rôles utilisent le même endpoint, leur débit et leurs bursts doivent être additionnés pour rester sous la limite globale de cet endpoint.
|
||||
|
||||
Pour `api.devnet.solana.com`, le profil d’exemple utilise volontairement :
|
||||
|
||||
```text
|
||||
http_queries 3 r/s, burst 3, concurrence 2
|
||||
http_transactions 1 r/s, burst 1, concurrence 1
|
||||
cooldown 10000 ms
|
||||
```
|
||||
|
||||
Un warning `retry_http_json_rpc_after_rate_limit` signifie que le serveur a tout de même renvoyé `429` et que la seconde ligne de défense a été activée. La requête n’est perdue que si le retry borné se termine en erreur.
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// file: kb-onchain-transport/src/constants.rs
|
||||
// version: 7
|
||||
// version: 8
|
||||
|
||||
//! Local constants for the `kb-onchain-transport` crate.
|
||||
|
||||
@@ -13,8 +13,12 @@ pub(crate) const TESTNET_GENESIS_HASH: &str = "4uhcVJyU9pJkvQyS88uRDiswHXSCkY3zQ
|
||||
pub(crate) const MAINNET_GENESIS_HASH: &str = "5eykt4UsFv8P8NJdTREpY1vzqKqZKvdpKuc147dw2N9d";
|
||||
/// Local defensive maximum for one base64 message or transaction request value.
|
||||
pub(crate) const MAX_EXECUTION_RPC_BASE64_LENGTH: usize = 65_536;
|
||||
/// Local defensive maximum for decoded account data returned by one execution RPC request.
|
||||
pub(crate) const MAX_EXECUTION_ACCOUNT_DATA_BYTES: usize = 65_536;
|
||||
/// Maximum decoded account bytes returned by one complete execution RPC account read.
|
||||
pub const MAX_COMPLETE_ACCOUNT_DATA_BYTES: usize = 65_536;
|
||||
/// Maximum retries after one HTTP or JSON-RPC rate-limit response.
|
||||
pub(crate) const MAX_HTTP_RATE_LIMIT_RETRIES: u32 = 4;
|
||||
/// Fallback pause when a matching endpoint role exposes no rate-limit delay.
|
||||
pub(crate) const DEFAULT_HTTP_RATE_LIMIT_PAUSE_MS: u64 = 1_500;
|
||||
/// Local defensive maximum for account snapshots requested from one simulation.
|
||||
pub(crate) const MAX_SIMULATION_ACCOUNT_COUNT: usize = 128;
|
||||
/// Maximum signatures accepted by one `getSignatureStatuses` request.
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// file: kb-onchain-transport/src/execution_rpc.rs
|
||||
// version: 8
|
||||
// version: 9
|
||||
|
||||
//! Typed Solana JSON-RPC adapters used by execution orchestration.
|
||||
|
||||
@@ -537,10 +537,10 @@ impl crate::GetAccountInfoConfig {
|
||||
min_context_slot: std::option::Option<u64>,
|
||||
max_data_bytes: usize,
|
||||
) -> kb_core::Result<Self> {
|
||||
if max_data_bytes == 0 || max_data_bytes > crate::MAX_EXECUTION_ACCOUNT_DATA_BYTES {
|
||||
if max_data_bytes == 0 || max_data_bytes > crate::MAX_COMPLETE_ACCOUNT_DATA_BYTES {
|
||||
return std::result::Result::Err(kb_core::Error::config(format!(
|
||||
"getAccountInfo complete data limit must be between 1 and {} bytes",
|
||||
crate::MAX_EXECUTION_ACCOUNT_DATA_BYTES
|
||||
crate::MAX_COMPLETE_ACCOUNT_DATA_BYTES
|
||||
)));
|
||||
}
|
||||
return std::result::Result::Ok(Self {
|
||||
@@ -569,10 +569,10 @@ impl crate::GetAccountInfoConfig {
|
||||
pubkey: &kb_lib::MdPubkey,
|
||||
) -> kb_core::Result<std::vec::Vec<serde_json::Value>> {
|
||||
if let std::option::Option::Some(max_data_bytes) = self.max_data_bytes {
|
||||
if max_data_bytes == 0 || max_data_bytes > crate::MAX_EXECUTION_ACCOUNT_DATA_BYTES {
|
||||
if max_data_bytes == 0 || max_data_bytes > crate::MAX_COMPLETE_ACCOUNT_DATA_BYTES {
|
||||
return std::result::Result::Err(kb_core::Error::config(format!(
|
||||
"getAccountInfo complete data limit must be between 1 and {} bytes",
|
||||
crate::MAX_EXECUTION_ACCOUNT_DATA_BYTES
|
||||
crate::MAX_COMPLETE_ACCOUNT_DATA_BYTES
|
||||
)));
|
||||
}
|
||||
}
|
||||
@@ -1344,10 +1344,10 @@ fn decode_account_data(
|
||||
}
|
||||
},
|
||||
std::option::Option::Some(max_data_bytes) => {
|
||||
if max_data_bytes == 0 || max_data_bytes > crate::MAX_EXECUTION_ACCOUNT_DATA_BYTES {
|
||||
if max_data_bytes == 0 || max_data_bytes > crate::MAX_COMPLETE_ACCOUNT_DATA_BYTES {
|
||||
return std::result::Result::Err(kb_core::Error::json(format!(
|
||||
"getAccountInfo complete data limit must be between 1 and {} bytes",
|
||||
crate::MAX_EXECUTION_ACCOUNT_DATA_BYTES
|
||||
crate::MAX_COMPLETE_ACCOUNT_DATA_BYTES
|
||||
)));
|
||||
}
|
||||
if space > max_data_bytes as u64 {
|
||||
@@ -2534,7 +2534,7 @@ mod tests {
|
||||
assert!(crate::GetAccountInfoConfig::confirmed_with_data(0).is_err());
|
||||
assert!(
|
||||
crate::GetAccountInfoConfig::confirmed_with_data(
|
||||
crate::MAX_EXECUTION_ACCOUNT_DATA_BYTES + 1,
|
||||
crate::MAX_COMPLETE_ACCOUNT_DATA_BYTES + 1,
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// file: kb-onchain-transport/src/http_client.rs
|
||||
// version: 11
|
||||
// version: 13
|
||||
|
||||
//! HTTP JSON-RPC client for standard Solana RPC endpoints.
|
||||
|
||||
@@ -30,12 +30,121 @@ pub struct HttpPoolClientSnapshot {
|
||||
pub status: std::string::String,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct HttpRequestLimitState {
|
||||
available_tokens: f64,
|
||||
blocked_until: std::time::Instant,
|
||||
last_refill: std::time::Instant,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct HttpRequestLimiter {
|
||||
burst_capacity: u32,
|
||||
pause_after_rate_limit_ms: u64,
|
||||
priority: u32,
|
||||
request_kinds: std::vec::Vec<std::string::String>,
|
||||
requests_per_second: u32,
|
||||
role: std::string::String,
|
||||
semaphore: std::sync::Arc<tokio::sync::Semaphore>,
|
||||
state: tokio::sync::Mutex<HttpRequestLimitState>,
|
||||
}
|
||||
|
||||
impl HttpRequestLimiter {
|
||||
fn new(config: &kb_config::EndpointRoleConfig) -> Self {
|
||||
let now = std::time::Instant::now();
|
||||
let burst_capacity = config.burst_capacity.max(1);
|
||||
let max_concurrent_requests = config.max_concurrent_requests.max(1);
|
||||
return Self {
|
||||
burst_capacity,
|
||||
pause_after_rate_limit_ms: config.pause_after_rate_limit_ms,
|
||||
priority: config.priority,
|
||||
request_kinds: config.request_kinds.clone(),
|
||||
requests_per_second: config.requests_per_second.max(1),
|
||||
role: config.role.clone(),
|
||||
semaphore: std::sync::Arc::new(tokio::sync::Semaphore::new(
|
||||
max_concurrent_requests as usize,
|
||||
)),
|
||||
state: tokio::sync::Mutex::new(HttpRequestLimitState {
|
||||
available_tokens: f64::from(burst_capacity),
|
||||
blocked_until: now,
|
||||
last_refill: now,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
fn handles_request(&self, request_kind: &str) -> bool {
|
||||
for configured_kind in &self.request_kinds {
|
||||
if configured_kind == request_kind || configured_kind == "*" {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
async fn acquire(&self) -> kb_core::Result<tokio::sync::OwnedSemaphorePermit> {
|
||||
loop {
|
||||
let wait_duration = {
|
||||
let mut state = self.state.lock().await;
|
||||
let now = std::time::Instant::now();
|
||||
if now < state.blocked_until {
|
||||
state.blocked_until.duration_since(now)
|
||||
} else {
|
||||
let elapsed_seconds = now.duration_since(state.last_refill).as_secs_f64();
|
||||
let refill = elapsed_seconds * f64::from(self.requests_per_second);
|
||||
state.available_tokens =
|
||||
(state.available_tokens + refill).min(f64::from(self.burst_capacity));
|
||||
state.last_refill = now;
|
||||
if state.available_tokens >= 1.0 {
|
||||
state.available_tokens -= 1.0;
|
||||
std::time::Duration::ZERO
|
||||
} else {
|
||||
let missing_tokens = 1.0 - state.available_tokens;
|
||||
std::time::Duration::from_secs_f64(
|
||||
missing_tokens / f64::from(self.requests_per_second),
|
||||
)
|
||||
}
|
||||
}
|
||||
};
|
||||
if wait_duration.is_zero() {
|
||||
break;
|
||||
}
|
||||
tokio::time::sleep(wait_duration).await;
|
||||
}
|
||||
let permit_result = self.semaphore.clone().acquire_owned().await;
|
||||
return match permit_result {
|
||||
std::result::Result::Ok(permit) => std::result::Result::Ok(permit),
|
||||
std::result::Result::Err(error) => {
|
||||
std::result::Result::Err(kb_core::Error::invalid_state(format!(
|
||||
"http request limiter '{}' is closed: {error}",
|
||||
self.role
|
||||
)))
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async fn block_for(&self, pause_ms: u64) {
|
||||
let mut state = self.state.lock().await;
|
||||
let candidate =
|
||||
std::time::Instant::now().checked_add(std::time::Duration::from_millis(pause_ms));
|
||||
let blocked_until = match candidate {
|
||||
std::option::Option::Some(value) => value,
|
||||
std::option::Option::None => std::time::Instant::now(),
|
||||
};
|
||||
if blocked_until > state.blocked_until {
|
||||
state.blocked_until = blocked_until;
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
/// HTTP JSON-RPC client bound to one configured endpoint.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct HttpClient {
|
||||
endpoint: kb_config::HttpEndpointConfig,
|
||||
client: reqwest::Client,
|
||||
next_request_id: std::sync::Arc<std::sync::atomic::AtomicU64>,
|
||||
request_limiters: std::sync::Arc<std::vec::Vec<std::sync::Arc<HttpRequestLimiter>>>,
|
||||
selected_role: std::option::Option<std::string::String>,
|
||||
}
|
||||
|
||||
impl crate::HttpClient {
|
||||
@@ -65,14 +174,29 @@ impl crate::HttpClient {
|
||||
)));
|
||||
},
|
||||
};
|
||||
tracing::debug!(target: crate::TRACING_TARGET, action = "create_http_client", endpoint_name = %endpoint.name, provider = %endpoint.provider, role_count = endpoint.roles.len(), "HTTP client created");
|
||||
let mut request_limiters = std::vec::Vec::new();
|
||||
for role in &endpoint.roles {
|
||||
if role.enabled {
|
||||
request_limiters.push(std::sync::Arc::new(HttpRequestLimiter::new(role)));
|
||||
}
|
||||
}
|
||||
tracing::debug!(target: crate::TRACING_TARGET, action = "create_http_client", endpoint_name = %endpoint.name, provider = %endpoint.provider, role_count = endpoint.roles.len(), limiter_count = request_limiters.len(), "HTTP client created");
|
||||
return std::result::Result::Ok(Self {
|
||||
endpoint,
|
||||
client,
|
||||
next_request_id: std::sync::Arc::new(std::sync::atomic::AtomicU64::new(1)),
|
||||
request_limiters: std::sync::Arc::new(request_limiters),
|
||||
selected_role: std::option::Option::None,
|
||||
});
|
||||
}
|
||||
|
||||
/// Returns a clone bound to the exact role selected by the endpoint pool.
|
||||
pub(crate) fn with_required_role(&self, required_role: &str) -> Self {
|
||||
let mut selected = self.clone();
|
||||
selected.selected_role = std::option::Option::Some(required_role.to_string());
|
||||
return selected;
|
||||
}
|
||||
|
||||
/// Returns the endpoint name.
|
||||
pub fn endpoint_name(&self) -> &str {
|
||||
return self.endpoint.name.as_str();
|
||||
@@ -199,65 +323,195 @@ impl crate::HttpClient {
|
||||
let request_id = self.next_request_id.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
|
||||
let parameter_count = params.len();
|
||||
let method_class = crate::HttpClient::classify_method(method.as_str());
|
||||
tracing::debug!(target: crate::TRACING_TARGET, action = "execute_http_json_rpc", endpoint_name = %self.endpoint.name, provider = %self.endpoint.provider, request_id, method = %method, method_class = ?method_class, parameter_count, "send HTTP JSON-RPC request");
|
||||
let request_kind = crate::request_kind_from_method(method.as_str());
|
||||
let request = crate::JsonRpcRequest::new_with_u64_id(request_id, method.clone(), params);
|
||||
let response_result =
|
||||
self.client.post(self.endpoint.url.as_str()).json(&request).send().await;
|
||||
let response = match response_result {
|
||||
std::result::Result::Ok(response) => response,
|
||||
std::result::Result::Err(error) => {
|
||||
tracing::error!(target: crate::TRACING_TARGET, action = "execute_http_json_rpc", endpoint_name = %self.endpoint.name, provider = %self.endpoint.provider, request_id, method = %method, error = %error, "HTTP JSON-RPC transport failed");
|
||||
let request_limiter = self.request_limiter(request_kind.as_str());
|
||||
let base_pause_ms = self.configured_rate_limit_pause_ms(request_kind.as_str());
|
||||
let mut retry_index = 0_u32;
|
||||
loop {
|
||||
let request_permit = match &request_limiter {
|
||||
std::option::Option::Some(limiter) => match limiter.acquire().await {
|
||||
std::result::Result::Ok(permit) => std::option::Option::Some(permit),
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
},
|
||||
std::option::Option::None => std::option::Option::None,
|
||||
};
|
||||
tracing::debug!(target: crate::TRACING_TARGET, action = "execute_http_json_rpc", endpoint_name = %self.endpoint.name, provider = %self.endpoint.provider, request_id, method = %method, method_class = ?method_class, parameter_count, retry_index, selected_role = ?self.selected_role.as_deref(), "send HTTP JSON-RPC request");
|
||||
let response_result =
|
||||
self.client.post(self.endpoint.url.as_str()).json(&request).send().await;
|
||||
let response = match response_result {
|
||||
std::result::Result::Ok(response) => response,
|
||||
std::result::Result::Err(error) => {
|
||||
tracing::error!(target: crate::TRACING_TARGET, action = "execute_http_json_rpc", endpoint_name = %self.endpoint.name, provider = %self.endpoint.provider, request_id, method = %method, retry_index, error = %error, "HTTP JSON-RPC transport failed");
|
||||
return std::result::Result::Err(kb_core::Error::http(format!(
|
||||
"http json-rpc request '{}' failed on endpoint '{}': {error}",
|
||||
method, self.endpoint.name
|
||||
)));
|
||||
},
|
||||
};
|
||||
let status = response.status();
|
||||
let retry_after_ms = crate::HttpClient::retry_after_millis(response.headers());
|
||||
let text_result = response.text().await;
|
||||
let text = match text_result {
|
||||
std::result::Result::Ok(text) => text,
|
||||
std::result::Result::Err(error) => {
|
||||
tracing::error!(target: crate::TRACING_TARGET, action = "execute_http_json_rpc", endpoint_name = %self.endpoint.name, provider = %self.endpoint.provider, request_id, method = %method, http_status = %status, retry_index, error = %error, "HTTP JSON-RPC response body read failed");
|
||||
return std::result::Result::Err(kb_core::Error::http(format!(
|
||||
"cannot read http json-rpc response '{}' from endpoint '{}': {error}",
|
||||
method, self.endpoint.name
|
||||
)));
|
||||
},
|
||||
};
|
||||
drop(request_permit);
|
||||
if status == reqwest::StatusCode::TOO_MANY_REQUESTS
|
||||
&& retry_index < crate::MAX_HTTP_RATE_LIMIT_RETRIES
|
||||
{
|
||||
retry_index = retry_index.saturating_add(1);
|
||||
let configured_pause_ms =
|
||||
crate::HttpClient::rate_limit_backoff_ms(base_pause_ms, retry_index);
|
||||
let pause_ms = match retry_after_ms {
|
||||
std::option::Option::Some(value) => std::cmp::max(value, configured_pause_ms),
|
||||
std::option::Option::None => configured_pause_ms,
|
||||
};
|
||||
tracing::warn!(target: crate::TRACING_TARGET, action = "retry_http_json_rpc_after_rate_limit", endpoint_name = %self.endpoint.name, provider = %self.endpoint.provider, request_id, method = %method, http_status = %status, retry_index, pause_ms, response_byte_length = text.len(), "HTTP JSON-RPC endpoint rate limited the request; retrying with bounded backoff");
|
||||
if let std::option::Option::Some(limiter) = &request_limiter {
|
||||
limiter.block_for(pause_ms).await;
|
||||
}
|
||||
tokio::time::sleep(std::time::Duration::from_millis(pause_ms)).await;
|
||||
continue;
|
||||
}
|
||||
if !status.is_success() {
|
||||
tracing::error!(target: crate::TRACING_TARGET, action = "execute_http_json_rpc", endpoint_name = %self.endpoint.name, provider = %self.endpoint.provider, request_id, method = %method, http_status = %status, retry_index, response_byte_length = text.len(), "HTTP JSON-RPC endpoint returned non-success status");
|
||||
return std::result::Result::Err(kb_core::Error::http(format!(
|
||||
"http json-rpc request '{}' failed on endpoint '{}': {error}",
|
||||
method, self.endpoint.name
|
||||
"http json-rpc endpoint '{}' returned status {} after {} retries: {}",
|
||||
self.endpoint.name, status, retry_index, text
|
||||
)));
|
||||
},
|
||||
};
|
||||
let status = response.status();
|
||||
let text_result = response.text().await;
|
||||
let text = match text_result {
|
||||
std::result::Result::Ok(text) => text,
|
||||
std::result::Result::Err(error) => {
|
||||
tracing::error!(target: crate::TRACING_TARGET, action = "execute_http_json_rpc", endpoint_name = %self.endpoint.name, provider = %self.endpoint.provider, request_id, method = %method, http_status = %status, error = %error, "HTTP JSON-RPC response body read failed");
|
||||
return std::result::Result::Err(kb_core::Error::http(format!(
|
||||
"cannot read http json-rpc response '{}' from endpoint '{}': {error}",
|
||||
method, self.endpoint.name
|
||||
)));
|
||||
},
|
||||
};
|
||||
if !status.is_success() {
|
||||
tracing::error!(target: crate::TRACING_TARGET, action = "execute_http_json_rpc", endpoint_name = %self.endpoint.name, provider = %self.endpoint.provider, request_id, method = %method, http_status = %status, response_byte_length = text.len(), "HTTP JSON-RPC endpoint returned non-success status");
|
||||
return std::result::Result::Err(kb_core::Error::http(format!(
|
||||
"http json-rpc endpoint '{}' returned status {}: {}",
|
||||
self.endpoint.name, status, text
|
||||
)));
|
||||
}
|
||||
let parsed = match crate::parse_json_rpc_text(&text) {
|
||||
std::result::Result::Ok(parsed) => parsed,
|
||||
std::result::Result::Err(error) => {
|
||||
tracing::error!(target: crate::TRACING_TARGET, action = "execute_http_json_rpc", endpoint_name = %self.endpoint.name, provider = %self.endpoint.provider, request_id, method = %method, http_status = %status, retry_index, response_byte_length = text.len(), error = %error, "HTTP JSON-RPC response parsing failed");
|
||||
return std::result::Result::Err(error);
|
||||
},
|
||||
};
|
||||
match parsed {
|
||||
crate::JsonRpcResponse::Success(success) => {
|
||||
tracing::debug!(target: crate::TRACING_TARGET, action = "execute_http_json_rpc", endpoint_name = %self.endpoint.name, provider = %self.endpoint.provider, request_id, method = %method, http_status = %status, retry_index, response_byte_length = text.len(), outcome = "success", "HTTP JSON-RPC request completed");
|
||||
return std::result::Result::Ok(success.result);
|
||||
},
|
||||
crate::JsonRpcResponse::Error(error_response)
|
||||
if error_response.error.code == 429
|
||||
&& retry_index < crate::MAX_HTTP_RATE_LIMIT_RETRIES =>
|
||||
{
|
||||
retry_index = retry_index.saturating_add(1);
|
||||
let configured_pause_ms =
|
||||
crate::HttpClient::rate_limit_backoff_ms(base_pause_ms, retry_index);
|
||||
let pause_ms = match retry_after_ms {
|
||||
std::option::Option::Some(value) => {
|
||||
std::cmp::max(value, configured_pause_ms)
|
||||
},
|
||||
std::option::Option::None => configured_pause_ms,
|
||||
};
|
||||
tracing::warn!(target: crate::TRACING_TARGET, action = "retry_http_json_rpc_after_rate_limit", endpoint_name = %self.endpoint.name, provider = %self.endpoint.provider, request_id, method = %method, rpc_error_code = error_response.error.code, rpc_error_message = %error_response.error.message, retry_index, pause_ms, "HTTP JSON-RPC endpoint returned a rate-limit RPC error; retrying with bounded backoff");
|
||||
if let std::option::Option::Some(limiter) = &request_limiter {
|
||||
limiter.block_for(pause_ms).await;
|
||||
}
|
||||
tokio::time::sleep(std::time::Duration::from_millis(pause_ms)).await;
|
||||
continue;
|
||||
},
|
||||
crate::JsonRpcResponse::Error(error_response) => {
|
||||
tracing::error!(target: crate::TRACING_TARGET, action = "execute_http_json_rpc", endpoint_name = %self.endpoint.name, provider = %self.endpoint.provider, request_id, method = %method, retry_index, rpc_error_code = error_response.error.code, rpc_error_message = %error_response.error.message, "HTTP JSON-RPC endpoint returned an RPC error");
|
||||
return std::result::Result::Err(kb_core::Error::http(format!(
|
||||
"json-rpc error {} from '{}' after {} retries: {}",
|
||||
error_response.error.code,
|
||||
self.endpoint.name,
|
||||
retry_index,
|
||||
error_response.error.message
|
||||
)));
|
||||
},
|
||||
crate::JsonRpcResponse::Notification(_) => {
|
||||
tracing::error!(target: crate::TRACING_TARGET, action = "execute_http_json_rpc", endpoint_name = %self.endpoint.name, provider = %self.endpoint.provider, request_id, method = %method, retry_index, "HTTP JSON-RPC response was an unexpected notification");
|
||||
return std::result::Result::Err(kb_core::Error::http(
|
||||
"http json-rpc response cannot be a notification".to_string(),
|
||||
));
|
||||
},
|
||||
}
|
||||
}
|
||||
let parsed = match crate::parse_json_rpc_text(&text) {
|
||||
std::result::Result::Ok(parsed) => parsed,
|
||||
std::result::Result::Err(error) => {
|
||||
tracing::error!(target: crate::TRACING_TARGET, action = "execute_http_json_rpc", endpoint_name = %self.endpoint.name, provider = %self.endpoint.provider, request_id, method = %method, http_status = %status, response_byte_length = text.len(), error = %error, "HTTP JSON-RPC response parsing failed");
|
||||
return std::result::Result::Err(error);
|
||||
},
|
||||
}
|
||||
|
||||
fn request_limiter(
|
||||
&self,
|
||||
request_kind: &str,
|
||||
) -> std::option::Option<std::sync::Arc<HttpRequestLimiter>> {
|
||||
if let std::option::Option::Some(selected_role) = &self.selected_role {
|
||||
for limiter in self.request_limiters.iter() {
|
||||
if limiter.role.as_str() == selected_role.as_str()
|
||||
&& limiter.handles_request(request_kind)
|
||||
{
|
||||
return std::option::Option::Some(limiter.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut selected: std::option::Option<std::sync::Arc<HttpRequestLimiter>> =
|
||||
std::option::Option::None;
|
||||
for limiter in self.request_limiters.iter() {
|
||||
if !limiter.handles_request(request_kind) {
|
||||
continue;
|
||||
}
|
||||
let replace = match &selected {
|
||||
std::option::Option::Some(current) => limiter.priority < current.priority,
|
||||
std::option::Option::None => true,
|
||||
};
|
||||
if replace {
|
||||
selected = std::option::Option::Some(limiter.clone());
|
||||
}
|
||||
}
|
||||
if selected.is_some() {
|
||||
return selected;
|
||||
}
|
||||
for limiter in self.request_limiters.iter() {
|
||||
let replace = match &selected {
|
||||
std::option::Option::Some(current) => limiter.priority < current.priority,
|
||||
std::option::Option::None => true,
|
||||
};
|
||||
if replace {
|
||||
selected = std::option::Option::Some(limiter.clone());
|
||||
}
|
||||
}
|
||||
return selected;
|
||||
}
|
||||
|
||||
fn configured_rate_limit_pause_ms(&self, request_kind: &str) -> u64 {
|
||||
let limiter = self.request_limiter(request_kind);
|
||||
return match limiter {
|
||||
std::option::Option::Some(value) => value.pause_after_rate_limit_ms,
|
||||
std::option::Option::None => crate::DEFAULT_HTTP_RATE_LIMIT_PAUSE_MS,
|
||||
};
|
||||
return match parsed {
|
||||
crate::JsonRpcResponse::Success(success) => {
|
||||
tracing::debug!(target: crate::TRACING_TARGET, action = "execute_http_json_rpc", endpoint_name = %self.endpoint.name, provider = %self.endpoint.provider, request_id, method = %method, http_status = %status, response_byte_length = text.len(), outcome = "success", "HTTP JSON-RPC request completed");
|
||||
std::result::Result::Ok(success.result)
|
||||
},
|
||||
crate::JsonRpcResponse::Error(error_response) => {
|
||||
tracing::error!(target: crate::TRACING_TARGET, action = "execute_http_json_rpc", endpoint_name = %self.endpoint.name, provider = %self.endpoint.provider, request_id, method = %method, rpc_error_code = error_response.error.code, rpc_error_message = %error_response.error.message, "HTTP JSON-RPC endpoint returned an RPC error");
|
||||
std::result::Result::Err(kb_core::Error::http(format!(
|
||||
"json-rpc error {} from '{}': {}",
|
||||
error_response.error.code, self.endpoint.name, error_response.error.message
|
||||
)))
|
||||
},
|
||||
crate::JsonRpcResponse::Notification(_) => {
|
||||
tracing::error!(target: crate::TRACING_TARGET, action = "execute_http_json_rpc", endpoint_name = %self.endpoint.name, provider = %self.endpoint.provider, request_id, method = %method, "HTTP JSON-RPC response was an unexpected notification");
|
||||
std::result::Result::Err(kb_core::Error::http(
|
||||
"http json-rpc response cannot be a notification".to_string(),
|
||||
))
|
||||
},
|
||||
}
|
||||
|
||||
fn rate_limit_backoff_ms(base_pause_ms: u64, retry_index: u32) -> u64 {
|
||||
let shift = retry_index.saturating_sub(1).min(6);
|
||||
let multiplier = match 1_u64.checked_shl(shift) {
|
||||
std::option::Option::Some(value) => value,
|
||||
std::option::Option::None => 64,
|
||||
};
|
||||
return base_pause_ms.saturating_mul(multiplier).min(60_000);
|
||||
}
|
||||
|
||||
fn retry_after_millis(headers: &reqwest::header::HeaderMap) -> std::option::Option<u64> {
|
||||
let value = match headers.get(reqwest::header::RETRY_AFTER) {
|
||||
std::option::Option::Some(value) => value,
|
||||
std::option::Option::None => return std::option::Option::None,
|
||||
};
|
||||
let text = match value.to_str() {
|
||||
std::result::Result::Ok(text) => text,
|
||||
std::result::Result::Err(_) => return std::option::Option::None,
|
||||
};
|
||||
let seconds = match text.parse::<u64>() {
|
||||
std::result::Result::Ok(seconds) => seconds,
|
||||
std::result::Result::Err(_) => return std::option::Option::None,
|
||||
};
|
||||
return std::option::Option::Some(seconds.saturating_mul(1_000).min(60_000));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -380,4 +634,84 @@ mod tests {
|
||||
crate::HttpMethodClass::GeneralRpc
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn configured_rate_limit_pause_uses_matching_role_and_wildcard() {
|
||||
let client = match crate::HttpClient::new(endpoint(true)) {
|
||||
std::result::Result::Ok(client) => client,
|
||||
std::result::Result::Err(error) => panic!("client creation failed: {error}"),
|
||||
};
|
||||
assert_eq!(client.configured_rate_limit_pause_ms("get_version"), 1500);
|
||||
assert_eq!(client.configured_rate_limit_pause_ms("send_transaction"), 1500);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rate_limit_backoff_is_exponential_and_bounded() {
|
||||
assert_eq!(crate::HttpClient::rate_limit_backoff_ms(1500, 1), 1500);
|
||||
assert_eq!(crate::HttpClient::rate_limit_backoff_ms(1500, 2), 3000);
|
||||
assert_eq!(crate::HttpClient::rate_limit_backoff_ms(1500, 3), 6000);
|
||||
assert_eq!(crate::HttpClient::rate_limit_backoff_ms(3000, 10), 60_000);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn selected_role_uses_its_exact_configured_limiter() {
|
||||
let mut endpoint = endpoint(true);
|
||||
endpoint.roles[0].priority = 10;
|
||||
endpoint.roles[0].requests_per_second = 8;
|
||||
endpoint.roles[0].burst_capacity = 16;
|
||||
endpoint.roles[0].max_concurrent_requests = 8;
|
||||
endpoint.roles[0].pause_after_rate_limit_ms = 1500;
|
||||
endpoint.roles[1].priority = 20;
|
||||
endpoint.roles[1].requests_per_second = 2;
|
||||
endpoint.roles[1].burst_capacity = 2;
|
||||
endpoint.roles[1].max_concurrent_requests = 2;
|
||||
endpoint.roles[1].pause_after_rate_limit_ms = 3000;
|
||||
let client = match crate::HttpClient::new(endpoint) {
|
||||
std::result::Result::Ok(client) => client,
|
||||
std::result::Result::Err(error) => panic!("client creation failed: {error}"),
|
||||
};
|
||||
let selected = client.with_required_role("http_heavy");
|
||||
let limiter = match selected.request_limiter("get_block") {
|
||||
std::option::Option::Some(limiter) => limiter,
|
||||
std::option::Option::None => panic!("selected role limiter missing"),
|
||||
};
|
||||
assert_eq!(limiter.role.as_str(), "http_heavy");
|
||||
assert_eq!(limiter.requests_per_second, 2);
|
||||
assert_eq!(limiter.burst_capacity, 2);
|
||||
assert_eq!(limiter.semaphore.available_permits(), 2);
|
||||
assert_eq!(limiter.pause_after_rate_limit_ms, 3000);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unbound_client_prefers_the_lowest_priority_matching_role() {
|
||||
let mut endpoint = endpoint(true);
|
||||
endpoint.roles[0].priority = 20;
|
||||
endpoint.roles[2].priority = 30;
|
||||
let client = match crate::HttpClient::new(endpoint) {
|
||||
std::result::Result::Ok(client) => client,
|
||||
std::result::Result::Err(error) => panic!("client creation failed: {error}"),
|
||||
};
|
||||
let limiter = match client.request_limiter("get_version") {
|
||||
std::option::Option::Some(limiter) => limiter,
|
||||
std::option::Option::None => panic!("matching limiter missing"),
|
||||
};
|
||||
assert_eq!(limiter.role.as_str(), "http_queries");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn retry_after_seconds_are_converted_to_bounded_milliseconds() {
|
||||
let mut headers = reqwest::header::HeaderMap::new();
|
||||
headers
|
||||
.insert(reqwest::header::RETRY_AFTER, reqwest::header::HeaderValue::from_static("3"));
|
||||
assert_eq!(
|
||||
crate::HttpClient::retry_after_millis(&headers),
|
||||
std::option::Option::Some(3000)
|
||||
);
|
||||
headers
|
||||
.insert(reqwest::header::RETRY_AFTER, reqwest::header::HeaderValue::from_static("999"));
|
||||
assert_eq!(
|
||||
crate::HttpClient::retry_after_millis(&headers),
|
||||
std::option::Option::Some(60_000)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// file: kb-onchain-transport/src/http_pool.rs
|
||||
// version: 7
|
||||
// version: 8
|
||||
|
||||
//! HTTP endpoint pool and role-based routing.
|
||||
|
||||
@@ -79,8 +79,9 @@ impl crate::HttpEndpointPool {
|
||||
let index = (start_index + offset) % client_count;
|
||||
let client = self.clients[index].clone();
|
||||
if client.can_handle(required_role, request_kind) {
|
||||
tracing::debug!(target: crate::TRACING_TARGET, action = "select_http_endpoint", required_role, request_kind, endpoint_name = %client.endpoint_name(), provider = %client.provider(), "selected HTTP endpoint");
|
||||
return std::result::Result::Ok(client);
|
||||
let selected = client.with_required_role(required_role);
|
||||
tracing::debug!(target: crate::TRACING_TARGET, action = "select_http_endpoint", required_role, request_kind, endpoint_name = %selected.endpoint_name(), provider = %selected.provider(), "selected HTTP endpoint");
|
||||
return std::result::Result::Ok(selected);
|
||||
}
|
||||
offset += 1;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// file: kb-onchain-transport/src/lib.rs
|
||||
// version: 7
|
||||
// version: 8
|
||||
|
||||
#![forbid(unsafe_code)]
|
||||
#![deny(unreachable_pub)]
|
||||
@@ -34,6 +34,8 @@ mod ws_session;
|
||||
pub use self::client::RpcEndpoint;
|
||||
/// Minimal Solana RPC client abstraction.
|
||||
pub use self::client::SolanaRpcClient;
|
||||
/// Maximum decoded account bytes accepted by one complete account read.
|
||||
pub use self::constants::MAX_COMPLETE_ACCOUNT_DATA_BYTES;
|
||||
/// Endpoint role snapshot shared by HTTP and WebSocket pools.
|
||||
pub use self::endpoint_role::EndpointRoleSnapshot;
|
||||
/// Converts a JSON-RPC method name into a stable request kind.
|
||||
@@ -461,6 +463,8 @@ pub use self::ws_session::WsSubscriptionSnapshot;
|
||||
/// Acknowledgement returned after a WebSocket unsubscribe request.
|
||||
pub use self::ws_session::WsUnsubscribeAck;
|
||||
|
||||
/// Internal DEFAULT_HTTP_RATE_LIMIT_PAUSE_MS contract.
|
||||
pub(crate) use self::constants::DEFAULT_HTTP_RATE_LIMIT_PAUSE_MS;
|
||||
/// Internal DEVNET_GENESIS_HASH contract.
|
||||
pub(crate) use self::constants::DEVNET_GENESIS_HASH;
|
||||
/// Internal MAINNET_GENESIS_HASH contract.
|
||||
@@ -471,10 +475,10 @@ pub(crate) use self::constants::MAX_BLOCK_RANGE;
|
||||
pub(crate) use self::constants::MAX_CONFIRMATION_ATTEMPTS;
|
||||
/// Internal MAX_CONFIRMATION_POLL_INTERVAL_MS contract.
|
||||
pub(crate) use self::constants::MAX_CONFIRMATION_POLL_INTERVAL_MS;
|
||||
/// Internal MAX_EXECUTION_ACCOUNT_DATA_BYTES contract.
|
||||
pub(crate) use self::constants::MAX_EXECUTION_ACCOUNT_DATA_BYTES;
|
||||
/// Internal MAX_EXECUTION_RPC_BASE64_LENGTH contract.
|
||||
pub(crate) use self::constants::MAX_EXECUTION_RPC_BASE64_LENGTH;
|
||||
/// Internal MAX_HTTP_RATE_LIMIT_RETRIES contract.
|
||||
pub(crate) use self::constants::MAX_HTTP_RATE_LIMIT_RETRIES;
|
||||
/// Internal MAX_MEMCMP_BASE58_LENGTH contract.
|
||||
pub(crate) use self::constants::MAX_MEMCMP_BASE58_LENGTH;
|
||||
/// Internal MAX_MEMCMP_BASE64_LENGTH contract.
|
||||
|
||||
@@ -1,10 +1,15 @@
|
||||
<!-- file: kb-pipeline-demo-scenarios/CHANGELOG.md -->
|
||||
<!-- version: 30 -->
|
||||
<!-- version: 32 -->
|
||||
|
||||
# CHANGELOG — kb-pipeline-demo-scenarios
|
||||
|
||||
## 0.4.8-pre.009 — scénarios Devnet Solana Program Metadata
|
||||
|
||||
- déplace `SetAuthority` du compte Metadata non canonique vers le Buffer non canonique, conformément au processeur officiel qui refuse cette opération sur les metadata non canoniques ;
|
||||
- réconcilie l’ordre des neuf étapes, la fixture, la matrice Devnet et les tests de campagne ;
|
||||
- ajoute une régression garantissant que `SetAuthority` ne cible jamais le compte Metadata non canonique de la fixture ;
|
||||
|
||||
- réutilise l’unique cible canonique `kb-pipeline-demo-scenarios` pour les logs de fixture et supprime la seconde constante incompatible avec la convention de logging de la crate ;
|
||||
- corrige les derniers diagnostics Clippy des tests : `str::len()` direct pour les seeds UTF-8 et `return` explicite dans la closure de statut de validation ;
|
||||
- corrige les tests de réconciliation des opérations : comparaison de références `&str` et durée de vie de l’inventaire de scénarios ;
|
||||
- documente la crate du test d’API publique externe afin de conserver `missing_docs` propre ;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<!-- file: kb-pipeline-demo-scenarios/USAGE.md -->
|
||||
<!-- version: 10 -->
|
||||
<!-- version: 11 -->
|
||||
|
||||
# Utilisation de kb-pipeline-demo-scenarios
|
||||
|
||||
@@ -350,8 +350,8 @@ fn program_metadata_scenarios(
|
||||
|
||||
Les deux parcours sont ordonnés ainsi :
|
||||
|
||||
1. `Allocate → Extend → Write → Trim → Close` pour le `Buffer` ;
|
||||
2. `Initialize → SetData → SetAuthority → SetImmutable` pour le compte `Metadata`.
|
||||
1. `Allocate → Extend → Write → SetAuthority → Trim → Close` pour le `Buffer` non canonique ;
|
||||
2. `Initialize → SetData → SetImmutable` pour le compte `Metadata` non canonique.
|
||||
|
||||
`Extend` précède volontairement `Write` : `Allocate` ne réserve que l’en-tête du Buffer, alors que l’écriture exige une capacité suffisante.
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// file: kb-pipeline-demo-scenarios/src/constants.rs
|
||||
// version: 2
|
||||
// version: 3
|
||||
|
||||
//! Demo scenario constants.
|
||||
|
||||
@@ -8,6 +8,3 @@ pub(crate) const TRACING_TARGET: &str = "kb-pipeline-demo-scenarios";
|
||||
|
||||
/// Total transactions reserved by one complete Solana Program Metadata campaign.
|
||||
pub(crate) const SOLANA_PROGRAM_METADATA_CAMPAIGN_TRANSACTION_COUNT: u64 = 11;
|
||||
/// Canonical tracing target for Solana Program Metadata Devnet fixtures.
|
||||
pub(crate) const TRACING_TARGET_METADATA_SOLANA_PROGRAM_FIXTURE: &str =
|
||||
"kb-pipeline-demo-scenarios.metadata.solana_program.fixture";
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// file: kb-pipeline-demo-scenarios/src/lib.rs
|
||||
// version: 13
|
||||
// version: 14
|
||||
|
||||
#![forbid(unsafe_code)]
|
||||
#![deny(unreachable_pub)]
|
||||
@@ -281,5 +281,3 @@ pub use self::token_2022_fixture::prepare_token_2022_fixture;
|
||||
pub(crate) use self::constants::SOLANA_PROGRAM_METADATA_CAMPAIGN_TRANSACTION_COUNT;
|
||||
/// Canonical tracing target for demo pipeline scenarios.
|
||||
pub(crate) use self::constants::TRACING_TARGET;
|
||||
/// Canonical tracing target for Solana Program Metadata Devnet fixtures.
|
||||
pub(crate) use self::constants::TRACING_TARGET_METADATA_SOLANA_PROGRAM_FIXTURE;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// file: kb-pipeline-demo-scenarios/src/metadata_solana_program_campaign.rs
|
||||
// version: 1
|
||||
// version: 2
|
||||
|
||||
//! Ordered confirmed Devnet campaign for all Solana Program Metadata operations.
|
||||
|
||||
@@ -126,8 +126,19 @@ mod tests {
|
||||
.flat_map(|scenario| return scenario.steps.iter())
|
||||
.map(|step| return step.id.as_str())
|
||||
.collect::<std::vec::Vec<_>>();
|
||||
assert_eq!(step_ids.len(), 9);
|
||||
assert_eq!(step_ids.first(), std::option::Option::Some(&"spm_buffer_allocate"));
|
||||
assert_eq!(step_ids.last(), std::option::Option::Some(&"spm_metadata_set_immutable"));
|
||||
assert_eq!(
|
||||
step_ids,
|
||||
vec![
|
||||
"spm_buffer_allocate",
|
||||
"spm_buffer_extend",
|
||||
"spm_buffer_write",
|
||||
"spm_buffer_set_authority",
|
||||
"spm_buffer_trim",
|
||||
"spm_buffer_close",
|
||||
"spm_metadata_initialize",
|
||||
"spm_metadata_set_data",
|
||||
"spm_metadata_set_immutable",
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// file: kb-pipeline-demo-scenarios/src/metadata_solana_program_fixture.rs
|
||||
// version: 3
|
||||
// version: 5
|
||||
|
||||
//! Native Solana Program Metadata fixture preparation for Devnet journeys.
|
||||
|
||||
@@ -328,7 +328,7 @@ pub async fn prepare_solana_program_metadata_fixture(
|
||||
updated_metadata_rent,
|
||||
);
|
||||
tracing::info!(
|
||||
target: crate::TRACING_TARGET_METADATA_SOLANA_PROGRAM_FIXTURE,
|
||||
target: crate::TRACING_TARGET,
|
||||
action = "prepare_solana_program_metadata_fixture",
|
||||
profile = profile.name.as_str(),
|
||||
authority = authority.0.as_str(),
|
||||
@@ -465,8 +465,23 @@ fn prepared_steps(
|
||||
),
|
||||
prepared_step(
|
||||
"spm_buffer_lifecycle",
|
||||
"spm_buffer_trim",
|
||||
"spm_buffer_set_authority",
|
||||
3,
|
||||
kb_lib::ExMetadataSpmOperation::SetAuthority {
|
||||
account: buffer.clone(),
|
||||
authority: authority.clone(),
|
||||
new_authority: std::option::Option::Some(authority.clone()),
|
||||
program_context: std::option::Option::None,
|
||||
},
|
||||
vec![buffer_state.clone()],
|
||||
std::vec::Vec::new(),
|
||||
vec![buffer_state.clone()],
|
||||
true,
|
||||
),
|
||||
prepared_step(
|
||||
"spm_buffer_lifecycle",
|
||||
"spm_buffer_trim",
|
||||
4,
|
||||
kb_lib::ExMetadataSpmOperation::Trim {
|
||||
account: buffer.clone(),
|
||||
authority: authority.clone(),
|
||||
@@ -481,7 +496,7 @@ fn prepared_steps(
|
||||
prepared_step(
|
||||
"spm_buffer_lifecycle",
|
||||
"spm_buffer_close",
|
||||
4,
|
||||
5,
|
||||
kb_lib::ExMetadataSpmOperation::Close {
|
||||
account: buffer.clone(),
|
||||
authority: authority.clone(),
|
||||
@@ -548,25 +563,10 @@ fn prepared_steps(
|
||||
vec![metadata_state.clone()],
|
||||
true,
|
||||
),
|
||||
prepared_step(
|
||||
"spm_metadata_lifecycle",
|
||||
"spm_metadata_set_authority",
|
||||
2,
|
||||
kb_lib::ExMetadataSpmOperation::SetAuthority {
|
||||
account: metadata.clone(),
|
||||
authority: authority.clone(),
|
||||
new_authority: std::option::Option::Some(authority.clone()),
|
||||
program_context: std::option::Option::None,
|
||||
},
|
||||
vec![metadata_state.clone()],
|
||||
std::vec::Vec::new(),
|
||||
vec![metadata_state.clone()],
|
||||
true,
|
||||
),
|
||||
prepared_step(
|
||||
"spm_metadata_lifecycle",
|
||||
"spm_metadata_set_immutable",
|
||||
3,
|
||||
2,
|
||||
kb_lib::ExMetadataSpmOperation::SetImmutable {
|
||||
metadata: metadata.clone(),
|
||||
authority,
|
||||
@@ -938,6 +938,19 @@ mod tests {
|
||||
.map(|step| return step.operation_code.as_str())
|
||||
.collect::<std::vec::Vec<_>>();
|
||||
assert_eq!(actual, expected);
|
||||
let expected_buffer =
|
||||
kb_lib::MdPubkey(solana_pubkey::Pubkey::new_from_array([2_u8; 32]).to_string());
|
||||
let set_authority = steps.iter().find(|step| {
|
||||
return step.operation.operation_code()
|
||||
== kb_lib::EX_METADATA_SPM_SET_AUTHORITY_OPERATION;
|
||||
});
|
||||
assert!(matches!(
|
||||
set_authority.map(|step| return &step.operation),
|
||||
std::option::Option::Some(kb_lib::ExMetadataSpmOperation::SetAuthority {
|
||||
account,
|
||||
..
|
||||
}) if account == &expected_buffer
|
||||
));
|
||||
assert!(steps.iter().all(|step| {
|
||||
return step
|
||||
.preflight_reads
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// file: kb-pipeline-demo-scenarios/src/metadata_solana_program_scenarios.rs
|
||||
// version: 2
|
||||
// version: 3
|
||||
|
||||
//! Coherent Solana Program Metadata journeys for Devnet validation.
|
||||
|
||||
@@ -59,7 +59,8 @@ pub fn solana_program_metadata_devnet_scenarios()
|
||||
return vec![
|
||||
crate::SolanaProgramMetadataScenario {
|
||||
id: "spm_buffer_lifecycle".to_string(),
|
||||
label: "Buffer : allocation, écriture, extension, trim et fermeture".to_string(),
|
||||
label: "Buffer : allocation, extension, écriture, autorité, trim et fermeture"
|
||||
.to_string(),
|
||||
fixture_account: "buffer".to_string(),
|
||||
steps: vec![
|
||||
step(
|
||||
@@ -86,6 +87,14 @@ pub fn solana_program_metadata_devnet_scenarios()
|
||||
crate::SolanaProgramMetadataFixtureState::Buffer,
|
||||
true,
|
||||
),
|
||||
step(
|
||||
"spm_buffer_set_authority",
|
||||
"Réaffirmer l’autorité du Buffer non canonique",
|
||||
kb_lib::EX_METADATA_SPM_SET_AUTHORITY_OPERATION,
|
||||
crate::SolanaProgramMetadataFixtureState::Buffer,
|
||||
crate::SolanaProgramMetadataFixtureState::Buffer,
|
||||
true,
|
||||
),
|
||||
step(
|
||||
"spm_buffer_trim",
|
||||
"Réduire le Buffer et restituer l’excédent",
|
||||
@@ -106,7 +115,7 @@ pub fn solana_program_metadata_devnet_scenarios()
|
||||
},
|
||||
crate::SolanaProgramMetadataScenario {
|
||||
id: "spm_metadata_lifecycle".to_string(),
|
||||
label: "Metadata : initialisation, données, autorité et immutabilité".to_string(),
|
||||
label: "Metadata : initialisation, données et immutabilité".to_string(),
|
||||
fixture_account: "metadata".to_string(),
|
||||
steps: vec![
|
||||
step(
|
||||
@@ -125,14 +134,6 @@ pub fn solana_program_metadata_devnet_scenarios()
|
||||
crate::SolanaProgramMetadataFixtureState::MutableMetadata,
|
||||
true,
|
||||
),
|
||||
step(
|
||||
"spm_metadata_set_authority",
|
||||
"Réaffirmer l’autorité du wallet de profil",
|
||||
kb_lib::EX_METADATA_SPM_SET_AUTHORITY_OPERATION,
|
||||
crate::SolanaProgramMetadataFixtureState::MutableMetadata,
|
||||
crate::SolanaProgramMetadataFixtureState::MutableMetadata,
|
||||
true,
|
||||
),
|
||||
step(
|
||||
"spm_metadata_set_immutable",
|
||||
"Rendre les metadata immuables",
|
||||
@@ -201,6 +202,26 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn set_authority_targets_the_non_canonical_buffer_not_metadata() {
|
||||
let scenarios = crate::solana_program_metadata_devnet_scenarios();
|
||||
let set_authority_steps = scenarios
|
||||
.iter()
|
||||
.flat_map(|scenario| {
|
||||
return scenario
|
||||
.steps
|
||||
.iter()
|
||||
.map(move |step| return (scenario.fixture_account.as_str(), step));
|
||||
})
|
||||
.filter(|(_, step)| {
|
||||
return step.operation_code == kb_lib::EX_METADATA_SPM_SET_AUTHORITY_OPERATION;
|
||||
})
|
||||
.collect::<std::vec::Vec<_>>();
|
||||
assert_eq!(set_authority_steps.len(), 1);
|
||||
assert_eq!(set_authority_steps[0].0, "buffer");
|
||||
assert_eq!(set_authority_steps[0].1.id, "spm_buffer_set_authority");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn destructive_flags_match_the_executor_contract() {
|
||||
for scenario in crate::solana_program_metadata_devnet_scenarios() {
|
||||
|
||||
@@ -1,8 +1,16 @@
|
||||
<!-- file: kb-pipeline/CHANGELOG.md -->
|
||||
<!-- version: 17 -->
|
||||
<!-- version: 19 -->
|
||||
|
||||
# CHANGELOG — kb-pipeline
|
||||
|
||||
## 0.4.8-pre.010
|
||||
|
||||
- remplace l’assertion runtime sur la relation entre borne RPC et borne de décodage par une assertion `const`, afin de supprimer le dernier warning `clippy::assertions_on_constants` ;
|
||||
|
||||
- borne les lectures complètes Solana Program Metadata au maximum réellement accepté par l’adaptateur RPC, soit `65536` octets ;
|
||||
- conserve séparément la capacité du décodeur à valider des données plus grandes provenant de sources offline ou d’un futur lecteur par tranches ;
|
||||
- ajoute un test empêchant de réaligner par erreur la borne stateful sur la limite de décodage de `10 MiB` ;
|
||||
|
||||
## 0.4.8-pre.008 — orchestration Solana Program Metadata généraliste
|
||||
|
||||
- ajoute les lectures de comptes `Buffer` et `Metadata` bornées, avec décodage et projection autoritative délégués à `kb-lib` ;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<!-- file: kb-pipeline/USAGE.md -->
|
||||
<!-- version: 8 -->
|
||||
<!-- version: 9 -->
|
||||
|
||||
# Utilisation de kb-pipeline
|
||||
|
||||
@@ -199,6 +199,8 @@ async fn read_program_metadata(
|
||||
}
|
||||
```
|
||||
|
||||
`MAX_SOLANA_PROGRAM_METADATA_STATEFUL_ACCOUNT_BYTES` représente la borne d’une lecture RPC complète et vaut actuellement `65536`. La capacité de décodage `DC_METADATA_SPM_MAX_ACCOUNT_BYTES` reste distincte pour les données déjà disponibles offline ou pour un futur lecteur par tranches.
|
||||
|
||||
Le préflight reçoit l’intent typé, le plan produit par `ExMetadataSolanaProgramMetadataExecutor`, les snapshots confirmés et, pour les opérations qui allouent ou agrandissent un compte, une observation explicite du minimum de rent. Il refuse un plan `Deny`, mais les opérations dangereuses restent exécutables lorsqu’elles sont explicitement approuvées et que l’enveloppe commune de sécurité est satisfaite.
|
||||
|
||||
Après simulation du message exact, `validate_solana_program_metadata_execution_readiness` vérifie les signers et l’autorisation de soumission. Après confirmation, `inspect_solana_program_metadata_post_execution` compare les lectures avant/après pour les neuf opérations.
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
// file: kb-pipeline/src/metadata_solana_program_stateful.rs
|
||||
// version: 2
|
||||
// version: 4
|
||||
|
||||
//! Bounded Solana Program Metadata account reads and authoritative state projections.
|
||||
|
||||
/// Maximum complete Solana Program Metadata account data accepted by one RPC read.
|
||||
///
|
||||
/// The decoder can validate larger account byte arrays supplied by offline or chunked sources, but
|
||||
/// the current execution RPC adapter intentionally bounds one complete `getAccountInfo` response.
|
||||
pub const MAX_SOLANA_PROGRAM_METADATA_STATEFUL_ACCOUNT_BYTES: usize =
|
||||
kb_lib::DC_METADATA_SPM_MAX_ACCOUNT_BYTES;
|
||||
kb_onchain_transport::MAX_COMPLETE_ACCOUNT_DATA_BYTES;
|
||||
|
||||
/// Expected state of one Solana Program Metadata account read.
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
|
||||
@@ -422,4 +425,18 @@ mod tests {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stateful_complete_read_bound_matches_the_transport_contract() {
|
||||
assert_eq!(
|
||||
crate::MAX_SOLANA_PROGRAM_METADATA_STATEFUL_ACCOUNT_BYTES,
|
||||
kb_onchain_transport::MAX_COMPLETE_ACCOUNT_DATA_BYTES
|
||||
);
|
||||
const {
|
||||
assert!(
|
||||
crate::MAX_SOLANA_PROGRAM_METADATA_STATEFUL_ACCOUNT_BYTES
|
||||
< kb_lib::DC_METADATA_SPM_MAX_ACCOUNT_BYTES
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -44,6 +44,20 @@
|
||||
],
|
||||
"evidence": []
|
||||
},
|
||||
{
|
||||
"operationCode": "metadata.solana_program_metadata.set_authority",
|
||||
"scenarioStepId": "spm_buffer_set_authority",
|
||||
"status": "not_run",
|
||||
"requiresExplicitApproval": true,
|
||||
"requiredEvidence": [
|
||||
"stateful_preflight",
|
||||
"rpc_simulation",
|
||||
"confirmed_signature",
|
||||
"stateful_postcondition",
|
||||
"materialized_snapshot"
|
||||
],
|
||||
"evidence": []
|
||||
},
|
||||
{
|
||||
"operationCode": "metadata.solana_program_metadata.trim",
|
||||
"scenarioStepId": "spm_buffer_trim",
|
||||
@@ -100,20 +114,6 @@
|
||||
],
|
||||
"evidence": []
|
||||
},
|
||||
{
|
||||
"operationCode": "metadata.solana_program_metadata.set_authority",
|
||||
"scenarioStepId": "spm_metadata_set_authority",
|
||||
"status": "not_run",
|
||||
"requiresExplicitApproval": true,
|
||||
"requiredEvidence": [
|
||||
"stateful_preflight",
|
||||
"rpc_simulation",
|
||||
"confirmed_signature",
|
||||
"stateful_postcondition",
|
||||
"materialized_snapshot"
|
||||
],
|
||||
"evidence": []
|
||||
},
|
||||
{
|
||||
"operationCode": "metadata.solana_program_metadata.set_immutable",
|
||||
"scenarioStepId": "spm_metadata_set_immutable",
|
||||
|
||||
Reference in New Issue
Block a user