0.5.1-pre.002
This commit is contained in:
324
ks-pipeline-demo-scenarios/CHANGELOG.md
Normal file
324
ks-pipeline-demo-scenarios/CHANGELOG.md
Normal file
@@ -0,0 +1,324 @@
|
||||
<!-- file: ks-pipeline-demo-scenarios/CHANGELOG.md -->
|
||||
<!-- version: 74 -->
|
||||
|
||||
# CHANGELOG — ks-pipeline-demo-scenarios
|
||||
|
||||
## `0.5.1-pre.002`
|
||||
|
||||
- renomme `kb-pipeline-demo-scenarios` en `ks-pipeline-demo-scenarios`, sa bibliothèque en `ks_pipeline_demo_scenarios` et son CLI en `ks-pipeline-demo-scenarios-cli` ;
|
||||
- renomme le fichier source du CLI et les adaptateurs desktop qui consomment les scénarios ;
|
||||
- aligne le target racine et les targets de fixtures propres à cette crate sans changer les scénarios qualifiés.
|
||||
|
||||
## `0.5.0-pre.003`
|
||||
|
||||
### Documentation
|
||||
|
||||
- clarifie que les fixtures, séquences et qualifications réseau réutilisables appartiennent à cette crate lorsqu’elles sont consommées par les tests, le CLI ou le desktop ;
|
||||
- prépare `0.5.4` pour centraliser les dispatchs, compositions decoder/materializer et critères de complétion encore réutilisables depuis `kb-app-demo-desktop`, sans rouvrir les preuves réseau déjà qualifiées.
|
||||
|
||||
## `0.4.8-pre.015`
|
||||
|
||||
- `pre.015-delta-fix-001` réconcilie l’USAGE avec la preuve Devnet Solana Program Metadata déjà acquise : les neuf opérations sont `confirmed` depuis `pre.010`, et `Close` conserve sa preuve spécifique `account_absence` ; aucune nouvelle campagne réseau n’est ouverte.
|
||||
|
||||
## `0.4.8-pre.013`
|
||||
|
||||
- `pre.013-delta-fix-037` consolide la clôture Metaplex après validation locale complète de `fix-036` : `cargo fmt --all`, `cargo check --workspace`, `cargo clippy --all-targets` sans warning, audit workspace clean et 105 tests unitaires + 1 CLI + 1 API externe réussissent. Les guides actifs cessent de présenter `Verify/Unverify`, le lifecycle pNFT ou Token Owned Escrow comme `not_run`, la campagne maintenance finale est documentée, et le TODO retire les tâches `pre.013` terminées. La matrice fonctionnelle reste inchangée à 15 `confirmed`, 5 `unavailable`, 0 `not_run`.
|
||||
- `pre.013-delta-fix-036` ferme la matrice Devnet des 20 opérations courantes : `Update` est confirmé sur NFT par `4hxxBnsCA1JBJPNHgXRDeq1W7rXbFj2yySscWJKxU1VTMDcPzPfoWy7QzANHSDsbQfxxcwuAQNDLqEukgsug6qoZ` au slot `482162413`, avec simulation au slot `482162408`, replay, matérialisation, idempotence et transition `primary_sale_happened false -> true`; `Resize`, `Migrate`, `Collect` et `CloseAccounts` sont classés `unavailable` après probes Devnet exacts `Custom(201)`, `Custom(75)`, `Custom(7)` et `Custom(188)` sans soumission. La matrice finale compte 15 `confirmed`, 5 `unavailable`, 0 `not_run`; le warning Clippy `if_same_then_else` du test de matrice est supprimé en fusionnant les branches NFT identiques.
|
||||
- `pre.013-delta-fix-035` corrige le parseur des refus runtime de la campagne maintenance : `simulation.error` contient déjà le JSON RPC sous forme de chaîne, et sa double sérialisation empêchait `fix-034` de reconnaître le `Custom(201)` pourtant observé. Le probe parse désormais structurellement `InstructionError[1].Custom`; le test de contrat existant couvre les codes `201`, `75`, `7` et `188`. La matrice reste à 14 `confirmed`, 2 `unavailable` et 4 `not_run`.
|
||||
- `pre.013-delta-fix-034` enregistre le premier run de maintenance : `Update` franchit confirmation et postcondition `primary_sale_happened false -> true`, puis `Resize` atteint le handler courant et la simulation est refusée avec `AlreadyResized`, `Custom(201)`, `14643` CU consommées et aucune soumission. Comme les comptes créés aujourd’hui sont déjà au layout cible et qu’un succès exigerait un compte legacy sous-dimensionné contrôlé, `Resize` passe à `unavailable`; la matrice devient 14 `confirmed`, 2 `unavailable` et 4 `not_run`. La campagne corrigée soumet uniquement `Update` et traite désormais `Resize`, `Migrate`, `Collect` et `CloseAccounts` comme probes simulation-only attendus respectivement en `201`, `75`, `7` et `188`.
|
||||
- `pre.013-delta-fix-033` qualifie définitivement Token Owned Escrow : `CreateEscrowAccount` (`Yy7NEvme3obuiGRBbBjpuk3dv4aPMPBQ2ns7KmbY4K5EtyAimaaM8qN5SQzEqr9SPbRu1VDCFkNpAjnRGW4JYuj`, slot `482147206`), `TransferOutOfEscrow` (`4yJcSvnZJqihfZTE7nxN3o7H7zBCacpdZdwNQ1ueWQMmQZxR7KRb99C7rfrXX2AyezfSStMdqqTYL3oEWgwRhFi3`, slot `482147252`) et `CloseEscrowAccount` (`3GuwmGf9JEK7rHE3tudnxskvruyML9AGVXQimxii3uHdXqNEF4ScpmDXwhU3y2eaUnTbBPPhVkhRfLwJr9jbGJBo`, slot `482147267`) terminent simulation, confirmation, hydratation canonique, extraction Core, replay, matérialisation, postconditions stateful et idempotence ; la matrice passe à 14 `confirmed`, 1 `unavailable` et 5 `not_run`.
|
||||
- prépare la campagne finale de maintenance sur un NFT frais : `Update` doit retourner `primary_sale_happened false -> true`, `Resize` doit confirmer sans altérer la possession du NFT, tandis que `Migrate`, `Collect` et `CloseAccounts` sont uniquement simulés et doivent respectivement exposer les refus runtime `Removed(75)`, `UpdateAuthorityIncorrect(7)` et `InvalidCloseAuthority(188)` sans aucune soumission.
|
||||
- ajoute trois tests au scénario maintenance et conserve les probes réservés fail-closed : la campagne n’essaie pas d’usurper `FEE_AUTHORITY` ni `OWNERLESS_CLOSE_AUTHORITY`.
|
||||
- `pre.013-delta-fix-032` enregistre le premier run Devnet escrow de `fix-031` : `CreateEscrowAccount` atteint bien le handler (`IX: Create Escrow Account`) mais la simulation est refusée avec `MissingRequiredSignature`, 17 268 CU consommées et aucune soumission. La cause est le placeholder `program_id` ajouté par le builder générique à l’`authority=None` terminale ; les trois opérations escrow restent non promues pendant le correctif.
|
||||
- `pre.013-delta-fix-031` enregistre le rerun `fix-030` du probe `Use` : le test termine `ok`, la simulation Devnet au slot `482133665` est refusée avec `InstructionError[0]=InvalidInstructionData`, quatre logs sont conservés et aucune soumission n’est effectuée ; tous les autres checks et tests annoncés restent sans régression.
|
||||
- prépare une campagne Devnet Token Owned Escrow strictement bornée : NFT parent frais, token attribut fungible frais, `CreateEscrowAccount`, création de l’ATA SPL classique détenu par le PDA escrow, dépôt contrôlé d’une unité brute, `TransferOutOfEscrow` avec fermeture de l’ATA vidé, puis `CloseEscrowAccount` avec disparition du compte escrow.
|
||||
- ajoute des postconditions stateful `TokenOwnedEscrow`, des preuves pipeline/idempotence pour les trois opérations Metaplex et maintient la matrice à 11 `confirmed`, 1 `unavailable` et 8 `not_run` tant que cette campagne n’a pas été exécutée sur Devnet.
|
||||
- `pre.013-delta-fix-030` classe `Use` comme `unavailable` après le probe Devnet réel : l'instruction courante de discriminant 51 est simulée mais rejetée par Token Metadata avec `InstructionError[0]=InvalidInstructionData`, `Program log: Error: InvalidInstructionData`, `12085` compute units consommées et aucune soumission ; la matrice passe à 11 `confirmed`, 1 `unavailable` et 8 `not_run`.
|
||||
- le probe `Use` peut désormais retourner cette simulation négative comme preuve et terminer `ok` en mode `submit=false`; le contrat de soumission reste simulation-first strict et ne signe jamais une simulation échouée.
|
||||
- `pre.013-delta-fix-029` qualifie définitivement le cycle pNFT : `Delegate(StakingV1)`, `Lock`, `Unlock`, `Revoke(StakingV1)`, `Delegate(TransferV1)` et `Transfer` terminent tous simulation, confirmation, hydratation canonique, extraction Core, replay, matérialisation et idempotence ; les cinq opérations canoniques `Delegate`, `Revoke`, `Lock`, `Unlock`, `Transfer` passent à `confirmed` sur `programmable_nft`, portant la matrice à 11/20 opérations confirmées.
|
||||
- prépare un probe Devnet borné pour `Use` sur un NFT classique frais avec `Uses::Multiple { remaining: 2, total: 2 }` : le probe simule d’abord l’instruction courante et distingue explicitement succès confirmé et indisponibilité runtime ; aucune promotion n’est faite tant que la réponse Devnet n’est pas observée.
|
||||
- `pre.013-delta-fix-028` enregistre le rerun `fix-027` : la campagne pNFT franchit désormais `Delegate(Staking) -> Lock -> Unlock -> Revoke(Staking) -> Delegate(Transfer) -> Transfer` jusqu’à la postcondition finale du token account source ; le transfert est confirmé et son TokenRecord destination est `Unlocked` sans delegate, mais la campagne exigeait à tort que l’ATA source vide reste `Frozen` alors que le handler Metaplex courant thaw la source, transfère, puis freeze uniquement la destination ;
|
||||
- corrige la postcondition finale en exigeant source `amount=0, state=Initialized` et destination `amount=1, state=Frozen`, et ajoute ces états à `METAPLEX_PNFT_LIFECYCLE_STATE` ; aucune opération lifecycle n’est encore promue avant un rerun complet émettant les six signatures/slots et le bundle evidence final.
|
||||
- `pre.013-delta-fix-027` enregistre le rerun `fix-026` : le workspace est entièrement propre (`ks-lib` 706/706, `ks-pipeline` 107/107, scénarios 96/96, desktop 135/135) et `Delegate(StakingV1)` franchit désormais confirmation, hydratation canonique, extraction Core, replay, matérialisation et idempotence avant qu’une postcondition de campagne ne lise à tort `delegate=None` dans la projection JSON brute du `TokenRecord` ; la campagne reste non qualifiée jusqu’au rerun avec projection canonique.
|
||||
- `pre.013-delta-fix-026` enregistre le rerun `fix-025` : `Create -> Mint` franchit désormais entièrement sa préparation pNFT, puis `Delegate(StakingV1)` est réellement confirmé par `4S2uDrVU6ci59eKeGtGeXoUCbsUr4WxzSa7uYhmBxLtjKfNwpBkZRqm59V9cfbD99oE9AugMYppiQqnMbV2Hy1p7` au slot `482106293` avec hydratation canonique et extraction Core réussies ;
|
||||
- le replay échoue ensuite dans `ks-lib` avant matérialisation parce que `Delegate.authority` et le fee payer sont le même wallet : Core replay expose donc le privilège writable global sur la position authority, alors que l’AccountMeta officiel de cette position est seulement signer ;
|
||||
- prépare le correctif de la même frontière pour `Delegate/Revoke` et pour `Lock/Unlock`, où `token_owner` est également le fee payer dans la campagne ; les cinq opérations lifecycle restent `not_run` jusqu’à un rerun complet.
|
||||
- `pre.013-delta-fix-025` enregistre la première tentative réseau du cycle pNFT après correction du harness : `Create` est réellement confirmé par `PNkQAnykFoHVsREkhDbTW9eccyJoQvQbRZ2MJ4FsFRXyXint9fj6FT9PW45W5R3yi9HJ2aiSZXssH6B6pmyCcEz` au slot `482096529`, puis la campagne s’arrête pendant sa post-validation stateful sur la Master Edition `HmCuW752ukBChVTbXtKJphUwJTJDdSycrgs6SrU2nT4B` du mint `DtBji9AYCqLRB48LFEFir427Kwy4Ehie6Pc8S87aQ6Rb` ;
|
||||
- le défaut est dans `ks-lib` : le trailer officiel de 2 octets d’une Master Edition compacte avait été introduit par `fix-021` comme padding nul, ce qui rejetait précisément le byte `ProgrammableNonFungible(4)` écrit par le programme courant ; aucune des cinq opérations lifecycle n’a encore été atteinte ni promue.
|
||||
- `pre.013-delta-fix-024` corrige uniquement le harness `#[cfg(test)]` de la campagne pNFT préparée par `fix-023` : il réutilise le chargement de configuration, la surcharge PostgreSQL, la construction `PostgresStoreOptions`, le pool HTTP et le wallet directory déjà éprouvés par les autres campagnes Metaplex ;
|
||||
- aligne l'émission `METAPLEX_PNFT_LIFECYCLE_EVIDENCE` sur les champs réels de `DevnetMetaplexTokenMetadataExecutionSummary` (`genesis_hash`, `simulation_context_slot`, `readiness.message_hash`, `fee.fee_lamports`) au lieu de champs inexistants du résultat brut de simulation ;
|
||||
- enregistre que `cargo check --workspace` de `fix-023` était vert parce que le défaut restait sous `#[cfg(test)]`, tandis que `clippy --all-targets` et `cargo test -p ks-pipeline-demo-scenarios` compilaient ce harness et échouaient avant toute transaction pNFT ; la matrice reste à 6 opérations `confirmed` et 14 `not_run`.
|
||||
- `pre.013-delta-fix-023` qualifie définitivement `Print` et `Burn` sur Devnet : `Print` est confirmé par `REHAxUZrSdKDj8F8k27Amrg7hwuJFhVfstjc9xjn2XGfTX38fH4tFwPeNrF5zeMN5pc21PpUPSgaKXMMfTFd7TE` au slot `482087799`, puis `Burn` par `4cNUxGGSWifuAtqdPJAtM3Cjpi4JATitRDqrvEAe85NLpMWcgWmC27bsfjNUmK6sEBDovNPtkPV3wDH1LpfGbhd1` au slot `482087818`; les deux étapes terminent hydratation canonique, extraction Core, replay, matérialisation et idempotence ;
|
||||
- promeut `Print` et `Burn` à `confirmed` sur la famille NFT dans la matrice Devnet v3, qui passe à 6 opérations `confirmed` et 14 `not_run` ;
|
||||
- prépare la campagne spécialisée pNFT `Delegate(Staking) -> Lock -> Unlock -> Revoke(Staking) -> Delegate(Transfer) -> Transfer` sur une fixture `programmable_nft` fraîche, avec signataire delegate supplémentaire, propriétaire destination frais, Token Records source/destination et ATA gelées validés à chaque transition ;
|
||||
- conserve `Delegate`, `Revoke`, `Lock`, `Unlock` et `Transfer` à `not_run` jusqu’au rerun Devnet complet de cette nouvelle campagne.
|
||||
- `pre.013-delta-fix-022` corrige la frontière `Future + Send` du runner Metaplex : les références `Signer + Sync` restent publiques, mais leur effacement vers `&dyn Signer` est désormais confiné à un helper synchrone de signature et ne traverse plus aucun `await` ;
|
||||
- aligne la postcondition `Burn` sur le comportement courant de `MetadataV1` : l'absence reste valide, et un compte résiduel n'est accepté que comme tombstone fee exact `owner=Token Metadata`, non exécutable, lamports positifs, `space=1`, `data=[0x00]` ; les comptes token, Edition et Edition Marker doivent toujours être absents ;
|
||||
- enregistre que le rerun `fix-021` atteint `Burn` et termine sa validation d'exécution avant cette seule garde sémantique, sans promouvoir `Print`/`Burn` tant que le bundle `METAPLEX_PRINT_BURN_*` complet n'est pas émis.
|
||||
- `pre.013-delta-fix-021` enregistre la deuxième tentative `Print -> Burn` : `Print` est effectivement confirmé sur Devnet avec la signature `2199PTvKyTAfnGRHVaaCceW4u8phdJSu5ULkceD9uJeQyjjenEupK9osRZu1Pzx2NuQaJnAwngSAXMvr5oNtf5y6` au slot `482068400`, puis la campagne s’arrête sur la lecture stateful du compte `Edition` avant hydratation/replay/matérialisation ; `Burn` n’est pas atteint et la matrice conserve `Print`/`Burn` à `not_run` ;
|
||||
- borne la voie multi-signataires publique à `Signer + Sync` tout en conservant la déclaration explicite, l’unicité et la présence réelle de chaque signataire ; la validation suivante montrera que l'effacement local vers `Vec<&dyn Signer>` doit encore être confiné pour rendre effectivement le futur Tauri `Send` ;
|
||||
- utilise `TemporaryWallet::as_sync_signer()` pour le keypair `edition_mint` et corrige le warning Clippy `cloned_ref_to_slice_refs` avec `std::slice::from_ref`.
|
||||
- `pre.013-delta-fix-020` conserve la première tentative réseau `Print -> Burn` comme preuve de simulation négative : `Print` s’arrête avant soumission avec `MetadataError::NotEnoughTokens` (`0x20`) parce que `fix-019` précréait l’ATA d’édition vide alors que le chemin courant exige `amount=1` pour un compte token déjà existant ; aucune ligne `Print`/`Burn` n’est promue ;
|
||||
- corrige la fixture en conservant seulement le keypair `edition_mint` frais et en exigeant que le mint et son ATA soient tous deux absents avant `Print`, afin de laisser le programme courant créer le mint, créer l’ATA et frapper l’unique token imprimé ;
|
||||
- vérifie explicitement que l’ATA du master contient exactement un token avant `Print`, conserve les deux absences fraîches dans le résumé de campagne et supprime le re-export `NativeFixturePreparationEvidence` devenu inutilisé ;
|
||||
- `pre.013-delta-fix-019` enregistre la campagne collection `Verify -> Unverify` entièrement confirmée : `Verify` signature `39JwUoxF2WWYWvEEWzFxSmKZVwoqXD1wH78sgGaoXv2vZKYUsXDNNnKikeuyQcfCqDftEMHSKFDHnTeSQ3bRpFcJ` slot `481936730`, puis `Unverify` signature `4Kfv42BGZtuBEwqTie5RWwdQ772hyxKiU9d3F3gfRnwVNrxCs2iZMvwL17Rwmuyr3XCVoHdaJVgaqCwv8y7bKoZY` slot `481936742`; les postconditions prouvent `verified false -> true -> false` et taille parent `0 -> 1 -> 0`, avec replay, matérialisation et idempotence complets ;
|
||||
- promeut `Verify` et `Unverify` à `confirmed` sur la famille `collection` dans la matrice Devnet v3 ; l’état réseau devient 4 opérations `confirmed` et 16 `not_run` ;
|
||||
- ajoute un runner Devnet `Print -> Burn` sur un master NFT `PrintSupply::Limited(1)`, un mint d’édition frais, l’Edition PDA et l’Edition Marker de l’édition 1 ; `Print` doit prouver master supply `0 -> 1`, création Metadata/Edition/marker, mint imprimé supply `0 -> 1` et ATA amount `1`, puis `Burn` doit prouver supply imprimée `1 -> 0`, master supply `1 -> 0` et fermeture du token account, de l’Edition et de l’Edition Marker devenu vide, ainsi que fermeture sémantique de Metadata ;
|
||||
- ajoute une voie explicite `execute_devnet_metaplex_token_metadata_with_signers` : les signataires supplémentaires doivent être déclarés dans `additional_authorized_signers`, effectivement fournis, uniques et bornés ; l’API historique reste profil-wallet-only ;
|
||||
- prépare les fixtures NFT imprimables avec `PrintSupply::Limited(1)` sans modifier les cinq profils `Create -> Mint` déjà qualifiés, et conserve `Print`/`Burn` à `not_run` jusqu’au test Devnet opt-in complet.
|
||||
- `pre.013-delta-fix-018` conserve la première tentative collection comme preuve partielle : `Verify` est confirmé avec la signature `2kR1VeWdCW3FcKRZdNuKMMc3kGUmWRBXxXX8W1NNi2rhWfZYTg9zsDvJHx7WvebgUfc2K382J14i66E2qPEnjsqR` au slot `481931061`, mais le replay échoue avant matérialisation parce que le décodeur refusait le privilège writable global de l'autorité fee payer ; aucune promotion de matrice n'est effectuée avant rerun complet ;
|
||||
- `pre.013-delta-fix-017` ferme la qualification multi-famille `Create -> Mint` : le rerun pNFT confirme `Create` au slot `481921980` puis `Mint` au slot `481921994`, avec Token Record présent, supply/amount `0 -> 1` et ATA `Initialized(1) -> Frozen(2)` ; la matrice v3 contient désormais cinq bundles `nft`, `sft`, `fungible`, `collection` et `programmable_nft` pour `Create` et `Mint` ;
|
||||
- ajoute un runner Devnet réutilisable de collection parent-membre : Collection NFT parent `size=0`, membre NFT initialement `verified=false`, puis `Verify(CollectionV1)` et `Unverify(CollectionV1)` avec postconditions fermées `verified false -> true -> false` et taille `0 -> 1 -> 0` ;
|
||||
- permet aux fixtures `Create` de déclarer un `collection_mint` initialement non vérifié sans modifier les cinq profils de base ;
|
||||
- verrouille le format stateful du parent sur la sérialisation SDK réelle `collection_details.V1.size` et ajoute une régression empêchant un retour accidentel à une clé camelCase inexistante ;
|
||||
- retire `SetCollectionSize` du parcours synthétique courant de collection : la taille est une postcondition stateful dérivée du programme, l’opération obsolète n’est pas utilisée pour fabriquer la preuve ;
|
||||
- conserve `Verify` et `Unverify` à `not_run` tant que le nouveau test Devnet opt-in n’a pas produit signatures, slots et evidence machine-readable.
|
||||
- `pre.013-delta-fix-016` corrige la postcondition SPL de la variante pNFT : après un `Mint` programmable confirmé, l’ATA classique est légitimement `Frozen` (`AccountState=2`) tandis que le Token Record Metaplex porte l’état programmable ; NFT/SFT/fungible/collection continuent d’exiger `Initialized` (`AccountState=1`) ;
|
||||
- la première tentative pNFT confirme déjà `Mint` avec la signature `Gkg5A6ehuSzkU1N318kYoRHm5RdxV2yBRpaK9Gwyt6vN1ToYwAS6Vzpk2rQYWS9NJz1rnUpypLCn7DqWFzQzxFc` au slot `481913765`, amount `1`, owner et mint exacts, mais reste non promue car la campagne échoue ensuite sur l’ancienne attente `state=1` ;
|
||||
- `DevnetMetaplexCreateMintTokenState` et `METAPLEX_CREATE_MINT_EVIDENCE` conservent désormais l’état SPL brut avant/après `Mint`, afin que la preuve pNFT encode explicitement `Initialized(1) -> Frozen(2)` en plus de la transition supply/amount.
|
||||
- `pre.013-delta-fix-015` enregistre la campagne collection `Create -> Mint` entièrement confirmée : `Create` signature `3jpxyWuxQVyBtbvqDnV1oahLyxCXMw1zQQZBGCKgQSfn2TNcBaUdfvJ6kFa8zL3XJLMpM6tahQTnu8AUsTkYc3Dy` slot `481911942`, puis `Mint` signature `4WEciqZA5vmtrh1ZeSbqcgVnezUy7qT6B6utySfkg7dbAmhH6SMwwY862zGbzPW3nBVFzMjFVGPEhnTt6TcAiEe2` slot `481911955`; les deux étapes terminent le pipeline de preuve complet avec deux snapshots stateful et supply/amount `0 -> 1`; la matrice v3 contient désormais les bundles `nft`, `sft`, `fungible` et `collection` pour `Create`/`Mint`, et le test de matrice n’est plus figé sur les deux premières familles ;
|
||||
- `pre.013-delta-fix-014` enregistre la campagne fungible `Create -> Mint` entièrement confirmée : `Create` signature `3J1Ebq9DVkupVFQmqQ84QaNeztwMuRwbtXj7CiyUWwZPj4KCAJpKLAbPvpYss5cdiY33foXXScWWZE98H7tvbZed` slot `481910724`, puis `Mint` signature `2GKiY9foLmioYtUHttA2D6nCSmsftRL1JDKxENHmCnb5w38FGSxwBiRNMfF7mH8htv3G45irmge6DVtr6VN359j2` slot `481910734`; chaque étape matérialise une ligne et `Mint` fait passer supply/amount de `0` à `1_000_000_000` sur un mint à 9 décimales ; la matrice v3 conserve désormais trois bundles `nft`, `sft` et `fungible` pour `Create` et `Mint`, tandis que collection et pNFT restent à exécuter ;
|
||||
- `pre.013-delta-fix-013` enregistre la campagne SFT `Create -> Mint` entièrement confirmée : `Create` signature `2s81m6k9HYDLmCy7oXKaqBvtb62iyX4bJmij2f2VjHuoRQBBA6YqRuAsjyaM7JCaJXasbU3WCfiko91cZcDLLdxV` slot `481908028`, puis `Mint` signature `4pAemhEJewuLAeqLkqwzPvVmsCRbkyLiN6uAUWBA5RMsofaEDt5Xr8YGiBEvLWQM7Qvxnd81mmCHpandmfmWuyAm` slot `481908039` ; chaque étape matérialise une ligne et `Mint` fait passer supply/amount de `0` à `10` ;
|
||||
- fait évoluer la matrice Devnet en version 3 afin de stocker une preuve complète distincte par famille au lieu d’agréger plusieurs signatures, slots et message hashes dans une seule liste d’évidence ; `Create` et `Mint` restent les deux seules opérations `confirmed`, désormais observées sur `nft` et `sft`, tandis que les 18 autres opérations restent `not_run` ;
|
||||
- `pre.013-delta-fix-012` enregistre la première campagne NFT `Create -> Mint` entièrement confirmée : `Create` signature `okwkuQFp7yVguYW7GEY9Chv6ochM8TWBEeCGV1gu5Waku9wQvMfrKjkig6JxXBwpKoFTECignzJ8HFf8dLtpsvS` slot `481904731`, puis `Mint` signature `3hSizfdvezyFpfhqX4Za4wmhdMHhSPAisFvYjFAxpRzYcUXhryxCzc9qpwod3Yjm6GmijXTGjjDUuVksfpBzLwz1` slot `481904744` ; chaque étape produit une matérialisation et `Mint` fait passer supply/amount de `0` à `1` ;
|
||||
- promeut `Create` et `Mint` à `confirmed` sur la famille NFT dans la matrice Devnet v2, conserve les 18 autres opérations à `not_run` et exige désormais des preuves observées complètes pour toute ligne `confirmed` ;
|
||||
- conserve le slot RPC de simulation dans `DevnetMetaplexTokenMetadataExecutionSummary` et imprime une ligne `METAPLEX_CREATE_MINT_EVIDENCE` machine-readable avec genesis hash, slot de simulation, message hash, fee, confirmation et état du pipeline pour les futures campagnes SFT/fungible/collection/pNFT ;
|
||||
- `pre.013-delta-fix-011` corrige la postcondition SPL de la campagne `Create -> Mint` : lorsqu’une famille demande une Master Edition, `Create` transfère légitimement les mint/freeze authorities vers le PDA Master Edition ; SFT et fungible conservent l’autorité opérateur ;
|
||||
- supprime le helper `read_token_account` devenu inutilisé et élimine le warning `dead_code` observé après `fix-010` ;
|
||||
- conserve signature + slot dans toute erreur SPL postérieure à un `Create` ou `Mint` déjà confirmé afin qu’une preuve pipeline complète ne soit plus perdue par une postcondition ultérieure ;
|
||||
- corrige l’interprétation de la tentative précédente : le mismatch d’autorité n’était pas une preuve de fixture stale. `fix-010` reste un durcissement fresh-only valide, mais le chemin de campagne montre que l’erreur était atteinte après `Create`. La tentative suivant `fix-010` franchit même `validate_confirmed_execution("create", ...)`, donc replay, matérialisation et idempotence ont réussi avant la postcondition SPL ; signature et slot n’ayant pas été imprimés, la matrice reste néanmoins `not_run`.
|
||||
- `pre.013-delta-fix-010` rend la fixture `Create -> Mint` strictement fraîche : le mint keypair est créé avec `TemporaryWalletStore::create` au lieu de `load_or_create`, son adresse doit être absente de Devnet avant préparation, puis le mint et l’ATA sont relus à `minContextSlot` égal au slot confirmé de la transaction native ;
|
||||
- `fix-010` a initialement été motivé par un mismatch d’autorité `3RJXuBF6xB3j47EeAHEBbcwvV66rJrkhmJvYNr36jz8x` contre l’opérateur `J12WA6c42oqpWkLu1dFa4pCegkQJPpxabwc3RRCMSUuH` ; le réaudit de `fix-011` corrige cette interprétation : la postcondition était exécutée après `Create` et observait le transfert normal des autorités vers le PDA Master Edition ;
|
||||
- `pre.013-delta-fix-009` enregistre la troisième tentative NFT : `Create` est confirmée sur Devnet avec la signature `51MXdFFFNY7UTS23cmfCQBfrDKC99uk5Trtw2gkptvEug9aWJ2R1cE9Ta5QHbe4kS1NYgRUSSMmmdbqdhyxxnR4Y` au slot `481882374`, puis la post-validation échoue uniquement au replay instructionnel ;
|
||||
- relie cet échec au contrat trop strict du décodeur `Create`/`Mint`, qui confondait flags positionnels et privilèges globaux de transaction, et ajoute un diagnostic compact des compteurs `DecodeReplaySummary` lorsqu’un replay reste incomplet ;
|
||||
- conserve `Create` et `Mint` à `not_run` tant que replay, matérialisation et idempotence ne sont pas tous démontrés.
|
||||
- `pre.013-delta-fix-008` corrige la seconde tentative NFT `Create -> Mint` : les snapshots Metaplex utilisaient encore la borne pipeline historique de `1 MiB`, alors que le transport refuse une lecture complète `getAccountInfo` au-delà de `65536` octets ;
|
||||
- utilise désormais la borne stateful commune du transport dans tous les snapshots `Create -> Mint` et verrouille cette valeur par test ;
|
||||
- enrichit une éventuelle erreur post-exécution restante avec la signature et le slot déjà confirmés afin de ne plus perdre l'identité d'une transaction ayant atteint la post-validation ;
|
||||
- conserve les 20 opérations à `not_run` jusqu'à une campagne terminée avec preuves exploitables.
|
||||
- `pre.013-delta-fix-007` réorganise la crate par domaines stables (`metadata/solana_program`, `metadata/metaplex_token_metadata`, `spl/associated_token_account`, `spl/memo`, `spl/token`, `spl/token_2022` et `spl/token_2022/metadata`) sans modifier les exports publics de crate-root ;
|
||||
- supprime les noms de modules racine historiques et incohérents `metadata_solana_program_*`, `solana_metaplex_token_metadata_*`, `solana_token_2022_*` et `token_2022_*` au profit d'une arborescence correspondant aux domaines du workspace ;
|
||||
- corrige le warning Clippy `cloned_ref_to_slice_refs` du test de borne `minContextSlot` avec `std::slice::from_ref` ;
|
||||
- durcit la sélection des preuves de matérialisation Metaplex : les lignes sont désormais retenues par l'identité du matérialiseur `MtMetadataMetaplexTokenMetadataMaterializer` et non par une chaîne de nom de décodeur ;
|
||||
- enrichit l'erreur `Create -> Mint` avec les booléens post-exécution, les nombres de lignes/snapshots et les diagnostics accumulés afin qu'une nouvelle tentative Devnet distingue immédiatement hydratation, extraction, replay ou matérialisation manquante ;
|
||||
- conserve `Create` et `Mint` à `not_run` : la première tentative NFT de `fix-006` a atteint la post-validation mais a été interrompue par `metaplex_create_mint_post_execution_incomplete` avant émission d'une preuve exploitable ;
|
||||
- `pre.013-delta-fix-006` ajoute une campagne Devnet réutilisable et bornée `Create -> Mint` exécutée une famille à la fois (`nft`, `sft`, `fungible`, `collection` ou `programmable_nft`) ;
|
||||
- lie désormais toutes les lectures Metaplex postcondition au slot de confirmation de la transaction via `minContextSlot`, afin que `after_state` ne puisse pas provenir d’un contexte antérieur ;
|
||||
- exige pour `Create` et `Mint` simulation réussie, confirmation, hydratation canonique, extraction Core, replay, matérialisation, seconde passe idempotente et snapshots Metaplex attendus ;
|
||||
- relit en plus le mint SPL classique et l’ATA au slot confirmé : supply et amount doivent rester nuls après `Create`, puis devenir exactement `mint_amount_raw` après `Mint` ;
|
||||
- exige la présence du master edition pour NFT/collection/pNFT et du token record après `Mint` pour pNFT, sans précréer ces preuves dans la fixture ;
|
||||
- conserve les 20 opérations de la matrice réseau à `not_run` jusqu’à l’exécution réelle de la nouvelle campagne ;
|
||||
- `pre.013-delta-fix-005` prépare désormais, dans la même transaction native, le mint SPL classique et l’ATA canonique de l’opérateur, tout en conservant une supply nulle avant l’opération Metaplex `Mint` ;
|
||||
- ajoute au résumé de fixture l’ATA, le token record pNFT éventuel, le montant brut par famille et un JSON `Mint` typé prêt à exécuter après `Create` ;
|
||||
- vérifie après confirmation le layout exact du mint et de l’ATA classique, notamment mint, owner, amount=0, état initialisé et taille 165 octets ;
|
||||
- dérive le token-record PDA uniquement pour pNFT et verrouille hors réseau les montants `Mint` : NFT/collection/pNFT=1, SFT=10 et fungible=1_000_000_000 à 9 décimales ;
|
||||
- conserve les 20 opérations Metaplex à `not_run` : ce correctif prépare les préconditions token accounts/supply mais ne transforme aucune fixture en preuve Devnet ;
|
||||
- `pre.013-delta-fix-002` rend les fixtures `Create` réellement cohérentes avec les cinq familles Devnet : NFT, SFT et collection restent à 0 décimale, tandis que le mint fongible est préparé à 9 décimales ;
|
||||
- déplace la sélection du `TokenStandard`, de `collection_details`, de `master_edition` et de `print_supply` depuis l’adaptateur desktop vers la fixture réutilisable afin que CLI, tests et futures campagnes consomment le même contrat ;
|
||||
- vérifie hors réseau que les cinq JSON `Create` se désérialisent vers `ExMetaplexTokenMetadataOperation::Create` et que seules les familles NFT, collection et pNFT demandent une master edition ;
|
||||
- `pre.013-delta-fix-001` corrige le test Devnet opt-in pour la signature actuelle de `PostgresStoreOptions::new(database_url, max_connections, connect_timeout_ms, auto_initialize_schema)` et conserve le timeout de cinq secondes sous forme `5_000` ms ;
|
||||
- réaudite Metaplex Token Metadata contre l’IDL historique de 58 instructions, la matrice d’exécution et la surface SDK actuelle : 20 opérations courantes, 15 obsolètes exécutables sous approbation explicite, 22 remplacées en décodage uniquement et une frontière Bubblegum ;
|
||||
- rouvre `METAPLEX_TOKEN_METADATA_DEVNET_EXECUTION_MATRIX.json` sous `0.4.8-pre.013` sans inventer de preuve réseau : les 20 opérations courantes restent initialement `not_run` ;
|
||||
- corrige le runner Devnet Metaplex afin d’exécuter réellement la post-validation déjà déclarée par sa policy : hydratation canonique, extraction Core, replay, matérialisation et seconde passe d’idempotence ;
|
||||
- expose dans le résumé les rapports de backfill, extraction, replay, idempotence, les matérialisations instructionnelles et le diagnostic agrégé ;
|
||||
- borne les retries de post-validation à 20 et exige PostgreSQL dans le test Devnet opt-in dès qu’une exécution réelle est demandée ;
|
||||
- conserve comme travail restant de la même prerelease les fixtures spécialisées et les preuves réseau des opérations autres que `Create` et `UpdateAsUpdateAuthorityV2`.
|
||||
|
||||
## `0.4.8-pre.012`
|
||||
|
||||
- corrige le delta initial en déclarant explicitement `bs58.workspace = true`, utilisé directement par les fixtures et gardes Token-2022 de la crate ;
|
||||
- confirme sur Devnet les cinq opérations Token Metadata de la campagne avec cinq signatures, cinq slots et des postconditions `Confirmed` ;
|
||||
- conserve pour `Emit` un `returnData` complet de 202 octets validé contre le snapshot TLV autoritatif ;
|
||||
- promeut les cinq scénarios de `SPL_TOKEN_2022_METADATA_DEVNET_VALIDATION_MATRIX.json` à `confirmed` avec leurs preuves réelles ;
|
||||
- clôt les TODO opérateur de `pre.012` après `cargo fmt`, `cargo check --workspace`, `cargo clippy --all-targets`, audit workspace, tests de crate et `cargo test --workspace` réussis ;
|
||||
- ajoute une fixture native de mint Token-2022 avec `MetadataPointer` auto-référent initialisé avant `InitializeMint2` ;
|
||||
- préfinance le mint pour les réallocations du TLV Token Metadata atteintes par la campagne ;
|
||||
- ajoute une campagne Devnet ordonnée `Initialize → UpdateField → Emit → RemoveKey → UpdateAuthority` avec simulation, soumission autorisée, confirmation, hydratation, replay, matérialisation et postconditions stateful ;
|
||||
- étend le préflight générique Token-2022 aux opérations Token Metadata et n’exige aucune matérialisation instructionnelle pour `Emit` ;
|
||||
- ajoute une matrice réseau conservative à cinq scénarios, initialement intégralement `not_run` ;
|
||||
- ajoute un test Devnet opt-in avec sortie structurée des signatures, slots, postconditions et octets de `returnData`.
|
||||
|
||||
## `0.4.8-pre.011`
|
||||
|
||||
- adapte la fixture synthétique de simulation au nouveau champ optionnel `return_data` du contrat commun ;
|
||||
- promeut les neuf lignes Solana Program Metadata à `confirmed` avec les preuves des deux campagnes Devnet finales de `pre.010`.
|
||||
|
||||
## 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 `ks-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 ;
|
||||
- ajoute deux parcours ordonnés couvrant exactement les neuf opérations stables de `ProgM6…` ;
|
||||
- prépare deux PDA non canoniques préfinancés, un `Buffer` et un compte `Metadata`, à partir du wallet persistant du profil Devnet ;
|
||||
- ajoute un runner simulation-first avec préflight stateful, `ExSafetyChecker`, signature, soumission, confirmation, lectures avant/après et matérialisation des snapshots ;
|
||||
- ajoute une campagne confirmée réutilisable qui exécute les neuf étapes sans déplacer l’orchestration dans le desktop ;
|
||||
- distingue la preuve `materialized_snapshot` de la preuve terminale `account_absence` après `Close` ;
|
||||
- ajoute une matrice conservative dont tous les statuts réseau restent `not_run` avant exécution réelle ;
|
||||
- conserve `ks-pipeline` indépendant de cette crate et ne modifie pas encore `demo_execution_metadata`.
|
||||
|
||||
## 0.4.7-pre.016
|
||||
|
||||
- généralise README et USAGE autour des groupes fonctionnels ;
|
||||
- nettoie le TODO et conserve les fixtures Devnet spécialisées comme dette explicite ;
|
||||
- qualifie séparément builders disponibles et preuves réseau observées.
|
||||
|
||||
## 0.4.7-pre.015
|
||||
- étend le parcours NFT Devnet à une troisième étape `UpdateAsUpdateAuthorityV2` qui rend les metadata immutables ;
|
||||
- distingue l’édition maître créée par `Create` des futures fixtures d’édition imprimée ;
|
||||
- conserve `Print` et `Burn` comme étapes ultérieures tant que leurs comptes SPL et édition ne sont pas préparés de bout en bout.
|
||||
|
||||
## 0.4.7-pre.014
|
||||
|
||||
- Expose cinq parcours Metaplex Devnet préparables : NFT, SFT, fungible, collection parent et pNFT sans rule set.
|
||||
- Conserver un cycle borné `Create -> UpdateAsUpdateAuthorityV2` pour chaque famille avant d'étendre les opérations spécialisées.
|
||||
|
||||
## 0.4.7-pre.013-fix-014
|
||||
|
||||
- crée un nouveau mint natif pour chaque nouvelle préparation `Create`, afin de ne jamais réutiliser un PDA metadata déjà créé ;
|
||||
- limite l’inventaire Devnet visible au parcours NFT `create -> update` réellement préparé de bout en bout ;
|
||||
- conserve les cinq parcours synthétiques comme matrice cible sans les présenter comme exécutables sur Devnet.
|
||||
|
||||
## 0.4.7-pre.013-fix-013
|
||||
|
||||
- retire l’appel résiduel à `ExSplTokenExecutor` dans la préparation native du mint Metaplex ;
|
||||
- construit directement l’instruction officielle `spl_token_interface::instruction::initialize_mint2` ;
|
||||
- ajoute cette instruction au plan composite `SystemCreateAccount + InitializeMint2` sans falsifier les exigences de post-validation de l’exécuteur SPL ;
|
||||
- ajoute les dépendances directes `solana-instruction` et `spl-token-interface` à la crate de scénarios ;
|
||||
- conserve la simulation exacte, les deux signatures, la confirmation et la relecture on-chain du mint.
|
||||
|
||||
## 0.4.7-pre.013-fix-012
|
||||
|
||||
- aligne la fixture Rust native sur les APIs publiques réelles du workspace ;
|
||||
- évalue le `ExApiPreparedExecutionPlan` avec `evaluate_prepared_plan` ;
|
||||
- conserve `MdSignature` pour l’envoi et la confirmation, puis retourne sa valeur chaîne ;
|
||||
- convertit la clé du mint en `MdPubkey` pour `getAccountInfo` ;
|
||||
- supprime l’avertissement du paramètre de rent conservé dans le contrat de politique.
|
||||
|
||||
## 0.4.7-pre.013-fix-011
|
||||
|
||||
- remplace la préparation Metaplex fondée sur `solana-keygen`, `solana` et `spl-token` par une transaction Rust native ;
|
||||
- crée le mint par `SystemCreateAccount` puis `SPL Token InitializeMint2` dans un même message simulé, signé et confirmé ;
|
||||
- utilise explicitement le wallet du profil comme mint authority et freeze authority ;
|
||||
- conserve le keypair du mint dans `ks-wallet` sans exposer ni déléguer ses secrets à une CLI externe.
|
||||
|
||||
## 0.4.7-pre.013-fix-010
|
||||
|
||||
- adapte la création de la fixture NFT à la CLI `spl-token` installée, qui ne prend pas en charge `create-token --freeze-authority` ;
|
||||
- crée le mint avec `--enable-freeze`, puis transfère explicitement les autorités `freeze` et `mint` au wallet du profil ;
|
||||
- versionne la fixture en `create-mint-profile-authority-v4.json` afin de ne pas réutiliser un mint incomplet issu du fix précédent.
|
||||
|
||||
## 0.4.7-pre.013-fix-009
|
||||
|
||||
- remplace l’inventaire Metaplex plat par cinq parcours cohérents avec fixture, état initial, état attendu et cibles de matérialisation ;
|
||||
- ajoute la matérialisation optionnelle des snapshots confirmés ;
|
||||
- crée la fixture NFT `v3` avec mint authority et freeze authority détenues par le wallet du profil ;
|
||||
- interdit une matérialisation demandée sans lecture de postcondition.
|
||||
|
||||
## 0.4.7-pre.013-fix-007
|
||||
|
||||
- fournit explicitement le Program ID SPL Token classique dans le modèle et la fixture `Create` NFT ;
|
||||
- ajoute des assertions empêchant le retour de `spl_token_program: null` pour ce parcours ;
|
||||
- conserve `PrintSupply::Zero` et les PDA dérivés depuis le mint préparé.
|
||||
|
||||
## 0.4.7-pre.013-fix-006
|
||||
|
||||
- corrige le modèle et la fixture `Create` NFT avec `PrintSupply::Zero` ;
|
||||
- ajoute un test garantissant que le modèle JSON nommé ne réintroduit pas un `print_supply` absent.
|
||||
|
||||
## 0.4.7-pre.013-fix-003
|
||||
|
||||
- Ajout du guide intégré, du journal d’exécution et de titres explicites pour les résultats JSON Metaplex.
|
||||
- Ajout d’un préparateur idempotent de fixture `Create` : mint SPL classique Devnet, PDA metadata et master edition dérivés, intent JSON prêt à simuler.
|
||||
|
||||
## 0.4.7-pre.012-fix-003
|
||||
|
||||
- ajout explicite de la dépendance workspace `mpl-token-metadata` requise par les modèles et constructeurs de scénarios ;
|
||||
- alignement du test de placeholder sur le code stable `config` réellement exposé par `ks_core::Error::config` ;
|
||||
- validation locale : formatage, workspace check, clippy et audits propres ; 53 tests de bibliothèque réussis avant correction de l’assertion, un seul test échouant sur le code attendu.
|
||||
|
||||
## 0.4.7-pre.012-fix-002
|
||||
|
||||
- rejet explicite des placeholders `...` dans le JSON d’opération Metaplex ;
|
||||
- inventaire public des vingt opérations courantes acceptées par les campagnes automatiques ;
|
||||
- modèles JSON structurés pour `Create`, `Update`, `Verify` et `Unverify` ;
|
||||
- constructeurs nommés pour les parcours créateur `Verify` et `Unverify` ;
|
||||
- documentation corrigée afin de ne plus présenter un placeholder comme une commande exécutable.
|
||||
|
||||
## 0.4.7-pre.012 — couverture du runner Devnet Metaplex
|
||||
|
||||
- passage de la matrice Devnet à `0.4.7-pre.012` ;
|
||||
- fermeture du statut `implemented` pour les 20 opérations courantes ;
|
||||
- ajout d’un constructeur de requête depuis le JSON typé `ExMetaplexTokenMetadataOperation` ;
|
||||
- ajout d’un test Devnet opt-in piloté par variables d’environnement pour simuler ou soumettre n’importe quelle opération courante ;
|
||||
- maintien des statuts réseau à `not_run` tant qu’aucune exécution locale n’a fourni les preuves attendues.
|
||||
- correction du contrat stateful partagé afin que les lectures préflight et postcondition soient désérialisables depuis les variables JSON du test Devnet opt-in.
|
||||
|
||||
## 0.4.7-pre.011 — réouverture des campagnes Devnet Metaplex
|
||||
|
||||
### Exécution Devnet
|
||||
|
||||
- ajout d’un exécuteur Metaplex Devnet réel, simulation-first et limité aux opérations non dépréciées ;
|
||||
- contrôle du genesis hash Devnet, du wallet de profil, des signers et des plafonds de coût ;
|
||||
- ajout des lectures stateful avant/après, du préflight, de la simulation RPC exacte, de la soumission contrôlée et de la confirmation ;
|
||||
- ajout d’une matrice fermée des 20 opérations courantes, avec `Create`, `Update`, `Verify` et `Unverify` attribuées au premier lot `pre.011` ;
|
||||
- conservation de tous les statuts réseau à `not_run` tant qu’aucune preuve locale n’a été observée.
|
||||
|
||||
### Documentation
|
||||
|
||||
- réorganisation des quatre documents de crate ;
|
||||
- ajout des lots `pre.011` et `pre.012` au TODO pour couvrir les 20 opérations courantes sur Devnet ;
|
||||
- clarification du rôle ciblé du CLI et du maintien des scénarios UI dans le desktop.
|
||||
|
||||
## 0.4.7-pre.010 — corpus de validation croisée
|
||||
|
||||
- ajout d’un corpus fermé couvrant outer/CPI, succès/échec, cinq familles d’actifs et neuf classes d’erreur ;
|
||||
- ajout de contrats distincts de preuve réseau pour simulation, soumission et postcondition ;
|
||||
- refus de toute promotion réseau fondée uniquement sur des preuves synthétiques.
|
||||
|
||||
## 0.4.7-pre.009
|
||||
|
||||
- ajout de l’inventaire Metaplex Devnet simulation et de son test de couverture.
|
||||
|
||||
## 0.4.7-pre.008
|
||||
|
||||
- ajout des scénarios synthétiques NFT, SFT, token fongible, collection et pNFT ;
|
||||
- ajout de la matrice de validation Devnet/Testnet ;
|
||||
- conservation du CLI sans commande Metaplex non justifiée.
|
||||
|
||||
## 0.4.6
|
||||
|
||||
- alignement de la crate sur la version fonctionnelle bot3 `0.4.6` ;
|
||||
- clôture des tâches de migration applicables et report explicite des évolutions ultérieures dans le TODO.
|
||||
|
||||
## 0.1.0-pre.075
|
||||
|
||||
- ajout du guide opérateur Devnet `0.4.6` distinguant les scénarios de validation des composants réutilisables ;
|
||||
|
||||
## 0.1.0-pre.073
|
||||
|
||||
- ajout du guide transversal [`docs/guides/DEVNET_VALIDATION.md`](../docs/guides/DEVNET_VALIDATION.md) ;
|
||||
|
||||
## 0.1.0-pre.072
|
||||
|
||||
- reclassement du TODO selon les blocants avant `0.4.6`, les travaux `0.4.7`, les versions ultérieures et les dépendances conditionnelles.
|
||||
|
||||
## 0.1.0-pre.070
|
||||
|
||||
- enrichissement du `USAGE.md` avec plusieurs exemples couvrant les principales familles d’API publiques ;
|
||||
- ajout du contrat documentaire de la crate mixte ;
|
||||
- inventaire des scénarios publics et du binaire CLI ;
|
||||
- documentation des tests Devnet comme références d’exécution réelle ;
|
||||
- classement des travaux d’autonomie CLI, fixtures et Metaplex dans le TODO.
|
||||
|
||||
## 0.1.0-pre.062
|
||||
|
||||
- extraction des scénarios de démonstration hors du desktop ;
|
||||
- conservation du nom de bibliothèque `ks_pipeline_demo_scenarios` ;
|
||||
- création du binaire explicite `ks-pipeline-demo-scenarios-cli` avec `autobins = false` ;
|
||||
- migration des scénarios System Transfer, Memo v4, ATA, SPL Token et Token-2022 ;
|
||||
- validation connue de 39 tests de bibliothèque et 1 test de binaire.
|
||||
47
ks-pipeline-demo-scenarios/Cargo.toml
Normal file
47
ks-pipeline-demo-scenarios/Cargo.toml
Normal file
@@ -0,0 +1,47 @@
|
||||
# file: ks-pipeline-demo-scenarios/Cargo.toml
|
||||
# version: 12
|
||||
|
||||
[package]
|
||||
name = "ks-pipeline-demo-scenarios"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
publish.workspace = true
|
||||
autobins = false
|
||||
|
||||
[lib]
|
||||
name = "ks_pipeline_demo_scenarios"
|
||||
path = "src/lib.rs"
|
||||
|
||||
[[bin]]
|
||||
name = "ks-pipeline-demo-scenarios-cli"
|
||||
path = "src/bin/ks_pipeline_demo_scenarios_cli.rs"
|
||||
|
||||
[dependencies]
|
||||
base64.workspace = true
|
||||
bs58.workspace = true
|
||||
chrono.workspace = true
|
||||
ks-core = { path = "../ks-core" }
|
||||
ks-config = { path = "../ks-config" }
|
||||
ks-lib = { path = "../ks-lib" }
|
||||
ks-onchain-transport = { path = "../ks-onchain-transport" }
|
||||
ks-pipeline = { path = "../ks-pipeline" }
|
||||
ks-program-ids = { path = "../ks-program-ids" }
|
||||
ks-store = { path = "../ks-store" }
|
||||
ks-wallet = { path = "../ks-wallet" }
|
||||
mpl-token-metadata.workspace = true
|
||||
solana-instruction.workspace = true
|
||||
solana-keypair.workspace = true
|
||||
solana-pubkey.workspace = true
|
||||
solana-signer.workspace = true
|
||||
spl-associated-token-account-interface.workspace = true
|
||||
spl-token-interface.workspace = true
|
||||
spl-token-2022-interface.workspace = true
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
tokio.workspace = true
|
||||
tracing.workspace = true
|
||||
uuid.workspace = true
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
72
ks-pipeline-demo-scenarios/README.md
Normal file
72
ks-pipeline-demo-scenarios/README.md
Normal file
@@ -0,0 +1,72 @@
|
||||
<!-- file: ks-pipeline-demo-scenarios/README.md -->
|
||||
<!-- version: 30 -->
|
||||
|
||||
# ks-pipeline-demo-scenarios
|
||||
|
||||
`ks-pipeline-demo-scenarios` fournit des scénarios synthétiques et des campagnes Devnet/Testnet automatisables au-dessus de `ks-pipeline`.
|
||||
|
||||
La crate contient :
|
||||
|
||||
- la bibliothèque Rust `ks_pipeline_demo_scenarios` ;
|
||||
- le binaire ciblé `ks-pipeline-demo-scenarios-cli`.
|
||||
|
||||
## Responsabilités
|
||||
|
||||
- préparer les profils, comptes et fixtures nécessaires aux campagnes de test ;
|
||||
- exécuter des scénarios synthétiques déterministes ;
|
||||
- exécuter des campagnes réseau explicites avec simulation ou soumission contrôlée ;
|
||||
- produire des résultats structurés et des preuves vérifiables ;
|
||||
- posséder les fixtures, séquences et qualifications réseau réutilisables appelées par les tests, CLI ou démonstrations UI ;
|
||||
- conserver la séparation entre preuve synthétique, simulation RPC, soumission confirmée et postcondition stateful.
|
||||
|
||||
La crate couvre les scénarios Solana Core et SPL historiques, les inventaires synthétiques et Devnet-cibles Metaplex pour NFT, SFT, token fongible, collection et pNFT, ainsi que le runner générique des opérations Metaplex courantes et deux parcours Solana Program Metadata couvrant les neuf opérations stables. Une famille déclarée dans l’inventaire ne vaut pas fixture spécialisée ni preuve réseau ; les validations restent qualifiées séparément.
|
||||
|
||||
Le réaudit Metaplex de `0.4.8-pre.013` ferme les 20 opérations courantes avec **15 `confirmed`, 5 `unavailable` et 0 `not_run`**. `Create` et `Mint` sont confirmés sur les cinq chemins NFT, SFT, fungible, collection et pNFT ; `Verify`/`Unverify`, `Print`/`Burn`, le lifecycle pNFT `Delegate/Revoke/Lock/Unlock/Transfer`, Token Owned Escrow `CreateEscrowAccount/TransferOutOfEscrow/CloseEscrowAccount` et `Update` disposent également de preuves Devnet complètes. `Use`, `Resize`, `Migrate`, `Collect` et `CloseAccounts` restent explicitement `unavailable` pour des causes distinctes documentées par leurs probes runtime. Toute soumission Metaplex confirmée passe par l’hydratation canonique de sa signature, l’extraction Core, le replay avec les matérialiseurs du domaine et une seconde passe d’idempotence. La fixture `Create` prépare en outre le mint SPL classique et l’ATA canonique de l’opérateur dans une même transaction, vérifie les deux comptes après confirmation et laisse volontairement la supply à zéro. Elle fournit ensuite un intent `Mint` typé par famille ; pour pNFT, elle dérive le token-record PDA attendu sans le créer prématurément. La campagne collection parent-membre qualifiée réutilise ces fixtures et valide la relation `verified false -> true -> false` ainsi que la taille de collection `0 -> 1 -> 0` sans utiliser `SetCollectionSize`.
|
||||
Depuis `pre.013-delta-fix-010`, cette fixture est **fresh-only** : elle crée un nouveau keypair persistant sans rechargement implicite, refuse toute adresse déjà présente on-chain, puis borne les relectures du mint et de son ATA au slot confirmé de la préparation native. Une campagne ne peut donc plus reprendre silencieusement un mint issu d’une exécution antérieure.
|
||||
Depuis `pre.013-delta-fix-011`, la campagne distingue aussi le contrat d’autorité SPL avant et après `Create` : NFT, collection et pNFT attendent le PDA Master Edition comme mint/freeze authority après création, alors que SFT et fungible conservent l’autorité opérateur.
|
||||
Depuis `pre.013-delta-fix-016`, la postcondition du compte token distingue aussi pNFT : l’ATA est `Initialized` avant `Mint`, puis doit être `Frozen` après `Mint`; les quatre autres familles restent `Initialized`. Le Token Record pNFT est validé séparément comme état programmable Metaplex.
|
||||
Depuis `pre.013-delta-fix-020`, la campagne `Print -> Burn` conserve uniquement un keypair frais pour `edition_mint` et exige que le mint **et** son ATA canonique soient absents avant `Print`. Le programme Metaplex courant crée alors le mint, crée l’ATA et frappe l’unique token imprimé. Précréer un ATA vide est interdit : le chemin `Print` d’un compte token déjà existant exige exactement un token. Le runner vérifie aussi que l’ATA du master contient exactement un token avant la simulation afin de distinguer une précondition master invalide d’un défaut de l’édition fraîche.
|
||||
Depuis `pre.013-delta-fix-030`, le probe `Use` est classé : Devnet rejette l’instruction courante de discriminant 51 avec `InvalidInstructionData`. Le scénario conserve ce refus comme preuve `unavailable`, ne soumet aucune transaction et peut retourner proprement une simulation négative en mode probe sans affaiblir la barrière de soumission.
|
||||
Depuis `pre.013-delta-fix-021`, les références de signataires supplémentaires conservent explicitement `Sync` et la lecture stateful accepte l'`Edition` compacte réellement allouée. La validation suivante montre toutefois que la conversion locale en `Vec<&dyn Signer>` rend encore le futur desktop non-`Send`; `pre.013-delta-fix-022` confine donc cette conversion à la fonction synchrone qui signe immédiatement la transaction. Le rerun Devnet de `fix-022` termine désormais `Print -> Burn` avec les deux signatures, les slots, les postconditions stateful, l’hydratation canonique, l’extraction Core, le replay, les matérialisations et l’idempotence. `Print` et `Burn` sont donc qualifiés sur la famille NFT. `fix-023` prépare ensuite une campagne pNFT spécialisée `Delegate(Staking) -> Lock -> Unlock -> Revoke(Staking) -> Delegate(Transfer) -> Transfer`, avec deux wallets temporaires frais pour le delegate et le propriétaire destination et des contrôles exacts du Token Record et des ATA gelées.
|
||||
|
||||
Pour Token-2022 Token Metadata, `0.4.8-pre.012` ajoute une campagne Devnet dédiée aux cinq instructions de `spl-token-metadata-interface`. Elle prépare nativement un mint frais avec `MetadataPointer`, puis enchaîne `Initialize`, `UpdateField`, `Emit`, `RemoveKey` et `UpdateAuthority` avec simulation-first, soumission explicitement autorisée, confirmation, replay, matérialisation lorsque applicable et postcondition stateful. La campagne de validation de `pre.012` a confirmé les cinq scénarios sur Devnet ; la matrice et le rapport conservent les signatures, slots et preuves observées.
|
||||
|
||||
## Organisation interne
|
||||
|
||||
Les modules internes suivent désormais les domaines fonctionnels plutôt que l'ordre historique des ajouts :
|
||||
|
||||
```text
|
||||
src/
|
||||
├── solana.rs
|
||||
├── metadata/
|
||||
│ ├── solana_program/
|
||||
│ └── metaplex_token_metadata/
|
||||
└── spl/
|
||||
├── associated_token_account.rs
|
||||
├── memo.rs
|
||||
├── token/
|
||||
└── token_2022/
|
||||
└── metadata/
|
||||
```
|
||||
|
||||
Les exports publics restent fournis depuis `lib.rs`. Le rangement interne ne doit donc pas imposer aux consommateurs des chemins de modules privés ni modifier les noms des contrats publics existants.
|
||||
|
||||
## Binaire CLI
|
||||
|
||||
Le CLI a été introduit pour les préparations de fixtures qui nécessitent une commande autonome, notamment Token-2022. Il n’est pas l’interface générale obligatoire de tous les scénarios. Une commande Metaplex ne doit être ajoutée que si une préparation de fixture ne peut pas être réalisée proprement par les tests ou APIs existantes.
|
||||
|
||||
## Relations avec le workspace
|
||||
|
||||
La crate dépend du pipeline généraliste. `ks-pipeline` ne dépend jamais d’elle. Le desktop réutilise déjà ses campagnes ; `0.5.4` doit déplacer ici les dispatchs et critères de campagne encore réutilisables afin de ne laisser localement que l'adaptation UI/Tauri, les presets strictement visuels et la présentation opérateur.
|
||||
|
||||
## Documentation
|
||||
|
||||
- [Guide d’utilisation](USAGE.md)
|
||||
- [Travaux restant à réaliser](TODO.md)
|
||||
- [Historique des changements](CHANGELOG.md)
|
||||
- [Architecture du pipeline](../docs/architecture/PIPELINE_ARCHITECTURE.md)
|
||||
## Qualification Metaplex `0.4.8-pre.013`
|
||||
|
||||
La matrice Devnet courante est désormais fermée avec **15 opérations `confirmed`**, **5 opérations `unavailable` (`Use`, `Resize`, `Migrate`, `Collect`, `CloseAccounts`)** et **0 opération `not_run`**. Les parcours qualifiés incluent `Create/Mint`, collection `Verify/Unverify`, `Print/Burn`, le lifecycle pNFT, les trois opérations Token Owned Escrow et `Update`.
|
||||
|
||||
La campagne finale `maintenance_campaign` confirme `Update` sur un NFT frais avec `primary_sale_happened false -> true`. Elle conserve `Resize`, `Migrate`, `Collect` et `CloseAccounts` comme probes simulation-only : `Resize` expose `AccountAlreadyResized(201)` sur les comptes au layout courant, `Migrate` est retiré avec `Removed(75)`, et `Collect`/`CloseAccounts` exigent des autorités Metaplex fixes que le scénario contrôlé n’essaie jamais d’imiter.
|
||||
15
ks-pipeline-demo-scenarios/TODO.md
Normal file
15
ks-pipeline-demo-scenarios/TODO.md
Normal file
@@ -0,0 +1,15 @@
|
||||
<!-- file: ks-pipeline-demo-scenarios/TODO.md -->
|
||||
<!-- version: 43 -->
|
||||
|
||||
# TODO — ks-pipeline-demo-scenarios
|
||||
|
||||
## Évolutions générales
|
||||
|
||||
- [ ] `0.5.4` — centraliser les dispatchs, compositions decoder/materializer et critères de complétion encore réutilisables depuis le desktop.
|
||||
- [ ] `0.5.4` — produire la matrice active decoder/materializer/executor/scénario/preuve sans transformer les surfaces réservées en dettes implicites.
|
||||
- [ ] Ajouter au CLI uniquement les préparations de fixtures explicitement justifiées.
|
||||
- [ ] Améliorer la réutilisation, le nettoyage et la validation des fixtures publiques.
|
||||
|
||||
## Report conditionnel — registre ElGamal
|
||||
|
||||
- [ ] Créer un scénario exécutable uniquement après confirmation du déploiement et disponibilité des preuves requises.
|
||||
545
ks-pipeline-demo-scenarios/USAGE.md
Normal file
545
ks-pipeline-demo-scenarios/USAGE.md
Normal file
@@ -0,0 +1,545 @@
|
||||
<!-- file: ks-pipeline-demo-scenarios/USAGE.md -->
|
||||
<!-- version: 27 -->
|
||||
|
||||
# Utilisation de ks-pipeline-demo-scenarios
|
||||
|
||||
## Objectif
|
||||
|
||||
La bibliothèque expose des scénarios Devnet contrôlés. Le binaire permet leur exécution hors de l’application desktop lorsque la commande correspondante est disponible.
|
||||
|
||||
## Initialiser l’environnement
|
||||
|
||||
```rust
|
||||
fn initialize_environment() -> ks_core::Result<()> {
|
||||
ks_pipeline_demo_scenarios::initialize_demo_scenario_environment()
|
||||
}
|
||||
```
|
||||
|
||||
`resolve_demo_devnet_profile` sélectionne un profil Devnet configuré et `prepare_demo_devnet_profile_store` vérifie ou initialise son stockage PostgreSQL.
|
||||
|
||||
## Observer une exécution
|
||||
|
||||
`NoopSolanaExecutionObserver` convient aux appels sans progression personnalisée.
|
||||
|
||||
```rust
|
||||
fn observer() -> ks_pipeline_demo_scenarios::NoopSolanaExecutionObserver {
|
||||
ks_pipeline_demo_scenarios::NoopSolanaExecutionObserver
|
||||
}
|
||||
```
|
||||
|
||||
Une application peut implémenter `SolanaExecutionObserver` pour recevoir les événements et demander une annulation coopérative.
|
||||
|
||||
## Préparer une simulation System Program
|
||||
|
||||
```rust
|
||||
fn system_transfer_request(
|
||||
recipient: ks_lib::MdPubkey,
|
||||
) -> ks_pipeline_demo_scenarios::DevnetSystemTransferRequest {
|
||||
ks_pipeline_demo_scenarios::DevnetSystemTransferRequest::new(
|
||||
"system-transfer-001",
|
||||
recipient,
|
||||
1_000,
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
Le constructeur crée une requête conservative : `submit` et `operator_confirmed` restent à `false`.
|
||||
|
||||
## Préparer et exécuter un Memo v4
|
||||
|
||||
```rust
|
||||
fn memo_request() -> ks_pipeline_demo_scenarios::DevnetMemoExecutionRequest {
|
||||
ks_pipeline_demo_scenarios::DevnetMemoExecutionRequest::new(
|
||||
"memo-001",
|
||||
"khadhroony-bot3 Devnet validation",
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
```rust
|
||||
async fn run_memo<S, O>(
|
||||
store: &S,
|
||||
observer: &O,
|
||||
request: ks_pipeline_demo_scenarios::DevnetMemoExecutionRequest,
|
||||
) -> ks_core::Result<ks_pipeline_demo_scenarios::DevnetMemoExecutionSummary>
|
||||
where
|
||||
S: ks_store::Store + Sync,
|
||||
O: ks_pipeline_demo_scenarios::SolanaExecutionObserver,
|
||||
{
|
||||
let result = ks_pipeline_demo_scenarios::execute_devnet_memo(
|
||||
store,
|
||||
observer,
|
||||
request,
|
||||
)
|
||||
.await;
|
||||
|
||||
match result {
|
||||
Ok(summary) => Ok(summary),
|
||||
Err(error) => Err(error),
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Simuler Token-2022
|
||||
|
||||
```rust
|
||||
async fn simulate_token_2022<O>(
|
||||
request: ks_pipeline_demo_scenarios::DevnetSplToken2022ExecutionRequest,
|
||||
observer: &O,
|
||||
) -> ks_core::Result<ks_pipeline_demo_scenarios::DevnetSplToken2022ExecutionSummary>
|
||||
where
|
||||
O: ks_pipeline_demo_scenarios::SolanaExecutionObserver,
|
||||
{
|
||||
let result =
|
||||
ks_pipeline_demo_scenarios::simulate_devnet_spl_token_2022(request, observer).await;
|
||||
|
||||
match result {
|
||||
Ok(summary) => Ok(summary),
|
||||
Err(error) => Err(error),
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Exécuter la campagne Devnet Token-2022 Token Metadata
|
||||
|
||||
La campagne `0.4.8-pre.012` prépare un mint Token-2022 frais, initialise son `MetadataPointer` vers le mint lui-même, puis exécute les cinq opérations Token Metadata dans l’ordre. Elle soumet des transactions réelles et exige donc une confirmation opérateur explicite.
|
||||
|
||||
Le test opt-in est le chemin opérateur recommandé :
|
||||
|
||||
```bash
|
||||
KB_DEVNET_TOKEN_2022_METADATA_CAMPAIGN_TEST=1 \
|
||||
KB_DEVNET_TOKEN_2022_METADATA_OPERATOR_CONFIRMED=1 \
|
||||
KB_POSTGRES_TEST_URL='postgresql://…' \
|
||||
cargo test -p ks-pipeline-demo-scenarios optional_devnet_token_2022_metadata_campaign_from_env -- --nocapture
|
||||
```
|
||||
|
||||
`KB_DEVNET_PROFILE` peut sélectionner explicitement le profil Devnet et `KB_DEVNET_CONFIG_PATH` peut remplacer `config/example.config.json`. Le profil doit utiliser un wallet persistant, autoriser les soumissions Devnet et respecter les plafonds de dépense et de frais.
|
||||
|
||||
La sortie `TOKEN_2022_METADATA_FIXTURE` conserve le mint et la signature de préparation. Chaque ligne `TOKEN_2022_METADATA_STEP` conserve l’opération, la signature, le slot, la postcondition, le nombre de matérialisations et, pour `Emit`, la taille du `returnData`. La campagne de référence de `pre.012` a été exécutée avec succès et ses preuves sont conservées dans `SPL_TOKEN_2022_METADATA_DEVNET_VALIDATION_MATRIX.json` et dans le rapport `docs/validation/V0_4_8_METADATA_VALIDATION_REPORT.md` ; le rapport détaillé `pre.012` est archivé sous `olddocs/archivekbot3/docs/validation/`. Une nouvelle exécution produit de nouvelles transactions et ne remplace ces preuves qu’après validation explicite.
|
||||
|
||||
## Charger la matrice de validation Token-2022
|
||||
|
||||
```rust
|
||||
fn load_validation_matrix(
|
||||
) -> ks_core::Result<ks_pipeline_demo_scenarios::Token2022ValidationMatrix> {
|
||||
let result = ks_pipeline_demo_scenarios::load_token_2022_validation_matrix();
|
||||
|
||||
match result {
|
||||
Ok(matrix) => Ok(matrix),
|
||||
Err(error) => Err(error),
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
La matrice provient de `test-fixtures/contract-matrices/SPL_TOKEN_2022_VALIDATION_MATRIX.json` et est validée avant d’être retournée.
|
||||
|
||||
## Parcourir les scénarios publics
|
||||
|
||||
```rust
|
||||
fn scenario_ids() -> Vec<String> {
|
||||
ks_pipeline_demo_scenarios::devnet_spl_validation_scenarios()
|
||||
.iter()
|
||||
.map(|scenario| scenario.id.clone())
|
||||
.collect()
|
||||
}
|
||||
```
|
||||
|
||||
Cette liste permet au CLI ou au desktop de présenter l’inventaire sans dupliquer les identifiants.
|
||||
|
||||
## Préparer une fixture Token-2022
|
||||
|
||||
```rust
|
||||
async fn prepare_fixture(
|
||||
wallet_path: std::path::PathBuf,
|
||||
wallet_dir: std::path::PathBuf,
|
||||
) -> ks_core::Result<ks_pipeline_demo_scenarios::Token2022FixturePreparationSummary> {
|
||||
let options = ks_pipeline_demo_scenarios::Token2022FixturePreparationOptions {
|
||||
rpc_url: "https://api.devnet.solana.com".to_string(),
|
||||
wallet_path,
|
||||
wallet_dir,
|
||||
decimals: 9,
|
||||
};
|
||||
let result = ks_pipeline_demo_scenarios::prepare_token_2022_fixture(&options).await;
|
||||
|
||||
match result {
|
||||
Ok(summary) => Ok(summary),
|
||||
Err(error) => Err(error),
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
La préparation peut créer des comptes et exécuter des commandes Solana CLI. Elle doit rester explicitement déclenchée par l’opérateur.
|
||||
|
||||
## Tests de référence
|
||||
|
||||
Les tests de cette crate sont particulièrement utiles pour comprendre l’orchestration réelle :
|
||||
|
||||
- validation de la matrice Token-2022 ;
|
||||
- préparation et réutilisation des fixtures ;
|
||||
- simulation et exécution des scénarios ;
|
||||
- hydratation canonique, extraction Core, replay et matérialisation ;
|
||||
- idempotence et confirmation des états finaux ;
|
||||
- test du binaire `ks-pipeline-demo-scenarios-cli`.
|
||||
|
||||
Les tests Devnet restent opt-in et peuvent produire des transactions réelles lorsque la soumission est explicitement activée.
|
||||
|
||||
## Erreurs et invariants
|
||||
|
||||
- aucun envoi ne doit résulter d’une simple demande de simulation ;
|
||||
- le profil doit cibler Devnet pour les scénarios Devnet ;
|
||||
- les secrets restent gérés par `ks-wallet` ;
|
||||
- les résumés ne doivent pas déclarer une validation non observée ;
|
||||
- ElGamal ne doit pas être présenté comme validé sur réseau.
|
||||
|
||||
## Limites durables
|
||||
|
||||
- cette crate est destinée aux démonstrations, validations et outils opérateur, pas au moteur de production autonome ;
|
||||
- les scénarios réseau exigent un endpoint, un wallet et des fonds compatibles ;
|
||||
- la bibliothèque ne fournit pas d’interface graphique.
|
||||
|
||||
## Charger les scénarios synthétiques Metaplex
|
||||
|
||||
```rust
|
||||
fn metaplex_scenario_ids() -> Vec<String> {
|
||||
ks_pipeline_demo_scenarios::metaplex_token_metadata_synthetic_scenarios()
|
||||
.iter()
|
||||
.map(|scenario| scenario.id.clone())
|
||||
.collect()
|
||||
}
|
||||
```
|
||||
|
||||
L’inventaire couvre NFT, SFT, token fongible, collection et pNFT. Ces scénarios sont déterministes et ne soumettent aucune transaction.
|
||||
|
||||
## Charger la matrice de validation Metaplex
|
||||
|
||||
```rust
|
||||
fn load_metaplex_matrix(
|
||||
) -> ks_core::Result<ks_pipeline_demo_scenarios::MetaplexTokenMetadataValidationMatrix> {
|
||||
let result = ks_pipeline_demo_scenarios::load_metaplex_token_metadata_validation_matrix();
|
||||
|
||||
match result {
|
||||
Ok(matrix) => Ok(matrix),
|
||||
Err(error) => Err(error),
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Une validation réseau ne peut passer à `confirmed` que lorsque toutes les preuves déclarées sont présentes. Les statuts `not_run` et `unavailable` n’acceptent aucune preuve observée.
|
||||
|
||||
## Inventaire Metaplex Devnet
|
||||
|
||||
```rust
|
||||
let scenarios = ks_pipeline_demo_scenarios::metaplex_token_metadata_devnet_scenarios();
|
||||
assert!(scenarios.iter().all(|scenario| {
|
||||
scenario.mode == ks_pipeline_demo_scenarios::MetaplexTokenMetadataScenarioMode::NetworkSimulation
|
||||
}));
|
||||
```
|
||||
|
||||
Cet inventaire prépare les tests et campagnes Devnet simulation-first. Il ne constitue pas à lui seul une validation réseau réussie.
|
||||
|
||||
## Charger le corpus de validation croisée Metaplex
|
||||
|
||||
```rust
|
||||
fn load_metaplex_cross_validation() -> ks_core::Result<usize> {
|
||||
let matrix = ks_pipeline_demo_scenarios::load_metaplex_token_metadata_cross_validation_matrix();
|
||||
return match matrix {
|
||||
Ok(value) => Ok(value.cases.len()),
|
||||
Err(error) => Err(error),
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
Les cas marqués `requiresNetworkEvidence` ne peuvent pas être déclarés validés à partir de preuves synthétiques. Une simulation, une soumission ou une confirmation exige les preuves RPC déclarées par le cas.
|
||||
|
||||
## Simuler une opération Metaplex réelle sur Devnet
|
||||
|
||||
L’API réseau prend un intent Metaplex typé déjà lié aux comptes et autorités de la fixture. Le wallet persistant du profil doit être l’unique signer requis.
|
||||
|
||||
```rust
|
||||
async fn simulate_metaplex_operation<O>(
|
||||
pool: &ks_onchain_transport::HttpEndpointPool,
|
||||
profile: &ks_config::ProfileConfig,
|
||||
workspace_root: &std::path::Path,
|
||||
operation: ks_lib::ExMetaplexTokenMetadataOperation,
|
||||
observer: &O,
|
||||
) -> ks_core::Result<ks_pipeline_demo_scenarios::DevnetMetaplexTokenMetadataExecutionSummary>
|
||||
where
|
||||
O: ks_pipeline_demo_scenarios::SolanaExecutionObserver,
|
||||
{
|
||||
let request = ks_pipeline_demo_scenarios::DevnetMetaplexTokenMetadataExecutionRequest::new(
|
||||
"metaplex-devnet-simulation",
|
||||
operation,
|
||||
);
|
||||
return ks_pipeline_demo_scenarios::simulate_devnet_metaplex_token_metadata(
|
||||
pool,
|
||||
profile,
|
||||
workspace_root,
|
||||
&request,
|
||||
observer,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
```
|
||||
|
||||
Pour une soumission, l’appelant doit définir `submit = true` et `operator_confirmed = true`. Les opérations dépréciées sont refusées par cette API automatique. Les lectures `preflight_reads` et `postcondition_reads` doivent décrire les comptes Metadata, Edition, Token Record ou Collection nécessaires au scénario.
|
||||
|
||||
## Charger la matrice des opérations courantes
|
||||
|
||||
```rust
|
||||
fn load_metaplex_devnet_contract(
|
||||
) -> ks_core::Result<ks_pipeline_demo_scenarios::MetaplexTokenMetadataDevnetExecutionMatrix> {
|
||||
return ks_pipeline_demo_scenarios::load_metaplex_token_metadata_devnet_execution_matrix();
|
||||
}
|
||||
```
|
||||
|
||||
La matrice ne transforme jamais une implémentation disponible en validation réseau. Les valeurs restent `not_run` jusqu’à l’observation d’une simulation ou d’une confirmation réelle.
|
||||
|
||||
## Exécuter une opération Metaplex courante depuis un JSON typé
|
||||
|
||||
Le runner Devnet commun accepte toute variante non dépréciée de `ExMetaplexTokenMetadataOperation`. Le test opt-in peut être lancé avec :
|
||||
|
||||
```bash
|
||||
KB_DEVNET_METAPLEX_EXECUTION_TEST=1 \
|
||||
KB_DEVNET_METAPLEX_OPERATION_JSON='{"operation":"..."}' \
|
||||
cargo test -p ks-pipeline-demo-scenarios optional_devnet_current_operation_from_env -- --nocapture
|
||||
```
|
||||
|
||||
Variables optionnelles :
|
||||
|
||||
- `KB_DEVNET_WALLET_DIR` pour sélectionner le répertoire du wallet persistant ;
|
||||
- `KB_POSTGRES_TEST_URL` est obligatoire pour le test opt-in afin de permettre l’hydratation canonique, l’extraction Core, le replay et la vérification d’idempotence après une soumission ;
|
||||
- `KB_DEVNET_METAPLEX_PREFLIGHT_READS_JSON` pour fournir les lectures stateful avant simulation ;
|
||||
- `KB_DEVNET_METAPLEX_POSTCONDITION_READS_JSON` pour fournir les lectures après confirmation ;
|
||||
- `KB_DEVNET_METAPLEX_MATERIALIZE_AFTER_CONFIRMATION=1` pour rendre obligatoire la preuve de matérialisation et produire les snapshots stateful demandés ;
|
||||
- `KB_DEVNET_METAPLEX_SUBMIT=1` pour autoriser la soumission ;
|
||||
- `KB_DEVNET_METAPLEX_OPERATOR_CONFIRMED=1` pour confirmer explicitement la soumission.
|
||||
|
||||
La soumission reste impossible si `KB_DEVNET_METAPLEX_SUBMIT` et `KB_DEVNET_METAPLEX_OPERATOR_CONFIRMED` ne sont pas activées ensemble. Après confirmation, le runner hydrate la transaction canonique, exécute l’extraction Core, rejoue le décodeur Metaplex avec ses matérialiseurs puis effectue une seconde passe qui doit être idempotente. Les opérations dépréciées sont rejetées avant tout appel RPC.
|
||||
|
||||
## Préparer une campagne Metaplex courante
|
||||
|
||||
Les exemples contenant `...` ne sont jamais exécutables. Pour connaître les opérations acceptées :
|
||||
|
||||
```rust
|
||||
let operations =
|
||||
ks_pipeline_demo_scenarios::metaplex_token_metadata_current_operation_names();
|
||||
```
|
||||
|
||||
Les opérations disposant d’un modèle générique peuvent être inspectées ainsi :
|
||||
|
||||
```rust
|
||||
fn verify_template() -> ks_core::Result<String> {
|
||||
ks_pipeline_demo_scenarios::metaplex_token_metadata_current_operation_json_template(
|
||||
"verify",
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
Les valeurs entre chevrons doivent être remplacées par les comptes Devnet réels. Le modèle peut ensuite être passé à `DevnetMetaplexTokenMetadataExecutionRequest::from_operation_json`.
|
||||
|
||||
Pour les parcours créateur, il est préférable de ne pas écrire de JSON manuellement :
|
||||
|
||||
```rust
|
||||
fn creator_verify_request(
|
||||
authority: ks_lib::MdPubkey,
|
||||
metadata: ks_lib::MdPubkey,
|
||||
) -> ks_pipeline_demo_scenarios::DevnetMetaplexTokenMetadataExecutionRequest {
|
||||
ks_pipeline_demo_scenarios::devnet_metaplex_creator_verify_request(
|
||||
"metaplex-verify-creator",
|
||||
authority,
|
||||
metadata,
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
`devnet_metaplex_creator_unverify_request` fournit le parcours inverse. Les deux requêtes restent en simulation-only par défaut.
|
||||
|
||||
Le test générique accepte toujours `KB_DEVNET_METAPLEX_OPERATION_JSON`, mais rejette explicitement le placeholder documentaire `...`. Les opérations plus complexes recevront des préparateurs de fixtures dédiés au lieu d’exiger de longs JSON écrits à la main.
|
||||
|
||||
## Préparer `Create` puis `Mint` avec un ATA canonique
|
||||
|
||||
`prepare_metaplex_create_fixture` prépare maintenant une base réseau commune aux cinq familles : un mint SPL classique frais et l’ATA canonique de l’opérateur sont créés dans une même transaction native, puis relus et validés. La supply du mint et le solde de l’ATA doivent rester à zéro à cette étape afin que l’opération Metaplex `Mint` constitue la preuve de création de supply.
|
||||
|
||||
Le résumé expose notamment :
|
||||
|
||||
- `token_account`, l’ATA classique canonique ;
|
||||
- `token_record`, renseigné uniquement pour le pNFT ;
|
||||
- `mint_amount_raw`, égal à `1` pour NFT/collection/pNFT, `10` pour SFT et `1_000_000_000` pour le fungible à 9 décimales ;
|
||||
- `operation_json`, l’intent `Create` typé ;
|
||||
- `mint_operation_json`, l’intent `Mint` typé à exécuter seulement après confirmation de `Create`.
|
||||
|
||||
Le token record pNFT est uniquement dérivé pendant la préparation. Sa création reste la responsabilité de `Mint`, de sorte que la fixture ne fabrique pas à l’avance l’état que la campagne doit démontrer.
|
||||
|
||||
## Exécuter la campagne Devnet `Create -> Mint`
|
||||
|
||||
La campagne est volontairement exécutée **une famille à la fois**. Elle prépare une fixture fraîche, soumet `Create`, vérifie que la supply et l’ATA restent à zéro, puis soumet `Mint` et exige la transition exacte vers `mint_amount_raw`. Pour NFT, collection et pNFT, la validation SPL exige après `Create` que les mint/freeze authorities aient été transférées au PDA Master Edition ; pour SFT et fungible, elles restent sur l’opérateur. Après `Mint`, NFT/SFT/fungible/collection exigent un ATA `Initialized` (`state=1`), alors que pNFT exige un ATA `Frozen` (`state=2`) et un Token Record Metaplex présent. Toutes les lectures Metaplex postcondition sont automatiquement bornées au slot de confirmation.
|
||||
|
||||
Le test opt-in utilise `nft` par défaut. Les valeurs acceptées par `KB_DEVNET_METAPLEX_CREATE_MINT_FAMILY` sont `nft`, `sft`, `fungible`, `collection`, `programmable_nft` et l’alias `pnft`.
|
||||
|
||||
```bash
|
||||
KB_DEVNET_METAPLEX_CREATE_MINT_CAMPAIGN_TEST=1 \
|
||||
KB_DEVNET_METAPLEX_OPERATOR_CONFIRMED=1 \
|
||||
KB_DEVNET_METAPLEX_CREATE_MINT_FAMILY=nft \
|
||||
KB_POSTGRES_TEST_URL='postgresql://…' \
|
||||
cargo test -p ks-pipeline-demo-scenarios \
|
||||
optional_devnet_metaplex_create_mint_campaign_from_env \
|
||||
-- --nocapture
|
||||
```
|
||||
|
||||
La sortie `METAPLEX_CREATE_MINT_FIXTURE` conserve les comptes de la fixture. Deux lignes `METAPLEX_CREATE_MINT_STEP` exposent ensuite les signatures/slots de `Create` et `Mint`, les matérialisations, la transition supply/amount et les états bruts de l’ATA avant/après `Mint`. Ces valeurs doivent être conservées avant de promouvoir une ligne de la matrice réseau.
|
||||
|
||||
## Exécuter la campagne collection `Verify -> Unverify`
|
||||
|
||||
`execute_devnet_metaplex_collection_verify_campaign` réutilise les fixtures `Create -> Mint` qualifiées pour construire un Collection NFT parent et un NFT membre lié au parent avec `verified=false`. Le runner exécute ensuite les wrappers courants `Verify(CollectionV1)` puis `Unverify(CollectionV1)`.
|
||||
|
||||
La preuve stateful exige simultanément :
|
||||
|
||||
- `collection.verified` du membre : `false -> true -> false` ;
|
||||
- `CollectionDetails::V1.size` du parent : `0 -> 1 -> 0` ;
|
||||
- Metadata PDA membre et parent présentes ;
|
||||
- Master Edition parent présente ;
|
||||
- hydratation canonique, Core extraction, replay, matérialisation et idempotence propres pour `Verify` et `Unverify`.
|
||||
|
||||
Le parcours ne soumet pas `SetCollectionSize` : cette opération est obsolète et ne doit pas fabriquer artificiellement la postcondition de taille.
|
||||
|
||||
```bash
|
||||
KB_DEVNET_METAPLEX_COLLECTION_VERIFY_CAMPAIGN_TEST=1 \
|
||||
KB_DEVNET_METAPLEX_OPERATOR_CONFIRMED=1 \
|
||||
KB_POSTGRES_TEST_URL='postgres://solana:solana@localhost:5432/solana_test' \
|
||||
cargo test -p ks-pipeline-demo-scenarios \
|
||||
optional_devnet_metaplex_collection_verify_campaign_from_env \
|
||||
-- --nocapture
|
||||
```
|
||||
|
||||
Une exécution réussie imprime `METAPLEX_COLLECTION_VERIFY_FIXTURE`, deux lignes `METAPLEX_COLLECTION_VERIFY_STEP`, l’état fermé `METAPLEX_COLLECTION_VERIFY_STATE` et une ligne machine-readable `METAPLEX_COLLECTION_VERIFY_EVIDENCE`. La campagne qualifiante a été conservée sous `pre.013` ; `Verify` et `Unverify` sont désormais `confirmed` sur la famille `collection` dans la matrice Devnet.
|
||||
|
||||
## Exécuter la campagne pNFT `Delegate -> Lock -> Unlock -> Revoke -> Delegate -> Transfer`
|
||||
|
||||
`execute_devnet_metaplex_pnft_lifecycle_campaign` crée d’abord une fixture `programmable_nft` fraîche via la campagne `Create -> Mint` qualifiée. Il crée ensuite un wallet temporaire de delegate et un propriétaire destination frais, puis enchaîne deux rôles de Token Delegate distincts :
|
||||
|
||||
1. `Delegate(StakingV1)` par le propriétaire ;
|
||||
2. `Lock` puis `Unlock` par le staking delegate ;
|
||||
3. `Revoke(StakingV1)` par le propriétaire ;
|
||||
4. `Delegate(TransferV1)` par le propriétaire ;
|
||||
5. `Transfer` par le transfer delegate vers une ATA destination fraîche.
|
||||
|
||||
Le runner exige que le Token Record source passe `Unlocked/Staking -> Locked/Staking -> Unlocked/Staking -> delegate cleared -> Unlocked/Transfer`. Après le transfert, l’ATA source doit contenir `0` et rester `Initialized`, tandis que l’ATA destination doit contenir `1` et rester `Frozen`; le Token Record destination doit être `Unlocked` sans delegate. Ce contrat suit le handler courant, qui thaw la source avant transfert puis freeze uniquement la destination. Les wallets temporaires ne remplacent jamais le wallet persistant du profil comme fee payer ; seul le delegate explicitement déclaré est ajouté aux signataires des étapes qui l’exigent.
|
||||
|
||||
```bash
|
||||
KB_DEVNET_METAPLEX_PNFT_LIFECYCLE_CAMPAIGN_TEST=1 \
|
||||
KB_DEVNET_METAPLEX_OPERATOR_CONFIRMED=1 \
|
||||
KB_POSTGRES_TEST_URL='postgres://solana:solana@localhost:5432/solana_test' \
|
||||
cargo test -p ks-pipeline-demo-scenarios \
|
||||
optional_devnet_metaplex_pnft_lifecycle_campaign_from_env \
|
||||
-- --nocapture
|
||||
```
|
||||
|
||||
Une exécution qualifiante doit terminer `ok` et produire `METAPLEX_PNFT_LIFECYCLE_FIXTURE`, six lignes `METAPLEX_PNFT_LIFECYCLE_STEP`, `METAPLEX_PNFT_LIFECYCLE_STATE` et `METAPLEX_PNFT_LIFECYCLE_EVIDENCE`. Le bundle qualifiant a été observé sous `pre.013` ; `Delegate`, `Revoke`, `Lock`, `Unlock` et `Transfer` sont désormais `confirmed` sur `programmable_nft`.
|
||||
|
||||
## Campagne Devnet Metaplex Token Owned Escrow
|
||||
|
||||
La campagne escrow prépare un NFT classique parent et un token fungible attribut, puis exécute un cycle `TokenOwner` strictement contrôlé. Elle crée le PDA escrow, crée son ATA SPL classique pour le mint attribut, y dépose exactement une unité brute, transfère cette unité vers l’ATA de l’opérateur puis ferme le compte escrow.
|
||||
|
||||
```bash
|
||||
KB_DEVNET_METAPLEX_ESCROW_CAMPAIGN_TEST=1 \
|
||||
KB_DEVNET_METAPLEX_OPERATOR_CONFIRMED=1 \
|
||||
KB_POSTGRES_TEST_URL='postgres://solana:solana@localhost:5432/solana_test' \
|
||||
cargo test -p ks-pipeline-demo-scenarios \
|
||||
optional_devnet_metaplex_escrow_campaign_from_env \
|
||||
-- --nocapture
|
||||
```
|
||||
|
||||
Une exécution qualifiante doit produire `METAPLEX_ESCROW_FIXTURE`, deux lignes `METAPLEX_ESCROW_SETUP`, trois lignes `METAPLEX_ESCROW_STEP`, `METAPLEX_ESCROW_STATE` et `METAPLEX_ESCROW_EVIDENCE`. Le bundle complet a été observé sous `pre.013` ; `CreateEscrowAccount`, `TransferOutOfEscrow` et `CloseEscrowAccount` sont désormais `confirmed` sur la famille `nft`.
|
||||
|
||||
## Campagne Devnet Metaplex maintenance
|
||||
|
||||
La campagne `maintenance_campaign` ferme les opérations courantes qui ne relèvent pas des parcours précédents. Elle crée un NFT classique frais, soumet réellement `Update` avec la transition bornée `primary_sale_happened: false -> true`, puis traite les surfaces non qualifiables positivement avec des probes **simulation-only**. Une simulation négative n'autorise jamais une soumission.
|
||||
|
||||
```bash
|
||||
KB_DEVNET_METAPLEX_MAINTENANCE_CAMPAIGN_TEST=1 \
|
||||
KB_DEVNET_METAPLEX_OPERATOR_CONFIRMED=1 \
|
||||
KB_POSTGRES_TEST_URL='postgres://solana:solana@localhost:5432/solana_test' \
|
||||
cargo test -p ks-pipeline-demo-scenarios \
|
||||
optional_devnet_metaplex_maintenance_campaign_from_env \
|
||||
-- --nocapture
|
||||
```
|
||||
|
||||
Le bundle qualifiant de `pre.013` confirme `Update` et conserve les refus runtime exacts suivants :
|
||||
|
||||
- `Resize` : `Custom(201)`, compte déjà au layout cible ;
|
||||
- `Migrate` : `Custom(75)`, instruction retirée ;
|
||||
- `Collect` : `Custom(7)`, autorité de collecte Metaplex réservée ;
|
||||
- `CloseAccounts` : `Custom(188)`, autorité ownerless-close Metaplex réservée.
|
||||
|
||||
Le bilan courant de la matrice Devnet Metaplex est **15 `confirmed`, 5 `unavailable`, 0 `not_run`**. Les campagnes restent réexécutables pour diagnostiquer une régression ou un changement de runtime, mais ne constituent plus des tâches ouvertes de `pre.013`.
|
||||
|
||||
### Preuve machine-readable `Create -> Mint`
|
||||
|
||||
À partir de `pre.013-delta-fix-012`, une campagne réussie imprime en plus une ligne unique `METAPLEX_CREATE_MINT_EVIDENCE`. Elle contient les deux objets `create` et `mint` avec le cluster, le genesis hash, le slot RPC de simulation, le nombre de logs, le message hash exact, le fee estimé, la signature, le statut/slot de confirmation, les nombres de snapshots et matérialisations ainsi que les drapeaux canonical/Core/replay/idempotence. Cette ligne est la forme à conserver pour les futures promotions de matrice.
|
||||
|
||||
Les cinq familles NFT, SFT, fungible, collection et pNFT sont confirmées. `Create` et `Mint` disposent chacune de cinq bundles `familyEvidence`; le pNFT prouve en plus la création du Token Record et l’état ATA `Initialized(1) -> Frozen(2)`.
|
||||
|
||||
## Probe Devnet Metaplex `Use`
|
||||
|
||||
La surface courante `Use` est classée `unavailable` sur Devnet : la simulation exacte du discriminant 51 retourne `InvalidInstructionData` et aucune transaction n'est soumise. Le test opt-in reste utile comme contrôle de non-régression du runtime :
|
||||
|
||||
```bash
|
||||
KB_DEVNET_METAPLEX_USE_PROBE_TEST=1 \
|
||||
KB_DEVNET_METAPLEX_OPERATOR_CONFIRMED=1 \
|
||||
KB_POSTGRES_TEST_URL='postgres://solana:solana@localhost:5432/solana_test' \
|
||||
cargo test -p ks-pipeline-demo-scenarios \
|
||||
optional_devnet_metaplex_use_probe_from_env \
|
||||
-- --nocapture
|
||||
```
|
||||
|
||||
Une simulation négative est retournée comme résultat uniquement lorsque `submit=false`. Elle ne peut jamais autoriser une signature ou une soumission.
|
||||
|
||||
## Solana Program Metadata
|
||||
|
||||
L’inventaire fermé est exposé par :
|
||||
|
||||
```rust
|
||||
fn program_metadata_scenarios(
|
||||
) -> Vec<ks_pipeline_demo_scenarios::SolanaProgramMetadataScenario> {
|
||||
ks_pipeline_demo_scenarios::solana_program_metadata_devnet_scenarios()
|
||||
}
|
||||
```
|
||||
|
||||
Les deux parcours sont ordonnés ainsi :
|
||||
|
||||
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.
|
||||
|
||||
La préparation crée deux PDA non canoniques, les préfinance avec deux transferts System Program confirmés, puis produit neuf opérations typées avec leurs lectures, preuves de rent et postconditions. Elle exige un profil Devnet persistant, `devnet_send_enabled` et une confirmation opérateur explicite.
|
||||
|
||||
```rust
|
||||
async fn run_program_metadata_campaign<O>(
|
||||
pool: &ks_onchain_transport::HttpEndpointPool,
|
||||
profile: &ks_config::ProfileConfig,
|
||||
workspace_root: &std::path::Path,
|
||||
observer: &O,
|
||||
) -> ks_core::Result<ks_pipeline_demo_scenarios::DevnetSolanaProgramMetadataCampaignSummary>
|
||||
where
|
||||
O: ks_pipeline_demo_scenarios::SolanaExecutionObserver,
|
||||
{
|
||||
let options = ks_pipeline_demo_scenarios::SolanaProgramMetadataFixturePreparationOptions {
|
||||
query_role: "http_queries".to_string(),
|
||||
transaction_role: "http_transactions".to_string(),
|
||||
operator_confirmed: true,
|
||||
};
|
||||
ks_pipeline_demo_scenarios::execute_devnet_solana_program_metadata_campaign(
|
||||
pool,
|
||||
profile,
|
||||
workspace_root,
|
||||
&options,
|
||||
observer,
|
||||
)
|
||||
.await
|
||||
}
|
||||
```
|
||||
|
||||
Cet appel crée des comptes et soumet onze transactions réelles au minimum : deux transferts de préfinancement et neuf opérations `ProgM6…`. Il ne doit jamais être déclenché implicitement par une ouverture de fenêtre ou une simple demande de simulation.
|
||||
|
||||
La matrice `SOLANA_PROGRAM_METADATA_DEVNET_VALIDATION_MATRIX.json` est fermée à neuf opérations `confirmed` depuis `0.4.8-pre.010`. Les deux parcours conservent leurs signatures, simulations, postconditions et preuves matérialisées ; `Close` utilise une preuve `account_absence`, car un compte absent ne produit pas de snapshot matérialisé. Une réexécution n’est nécessaire qu’en cas de régression ou de changement du runtime Devnet.
|
||||
@@ -0,0 +1,152 @@
|
||||
// file: ks-pipeline-demo-scenarios/src/bin/ks_pipeline_demo_scenarios_cli.rs
|
||||
// version: 2
|
||||
|
||||
#![forbid(unsafe_code)]
|
||||
#![deny(unreachable_pub)]
|
||||
#![warn(missing_docs)]
|
||||
|
||||
//! Command-line entry point for reusable Devnet fixture preparation.
|
||||
|
||||
macro_rules! ks_try {
|
||||
($expression:expr) => {
|
||||
match $expression {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() {
|
||||
let result = run().await;
|
||||
if let std::result::Result::Err(error) = result {
|
||||
eprintln!("{}: {}", error.code(), error.message());
|
||||
std::process::exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
async fn run() -> ks_core::Result<()> {
|
||||
let arguments = std::env::args().skip(1).collect::<std::vec::Vec<_>>();
|
||||
let command = match arguments.first() {
|
||||
std::option::Option::Some(value) => value.as_str(),
|
||||
std::option::Option::None => return usage_error("missing command"),
|
||||
};
|
||||
if command != "prepare-token-2022-fixture" {
|
||||
return usage_error(format!("unknown command {command}").as_str());
|
||||
}
|
||||
let options = ks_try!(parse_prepare_options(arguments.as_slice()));
|
||||
let summary = ks_try!(ks_pipeline_demo_scenarios::prepare_token_2022_fixture(&options).await);
|
||||
let output = match serde_json::to_string_pretty(&summary) {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => {
|
||||
return std::result::Result::Err(ks_core::Error::json(format!(
|
||||
"unable to serialize Token-2022 fixture summary: {error}"
|
||||
)));
|
||||
},
|
||||
};
|
||||
println!("{output}");
|
||||
return std::result::Result::Ok(());
|
||||
}
|
||||
|
||||
fn parse_prepare_options(
|
||||
arguments: &[std::string::String],
|
||||
) -> ks_core::Result<ks_pipeline_demo_scenarios::Token2022FixturePreparationOptions> {
|
||||
let mut rpc_url = std::option::Option::None;
|
||||
let mut wallet_path = std::option::Option::None;
|
||||
let mut wallet_dir = std::option::Option::None;
|
||||
let mut decimals = 9_u8;
|
||||
let mut index = 1_usize;
|
||||
while index < arguments.len() {
|
||||
let name = arguments[index].as_str();
|
||||
let value = match arguments.get(index + 1) {
|
||||
std::option::Option::Some(value) => value,
|
||||
std::option::Option::None => {
|
||||
return usage_error(format!("missing value for {name}").as_str());
|
||||
},
|
||||
};
|
||||
match name {
|
||||
"--rpc-url" => rpc_url = std::option::Option::Some(value.clone()),
|
||||
"--wallet" => wallet_path = std::option::Option::Some(std::path::PathBuf::from(value)),
|
||||
"--wallet-dir" => {
|
||||
wallet_dir = std::option::Option::Some(std::path::PathBuf::from(value))
|
||||
},
|
||||
"--decimals" => {
|
||||
decimals = match value.parse::<u8>() {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => {
|
||||
return std::result::Result::Err(ks_core::Error::config(format!(
|
||||
"invalid --decimals value {value}: {error}"
|
||||
)));
|
||||
},
|
||||
}
|
||||
},
|
||||
_ => return usage_error(format!("unknown option {name}").as_str()),
|
||||
}
|
||||
index += 2;
|
||||
}
|
||||
let rpc_url = match rpc_url.or_else(|| return std::env::var("KB_DEVNET_RPC_URL").ok()) {
|
||||
std::option::Option::Some(value) => value,
|
||||
std::option::Option::None => {
|
||||
return std::result::Result::Err(ks_core::Error::config(
|
||||
"missing --rpc-url or KB_DEVNET_RPC_URL",
|
||||
));
|
||||
},
|
||||
};
|
||||
let wallet_path = match wallet_path
|
||||
.or_else(|| return std::env::var("KB_DEVNET_WALLET").ok().map(std::path::PathBuf::from))
|
||||
{
|
||||
std::option::Option::Some(value) => value,
|
||||
std::option::Option::None => {
|
||||
return std::result::Result::Err(ks_core::Error::config(
|
||||
"missing --wallet or KB_DEVNET_WALLET",
|
||||
));
|
||||
},
|
||||
};
|
||||
let wallet_dir = match wallet_dir {
|
||||
std::option::Option::Some(value) => value,
|
||||
std::option::Option::None => match wallet_path.parent() {
|
||||
std::option::Option::Some(value) => std::path::Path::to_path_buf(value),
|
||||
std::option::Option::None => std::path::PathBuf::from("."),
|
||||
},
|
||||
};
|
||||
return std::result::Result::Ok(
|
||||
ks_pipeline_demo_scenarios::Token2022FixturePreparationOptions {
|
||||
rpc_url,
|
||||
wallet_path,
|
||||
wallet_dir,
|
||||
decimals,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
fn usage_error<T>(message: &str) -> ks_core::Result<T> {
|
||||
return std::result::Result::Err(ks_core::Error::config(format!(
|
||||
"{message}. Usage: cargo run -p ks-pipeline-demo-scenarios --bin ks-pipeline-demo-scenarios-cli -- prepare-token-2022-fixture --rpc-url URL --wallet PATH [--wallet-dir PATH] [--decimals 9]"
|
||||
)));
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#[test]
|
||||
fn prepare_options_accept_explicit_values() {
|
||||
let arguments = vec![
|
||||
"prepare-token-2022-fixture".to_string(),
|
||||
"--rpc-url".to_string(),
|
||||
"https://api.devnet.solana.com".to_string(),
|
||||
"--wallet".to_string(),
|
||||
"wallet.json".to_string(),
|
||||
"--wallet-dir".to_string(),
|
||||
"wallet-dir".to_string(),
|
||||
"--decimals".to_string(),
|
||||
"9".to_string(),
|
||||
];
|
||||
let options = super::parse_prepare_options(arguments.as_slice());
|
||||
assert!(options.is_ok());
|
||||
let options = match options {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(_) => return,
|
||||
};
|
||||
assert_eq!(options.wallet_dir, std::path::PathBuf::from("wallet-dir"));
|
||||
assert_eq!(options.decimals, 9);
|
||||
}
|
||||
}
|
||||
10
ks-pipeline-demo-scenarios/src/constants.rs
Normal file
10
ks-pipeline-demo-scenarios/src/constants.rs
Normal file
@@ -0,0 +1,10 @@
|
||||
// file: ks-pipeline-demo-scenarios/src/constants.rs
|
||||
// version: 4
|
||||
|
||||
//! Demo scenario constants.
|
||||
|
||||
/// Canonical tracing target for demo pipeline scenarios.
|
||||
pub(crate) const TRACING_TARGET: &str = "ks-pipeline-demo-scenarios";
|
||||
|
||||
/// Total transactions reserved by one complete Solana Program Metadata campaign.
|
||||
pub(crate) const SOLANA_PROGRAM_METADATA_CAMPAIGN_TRANSACTION_COUNT: u64 = 11;
|
||||
184
ks-pipeline-demo-scenarios/src/environment.rs
Normal file
184
ks-pipeline-demo-scenarios/src/environment.rs
Normal file
@@ -0,0 +1,184 @@
|
||||
// file: ks-pipeline-demo-scenarios/src/environment.rs
|
||||
// version: 3
|
||||
|
||||
//! Environment initialization for opt-in demonstration scenarios.
|
||||
|
||||
/// Loads the workspace `.env` selected by `ks-config`.
|
||||
pub fn initialize_demo_scenario_environment(
|
||||
workspace_root: &std::path::Path,
|
||||
) -> ks_core::Result<ks_config::EnvironmentLoadReport> {
|
||||
return ks_config::load_workspace_environment(workspace_root);
|
||||
}
|
||||
|
||||
/// Resolves one compatible Devnet profile for demo scenarios.
|
||||
///
|
||||
/// An explicit name has priority, followed by `KB_DEVNET_PROFILE`, then the
|
||||
/// first compatible Devnet profile declared by the configuration.
|
||||
pub fn resolve_demo_devnet_profile(
|
||||
config: &ks_config::AppConfig,
|
||||
requested_profile_name: std::option::Option<&str>,
|
||||
) -> ks_core::Result<ks_config::ProfileConfig> {
|
||||
let explicit = requested_profile_name
|
||||
.map(str::trim)
|
||||
.filter(|value| return !value.is_empty())
|
||||
.map(std::string::ToString::to_string);
|
||||
let selected_name = match explicit {
|
||||
std::option::Option::Some(value) => std::option::Option::Some(value),
|
||||
std::option::Option::None => std::env::var("KB_DEVNET_PROFILE")
|
||||
.ok()
|
||||
.map(|value| return value.trim().to_string())
|
||||
.filter(|value| return !value.is_empty()),
|
||||
};
|
||||
for profile in &config.profiles {
|
||||
let compatible = profile.wallet.cluster == "devnet"
|
||||
&& profile.wallet.temporary_wallet_enabled
|
||||
&& profile.wallet.temporary_wallet_persist;
|
||||
if !compatible {
|
||||
continue;
|
||||
}
|
||||
if let std::option::Option::Some(name) = selected_name.as_ref()
|
||||
&& profile.name != *name
|
||||
{
|
||||
continue;
|
||||
}
|
||||
return std::result::Result::Ok(profile.clone());
|
||||
}
|
||||
return match selected_name {
|
||||
std::option::Option::Some(name) => std::result::Result::Err(ks_core::Error::config(
|
||||
format!("compatible Devnet profile '{name}' is unavailable"),
|
||||
)),
|
||||
std::option::Option::None => std::result::Result::Err(ks_core::Error::config(
|
||||
"no compatible Devnet profile is configured",
|
||||
)),
|
||||
};
|
||||
}
|
||||
|
||||
/// Readiness report for one Devnet profile PostgreSQL store.
|
||||
#[derive(Clone, Debug, serde::Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct DevnetProfileStoreReadiness {
|
||||
/// Selected Devnet profile.
|
||||
pub profile_name: std::string::String,
|
||||
/// Whether schema creation was allowed by configuration.
|
||||
pub auto_initialize_schema: bool,
|
||||
/// Number of known tables already present before preparation.
|
||||
pub existing_tables_before: usize,
|
||||
/// Number of known tables created during preparation.
|
||||
pub created_tables: usize,
|
||||
/// Number of known tables present after preparation.
|
||||
pub existing_tables_after: usize,
|
||||
/// Total number of known tables expected by the current store.
|
||||
pub expected_tables: usize,
|
||||
}
|
||||
|
||||
/// Verifies or initializes the PostgreSQL schema selected by one Devnet profile.
|
||||
pub async fn prepare_demo_devnet_profile_store(
|
||||
profile: &ks_config::ProfileConfig,
|
||||
) -> ks_core::Result<DevnetProfileStoreReadiness> {
|
||||
if profile.wallet.cluster != "devnet" {
|
||||
return std::result::Result::Err(ks_core::Error::config(format!(
|
||||
"profile '{}' is not configured for Devnet",
|
||||
profile.name
|
||||
)));
|
||||
}
|
||||
if !profile.database.enabled {
|
||||
return std::result::Result::Err(ks_core::Error::config(format!(
|
||||
"Devnet profile '{}' has database storage disabled",
|
||||
profile.name
|
||||
)));
|
||||
}
|
||||
if profile.database.backend != "postgres" {
|
||||
return std::result::Result::Err(ks_core::Error::config(format!(
|
||||
"Devnet profile '{}' must use the PostgreSQL backend",
|
||||
profile.name
|
||||
)));
|
||||
}
|
||||
let options = match ks_store::PostgresStoreOptions::new(
|
||||
profile.database.postgres.url.clone(),
|
||||
profile.database.postgres.max_connections,
|
||||
profile.database.postgres.connect_timeout_ms,
|
||||
false,
|
||||
) {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let store = match ks_store::PostgresStore::connect(options).await {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let before = match store.known_table_diagnostics().await {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let existing_tables_before = before.iter().filter(|table| return table.exists).count();
|
||||
if profile.database.postgres.auto_initialize_schema
|
||||
&& let std::result::Result::Err(error) = store.initialize_store_schema().await
|
||||
{
|
||||
return std::result::Result::Err(error);
|
||||
}
|
||||
let after = match store.known_table_diagnostics().await {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let existing_tables_after = after.iter().filter(|table| return table.exists).count();
|
||||
if existing_tables_after != after.len() {
|
||||
let missing = after
|
||||
.iter()
|
||||
.filter(|table| return !table.exists)
|
||||
.map(|table| return table.table_name.clone())
|
||||
.collect::<std::vec::Vec<_>>();
|
||||
return std::result::Result::Err(ks_core::Error::config(format!(
|
||||
"Devnet profile '{}' PostgreSQL schema is incomplete and auto initialization is {}: missing {}",
|
||||
profile.name,
|
||||
if profile.database.postgres.auto_initialize_schema {
|
||||
"enabled"
|
||||
} else {
|
||||
"disabled"
|
||||
},
|
||||
missing.join(", ")
|
||||
)));
|
||||
}
|
||||
return std::result::Result::Ok(DevnetProfileStoreReadiness {
|
||||
profile_name: profile.name.clone(),
|
||||
auto_initialize_schema: profile.database.postgres.auto_initialize_schema,
|
||||
existing_tables_before,
|
||||
created_tables: existing_tables_after.saturating_sub(existing_tables_before),
|
||||
existing_tables_after,
|
||||
expected_tables: after.len(),
|
||||
});
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
fn example_config() -> ks_config::AppConfig {
|
||||
return match ks_config::parse_config_json(include_str!("../../config/example.config.json"))
|
||||
{
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => panic!("example config parse failed: {error}"),
|
||||
};
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolver_finds_a_compatible_devnet_profile_without_a_fixed_name() {
|
||||
let config = example_config();
|
||||
let profile = match super::resolve_demo_devnet_profile(&config, std::option::Option::None) {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => panic!("Devnet profile resolution failed: {error}"),
|
||||
};
|
||||
assert_eq!(profile.wallet.cluster, "devnet");
|
||||
assert!(profile.wallet.temporary_wallet_enabled);
|
||||
assert!(profile.wallet.temporary_wallet_persist);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolver_rejects_an_explicit_non_devnet_profile() {
|
||||
let config = example_config();
|
||||
assert!(
|
||||
super::resolve_demo_devnet_profile(
|
||||
&config,
|
||||
std::option::Option::Some("mainnet_research"),
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
}
|
||||
378
ks-pipeline-demo-scenarios/src/lib.rs
Normal file
378
ks-pipeline-demo-scenarios/src/lib.rs
Normal file
@@ -0,0 +1,378 @@
|
||||
// file: ks-pipeline-demo-scenarios/src/lib.rs
|
||||
// version: 28
|
||||
|
||||
#![forbid(unsafe_code)]
|
||||
#![deny(unreachable_pub)]
|
||||
#![warn(missing_docs)]
|
||||
|
||||
//! Reusable demonstration and Devnet validation scenarios built on `ks-pipeline`.
|
||||
|
||||
mod constants;
|
||||
mod environment;
|
||||
mod metadata;
|
||||
mod solana;
|
||||
mod spl;
|
||||
|
||||
/// Readiness report for one Devnet profile PostgreSQL store.
|
||||
pub use self::environment::DevnetProfileStoreReadiness;
|
||||
/// Loads the workspace environment for opt-in demo and Devnet scenarios.
|
||||
pub use self::environment::initialize_demo_scenario_environment;
|
||||
/// Verifies or initializes the PostgreSQL schema selected by one Devnet profile.
|
||||
pub use self::environment::prepare_demo_devnet_profile_store;
|
||||
/// Resolves one configured Devnet profile for UI, CLI and test scenarios.
|
||||
pub use self::environment::resolve_demo_devnet_profile;
|
||||
/// Complete evidence produced by one collection parent/member verification campaign.
|
||||
pub use self::metadata::metaplex_token_metadata::collection_verify_campaign::DevnetMetaplexCollectionVerifyCampaignSummary;
|
||||
/// Collection relation and sized-collection state observed across verification.
|
||||
pub use self::metadata::metaplex_token_metadata::collection_verify_campaign::DevnetMetaplexCollectionVerifyState;
|
||||
/// Executes one fresh collection parent/member verification campaign on Devnet.
|
||||
pub use self::metadata::metaplex_token_metadata::collection_verify_campaign::execute_devnet_metaplex_collection_verify_campaign;
|
||||
/// Returns the exact current operations qualified by the collection verification campaign.
|
||||
pub use self::metadata::metaplex_token_metadata::collection_verify_campaign::metaplex_collection_verify_campaign_operation_names;
|
||||
/// Complete evidence produced by one family-specific Metaplex `Create -> Mint` campaign.
|
||||
pub use self::metadata::metaplex_token_metadata::create_mint_campaign::DevnetMetaplexCreateMintCampaignSummary;
|
||||
/// Confirmed SPL state observed around the Metaplex `Mint` operation.
|
||||
pub use self::metadata::metaplex_token_metadata::create_mint_campaign::DevnetMetaplexCreateMintTokenState;
|
||||
/// Executes one fresh family-specific Metaplex `Create -> Mint` campaign on Devnet.
|
||||
pub use self::metadata::metaplex_token_metadata::create_mint_campaign::execute_devnet_metaplex_create_mint_campaign;
|
||||
/// Returns the exact ordered operations covered by the `Create -> Mint` campaign.
|
||||
pub use self::metadata::metaplex_token_metadata::create_mint_campaign::metaplex_create_mint_campaign_operation_names;
|
||||
/// Complete request for one real Devnet Metaplex Token Metadata execution.
|
||||
pub use self::metadata::metaplex_token_metadata::devnet_execution::DevnetMetaplexTokenMetadataExecutionRequest;
|
||||
/// Complete evidence produced by one real Devnet Metaplex execution.
|
||||
pub use self::metadata::metaplex_token_metadata::devnet_execution::DevnetMetaplexTokenMetadataExecutionSummary;
|
||||
/// Creates one named creator unverification request without handwritten JSON.
|
||||
pub use self::metadata::metaplex_token_metadata::devnet_execution::devnet_metaplex_creator_unverify_request;
|
||||
/// Creates one named creator verification request without handwritten JSON.
|
||||
pub use self::metadata::metaplex_token_metadata::devnet_execution::devnet_metaplex_creator_verify_request;
|
||||
/// Executes one real Devnet Metaplex simulation or explicitly authorized submission.
|
||||
pub use self::metadata::metaplex_token_metadata::devnet_execution::execute_devnet_metaplex_token_metadata;
|
||||
/// Executes one real Devnet Metaplex submission with explicitly authorized extra signers.
|
||||
pub use self::metadata::metaplex_token_metadata::devnet_execution::execute_devnet_metaplex_token_metadata_with_signers;
|
||||
/// Returns a formatted JSON template for one named current operation.
|
||||
pub use self::metadata::metaplex_token_metadata::devnet_execution::metaplex_token_metadata_current_operation_json_template;
|
||||
/// Returns the current operation names accepted by automatic Devnet campaigns.
|
||||
pub use self::metadata::metaplex_token_metadata::devnet_execution::metaplex_token_metadata_current_operation_names;
|
||||
/// Simulates one current Metaplex operation against a real Devnet endpoint.
|
||||
pub use self::metadata::metaplex_token_metadata::devnet_execution::simulate_devnet_metaplex_token_metadata;
|
||||
/// Exact SPL state transitions exercised around the Token Owned Escrow operations.
|
||||
pub use self::metadata::metaplex_token_metadata::escrow_campaign::DevnetMetaplexEscrowCampaignState;
|
||||
/// Complete evidence retained by one Token Owned Escrow Devnet campaign.
|
||||
pub use self::metadata::metaplex_token_metadata::escrow_campaign::DevnetMetaplexEscrowCampaignSummary;
|
||||
/// Executes a fresh Token Owned Escrow lifecycle on Devnet.
|
||||
pub use self::metadata::metaplex_token_metadata::escrow_campaign::execute_devnet_metaplex_escrow_campaign;
|
||||
/// Returns the exact ordered Metaplex operations qualified by the escrow campaign.
|
||||
pub use self::metadata::metaplex_token_metadata::escrow_campaign::metaplex_escrow_campaign_operation_names;
|
||||
/// Options for one Metaplex Create fixture preparation.
|
||||
pub use self::metadata::metaplex_token_metadata::fixture::MetaplexCreateFixturePreparationOptions;
|
||||
/// Public values generated for one Metaplex Create fixture.
|
||||
pub use self::metadata::metaplex_token_metadata::fixture::MetaplexCreateFixturePreparationSummary;
|
||||
/// Reads the validated classic SPL mint supply for an internal Metaplex campaign.
|
||||
pub(crate) use self::metadata::metaplex_token_metadata::fixture::classic_fixture_mint_supply;
|
||||
/// Reads the validated classic SPL token-account state for an internal Metaplex campaign.
|
||||
pub(crate) use self::metadata::metaplex_token_metadata::fixture::classic_fixture_token_account_state;
|
||||
/// Reads the validated classic SPL token-account amount for an internal Metaplex campaign.
|
||||
pub(crate) use self::metadata::metaplex_token_metadata::fixture::classic_fixture_token_amount;
|
||||
/// Creates or reuses a classic SPL mint and derives its Metaplex PDAs.
|
||||
pub use self::metadata::metaplex_token_metadata::fixture::prepare_metaplex_create_fixture;
|
||||
/// Reads one classic SPL mint account at or after a minimum context slot.
|
||||
pub(crate) use self::metadata::metaplex_token_metadata::fixture::read_mint_account_at_or_after;
|
||||
/// Reads one classic SPL token account at or after a minimum context slot.
|
||||
pub(crate) use self::metadata::metaplex_token_metadata::fixture::read_token_account_at_or_after;
|
||||
/// Validates one classic SPL mint fixture against an exact expected supply.
|
||||
pub(crate) use self::metadata::metaplex_token_metadata::fixture::validate_classic_fixture_mint_state;
|
||||
/// Validates one classic SPL token-account fixture against an exact expected amount.
|
||||
pub(crate) use self::metadata::metaplex_token_metadata::fixture::validate_classic_fixture_token_account_state;
|
||||
/// Complete evidence produced by the final Metaplex maintenance campaign.
|
||||
pub use self::metadata::metaplex_token_metadata::maintenance_campaign::DevnetMetaplexMaintenanceCampaignSummary;
|
||||
/// Executes the final bounded current maintenance campaign on Devnet.
|
||||
pub use self::metadata::metaplex_token_metadata::maintenance_campaign::execute_devnet_metaplex_maintenance_campaign;
|
||||
/// Returns the exact ordered operations exercised by the maintenance campaign.
|
||||
pub use self::metadata::metaplex_token_metadata::maintenance_campaign::metaplex_maintenance_campaign_operation_names;
|
||||
/// Complete evidence produced by one pNFT lifecycle campaign.
|
||||
pub use self::metadata::metaplex_token_metadata::pnft_lifecycle_campaign::DevnetMetaplexPnftLifecycleCampaignSummary;
|
||||
/// Exact programmable-token state observed across the pNFT lifecycle campaign.
|
||||
pub use self::metadata::metaplex_token_metadata::pnft_lifecycle_campaign::DevnetMetaplexPnftLifecycleState;
|
||||
/// Executes one fresh pNFT delegate/lock/unlock/revoke/transfer campaign on Devnet.
|
||||
pub use self::metadata::metaplex_token_metadata::pnft_lifecycle_campaign::execute_devnet_metaplex_pnft_lifecycle_campaign;
|
||||
/// Returns the exact ordered current operations exercised by the pNFT lifecycle campaign.
|
||||
pub use self::metadata::metaplex_token_metadata::pnft_lifecycle_campaign::metaplex_pnft_lifecycle_campaign_operation_names;
|
||||
/// Complete evidence produced by one printable master NFT `Print -> Burn` campaign.
|
||||
pub use self::metadata::metaplex_token_metadata::print_burn_campaign::DevnetMetaplexPrintBurnCampaignSummary;
|
||||
/// Exact printed-edition state transition observed around `Print` and `Burn`.
|
||||
pub use self::metadata::metaplex_token_metadata::print_burn_campaign::DevnetMetaplexPrintBurnState;
|
||||
/// Executes one printable master NFT `Print -> Burn` campaign on Devnet.
|
||||
pub use self::metadata::metaplex_token_metadata::print_burn_campaign::execute_devnet_metaplex_print_burn_campaign;
|
||||
/// Returns the exact ordered current operations qualified by the print/burn campaign.
|
||||
pub use self::metadata::metaplex_token_metadata::print_burn_campaign::metaplex_print_burn_campaign_operation_names;
|
||||
/// Stable asset family covered by one Metaplex scenario.
|
||||
pub use self::metadata::metaplex_token_metadata::scenarios::MetaplexTokenMetadataAssetFamily;
|
||||
/// Stable fixture graph required by one Metaplex scenario journey.
|
||||
pub use self::metadata::metaplex_token_metadata::scenarios::MetaplexTokenMetadataFixtureKind;
|
||||
/// Stable lifecycle state consumed or produced by one Metaplex scenario journey.
|
||||
pub use self::metadata::metaplex_token_metadata::scenarios::MetaplexTokenMetadataFixtureState;
|
||||
/// One stable Metaplex scenario reusable by automated tests and demo adapters.
|
||||
pub use self::metadata::metaplex_token_metadata::scenarios::MetaplexTokenMetadataScenario;
|
||||
/// Execution mode accepted by one Metaplex scenario.
|
||||
pub use self::metadata::metaplex_token_metadata::scenarios::MetaplexTokenMetadataScenarioMode;
|
||||
/// Returns the ordered Metaplex Devnet simulation scenario inventory.
|
||||
pub use self::metadata::metaplex_token_metadata::scenarios::metaplex_token_metadata_devnet_scenarios;
|
||||
/// Returns the complete ordered synthetic Metaplex scenario inventory.
|
||||
pub use self::metadata::metaplex_token_metadata::scenarios::metaplex_token_metadata_synthetic_scenarios;
|
||||
/// Runtime availability classification produced by one bounded current `Use` probe.
|
||||
pub use self::metadata::metaplex_token_metadata::use_campaign::DevnetMetaplexUseProbeStatus;
|
||||
/// Complete evidence produced by one bounded current `Use` probe.
|
||||
pub use self::metadata::metaplex_token_metadata::use_campaign::DevnetMetaplexUseProbeSummary;
|
||||
/// Executes one bounded current `Use` availability probe on Devnet.
|
||||
pub use self::metadata::metaplex_token_metadata::use_campaign::execute_devnet_metaplex_use_probe;
|
||||
/// Returns the exact setup and target operations exercised by the `Use` probe.
|
||||
pub use self::metadata::metaplex_token_metadata::use_campaign::metaplex_use_probe_operation_names;
|
||||
/// Maximum evidence entries accepted by one Metaplex validation scenario.
|
||||
pub use self::metadata::metaplex_token_metadata::validation::MAX_METAPLEX_TOKEN_METADATA_VALIDATION_EVIDENCE;
|
||||
/// One case in the closed Metaplex cross-validation corpus.
|
||||
pub use self::metadata::metaplex_token_metadata::validation::MetaplexTokenMetadataCrossValidationCase;
|
||||
/// Closed Metaplex cross-validation corpus for `0.4.7-pre.010`.
|
||||
pub use self::metadata::metaplex_token_metadata::validation::MetaplexTokenMetadataCrossValidationMatrix;
|
||||
/// Family-specific evidence bundle for one current Metaplex Devnet operation.
|
||||
pub use self::metadata::metaplex_token_metadata::validation::MetaplexTokenMetadataDevnetExecutionFamilyEvidence;
|
||||
/// Closed inventory of every current Metaplex operation requiring Devnet coverage.
|
||||
pub use self::metadata::metaplex_token_metadata::validation::MetaplexTokenMetadataDevnetExecutionMatrix;
|
||||
/// One current Metaplex operation in the closed Devnet execution matrix.
|
||||
pub use self::metadata::metaplex_token_metadata::validation::MetaplexTokenMetadataDevnetExecutionOperation;
|
||||
/// Stable failure category exercised by one negative case.
|
||||
pub use self::metadata::metaplex_token_metadata::validation::MetaplexTokenMetadataFailureClass;
|
||||
/// Expected transaction outcome for one cross-validation case.
|
||||
pub use self::metadata::metaplex_token_metadata::validation::MetaplexTokenMetadataTransactionOutcome;
|
||||
/// Transaction location covered by one cross-validation case.
|
||||
pub use self::metadata::metaplex_token_metadata::validation::MetaplexTokenMetadataTransactionPath;
|
||||
/// One bounded Metaplex validation evidence entry.
|
||||
pub use self::metadata::metaplex_token_metadata::validation::MetaplexTokenMetadataValidationEvidence;
|
||||
/// Canonical Metaplex validation matrix.
|
||||
pub use self::metadata::metaplex_token_metadata::validation::MetaplexTokenMetadataValidationMatrix;
|
||||
/// One scenario declared by the canonical Metaplex validation matrix.
|
||||
pub use self::metadata::metaplex_token_metadata::validation::MetaplexTokenMetadataValidationMatrixScenario;
|
||||
/// Exact validation status for one Metaplex scenario.
|
||||
pub use self::metadata::metaplex_token_metadata::validation::MetaplexTokenMetadataValidationStatus;
|
||||
/// Loads and validates the closed Metaplex cross-validation corpus.
|
||||
pub use self::metadata::metaplex_token_metadata::validation::load_metaplex_token_metadata_cross_validation_matrix;
|
||||
/// Loads and validates the closed Devnet execution matrix.
|
||||
pub use self::metadata::metaplex_token_metadata::validation::load_metaplex_token_metadata_devnet_execution_matrix;
|
||||
/// Loads and validates the canonical Metaplex validation matrix.
|
||||
pub use self::metadata::metaplex_token_metadata::validation::load_metaplex_token_metadata_validation_matrix;
|
||||
/// Validates the closed Metaplex cross-validation corpus and evidence claims.
|
||||
pub use self::metadata::metaplex_token_metadata::validation::validate_metaplex_token_metadata_cross_validation_matrix;
|
||||
/// Validates exact current-operation coverage and conservative network statuses.
|
||||
pub use self::metadata::metaplex_token_metadata::validation::validate_metaplex_token_metadata_devnet_execution_matrix;
|
||||
/// Validates the canonical Metaplex validation matrix.
|
||||
pub use self::metadata::metaplex_token_metadata::validation::validate_metaplex_token_metadata_validation_matrix;
|
||||
/// One confirmed step retained by the complete Devnet campaign.
|
||||
pub use self::metadata::solana_program::campaign::DevnetSolanaProgramMetadataCampaignStepSummary;
|
||||
/// Complete evidence retained by the two-journey Devnet campaign.
|
||||
pub use self::metadata::solana_program::campaign::DevnetSolanaProgramMetadataCampaignSummary;
|
||||
/// Prepares fresh accounts and executes the nine stable operations on Devnet.
|
||||
pub use self::metadata::solana_program::campaign::execute_devnet_solana_program_metadata_campaign;
|
||||
/// Complete request for one Solana Program Metadata Devnet execution.
|
||||
pub use self::metadata::solana_program::devnet_execution::DevnetSolanaProgramMetadataExecutionRequest;
|
||||
/// Complete evidence produced by one Solana Program Metadata Devnet execution.
|
||||
pub use self::metadata::solana_program::devnet_execution::DevnetSolanaProgramMetadataExecutionSummary;
|
||||
/// Executes one Solana Program Metadata simulation or authorized submission.
|
||||
pub use self::metadata::solana_program::devnet_execution::execute_devnet_solana_program_metadata;
|
||||
/// Simulates one stable Solana Program Metadata operation against Devnet.
|
||||
pub use self::metadata::solana_program::devnet_execution::simulate_devnet_solana_program_metadata;
|
||||
/// Bytes reserved for the public Buffer journey before `Trim`.
|
||||
pub use self::metadata::solana_program::fixture::SOLANA_PROGRAM_METADATA_FIXTURE_BUFFER_DATA_BYTES;
|
||||
/// Options used to prepare one complete Solana Program Metadata fixture.
|
||||
pub use self::metadata::solana_program::fixture::SolanaProgramMetadataFixturePreparationOptions;
|
||||
/// Public fixture values for the two Solana Program Metadata journeys.
|
||||
pub use self::metadata::solana_program::fixture::SolanaProgramMetadataFixturePreparationSummary;
|
||||
/// One prepared executable step in a Solana Program Metadata journey.
|
||||
pub use self::metadata::solana_program::fixture::SolanaProgramMetadataPreparedStep;
|
||||
/// Creates two unique pre-funded PDAs and the nine typed journey steps.
|
||||
pub use self::metadata::solana_program::fixture::prepare_solana_program_metadata_fixture;
|
||||
/// Stable lifecycle state consumed or produced by one scenario step.
|
||||
pub use self::metadata::solana_program::scenarios::SolanaProgramMetadataFixtureState;
|
||||
/// One ordered multi-step Solana Program Metadata journey.
|
||||
pub use self::metadata::solana_program::scenarios::SolanaProgramMetadataScenario;
|
||||
/// One stable step in a Solana Program Metadata Devnet journey.
|
||||
pub use self::metadata::solana_program::scenarios::SolanaProgramMetadataScenarioStep;
|
||||
/// Returns the two ordered Devnet journeys covering all nine stable operations.
|
||||
pub use self::metadata::solana_program::scenarios::solana_program_metadata_devnet_scenarios;
|
||||
/// Maximum evidence entries accepted by one operation validation row.
|
||||
pub use self::metadata::solana_program::validation::MAX_SOLANA_PROGRAM_METADATA_VALIDATION_EVIDENCE;
|
||||
/// One bounded evidence record.
|
||||
pub use self::metadata::solana_program::validation::SolanaProgramMetadataValidationEvidence;
|
||||
/// Closed validation matrix for all nine stable operations.
|
||||
pub use self::metadata::solana_program::validation::SolanaProgramMetadataValidationMatrix;
|
||||
/// One operation row in the closed Devnet validation matrix.
|
||||
pub use self::metadata::solana_program::validation::SolanaProgramMetadataValidationOperation;
|
||||
/// Exact network validation status.
|
||||
pub use self::metadata::solana_program::validation::SolanaProgramMetadataValidationStatus;
|
||||
/// Loads and validates the canonical Solana Program Metadata Devnet matrix.
|
||||
pub use self::metadata::solana_program::validation::load_solana_program_metadata_validation_matrix;
|
||||
/// Validates exact inventory, order and conservative evidence claims.
|
||||
pub use self::metadata::solana_program::validation::validate_solana_program_metadata_validation_matrix;
|
||||
/// Complete request for one bounded System Program transfer on Devnet.
|
||||
pub use self::solana::DevnetSystemTransferRequest;
|
||||
/// Summary returned by one bounded System Program transfer execution.
|
||||
pub use self::solana::DevnetSystemTransferSummary;
|
||||
/// No-op observer suitable for CLI tools and opt-in integration tests.
|
||||
pub use self::solana::NoopSolanaExecutionObserver;
|
||||
/// Observer notified during Solana execution orchestration.
|
||||
pub use self::solana::SolanaExecutionObserver;
|
||||
/// Progress event emitted by Solana execution orchestration.
|
||||
pub use self::solana::SolanaExecutionProgressEvent;
|
||||
/// Severity of one Solana execution progress event.
|
||||
pub use self::solana::SolanaExecutionProgressLevel;
|
||||
/// Emits one execution progress event for specialized pipeline orchestrators.
|
||||
pub(crate) use self::solana::emit;
|
||||
/// Rejects one execution stage when the observer reports cancellation.
|
||||
pub(crate) use self::solana::ensure_not_cancelled;
|
||||
/// Executes one bounded System Program transfer on Devnet.
|
||||
pub use self::solana::execute_devnet_system_transfer;
|
||||
/// Loads the persistent wallet configured by one execution profile.
|
||||
pub(crate) use self::solana::load_profile_wallet;
|
||||
/// Formats one failed simulation without discarding runtime diagnostics.
|
||||
pub(crate) use self::solana::simulation_failure_message;
|
||||
/// Formats safety violations for one denied execution plan.
|
||||
pub(crate) use self::solana::violation_message;
|
||||
/// Complete request for one Devnet Associated Token Account execution.
|
||||
pub use self::spl::associated_token_account::DevnetSplAssociatedTokenAccountExecutionRequest;
|
||||
/// Complete result of one Devnet Associated Token Account execution.
|
||||
pub use self::spl::associated_token_account::DevnetSplAssociatedTokenAccountExecutionSummary;
|
||||
/// Executes one Devnet Associated Token Account simulation or authorized submission.
|
||||
pub use self::spl::associated_token_account::execute_devnet_spl_associated_token_account;
|
||||
/// Simulates one Devnet Associated Token Account operation after stateful preflight.
|
||||
pub use self::spl::associated_token_account::simulate_devnet_spl_associated_token_account;
|
||||
/// Complete request for one SPL Memo v4 Devnet execution.
|
||||
pub use self::spl::memo::DevnetMemoExecutionRequest;
|
||||
/// Complete result of one SPL Memo v4 Devnet execution and post-validation.
|
||||
pub use self::spl::memo::DevnetMemoExecutionSummary;
|
||||
/// Executes one SPL Memo v4 Devnet simulation or explicitly authorized submission.
|
||||
pub use self::spl::memo::execute_devnet_memo;
|
||||
/// Complete request for one Devnet classic SPL Token execution.
|
||||
pub use self::spl::token::execution::DevnetSplTokenExecutionRequest;
|
||||
/// Complete result of one Devnet classic SPL Token execution.
|
||||
pub use self::spl::token::execution::DevnetSplTokenExecutionSummary;
|
||||
/// Canonical hydration availability helper shared by Token lifecycle orchestration.
|
||||
pub(crate) use self::spl::token::execution::canonical_available;
|
||||
/// Decode completion helper shared by Token lifecycle orchestration.
|
||||
pub(crate) use self::spl::token::execution::decode_completed;
|
||||
/// Executes one Devnet classic SPL Token simulation or authorized submission.
|
||||
pub use self::spl::token::execution::execute_devnet_spl_token;
|
||||
/// Canonical signature hydration helper shared by Token lifecycle orchestration.
|
||||
pub(crate) use self::spl::token::execution::hydrate_signature;
|
||||
/// Targeted program replay helper shared by Token lifecycle orchestration.
|
||||
pub(crate) use self::spl::token::execution::replay_program;
|
||||
/// Simulates one Devnet classic SPL Token operation after stateful preflight.
|
||||
pub use self::spl::token::execution::simulate_devnet_spl_token;
|
||||
/// Complete request to prepare raw accounts for one Devnet SPL Token lifecycle.
|
||||
pub use self::spl::token::lifecycle::DevnetSplTokenLifecyclePreparationRequest;
|
||||
/// One prepared raw account step.
|
||||
pub use self::spl::token::lifecycle::DevnetSplTokenLifecyclePreparationStep;
|
||||
/// Summary of raw-account preparation for one lifecycle.
|
||||
pub use self::spl::token::lifecycle::DevnetSplTokenLifecyclePreparationSummary;
|
||||
/// Complete request for one controlled Devnet SPL Token lifecycle.
|
||||
pub use self::spl::token::lifecycle::DevnetSplTokenLifecycleRequest;
|
||||
/// Summary of one lifecycle operation step.
|
||||
pub use self::spl::token::lifecycle::DevnetSplTokenLifecycleStepSummary;
|
||||
/// Complete controlled lifecycle summary.
|
||||
pub use self::spl::token::lifecycle::DevnetSplTokenLifecycleSummary;
|
||||
/// Executes one controlled Devnet SPL Token lifecycle.
|
||||
pub use self::spl::token::lifecycle::execute_devnet_spl_token_lifecycle;
|
||||
/// Prepares raw accounts for one controlled Devnet SPL Token lifecycle.
|
||||
pub use self::spl::token::lifecycle::prepare_devnet_spl_token_lifecycle_accounts;
|
||||
/// Complete request for one Devnet Token-2022 execution.
|
||||
pub use self::spl::token_2022::devnet_execution::DevnetSplToken2022ExecutionRequest;
|
||||
/// Complete result of one Devnet Token-2022 execution.
|
||||
pub use self::spl::token_2022::devnet_execution::DevnetSplToken2022ExecutionSummary;
|
||||
/// Executes one Devnet Token-2022 simulation or authorized submission.
|
||||
pub use self::spl::token_2022::devnet_execution::execute_devnet_spl_token_2022;
|
||||
/// Simulates one Devnet Token-2022 operation after stateful preflight.
|
||||
pub use self::spl::token_2022::devnet_execution::simulate_devnet_spl_token_2022;
|
||||
/// Stable category of one independent Devnet validation scenario.
|
||||
pub use self::spl::token_2022::devnet_scenarios::DevnetSplValidationFamily;
|
||||
/// Current implementation status of one Devnet validation scenario.
|
||||
pub use self::spl::token_2022::devnet_scenarios::DevnetSplValidationImplementationStatus;
|
||||
/// One independent Devnet validation scenario exposed to applications.
|
||||
pub use self::spl::token_2022::devnet_scenarios::DevnetSplValidationScenario;
|
||||
/// Returns the complete ordered Devnet scenario inventory.
|
||||
pub use self::spl::token_2022::devnet_scenarios::devnet_spl_validation_scenarios;
|
||||
/// Default raw amount delegated by the public ApproveChecked scenario.
|
||||
pub use self::spl::token_2022::fixture::DEFAULT_TOKEN_2022_APPROVE_AMOUNT_RAW;
|
||||
/// Default raw amount burned by the public BurnChecked scenario.
|
||||
pub use self::spl::token_2022::fixture::DEFAULT_TOKEN_2022_BURN_AMOUNT_RAW;
|
||||
/// Default raw amount minted by the public MintToChecked scenario.
|
||||
pub use self::spl::token_2022::fixture::DEFAULT_TOKEN_2022_MINT_AMOUNT_RAW;
|
||||
/// Default raw amount transferred by the public TransferChecked scenario.
|
||||
pub use self::spl::token_2022::fixture::DEFAULT_TOKEN_2022_TRANSFER_AMOUNT_RAW;
|
||||
/// Command-line options for one Token-2022 fixture preparation.
|
||||
pub use self::spl::token_2022::fixture::Token2022FixturePreparationOptions;
|
||||
/// Public values written to one Token-2022 fixture file.
|
||||
pub use self::spl::token_2022::fixture::Token2022FixturePreparationSummary;
|
||||
/// Creates or reuses one complete Token-2022 public scenario fixture.
|
||||
pub use self::spl::token_2022::fixture::prepare_token_2022_fixture;
|
||||
/// One confirmed step in the five-instruction Token Metadata Devnet campaign.
|
||||
pub use self::spl::token_2022::metadata::campaign::DevnetToken2022MetadataCampaignStepSummary;
|
||||
/// Complete evidence retained by the Token Metadata Devnet campaign.
|
||||
pub use self::spl::token_2022::metadata::campaign::DevnetToken2022MetadataCampaignSummary;
|
||||
/// Executes all five Token Metadata interface instructions on one fresh Devnet mint.
|
||||
pub use self::spl::token_2022::metadata::campaign::execute_devnet_token_2022_metadata_campaign;
|
||||
/// Returns the exact ordered operation names covered by the Token Metadata campaign.
|
||||
pub use self::spl::token_2022::metadata::campaign::token_2022_metadata_campaign_operation_names;
|
||||
/// Additional metadata key created and removed by the campaign.
|
||||
pub use self::spl::token_2022::metadata::fixture::TOKEN_2022_METADATA_CAMPAIGN_KEY;
|
||||
/// Stable metadata name used by the `0.4.8-pre.012` campaign.
|
||||
pub use self::spl::token_2022::metadata::fixture::TOKEN_2022_METADATA_CAMPAIGN_NAME;
|
||||
/// Stable metadata symbol used by the `0.4.8-pre.012` campaign.
|
||||
pub use self::spl::token_2022::metadata::fixture::TOKEN_2022_METADATA_CAMPAIGN_SYMBOL;
|
||||
/// Stable opaque URI stored by the `0.4.8-pre.012` campaign.
|
||||
pub use self::spl::token_2022::metadata::fixture::TOKEN_2022_METADATA_CAMPAIGN_URI;
|
||||
/// Additional metadata value created and removed by the campaign.
|
||||
pub use self::spl::token_2022::metadata::fixture::TOKEN_2022_METADATA_CAMPAIGN_VALUE;
|
||||
/// Options used to prepare one fresh Token-2022 metadata fixture.
|
||||
pub use self::spl::token_2022::metadata::fixture::Token2022MetadataFixturePreparationOptions;
|
||||
/// Fresh mint and authorities prepared for one Token Metadata campaign.
|
||||
pub use self::spl::token_2022::metadata::fixture::Token2022MetadataFixturePreparationSummary;
|
||||
/// Creates one fresh Token-2022 mint with a self-referential Metadata Pointer.
|
||||
pub use self::spl::token_2022::metadata::fixture::prepare_token_2022_metadata_fixture;
|
||||
/// Maximum evidence entries retained by one Token Metadata validation scenario.
|
||||
pub use self::spl::token_2022::metadata::validation::MAX_TOKEN_2022_METADATA_VALIDATION_EVIDENCE;
|
||||
/// One bounded evidence item retained by the Token Metadata validation matrix.
|
||||
pub use self::spl::token_2022::metadata::validation::Token2022MetadataValidationEvidence;
|
||||
/// Canonical Token Metadata Devnet validation matrix.
|
||||
pub use self::spl::token_2022::metadata::validation::Token2022MetadataValidationMatrix;
|
||||
/// One scenario in the Token Metadata Devnet validation matrix.
|
||||
pub use self::spl::token_2022::metadata::validation::Token2022MetadataValidationScenario;
|
||||
/// Exact network-validation status for one Token Metadata scenario.
|
||||
pub use self::spl::token_2022::metadata::validation::Token2022MetadataValidationStatus;
|
||||
/// Loads and validates the Token Metadata Devnet validation matrix.
|
||||
pub use self::spl::token_2022::metadata::validation::load_token_2022_metadata_validation_matrix;
|
||||
/// Validates exact Token Metadata campaign coverage and evidence claims.
|
||||
pub use self::spl::token_2022::metadata::validation::validate_token_2022_metadata_validation_matrix;
|
||||
/// Maximum number of evidence records accepted in one validation report.
|
||||
pub use self::spl::token_2022::validation::MAX_TOKEN_2022_VALIDATION_EVIDENCE;
|
||||
/// Required validation environment for one Token-2022 scenario.
|
||||
pub use self::spl::token_2022::validation::Token2022ValidationEnvironment;
|
||||
/// One bounded proof attached to a Token-2022 validation scenario.
|
||||
pub use self::spl::token_2022::validation::Token2022ValidationEvidence;
|
||||
/// Machine-readable Token-2022 validation matrix.
|
||||
pub use self::spl::token_2022::validation::Token2022ValidationMatrix;
|
||||
/// One scenario declared by the canonical validation matrix.
|
||||
pub use self::spl::token_2022::validation::Token2022ValidationMatrixScenario;
|
||||
/// Complete bounded Token-2022 validation report.
|
||||
pub use self::spl::token_2022::validation::Token2022ValidationReport;
|
||||
/// One declared Token-2022 validation scenario and its observed evidence.
|
||||
pub use self::spl::token_2022::validation::Token2022ValidationScenario;
|
||||
/// Exact status of one Token-2022 validation scenario.
|
||||
pub use self::spl::token_2022::validation::Token2022ValidationStatus;
|
||||
/// Loads and validates the canonical Token-2022 validation matrix.
|
||||
pub use self::spl::token_2022::validation::load_token_2022_validation_matrix;
|
||||
/// Validates one Token-2022 validation matrix.
|
||||
pub use self::spl::token_2022::validation::validate_token_2022_validation_matrix;
|
||||
/// Validates one bounded Token-2022 milestone report.
|
||||
pub use self::spl::token_2022::validation::validate_token_2022_validation_report;
|
||||
|
||||
/// Total transactions reserved by one complete Solana Program Metadata campaign.
|
||||
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;
|
||||
7
ks-pipeline-demo-scenarios/src/metadata.rs
Normal file
7
ks-pipeline-demo-scenarios/src/metadata.rs
Normal file
@@ -0,0 +1,7 @@
|
||||
// file: ks-pipeline-demo-scenarios/src/metadata.rs
|
||||
// version: 2
|
||||
|
||||
//! Metadata-oriented reusable demo and Devnet validation scenarios.
|
||||
|
||||
pub(crate) mod metaplex_token_metadata;
|
||||
pub(crate) mod solana_program;
|
||||
@@ -0,0 +1,16 @@
|
||||
// file: ks-pipeline-demo-scenarios/src/metadata/metaplex_token_metadata.rs
|
||||
// version: 8
|
||||
|
||||
//! Metaplex Token Metadata demo and Devnet validation scenarios.
|
||||
|
||||
pub(crate) mod collection_verify_campaign;
|
||||
pub(crate) mod create_mint_campaign;
|
||||
pub(crate) mod devnet_execution;
|
||||
pub(crate) mod escrow_campaign;
|
||||
pub(crate) mod fixture;
|
||||
pub(crate) mod maintenance_campaign;
|
||||
pub(crate) mod pnft_lifecycle_campaign;
|
||||
pub(crate) mod print_burn_campaign;
|
||||
pub(crate) mod scenarios;
|
||||
pub(crate) mod use_campaign;
|
||||
pub(crate) mod validation;
|
||||
@@ -0,0 +1,747 @@
|
||||
// file: ks-pipeline-demo-scenarios/src/metadata/metaplex_token_metadata/collection_verify_campaign.rs
|
||||
// version: 2
|
||||
|
||||
//! Confirmed Devnet collection parent/member `Verify -> Unverify` campaign.
|
||||
|
||||
/// Collection relation and sized-collection state observed across verification.
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub struct DevnetMetaplexCollectionVerifyState {
|
||||
/// Member collection flag before `Verify`.
|
||||
pub verified_before: bool,
|
||||
/// Member collection flag after `Verify`.
|
||||
pub verified_after_verify: bool,
|
||||
/// Member collection flag after `Unverify`.
|
||||
pub verified_after_unverify: bool,
|
||||
/// Collection size before `Verify`.
|
||||
pub collection_size_before: u64,
|
||||
/// Collection size after `Verify`.
|
||||
pub collection_size_after_verify: u64,
|
||||
/// Collection size after `Unverify`.
|
||||
pub collection_size_after_unverify: u64,
|
||||
}
|
||||
|
||||
/// Complete evidence produced by one collection parent/member verification campaign.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct DevnetMetaplexCollectionVerifyCampaignSummary {
|
||||
/// Fully qualified collection parent fixture and `Create -> Mint` evidence.
|
||||
pub parent: crate::DevnetMetaplexCreateMintCampaignSummary,
|
||||
/// Fully qualified unverified member fixture and `Create -> Mint` evidence.
|
||||
pub member: crate::DevnetMetaplexCreateMintCampaignSummary,
|
||||
/// Confirmed collection verification evidence.
|
||||
pub verify: crate::DevnetMetaplexTokenMetadataExecutionSummary,
|
||||
/// Confirmed collection unverification evidence.
|
||||
pub unverify: crate::DevnetMetaplexTokenMetadataExecutionSummary,
|
||||
/// Exact collection relation and size transitions.
|
||||
pub state: crate::DevnetMetaplexCollectionVerifyState,
|
||||
}
|
||||
|
||||
/// Returns the exact current Metaplex operations qualified by this campaign after fixture setup.
|
||||
pub fn metaplex_collection_verify_campaign_operation_names() -> &'static [&'static str; 2] {
|
||||
return &["verify", "unverify"];
|
||||
}
|
||||
|
||||
/// Executes one fresh collection parent/member `Verify -> Unverify` campaign on Devnet.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub async fn execute_devnet_metaplex_collection_verify_campaign<S, O>(
|
||||
http_pool: &ks_onchain_transport::HttpEndpointPool,
|
||||
store: &S,
|
||||
profile: &ks_config::ProfileConfig,
|
||||
workspace_root: &std::path::Path,
|
||||
options: &crate::MetaplexCreateFixturePreparationOptions,
|
||||
observer: &O,
|
||||
) -> ks_core::Result<crate::DevnetMetaplexCollectionVerifyCampaignSummary>
|
||||
where
|
||||
S: ks_store::RawTransactionStore
|
||||
+ ks_store::CoreExtractionStore
|
||||
+ ks_store::DecodePipelineStore
|
||||
+ Sync,
|
||||
O: crate::SolanaExecutionObserver,
|
||||
{
|
||||
if options.collection_mint.is_some() {
|
||||
return std::result::Result::Err(ks_core::Error::config(
|
||||
"collection verification campaign base options must not preselect a collection mint",
|
||||
));
|
||||
}
|
||||
let mut parent_options = options.clone();
|
||||
parent_options.asset_family = crate::MetaplexTokenMetadataAssetFamily::Collection;
|
||||
parent_options.collection_mint = std::option::Option::None;
|
||||
let parent = match crate::execute_devnet_metaplex_create_mint_campaign(
|
||||
http_pool,
|
||||
store,
|
||||
profile,
|
||||
workspace_root,
|
||||
&parent_options,
|
||||
observer,
|
||||
)
|
||||
.await
|
||||
{
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let mut member_options = options.clone();
|
||||
member_options.asset_family = crate::MetaplexTokenMetadataAssetFamily::Nft;
|
||||
member_options.collection_mint = std::option::Option::Some(parent.fixture.mint.clone());
|
||||
let member = match crate::execute_devnet_metaplex_create_mint_campaign(
|
||||
http_pool,
|
||||
store,
|
||||
profile,
|
||||
workspace_root,
|
||||
&member_options,
|
||||
observer,
|
||||
)
|
||||
.await
|
||||
{
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let verified_before = match collection_member_verified(
|
||||
member.mint.after.as_slice(),
|
||||
member.fixture.metadata.as_str(),
|
||||
parent.fixture.mint.as_str(),
|
||||
) {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let collection_size_before =
|
||||
match collection_size(parent.mint.after.as_slice(), parent.fixture.metadata.as_str()) {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
if verified_before || collection_size_before != 0 {
|
||||
return std::result::Result::Err(ks_core::Error::new(
|
||||
"metaplex_collection_verify_initial_state_invalid",
|
||||
format!(
|
||||
"fresh collection member must start unverified with parent size 0; verified={verified_before}, size={collection_size_before}"
|
||||
),
|
||||
));
|
||||
}
|
||||
let parent_mint_slot = match confirmed_slot("parent mint", &parent.mint) {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let member_mint_slot = match confirmed_slot("member mint", &member.mint) {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let fixture_slot = std::cmp::max(parent_mint_slot, member_mint_slot);
|
||||
let decoders: std::vec::Vec<std::sync::Arc<dyn ks_lib::DcApiInstructionDecoder>> =
|
||||
std::vec![std::sync::Arc::new(ks_lib::DcMetadataMetaplexTokenMetadataDecoder)];
|
||||
let materializers: std::vec::Vec<std::sync::Arc<dyn ks_lib::MtApiEventMaterializer>> = std::vec![
|
||||
std::sync::Arc::new(ks_lib::MtAdminMaterializer),
|
||||
std::sync::Arc::new(ks_lib::MtComplianceAuditMaterializer),
|
||||
std::sync::Arc::new(ks_lib::MtLifecycleMaterializer),
|
||||
std::sync::Arc::new(ks_lib::MtMetadataMetaplexTokenMetadataMaterializer),
|
||||
std::sync::Arc::new(ks_lib::MtRiskMaterializer),
|
||||
];
|
||||
let verify_operation = ks_lib::ExMetaplexTokenMetadataOperation::Verify {
|
||||
authority: ks_lib::MdPubkey(parent.fixture.authority.clone()),
|
||||
delegate_record: std::option::Option::None,
|
||||
metadata: ks_lib::MdPubkey(member.fixture.metadata.clone()),
|
||||
collection_mint: std::option::Option::Some(ks_lib::MdPubkey(parent.fixture.mint.clone())),
|
||||
collection_metadata: std::option::Option::Some(ks_lib::MdPubkey(
|
||||
parent.fixture.metadata.clone(),
|
||||
)),
|
||||
collection_master_edition: std::option::Option::Some(ks_lib::MdPubkey(
|
||||
parent.fixture.master_edition.clone(),
|
||||
)),
|
||||
verification_args: mpl_token_metadata::types::VerificationArgs::CollectionV1,
|
||||
};
|
||||
let mut verify_request = crate::DevnetMetaplexTokenMetadataExecutionRequest::new(
|
||||
format!("metaplex-collection-verify-{}", uuid::Uuid::new_v4()),
|
||||
verify_operation,
|
||||
);
|
||||
verify_request.query_role = options.query_role.clone();
|
||||
verify_request.transaction_role = options.transaction_role.clone();
|
||||
verify_request.submit = true;
|
||||
verify_request.operator_confirmed = true;
|
||||
verify_request.materialize_after_confirmation = true;
|
||||
verify_request.post_validation_max_retries = 20;
|
||||
verify_request.preflight_reads = collection_reads(
|
||||
&parent.fixture,
|
||||
&member.fixture,
|
||||
options.query_role.as_str(),
|
||||
std::option::Option::Some(fixture_slot),
|
||||
);
|
||||
verify_request.postcondition_reads = collection_reads(
|
||||
&parent.fixture,
|
||||
&member.fixture,
|
||||
options.query_role.as_str(),
|
||||
std::option::Option::Some(fixture_slot),
|
||||
);
|
||||
let verify = match crate::execute_devnet_metaplex_token_metadata(
|
||||
http_pool,
|
||||
store,
|
||||
profile,
|
||||
workspace_root,
|
||||
&verify_request,
|
||||
decoders.as_slice(),
|
||||
materializers.as_slice(),
|
||||
observer,
|
||||
)
|
||||
.await
|
||||
{
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let verify_slot = match validate_confirmed_execution("verify", &verify) {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let verified_after_verify = match collection_member_verified(
|
||||
verify.after.as_slice(),
|
||||
member.fixture.metadata.as_str(),
|
||||
parent.fixture.mint.as_str(),
|
||||
) {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let collection_size_after_verify =
|
||||
match collection_size(verify.after.as_slice(), parent.fixture.metadata.as_str()) {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
if !verified_after_verify || collection_size_after_verify != 1 {
|
||||
return std::result::Result::Err(ks_core::Error::new(
|
||||
"metaplex_collection_verify_postcondition_failed",
|
||||
format!(
|
||||
"confirmed collection Verify must set member verified=true and parent size=1; verified={verified_after_verify}, size={collection_size_after_verify}"
|
||||
),
|
||||
));
|
||||
}
|
||||
let unverify_operation = ks_lib::ExMetaplexTokenMetadataOperation::Unverify {
|
||||
authority: ks_lib::MdPubkey(parent.fixture.authority.clone()),
|
||||
delegate_record: std::option::Option::None,
|
||||
metadata: ks_lib::MdPubkey(member.fixture.metadata.clone()),
|
||||
collection_mint: std::option::Option::Some(ks_lib::MdPubkey(parent.fixture.mint.clone())),
|
||||
collection_metadata: std::option::Option::Some(ks_lib::MdPubkey(
|
||||
parent.fixture.metadata.clone(),
|
||||
)),
|
||||
verification_args: mpl_token_metadata::types::VerificationArgs::CollectionV1,
|
||||
};
|
||||
let mut unverify_request = crate::DevnetMetaplexTokenMetadataExecutionRequest::new(
|
||||
format!("metaplex-collection-unverify-{}", uuid::Uuid::new_v4()),
|
||||
unverify_operation,
|
||||
);
|
||||
unverify_request.query_role = options.query_role.clone();
|
||||
unverify_request.transaction_role = options.transaction_role.clone();
|
||||
unverify_request.submit = true;
|
||||
unverify_request.operator_confirmed = true;
|
||||
unverify_request.materialize_after_confirmation = true;
|
||||
unverify_request.post_validation_max_retries = 20;
|
||||
unverify_request.preflight_reads = collection_reads(
|
||||
&parent.fixture,
|
||||
&member.fixture,
|
||||
options.query_role.as_str(),
|
||||
std::option::Option::Some(verify_slot),
|
||||
);
|
||||
unverify_request.postcondition_reads = collection_reads(
|
||||
&parent.fixture,
|
||||
&member.fixture,
|
||||
options.query_role.as_str(),
|
||||
std::option::Option::Some(verify_slot),
|
||||
);
|
||||
let unverify = match crate::execute_devnet_metaplex_token_metadata(
|
||||
http_pool,
|
||||
store,
|
||||
profile,
|
||||
workspace_root,
|
||||
&unverify_request,
|
||||
decoders.as_slice(),
|
||||
materializers.as_slice(),
|
||||
observer,
|
||||
)
|
||||
.await
|
||||
{
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let unverify_slot = match validate_confirmed_execution("unverify", &unverify) {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let verified_after_unverify = match collection_member_verified(
|
||||
unverify.after.as_slice(),
|
||||
member.fixture.metadata.as_str(),
|
||||
parent.fixture.mint.as_str(),
|
||||
) {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let collection_size_after_unverify =
|
||||
match collection_size(unverify.after.as_slice(), parent.fixture.metadata.as_str()) {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
if verified_after_unverify || collection_size_after_unverify != 0 {
|
||||
return std::result::Result::Err(ks_core::Error::new(
|
||||
"metaplex_collection_unverify_postcondition_failed",
|
||||
format!(
|
||||
"confirmed collection Unverify at slot {unverify_slot} must restore verified=false and parent size=0; verified={verified_after_unverify}, size={collection_size_after_unverify}"
|
||||
),
|
||||
));
|
||||
}
|
||||
return std::result::Result::Ok(crate::DevnetMetaplexCollectionVerifyCampaignSummary {
|
||||
parent,
|
||||
member,
|
||||
verify,
|
||||
unverify,
|
||||
state: crate::DevnetMetaplexCollectionVerifyState {
|
||||
verified_before,
|
||||
verified_after_verify,
|
||||
verified_after_unverify,
|
||||
collection_size_before,
|
||||
collection_size_after_verify,
|
||||
collection_size_after_unverify,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
fn collection_reads(
|
||||
parent: &crate::MetaplexCreateFixturePreparationSummary,
|
||||
member: &crate::MetaplexCreateFixturePreparationSummary,
|
||||
query_role: &str,
|
||||
min_context_slot: std::option::Option<u64>,
|
||||
) -> std::vec::Vec<ks_pipeline::MetaplexTokenMetadataStatefulReadRequest> {
|
||||
return vec![
|
||||
ks_pipeline::MetaplexTokenMetadataStatefulReadRequest {
|
||||
query_role: query_role.to_string(),
|
||||
account: ks_lib::MdPubkey(member.metadata.clone()),
|
||||
kind: ks_pipeline::MetaplexTokenMetadataAccountKind::Metadata,
|
||||
min_context_slot,
|
||||
max_data_bytes: ks_pipeline::MAX_METAPLEX_TOKEN_METADATA_ACCOUNT_BYTES,
|
||||
},
|
||||
ks_pipeline::MetaplexTokenMetadataStatefulReadRequest {
|
||||
query_role: query_role.to_string(),
|
||||
account: ks_lib::MdPubkey(parent.metadata.clone()),
|
||||
kind: ks_pipeline::MetaplexTokenMetadataAccountKind::Metadata,
|
||||
min_context_slot,
|
||||
max_data_bytes: ks_pipeline::MAX_METAPLEX_TOKEN_METADATA_ACCOUNT_BYTES,
|
||||
},
|
||||
ks_pipeline::MetaplexTokenMetadataStatefulReadRequest {
|
||||
query_role: query_role.to_string(),
|
||||
account: ks_lib::MdPubkey(parent.master_edition.clone()),
|
||||
kind: ks_pipeline::MetaplexTokenMetadataAccountKind::Edition {
|
||||
mint: ks_lib::MdPubkey(parent.mint.clone()),
|
||||
},
|
||||
min_context_slot,
|
||||
max_data_bytes: ks_pipeline::MAX_METAPLEX_TOKEN_METADATA_ACCOUNT_BYTES,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
fn confirmed_slot(
|
||||
step: &str,
|
||||
execution: &crate::DevnetMetaplexTokenMetadataExecutionSummary,
|
||||
) -> ks_core::Result<u64> {
|
||||
return match execution.confirmation.as_ref().and_then(|value| return value.slot) {
|
||||
std::option::Option::Some(value) => std::result::Result::Ok(value),
|
||||
std::option::Option::None => std::result::Result::Err(ks_core::Error::new(
|
||||
"metaplex_collection_fixture_confirmation_slot_missing",
|
||||
format!("Metaplex {step} has no confirmed slot"),
|
||||
)),
|
||||
};
|
||||
}
|
||||
|
||||
fn validate_confirmed_execution(
|
||||
step: &str,
|
||||
execution: &crate::DevnetMetaplexTokenMetadataExecutionSummary,
|
||||
) -> ks_core::Result<u64> {
|
||||
if !execution.simulation.success {
|
||||
return std::result::Result::Err(ks_core::Error::new(
|
||||
"metaplex_collection_simulation_failed",
|
||||
format!("Metaplex collection {step} simulation failed"),
|
||||
));
|
||||
}
|
||||
let confirmation = match execution.confirmation.as_ref() {
|
||||
std::option::Option::Some(value)
|
||||
if matches!(
|
||||
value.status,
|
||||
ks_lib::ExApiExecutionConfirmationStatus::Confirmed
|
||||
| ks_lib::ExApiExecutionConfirmationStatus::Finalized
|
||||
) =>
|
||||
{
|
||||
value
|
||||
},
|
||||
std::option::Option::Some(value) => {
|
||||
return std::result::Result::Err(ks_core::Error::new(
|
||||
"metaplex_collection_confirmation_incomplete",
|
||||
format!("Metaplex collection {step} stopped at {:?}", value.status),
|
||||
));
|
||||
},
|
||||
std::option::Option::None => {
|
||||
return std::result::Result::Err(ks_core::Error::new(
|
||||
"metaplex_collection_confirmation_missing",
|
||||
format!("Metaplex collection {step} has no confirmation evidence"),
|
||||
));
|
||||
},
|
||||
};
|
||||
let diagnostic = match execution.post_execution.as_ref() {
|
||||
std::option::Option::Some(value) => value,
|
||||
std::option::Option::None => {
|
||||
return std::result::Result::Err(ks_core::Error::new(
|
||||
"metaplex_collection_post_execution_missing",
|
||||
format!("Metaplex collection {step} has no post-execution diagnostic"),
|
||||
));
|
||||
},
|
||||
};
|
||||
if !diagnostic.canonical_inserted
|
||||
|| !diagnostic.core_extracted
|
||||
|| !diagnostic.decode_replayed
|
||||
|| !diagnostic.materialized
|
||||
|| execution.materializations.is_empty()
|
||||
|| execution.materialized_snapshots.is_empty()
|
||||
{
|
||||
return std::result::Result::Err(ks_core::Error::new(
|
||||
"metaplex_collection_post_execution_incomplete",
|
||||
format!(
|
||||
"Metaplex collection {step} post-execution incomplete: signature={}, slot={:?}, canonical_inserted={}, core_extracted={}, decode_replayed={}, materialized={}, materialization_rows={}, materialized_snapshots={}, diagnostics={:?}",
|
||||
confirmation.signature.0,
|
||||
confirmation.slot,
|
||||
diagnostic.canonical_inserted,
|
||||
diagnostic.core_extracted,
|
||||
diagnostic.decode_replayed,
|
||||
diagnostic.materialized,
|
||||
execution.materializations.len(),
|
||||
execution.materialized_snapshots.len(),
|
||||
diagnostic.diagnostics
|
||||
),
|
||||
));
|
||||
}
|
||||
let second_replay = match execution.idempotence_replay.as_ref() {
|
||||
std::option::Option::Some(value) => value,
|
||||
std::option::Option::None => {
|
||||
return std::result::Result::Err(ks_core::Error::new(
|
||||
"metaplex_collection_idempotence_missing",
|
||||
format!("Metaplex collection {step} has no idempotence replay evidence"),
|
||||
));
|
||||
},
|
||||
};
|
||||
if second_replay.failed_inputs != 0
|
||||
|| second_replay.processing_error_inputs != 0
|
||||
|| second_replay.processors.iter().any(|processor| {
|
||||
return processor.failed != 0
|
||||
|| processor.processing_errors != 0
|
||||
|| processor.materialized_outputs != 0
|
||||
|| processor.materialization_refused != 0;
|
||||
})
|
||||
{
|
||||
return std::result::Result::Err(ks_core::Error::new(
|
||||
"metaplex_collection_idempotence_failed",
|
||||
format!("Metaplex collection {step} second replay is not idempotent"),
|
||||
));
|
||||
}
|
||||
return match confirmation.slot {
|
||||
std::option::Option::Some(value) => std::result::Result::Ok(value),
|
||||
std::option::Option::None => std::result::Result::Err(ks_core::Error::new(
|
||||
"metaplex_collection_confirmation_slot_missing",
|
||||
format!("Metaplex collection {step} confirmation has no slot"),
|
||||
)),
|
||||
};
|
||||
}
|
||||
|
||||
fn metadata_payload<'a>(
|
||||
snapshots: &'a [ks_pipeline::MetaplexTokenMetadataStatefulReadResult],
|
||||
metadata: &str,
|
||||
) -> ks_core::Result<&'a serde_json::Value> {
|
||||
let snapshot = snapshots.iter().find(|value| {
|
||||
return value.snapshot.account.0.as_str() == metadata
|
||||
&& value.snapshot.account_kind == "metadata";
|
||||
});
|
||||
return match snapshot {
|
||||
std::option::Option::Some(value) => std::result::Result::Ok(&value.snapshot.payload_json),
|
||||
std::option::Option::None => std::result::Result::Err(ks_core::Error::new(
|
||||
"metaplex_collection_metadata_snapshot_missing",
|
||||
format!("Metaplex metadata snapshot {metadata} is missing"),
|
||||
)),
|
||||
};
|
||||
}
|
||||
|
||||
fn collection_member_verified(
|
||||
snapshots: &[ks_pipeline::MetaplexTokenMetadataStatefulReadResult],
|
||||
member_metadata: &str,
|
||||
expected_collection_mint: &str,
|
||||
) -> ks_core::Result<bool> {
|
||||
let payload = match metadata_payload(snapshots, member_metadata) {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let collection = match payload.get("collection") {
|
||||
std::option::Option::Some(value) if value.is_object() => value,
|
||||
_ => {
|
||||
return std::result::Result::Err(ks_core::Error::new(
|
||||
"metaplex_collection_relation_missing",
|
||||
"member metadata does not contain a collection relation",
|
||||
));
|
||||
},
|
||||
};
|
||||
let key = collection.get("key").and_then(serde_json::Value::as_str);
|
||||
let verified = collection.get("verified").and_then(serde_json::Value::as_bool);
|
||||
if key != std::option::Option::Some(expected_collection_mint) || verified.is_none() {
|
||||
return std::result::Result::Err(ks_core::Error::new(
|
||||
"metaplex_collection_relation_invalid",
|
||||
format!(
|
||||
"member collection relation mismatch: expected key={expected_collection_mint}, got key={key:?}, verified={verified:?}"
|
||||
),
|
||||
));
|
||||
}
|
||||
return match verified {
|
||||
std::option::Option::Some(value) => std::result::Result::Ok(value),
|
||||
std::option::Option::None => std::result::Result::Err(ks_core::Error::new(
|
||||
"metaplex_collection_verified_flag_missing",
|
||||
"member collection relation does not expose a verified flag",
|
||||
)),
|
||||
};
|
||||
}
|
||||
|
||||
fn collection_size(
|
||||
snapshots: &[ks_pipeline::MetaplexTokenMetadataStatefulReadResult],
|
||||
collection_metadata: &str,
|
||||
) -> ks_core::Result<u64> {
|
||||
let payload = match metadata_payload(snapshots, collection_metadata) {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let size = payload
|
||||
.get("collection_details")
|
||||
.and_then(|value| return value.get("V1"))
|
||||
.and_then(|value| return value.get("size"))
|
||||
.and_then(serde_json::Value::as_u64);
|
||||
return match size {
|
||||
std::option::Option::Some(value) => std::result::Result::Ok(value),
|
||||
std::option::Option::None => std::result::Result::Err(ks_core::Error::new(
|
||||
"metaplex_collection_size_missing",
|
||||
"collection metadata does not expose CollectionDetails::V1 size",
|
||||
)),
|
||||
};
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#[test]
|
||||
fn collection_campaign_contract_is_exactly_verify_then_unverify() {
|
||||
assert_eq!(
|
||||
crate::metaplex_collection_verify_campaign_operation_names(),
|
||||
&["verify", "unverify"]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn collection_payload_contract_tracks_relation_and_size() {
|
||||
let collection = solana_pubkey::Pubkey::new_unique().to_string();
|
||||
let relation = serde_json::json!({"collection": {"verified": false, "key": collection}});
|
||||
let details = serde_json::json!({"collection_details": {"V1": {"size": 0}}});
|
||||
assert_eq!(
|
||||
relation.pointer("/collection/verified").and_then(serde_json::Value::as_bool),
|
||||
std::option::Option::Some(false)
|
||||
);
|
||||
assert_eq!(
|
||||
details
|
||||
.pointer("/collection_details/V1/size")
|
||||
.and_then(serde_json::Value::as_u64),
|
||||
std::option::Option::Some(0)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn metadata_serde_contract_uses_stateful_collection_keys() {
|
||||
let metadata = mpl_token_metadata::accounts::Metadata {
|
||||
key: mpl_token_metadata::types::Key::MetadataV1,
|
||||
update_authority: mpl_token_metadata::ID,
|
||||
mint: mpl_token_metadata::ID,
|
||||
name: "Collection".to_string(),
|
||||
symbol: "COL".to_string(),
|
||||
uri: "https://example.invalid/collection.json".to_string(),
|
||||
seller_fee_basis_points: 0,
|
||||
creators: std::option::Option::None,
|
||||
primary_sale_happened: false,
|
||||
is_mutable: true,
|
||||
edition_nonce: std::option::Option::None,
|
||||
token_standard: std::option::Option::Some(
|
||||
mpl_token_metadata::types::TokenStandard::NonFungible,
|
||||
),
|
||||
collection: std::option::Option::None,
|
||||
uses: std::option::Option::None,
|
||||
collection_details: std::option::Option::Some(
|
||||
mpl_token_metadata::types::CollectionDetails::V1 { size: 7 },
|
||||
),
|
||||
programmable_config: std::option::Option::None,
|
||||
};
|
||||
let payload = match serde_json::to_value(metadata) {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => panic!("metadata serialization failed: {error}"),
|
||||
};
|
||||
assert_eq!(
|
||||
payload
|
||||
.pointer("/collection_details/V1/size")
|
||||
.and_then(serde_json::Value::as_u64),
|
||||
std::option::Option::Some(7)
|
||||
);
|
||||
assert!(payload.get("collectionDetails").is_none());
|
||||
}
|
||||
|
||||
fn execution_evidence_json(
|
||||
execution: &crate::DevnetMetaplexTokenMetadataExecutionSummary,
|
||||
) -> serde_json::Value {
|
||||
let confirmation = execution.confirmation.as_ref();
|
||||
let confirmation_status = confirmation
|
||||
.map(|value| return format!("{:?}", value.status))
|
||||
.unwrap_or_else(|| return "missing".to_string());
|
||||
let confirmation_slot = confirmation.and_then(|value| return value.slot);
|
||||
let signature = confirmation
|
||||
.map(|value| return value.signature.0.clone())
|
||||
.unwrap_or_else(|| return "missing".to_string());
|
||||
let diagnostic = execution.post_execution.as_ref();
|
||||
let idempotence_clean = execution.idempotence_replay.as_ref().is_some_and(|replay| {
|
||||
return replay.failed_inputs == 0
|
||||
&& replay.processing_error_inputs == 0
|
||||
&& replay.processors.iter().all(|processor| {
|
||||
return processor.failed == 0
|
||||
&& processor.processing_errors == 0
|
||||
&& processor.materialized_outputs == 0
|
||||
&& processor.materialization_refused == 0;
|
||||
});
|
||||
});
|
||||
return serde_json::json!({
|
||||
"operation": execution.plan.operation_code.as_str(),
|
||||
"cluster": execution.cluster,
|
||||
"genesisHash": execution.genesis_hash.as_str(),
|
||||
"simulationContextSlot": execution.simulation_context_slot,
|
||||
"simulationSuccess": execution.simulation.success,
|
||||
"simulationLogCount": execution.simulation.logs.len(),
|
||||
"messageHash": execution.readiness.message_hash.as_str(),
|
||||
"feeLamports": execution.fee.fee_lamports,
|
||||
"signature": signature,
|
||||
"confirmationStatus": confirmation_status,
|
||||
"confirmationSlot": confirmation_slot,
|
||||
"beforeSnapshots": execution.before.len(),
|
||||
"afterSnapshots": execution.after.len(),
|
||||
"canonicalHydration": diagnostic.is_some_and(|value| return value.canonical_inserted),
|
||||
"coreExtraction": diagnostic.is_some_and(|value| return value.core_extracted),
|
||||
"decodeReplay": diagnostic.is_some_and(|value| return value.decode_replayed),
|
||||
"materialized": diagnostic.is_some_and(|value| return value.materialized),
|
||||
"instructionMaterializations": execution.materializations.len(),
|
||||
"materializedSnapshots": execution.materialized_snapshots.len(),
|
||||
"idempotenceReplayClean": idempotence_clean,
|
||||
});
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn optional_devnet_metaplex_collection_verify_campaign_from_env() {
|
||||
if std::env::var("KB_DEVNET_METAPLEX_COLLECTION_VERIFY_CAMPAIGN_TEST")
|
||||
.ok()
|
||||
.as_deref()
|
||||
!= std::option::Option::Some("1")
|
||||
{
|
||||
return;
|
||||
}
|
||||
if std::env::var("KB_DEVNET_METAPLEX_OPERATOR_CONFIRMED").ok().as_deref()
|
||||
!= std::option::Option::Some("1")
|
||||
{
|
||||
panic!("KB_DEVNET_METAPLEX_OPERATOR_CONFIRMED=1 is required for the spending campaign");
|
||||
}
|
||||
let workspace_root = match std::path::Path::new(env!("CARGO_MANIFEST_DIR")).parent() {
|
||||
std::option::Option::Some(value) => value,
|
||||
std::option::Option::None => panic!("workspace root cannot be resolved"),
|
||||
};
|
||||
let config_path = match std::env::var("KB_DEVNET_CONFIG_PATH") {
|
||||
std::result::Result::Ok(value) => {
|
||||
let path = std::path::PathBuf::from(value);
|
||||
if path.is_absolute() { path } else { workspace_root.join(path) }
|
||||
},
|
||||
std::result::Result::Err(_) => workspace_root.join("config/example.config.json"),
|
||||
};
|
||||
let config = ks_config::read_config_json_file_with_environment(
|
||||
config_path.as_path(),
|
||||
workspace_root,
|
||||
)
|
||||
.unwrap_or_else(|error| panic!("Devnet configuration loading failed: {error}"));
|
||||
let profile_name = std::env::var("KB_DEVNET_PROFILE").ok();
|
||||
let mut profile = crate::resolve_demo_devnet_profile(&config, profile_name.as_deref())
|
||||
.unwrap_or_else(|error| panic!("Devnet profile resolution failed: {error}"));
|
||||
let database_url = std::env::var("KB_POSTGRES_TEST_URL")
|
||||
.unwrap_or_else(|error| panic!("KB_POSTGRES_TEST_URL is required: {error}"));
|
||||
profile.database.backend = "postgres".to_string();
|
||||
profile.database.postgres.url = database_url;
|
||||
let pool = ks_onchain_transport::HttpEndpointPool::from_profile(&profile)
|
||||
.unwrap_or_else(|error| panic!("HTTP pool creation failed: {error}"));
|
||||
let store_options = ks_store::PostgresStoreOptions::new(
|
||||
profile.database.postgres.url.clone(),
|
||||
profile.database.postgres.max_connections,
|
||||
profile.database.postgres.connect_timeout_ms,
|
||||
false,
|
||||
)
|
||||
.unwrap_or_else(|error| panic!("PostgreSQL options failed: {error}"));
|
||||
let store = ks_store::PostgresStore::connect(store_options)
|
||||
.await
|
||||
.unwrap_or_else(|error| panic!("PostgreSQL connection failed: {error}"));
|
||||
if let std::result::Result::Err(error) = store.initialize_store_schema().await {
|
||||
panic!("PostgreSQL schema initialization failed: {error}");
|
||||
}
|
||||
let configured_wallet_dir = std::path::PathBuf::from(profile.wallet.wallet_dir.as_str());
|
||||
let wallet_dir = if configured_wallet_dir.is_absolute() {
|
||||
configured_wallet_dir
|
||||
} else {
|
||||
workspace_root.join(configured_wallet_dir)
|
||||
};
|
||||
let options = crate::MetaplexCreateFixturePreparationOptions::new(wallet_dir);
|
||||
let summary = crate::execute_devnet_metaplex_collection_verify_campaign(
|
||||
&pool,
|
||||
&store,
|
||||
&profile,
|
||||
workspace_root,
|
||||
&options,
|
||||
&crate::NoopSolanaExecutionObserver,
|
||||
)
|
||||
.await
|
||||
.unwrap_or_else(|error| {
|
||||
panic!("Metaplex collection Verify -> Unverify Devnet campaign failed: {error}")
|
||||
});
|
||||
let verify_confirmation = summary
|
||||
.verify
|
||||
.confirmation
|
||||
.as_ref()
|
||||
.unwrap_or_else(|| panic!("Verify confirmation missing"));
|
||||
let unverify_confirmation = summary
|
||||
.unverify
|
||||
.confirmation
|
||||
.as_ref()
|
||||
.unwrap_or_else(|| panic!("Unverify confirmation missing"));
|
||||
println!(
|
||||
"METAPLEX_COLLECTION_VERIFY_FIXTURE parent_mint={} parent_metadata={} parent_master_edition={} member_mint={} member_metadata={} member_master_edition={}",
|
||||
summary.parent.fixture.mint,
|
||||
summary.parent.fixture.metadata,
|
||||
summary.parent.fixture.master_edition,
|
||||
summary.member.fixture.mint,
|
||||
summary.member.fixture.metadata,
|
||||
summary.member.fixture.master_edition,
|
||||
);
|
||||
println!(
|
||||
"METAPLEX_COLLECTION_VERIFY_STEP operation={} signature={} slot={:?} materializations={}",
|
||||
summary.verify.plan.operation_code,
|
||||
verify_confirmation.signature.0,
|
||||
verify_confirmation.slot,
|
||||
summary.verify.materializations.len(),
|
||||
);
|
||||
println!(
|
||||
"METAPLEX_COLLECTION_VERIFY_STEP operation={} signature={} slot={:?} materializations={}",
|
||||
summary.unverify.plan.operation_code,
|
||||
unverify_confirmation.signature.0,
|
||||
unverify_confirmation.slot,
|
||||
summary.unverify.materializations.len(),
|
||||
);
|
||||
println!(
|
||||
"METAPLEX_COLLECTION_VERIFY_STATE verified_before={} verified_after_verify={} verified_after_unverify={} size_before={} size_after_verify={} size_after_unverify={}",
|
||||
summary.state.verified_before,
|
||||
summary.state.verified_after_verify,
|
||||
summary.state.verified_after_unverify,
|
||||
summary.state.collection_size_before,
|
||||
summary.state.collection_size_after_verify,
|
||||
summary.state.collection_size_after_unverify,
|
||||
);
|
||||
println!(
|
||||
"METAPLEX_COLLECTION_VERIFY_EVIDENCE verify={} unverify={}",
|
||||
execution_evidence_json(&summary.verify),
|
||||
execution_evidence_json(&summary.unverify),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,922 @@
|
||||
// file: ks-pipeline-demo-scenarios/src/metadata/metaplex_token_metadata/create_mint_campaign.rs
|
||||
// version: 10
|
||||
|
||||
//! Confirmed Devnet `Create -> Mint` campaign for one Metaplex asset family.
|
||||
|
||||
/// Confirmed SPL state observed before and after the Metaplex `Mint` operation.
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub struct DevnetMetaplexCreateMintTokenState {
|
||||
/// Raw amount minted by the campaign.
|
||||
pub expected_amount_raw: u64,
|
||||
/// SPL mint supply observed after `Create` and before `Mint`.
|
||||
pub supply_before_mint: u64,
|
||||
/// Operator token-account amount observed after `Create` and before `Mint`.
|
||||
pub token_amount_before_mint: u64,
|
||||
/// Raw classic SPL token-account state observed before `Mint`.
|
||||
pub token_account_state_before_mint: u8,
|
||||
/// SPL mint supply observed at or after the confirmed `Mint` slot.
|
||||
pub supply_after_mint: u64,
|
||||
/// Operator token-account amount observed at or after the confirmed `Mint` slot.
|
||||
pub token_amount_after_mint: u64,
|
||||
/// Raw classic SPL token-account state observed after `Mint`.
|
||||
pub token_account_state_after_mint: u8,
|
||||
}
|
||||
|
||||
/// Complete evidence produced by one family-specific Metaplex `Create -> Mint` campaign.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct DevnetMetaplexCreateMintCampaignSummary {
|
||||
/// Asset family exercised by this campaign.
|
||||
pub asset_family: crate::MetaplexTokenMetadataAssetFamily,
|
||||
/// Fresh classic SPL mint and ATA fixture.
|
||||
pub fixture: crate::MetaplexCreateFixturePreparationSummary,
|
||||
/// Confirmed `Create` execution evidence.
|
||||
pub create: crate::DevnetMetaplexTokenMetadataExecutionSummary,
|
||||
/// Confirmed `Mint` execution evidence.
|
||||
pub mint: crate::DevnetMetaplexTokenMetadataExecutionSummary,
|
||||
/// Exact classic SPL supply and token-account transition caused by `Mint`.
|
||||
pub token_state: crate::DevnetMetaplexCreateMintTokenState,
|
||||
}
|
||||
|
||||
/// Returns the exact ordered operation names exercised by this campaign.
|
||||
pub fn metaplex_create_mint_campaign_operation_names() -> &'static [&'static str; 2] {
|
||||
return &["create", "mint"];
|
||||
}
|
||||
|
||||
/// Executes one fresh family-specific `Create -> Mint` campaign on Devnet.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub async fn execute_devnet_metaplex_create_mint_campaign<S, O>(
|
||||
http_pool: &ks_onchain_transport::HttpEndpointPool,
|
||||
store: &S,
|
||||
profile: &ks_config::ProfileConfig,
|
||||
workspace_root: &std::path::Path,
|
||||
options: &crate::MetaplexCreateFixturePreparationOptions,
|
||||
observer: &O,
|
||||
) -> ks_core::Result<crate::DevnetMetaplexCreateMintCampaignSummary>
|
||||
where
|
||||
S: ks_store::RawTransactionStore
|
||||
+ ks_store::CoreExtractionStore
|
||||
+ ks_store::DecodePipelineStore
|
||||
+ Sync,
|
||||
O: crate::SolanaExecutionObserver,
|
||||
{
|
||||
if !profile.wallet.devnet_send_enabled {
|
||||
return std::result::Result::Err(ks_core::Error::config(
|
||||
"Metaplex Create -> Mint Devnet campaign requires devnet_send_enabled=true",
|
||||
));
|
||||
}
|
||||
let fixture =
|
||||
match crate::prepare_metaplex_create_fixture(http_pool, profile, workspace_root, options)
|
||||
.await
|
||||
{
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let decoders: std::vec::Vec<std::sync::Arc<dyn ks_lib::DcApiInstructionDecoder>> =
|
||||
std::vec![std::sync::Arc::new(ks_lib::DcMetadataMetaplexTokenMetadataDecoder)];
|
||||
let materializers: std::vec::Vec<std::sync::Arc<dyn ks_lib::MtApiEventMaterializer>> = std::vec![
|
||||
std::sync::Arc::new(ks_lib::MtAdminMaterializer),
|
||||
std::sync::Arc::new(ks_lib::MtComplianceAuditMaterializer),
|
||||
std::sync::Arc::new(ks_lib::MtLifecycleMaterializer),
|
||||
std::sync::Arc::new(ks_lib::MtMetadataMetaplexTokenMetadataMaterializer),
|
||||
std::sync::Arc::new(ks_lib::MtRiskMaterializer),
|
||||
];
|
||||
let mut create_request =
|
||||
match crate::DevnetMetaplexTokenMetadataExecutionRequest::from_operation_json(
|
||||
format!("metaplex-create-mint-create-{}", uuid::Uuid::new_v4()),
|
||||
fixture.operation_json.as_str(),
|
||||
) {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
create_request.query_role = options.query_role.clone();
|
||||
create_request.transaction_role = options.transaction_role.clone();
|
||||
create_request.submit = true;
|
||||
create_request.operator_confirmed = true;
|
||||
create_request.materialize_after_confirmation = true;
|
||||
create_request.post_validation_max_retries = 20;
|
||||
create_request.postcondition_reads = postcondition_reads(
|
||||
&fixture,
|
||||
options.query_role.as_str(),
|
||||
std::option::Option::None,
|
||||
false,
|
||||
);
|
||||
let create = match crate::execute_devnet_metaplex_token_metadata(
|
||||
http_pool,
|
||||
store,
|
||||
profile,
|
||||
workspace_root,
|
||||
&create_request,
|
||||
decoders.as_slice(),
|
||||
materializers.as_slice(),
|
||||
observer,
|
||||
)
|
||||
.await
|
||||
{
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let create_slot = match validate_confirmed_execution("create", &create) {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let create_signature = match create.confirmation.as_ref() {
|
||||
std::option::Option::Some(value) => value.signature.0.as_str(),
|
||||
std::option::Option::None => "<missing-signature>",
|
||||
};
|
||||
if let std::result::Result::Err(error) =
|
||||
validate_expected_stateful_accounts(&fixture, &create.after, false)
|
||||
{
|
||||
return std::result::Result::Err(ks_core::Error::new(
|
||||
"metaplex_create_mint_post_create_stateful_invalid",
|
||||
format!(
|
||||
"confirmed Metaplex Create {create_signature} at slot {create_slot} left invalid expected stateful accounts: {error}"
|
||||
),
|
||||
));
|
||||
}
|
||||
let mint_before = match crate::read_mint_account_at_or_after(
|
||||
http_pool,
|
||||
options.query_role.as_str(),
|
||||
fixture.mint.as_str(),
|
||||
std::option::Option::Some(create_slot),
|
||||
)
|
||||
.await
|
||||
{
|
||||
std::result::Result::Ok(std::option::Option::Some(value)) => value,
|
||||
std::result::Result::Ok(std::option::Option::None) => {
|
||||
return std::result::Result::Err(ks_core::Error::new(
|
||||
"metaplex_create_mint_mint_missing_before_mint",
|
||||
format!(
|
||||
"classic SPL mint is unavailable after confirmed Metaplex Create {create_signature} at slot {create_slot}"
|
||||
),
|
||||
));
|
||||
},
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let expected_mint_authority = expected_mint_authority_after_create(&fixture);
|
||||
if let std::result::Result::Err(error) = crate::validate_classic_fixture_mint_state(
|
||||
&mint_before,
|
||||
expected_mint_authority,
|
||||
fixture.mint_decimals,
|
||||
0,
|
||||
) {
|
||||
return std::result::Result::Err(ks_core::Error::new(
|
||||
"metaplex_create_mint_post_create_spl_state_invalid",
|
||||
format!(
|
||||
"confirmed Metaplex Create {} at slot {} left an invalid classic mint state; expected mint/freeze authority {}; {error}",
|
||||
create_signature, create_slot, expected_mint_authority
|
||||
),
|
||||
));
|
||||
}
|
||||
let token_before = match crate::read_token_account_at_or_after(
|
||||
http_pool,
|
||||
options.query_role.as_str(),
|
||||
fixture.token_account.as_str(),
|
||||
std::option::Option::Some(create_slot),
|
||||
)
|
||||
.await
|
||||
{
|
||||
std::result::Result::Ok(std::option::Option::Some(value)) => value,
|
||||
std::result::Result::Ok(std::option::Option::None) => {
|
||||
return std::result::Result::Err(ks_core::Error::new(
|
||||
"metaplex_create_mint_token_missing_before_mint",
|
||||
format!(
|
||||
"classic SPL token account is unavailable after confirmed Metaplex Create {create_signature} at slot {create_slot}"
|
||||
),
|
||||
));
|
||||
},
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
if let std::result::Result::Err(error) = crate::validate_classic_fixture_token_account_state(
|
||||
&token_before,
|
||||
fixture.mint.as_str(),
|
||||
fixture.authority.as_str(),
|
||||
0,
|
||||
spl_token_interface::state::AccountState::Initialized,
|
||||
) {
|
||||
return std::result::Result::Err(ks_core::Error::new(
|
||||
"metaplex_create_mint_post_create_token_state_invalid",
|
||||
format!(
|
||||
"confirmed Metaplex Create {create_signature} at slot {create_slot} left an invalid classic token-account state: {error}"
|
||||
),
|
||||
));
|
||||
}
|
||||
let supply_before_mint = match crate::classic_fixture_mint_supply(&mint_before) {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let token_amount_before_mint = match crate::classic_fixture_token_amount(&token_before) {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let token_account_state_before_mint =
|
||||
match crate::classic_fixture_token_account_state(&token_before) {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let mut mint_request =
|
||||
match crate::DevnetMetaplexTokenMetadataExecutionRequest::from_operation_json(
|
||||
format!("metaplex-create-mint-mint-{}", uuid::Uuid::new_v4()),
|
||||
fixture.mint_operation_json.as_str(),
|
||||
) {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
mint_request.query_role = options.query_role.clone();
|
||||
mint_request.transaction_role = options.transaction_role.clone();
|
||||
mint_request.submit = true;
|
||||
mint_request.operator_confirmed = true;
|
||||
mint_request.materialize_after_confirmation = true;
|
||||
mint_request.post_validation_max_retries = 20;
|
||||
mint_request.preflight_reads = postcondition_reads(
|
||||
&fixture,
|
||||
options.query_role.as_str(),
|
||||
std::option::Option::Some(create_slot),
|
||||
false,
|
||||
);
|
||||
mint_request.postcondition_reads = postcondition_reads(
|
||||
&fixture,
|
||||
options.query_role.as_str(),
|
||||
std::option::Option::Some(create_slot),
|
||||
true,
|
||||
);
|
||||
let mint = match crate::execute_devnet_metaplex_token_metadata(
|
||||
http_pool,
|
||||
store,
|
||||
profile,
|
||||
workspace_root,
|
||||
&mint_request,
|
||||
decoders.as_slice(),
|
||||
materializers.as_slice(),
|
||||
observer,
|
||||
)
|
||||
.await
|
||||
{
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let mint_slot = match validate_confirmed_execution("mint", &mint) {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let mint_signature = match mint.confirmation.as_ref() {
|
||||
std::option::Option::Some(value) => value.signature.0.as_str(),
|
||||
std::option::Option::None => "<missing-signature>",
|
||||
};
|
||||
if let std::result::Result::Err(error) =
|
||||
validate_expected_stateful_accounts(&fixture, &mint.after, true)
|
||||
{
|
||||
return std::result::Result::Err(ks_core::Error::new(
|
||||
"metaplex_create_mint_post_mint_stateful_invalid",
|
||||
format!(
|
||||
"confirmed Metaplex Mint {mint_signature} at slot {mint_slot} left invalid expected stateful accounts: {error}"
|
||||
),
|
||||
));
|
||||
}
|
||||
let mint_after = match crate::read_mint_account_at_or_after(
|
||||
http_pool,
|
||||
options.query_role.as_str(),
|
||||
fixture.mint.as_str(),
|
||||
std::option::Option::Some(mint_slot),
|
||||
)
|
||||
.await
|
||||
{
|
||||
std::result::Result::Ok(std::option::Option::Some(value)) => value,
|
||||
std::result::Result::Ok(std::option::Option::None) => {
|
||||
return std::result::Result::Err(ks_core::Error::new(
|
||||
"metaplex_create_mint_mint_missing_after_mint",
|
||||
format!(
|
||||
"classic SPL mint is unavailable after confirmed Metaplex Mint {mint_signature} at slot {mint_slot}"
|
||||
),
|
||||
));
|
||||
},
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
if let std::result::Result::Err(error) = crate::validate_classic_fixture_mint_state(
|
||||
&mint_after,
|
||||
expected_mint_authority,
|
||||
fixture.mint_decimals,
|
||||
fixture.mint_amount_raw,
|
||||
) {
|
||||
return std::result::Result::Err(ks_core::Error::new(
|
||||
"metaplex_create_mint_post_mint_spl_state_invalid",
|
||||
format!(
|
||||
"confirmed Metaplex Mint {} at slot {} left an invalid classic mint state; expected mint/freeze authority {}; {error}",
|
||||
mint_signature, mint_slot, expected_mint_authority
|
||||
),
|
||||
));
|
||||
}
|
||||
let token_after = match crate::read_token_account_at_or_after(
|
||||
http_pool,
|
||||
options.query_role.as_str(),
|
||||
fixture.token_account.as_str(),
|
||||
std::option::Option::Some(mint_slot),
|
||||
)
|
||||
.await
|
||||
{
|
||||
std::result::Result::Ok(std::option::Option::Some(value)) => value,
|
||||
std::result::Result::Ok(std::option::Option::None) => {
|
||||
return std::result::Result::Err(ks_core::Error::new(
|
||||
"metaplex_create_mint_token_missing_after_mint",
|
||||
format!(
|
||||
"classic SPL token account is unavailable after confirmed Metaplex Mint {mint_signature} at slot {mint_slot}"
|
||||
),
|
||||
));
|
||||
},
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let expected_token_account_state_after_mint =
|
||||
expected_token_account_state_after_mint(options.asset_family);
|
||||
if let std::result::Result::Err(error) = crate::validate_classic_fixture_token_account_state(
|
||||
&token_after,
|
||||
fixture.mint.as_str(),
|
||||
fixture.authority.as_str(),
|
||||
fixture.mint_amount_raw,
|
||||
expected_token_account_state_after_mint,
|
||||
) {
|
||||
return std::result::Result::Err(ks_core::Error::new(
|
||||
"metaplex_create_mint_post_mint_token_state_invalid",
|
||||
format!(
|
||||
"confirmed Metaplex Mint {mint_signature} at slot {mint_slot} left an invalid classic token-account state: {error}"
|
||||
),
|
||||
));
|
||||
}
|
||||
let supply_after_mint = match crate::classic_fixture_mint_supply(&mint_after) {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let token_amount_after_mint = match crate::classic_fixture_token_amount(&token_after) {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let token_account_state_after_mint =
|
||||
match crate::classic_fixture_token_account_state(&token_after) {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
return std::result::Result::Ok(crate::DevnetMetaplexCreateMintCampaignSummary {
|
||||
asset_family: options.asset_family,
|
||||
token_state: crate::DevnetMetaplexCreateMintTokenState {
|
||||
expected_amount_raw: fixture.mint_amount_raw,
|
||||
supply_before_mint,
|
||||
token_amount_before_mint,
|
||||
token_account_state_before_mint,
|
||||
supply_after_mint,
|
||||
token_amount_after_mint,
|
||||
token_account_state_after_mint,
|
||||
},
|
||||
fixture,
|
||||
create,
|
||||
mint,
|
||||
});
|
||||
}
|
||||
|
||||
fn expected_mint_authority_after_create(
|
||||
fixture: &crate::MetaplexCreateFixturePreparationSummary,
|
||||
) -> &str {
|
||||
if fixture.master_edition_requested {
|
||||
return fixture.master_edition.as_str();
|
||||
}
|
||||
return fixture.authority.as_str();
|
||||
}
|
||||
|
||||
fn expected_token_account_state_after_mint(
|
||||
asset_family: crate::MetaplexTokenMetadataAssetFamily,
|
||||
) -> spl_token_interface::state::AccountState {
|
||||
if asset_family == crate::MetaplexTokenMetadataAssetFamily::ProgrammableNft {
|
||||
return spl_token_interface::state::AccountState::Frozen;
|
||||
}
|
||||
return spl_token_interface::state::AccountState::Initialized;
|
||||
}
|
||||
|
||||
fn postcondition_reads(
|
||||
fixture: &crate::MetaplexCreateFixturePreparationSummary,
|
||||
query_role: &str,
|
||||
min_context_slot: std::option::Option<u64>,
|
||||
include_token_record: bool,
|
||||
) -> std::vec::Vec<ks_pipeline::MetaplexTokenMetadataStatefulReadRequest> {
|
||||
let mint = ks_lib::MdPubkey(fixture.mint.clone());
|
||||
let mut reads = vec![ks_pipeline::MetaplexTokenMetadataStatefulReadRequest {
|
||||
query_role: query_role.to_string(),
|
||||
account: ks_lib::MdPubkey(fixture.metadata.clone()),
|
||||
kind: ks_pipeline::MetaplexTokenMetadataAccountKind::Metadata,
|
||||
min_context_slot,
|
||||
max_data_bytes: ks_pipeline::MAX_METAPLEX_TOKEN_METADATA_ACCOUNT_BYTES,
|
||||
}];
|
||||
if fixture.master_edition_requested {
|
||||
reads.push(ks_pipeline::MetaplexTokenMetadataStatefulReadRequest {
|
||||
query_role: query_role.to_string(),
|
||||
account: ks_lib::MdPubkey(fixture.master_edition.clone()),
|
||||
kind: ks_pipeline::MetaplexTokenMetadataAccountKind::Edition { mint: mint.clone() },
|
||||
min_context_slot,
|
||||
max_data_bytes: ks_pipeline::MAX_METAPLEX_TOKEN_METADATA_ACCOUNT_BYTES,
|
||||
});
|
||||
}
|
||||
if include_token_record {
|
||||
if let std::option::Option::Some(token_record) = fixture.token_record.as_ref() {
|
||||
reads.push(ks_pipeline::MetaplexTokenMetadataStatefulReadRequest {
|
||||
query_role: query_role.to_string(),
|
||||
account: ks_lib::MdPubkey(token_record.clone()),
|
||||
kind: ks_pipeline::MetaplexTokenMetadataAccountKind::TokenRecord {
|
||||
mint,
|
||||
token: ks_lib::MdPubkey(fixture.token_account.clone()),
|
||||
},
|
||||
min_context_slot,
|
||||
max_data_bytes: ks_pipeline::MAX_METAPLEX_TOKEN_METADATA_ACCOUNT_BYTES,
|
||||
});
|
||||
}
|
||||
}
|
||||
return reads;
|
||||
}
|
||||
|
||||
fn validate_confirmed_execution(
|
||||
step: &str,
|
||||
execution: &crate::DevnetMetaplexTokenMetadataExecutionSummary,
|
||||
) -> ks_core::Result<u64> {
|
||||
if !execution.simulation.success {
|
||||
return std::result::Result::Err(ks_core::Error::new(
|
||||
"metaplex_create_mint_simulation_failed",
|
||||
format!("Metaplex {step} simulation failed"),
|
||||
));
|
||||
}
|
||||
let confirmation = match execution.confirmation.as_ref() {
|
||||
std::option::Option::Some(value)
|
||||
if matches!(
|
||||
value.status,
|
||||
ks_lib::ExApiExecutionConfirmationStatus::Confirmed
|
||||
| ks_lib::ExApiExecutionConfirmationStatus::Finalized
|
||||
) =>
|
||||
{
|
||||
value
|
||||
},
|
||||
std::option::Option::Some(value) => {
|
||||
return std::result::Result::Err(ks_core::Error::new(
|
||||
"metaplex_create_mint_confirmation_incomplete",
|
||||
format!("Metaplex {step} stopped at {:?}", value.status),
|
||||
));
|
||||
},
|
||||
std::option::Option::None => {
|
||||
return std::result::Result::Err(ks_core::Error::new(
|
||||
"metaplex_create_mint_confirmation_missing",
|
||||
format!("Metaplex {step} has no confirmation evidence"),
|
||||
));
|
||||
},
|
||||
};
|
||||
let diagnostic = match execution.post_execution.as_ref() {
|
||||
std::option::Option::Some(value) => value,
|
||||
std::option::Option::None => {
|
||||
return std::result::Result::Err(ks_core::Error::new(
|
||||
"metaplex_create_mint_post_execution_missing",
|
||||
format!("Metaplex {step} has no post-execution diagnostic"),
|
||||
));
|
||||
},
|
||||
};
|
||||
if !diagnostic.canonical_inserted
|
||||
|| !diagnostic.core_extracted
|
||||
|| !diagnostic.decode_replayed
|
||||
|| !diagnostic.materialized
|
||||
|| execution.materializations.is_empty()
|
||||
|| execution.materialized_snapshots.is_empty()
|
||||
{
|
||||
return std::result::Result::Err(ks_core::Error::new(
|
||||
"metaplex_create_mint_post_execution_incomplete",
|
||||
format!(
|
||||
"Metaplex {step} post-execution incomplete: signature={}, slot={:?}, canonical_inserted={}, core_extracted={}, decode_replayed={}, materialized={}, materialization_rows={}, materialized_snapshots={}, diagnostics={:?}",
|
||||
confirmation.signature.0,
|
||||
confirmation.slot,
|
||||
diagnostic.canonical_inserted,
|
||||
diagnostic.core_extracted,
|
||||
diagnostic.decode_replayed,
|
||||
diagnostic.materialized,
|
||||
execution.materializations.len(),
|
||||
execution.materialized_snapshots.len(),
|
||||
diagnostic.diagnostics
|
||||
),
|
||||
));
|
||||
}
|
||||
let second_replay = match execution.idempotence_replay.as_ref() {
|
||||
std::option::Option::Some(value) => value,
|
||||
std::option::Option::None => {
|
||||
return std::result::Result::Err(ks_core::Error::new(
|
||||
"metaplex_create_mint_idempotence_missing",
|
||||
format!("Metaplex {step} has no idempotence replay evidence"),
|
||||
));
|
||||
},
|
||||
};
|
||||
if second_replay.failed_inputs != 0
|
||||
|| second_replay.processing_error_inputs != 0
|
||||
|| second_replay.processors.iter().any(|processor| {
|
||||
return processor.failed != 0
|
||||
|| processor.processing_errors != 0
|
||||
|| processor.materialized_outputs != 0
|
||||
|| processor.materialization_refused != 0;
|
||||
})
|
||||
{
|
||||
return std::result::Result::Err(ks_core::Error::new(
|
||||
"metaplex_create_mint_idempotence_failed",
|
||||
format!("Metaplex {step} second replay is not idempotent"),
|
||||
));
|
||||
}
|
||||
return match confirmation.slot {
|
||||
std::option::Option::Some(value) => std::result::Result::Ok(value),
|
||||
std::option::Option::None => std::result::Result::Err(ks_core::Error::new(
|
||||
"metaplex_create_mint_confirmation_slot_missing",
|
||||
format!("Metaplex {step} confirmation has no slot"),
|
||||
)),
|
||||
};
|
||||
}
|
||||
|
||||
fn validate_expected_stateful_accounts(
|
||||
fixture: &crate::MetaplexCreateFixturePreparationSummary,
|
||||
snapshots: &[ks_pipeline::MetaplexTokenMetadataStatefulReadResult],
|
||||
include_token_record: bool,
|
||||
) -> ks_core::Result<()> {
|
||||
let metadata = snapshots.iter().any(|value| {
|
||||
return value.snapshot.account.0.as_str() == fixture.metadata.as_str()
|
||||
&& value.snapshot.account_kind == "metadata"
|
||||
&& value
|
||||
.snapshot
|
||||
.mint
|
||||
.as_ref()
|
||||
.is_some_and(|mint| return mint.0.as_str() == fixture.mint.as_str());
|
||||
});
|
||||
let edition = snapshots.iter().any(|value| {
|
||||
return value.snapshot.account.0.as_str() == fixture.master_edition.as_str()
|
||||
&& value.snapshot.account_kind == "edition"
|
||||
&& value
|
||||
.snapshot
|
||||
.mint
|
||||
.as_ref()
|
||||
.is_some_and(|mint| return mint.0.as_str() == fixture.mint.as_str());
|
||||
});
|
||||
let token_record = fixture.token_record.as_ref().is_some_and(|expected| {
|
||||
return snapshots.iter().any(|value| {
|
||||
return value.snapshot.account.0.as_str() == expected.as_str()
|
||||
&& value.snapshot.account_kind == "token_record"
|
||||
&& value
|
||||
.snapshot
|
||||
.mint
|
||||
.as_ref()
|
||||
.is_some_and(|mint| return mint.0.as_str() == fixture.mint.as_str());
|
||||
});
|
||||
});
|
||||
if !metadata
|
||||
|| fixture.master_edition_requested != edition
|
||||
|| (include_token_record && fixture.token_record.is_some() && !token_record)
|
||||
|| (!include_token_record && token_record)
|
||||
{
|
||||
return std::result::Result::Err(ks_core::Error::new(
|
||||
"metaplex_create_mint_stateful_postcondition_failed",
|
||||
format!(
|
||||
"Metaplex stateful accounts mismatch: metadata={metadata}, edition={edition}, token_record={token_record}, master_edition_requested={}, token_record_expected={}",
|
||||
fixture.master_edition_requested,
|
||||
include_token_record && fixture.token_record.is_some()
|
||||
),
|
||||
));
|
||||
}
|
||||
return std::result::Result::Ok(());
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
fn fixture(
|
||||
family: crate::MetaplexTokenMetadataAssetFamily,
|
||||
master_edition_requested: bool,
|
||||
token_record: std::option::Option<&str>,
|
||||
) -> crate::MetaplexCreateFixturePreparationSummary {
|
||||
return crate::MetaplexCreateFixturePreparationSummary {
|
||||
mint_keypair_path: std::path::PathBuf::from("mint.json"),
|
||||
asset_family: family,
|
||||
mint: "11111111111111111111111111111111".to_string(),
|
||||
mint_decimals: if family == crate::MetaplexTokenMetadataAssetFamily::Fungible {
|
||||
9
|
||||
} else {
|
||||
0
|
||||
},
|
||||
metadata: "SysvarC1ock11111111111111111111111111111111".to_string(),
|
||||
master_edition: "SysvarRent111111111111111111111111111111111".to_string(),
|
||||
master_edition_requested,
|
||||
token_account: "SysvarS1otHashes111111111111111111111111111".to_string(),
|
||||
token_record: token_record.map(|value| return value.to_string()),
|
||||
collection_mint: std::option::Option::None,
|
||||
authority: "11111111111111111111111111111111".to_string(),
|
||||
mint_amount_raw: if family == crate::MetaplexTokenMetadataAssetFamily::Fungible {
|
||||
1_000_000_000
|
||||
} else {
|
||||
1
|
||||
},
|
||||
operation_json: "{}".to_string(),
|
||||
mint_operation_json: "{}".to_string(),
|
||||
mint_created: true,
|
||||
preparation_signature: std::option::Option::Some("signature".to_string()),
|
||||
};
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn campaign_contract_is_exactly_create_then_mint() {
|
||||
assert_eq!(crate::metaplex_create_mint_campaign_operation_names(), &["create", "mint"],);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stateful_read_inventory_tracks_master_edition_and_pnft_token_record() {
|
||||
let nft =
|
||||
fixture(crate::MetaplexTokenMetadataAssetFamily::Nft, true, std::option::Option::None);
|
||||
let nft_create =
|
||||
super::postcondition_reads(&nft, "rpc", std::option::Option::Some(7), false);
|
||||
assert_eq!(nft_create.len(), 2);
|
||||
assert!(
|
||||
nft_create
|
||||
.iter()
|
||||
.all(|value| return value.min_context_slot == std::option::Option::Some(7))
|
||||
);
|
||||
assert!(nft_create.iter().all(|value| {
|
||||
return value.max_data_bytes == ks_onchain_transport::MAX_COMPLETE_ACCOUNT_DATA_BYTES;
|
||||
}));
|
||||
let pnft = fixture(
|
||||
crate::MetaplexTokenMetadataAssetFamily::ProgrammableNft,
|
||||
true,
|
||||
std::option::Option::Some("Stake11111111111111111111111111111111111111"),
|
||||
);
|
||||
let pnft_mint =
|
||||
super::postcondition_reads(&pnft, "rpc", std::option::Option::Some(9), true);
|
||||
assert_eq!(pnft_mint.len(), 3);
|
||||
assert!(matches!(
|
||||
&pnft_mint[2].kind,
|
||||
ks_pipeline::MetaplexTokenMetadataAccountKind::TokenRecord { .. }
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn campaign_supports_all_five_fixture_families() {
|
||||
for family in [
|
||||
crate::MetaplexTokenMetadataAssetFamily::Nft,
|
||||
crate::MetaplexTokenMetadataAssetFamily::Sft,
|
||||
crate::MetaplexTokenMetadataAssetFamily::Fungible,
|
||||
crate::MetaplexTokenMetadataAssetFamily::Collection,
|
||||
crate::MetaplexTokenMetadataAssetFamily::ProgrammableNft,
|
||||
] {
|
||||
let options = crate::MetaplexCreateFixturePreparationOptions::new(
|
||||
std::path::PathBuf::from("wallets"),
|
||||
)
|
||||
.with_asset_family(family);
|
||||
assert_eq!(options.asset_family, family);
|
||||
}
|
||||
}
|
||||
|
||||
fn family_from_env(value: &str) -> crate::MetaplexTokenMetadataAssetFamily {
|
||||
return match value {
|
||||
"nft" => crate::MetaplexTokenMetadataAssetFamily::Nft,
|
||||
"sft" => crate::MetaplexTokenMetadataAssetFamily::Sft,
|
||||
"fungible" => crate::MetaplexTokenMetadataAssetFamily::Fungible,
|
||||
"collection" => crate::MetaplexTokenMetadataAssetFamily::Collection,
|
||||
"programmable_nft" | "pnft" => crate::MetaplexTokenMetadataAssetFamily::ProgrammableNft,
|
||||
_ => panic!("unsupported KB_DEVNET_METAPLEX_CREATE_MINT_FAMILY `{value}`"),
|
||||
};
|
||||
}
|
||||
|
||||
fn execution_evidence_json(
|
||||
execution: &crate::DevnetMetaplexTokenMetadataExecutionSummary,
|
||||
) -> serde_json::Value {
|
||||
let confirmation = execution.confirmation.as_ref();
|
||||
let confirmation_status = confirmation
|
||||
.map(|value| return format!("{:?}", value.status))
|
||||
.unwrap_or_else(|| return "missing".to_string());
|
||||
let confirmation_slot = confirmation.and_then(|value| return value.slot);
|
||||
let signature = confirmation
|
||||
.map(|value| return value.signature.0.clone())
|
||||
.unwrap_or_else(|| return "missing".to_string());
|
||||
let diagnostic = execution.post_execution.as_ref();
|
||||
let idempotence_clean = execution.idempotence_replay.as_ref().is_some_and(|replay| {
|
||||
return replay.failed_inputs == 0
|
||||
&& replay.processing_error_inputs == 0
|
||||
&& replay.processors.iter().all(|processor| {
|
||||
return processor.failed == 0
|
||||
&& processor.processing_errors == 0
|
||||
&& processor.materialized_outputs == 0
|
||||
&& processor.materialization_refused == 0;
|
||||
});
|
||||
});
|
||||
return serde_json::json!({
|
||||
"operation": execution.plan.operation_code.as_str(),
|
||||
"cluster": execution.cluster,
|
||||
"genesisHash": execution.genesis_hash.as_str(),
|
||||
"simulationContextSlot": execution.simulation_context_slot,
|
||||
"simulationSuccess": execution.simulation.success,
|
||||
"simulationLogCount": execution.simulation.logs.len(),
|
||||
"messageHash": execution.readiness.message_hash.as_str(),
|
||||
"feeLamports": execution.fee.fee_lamports,
|
||||
"signature": signature,
|
||||
"confirmationStatus": confirmation_status,
|
||||
"confirmationSlot": confirmation_slot,
|
||||
"beforeSnapshots": execution.before.len(),
|
||||
"afterSnapshots": execution.after.len(),
|
||||
"canonicalHydration": diagnostic.is_some_and(|value| return value.canonical_inserted),
|
||||
"coreExtraction": diagnostic.is_some_and(|value| return value.core_extracted),
|
||||
"decodeReplay": diagnostic.is_some_and(|value| return value.decode_replayed),
|
||||
"materialized": diagnostic.is_some_and(|value| return value.materialized),
|
||||
"instructionMaterializations": execution.materializations.len(),
|
||||
"materializedSnapshots": execution.materialized_snapshots.len(),
|
||||
"idempotenceReplayClean": idempotence_clean,
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn post_create_mint_authority_tracks_master_edition_only_for_nft_families() {
|
||||
let authority = solana_pubkey::Pubkey::new_unique().to_string();
|
||||
let master_edition = solana_pubkey::Pubkey::new_unique().to_string();
|
||||
let families = [
|
||||
(crate::MetaplexTokenMetadataAssetFamily::Nft, true),
|
||||
(crate::MetaplexTokenMetadataAssetFamily::Sft, false),
|
||||
(crate::MetaplexTokenMetadataAssetFamily::Fungible, false),
|
||||
(crate::MetaplexTokenMetadataAssetFamily::Collection, true),
|
||||
(crate::MetaplexTokenMetadataAssetFamily::ProgrammableNft, true),
|
||||
];
|
||||
for (asset_family, master_edition_requested) in families {
|
||||
let fixture = crate::MetaplexCreateFixturePreparationSummary {
|
||||
mint_keypair_path: std::path::PathBuf::from("fixture.json"),
|
||||
asset_family,
|
||||
mint: solana_pubkey::Pubkey::new_unique().to_string(),
|
||||
mint_decimals: 0,
|
||||
metadata: solana_pubkey::Pubkey::new_unique().to_string(),
|
||||
master_edition: master_edition.clone(),
|
||||
master_edition_requested,
|
||||
token_account: solana_pubkey::Pubkey::new_unique().to_string(),
|
||||
token_record: std::option::Option::None,
|
||||
collection_mint: std::option::Option::None,
|
||||
authority: authority.clone(),
|
||||
mint_amount_raw: 1,
|
||||
operation_json: "{}".to_string(),
|
||||
mint_operation_json: "{}".to_string(),
|
||||
mint_created: true,
|
||||
preparation_signature: std::option::Option::None,
|
||||
};
|
||||
assert_eq!(
|
||||
super::expected_mint_authority_after_create(&fixture),
|
||||
if master_edition_requested {
|
||||
master_edition.as_str()
|
||||
} else {
|
||||
authority.as_str()
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn post_mint_token_account_state_is_frozen_only_for_programmable_nft() {
|
||||
let families = [
|
||||
(
|
||||
crate::MetaplexTokenMetadataAssetFamily::Nft,
|
||||
spl_token_interface::state::AccountState::Initialized,
|
||||
),
|
||||
(
|
||||
crate::MetaplexTokenMetadataAssetFamily::Sft,
|
||||
spl_token_interface::state::AccountState::Initialized,
|
||||
),
|
||||
(
|
||||
crate::MetaplexTokenMetadataAssetFamily::Fungible,
|
||||
spl_token_interface::state::AccountState::Initialized,
|
||||
),
|
||||
(
|
||||
crate::MetaplexTokenMetadataAssetFamily::Collection,
|
||||
spl_token_interface::state::AccountState::Initialized,
|
||||
),
|
||||
(
|
||||
crate::MetaplexTokenMetadataAssetFamily::ProgrammableNft,
|
||||
spl_token_interface::state::AccountState::Frozen,
|
||||
),
|
||||
];
|
||||
for (asset_family, expected_state) in families {
|
||||
assert_eq!(
|
||||
super::expected_token_account_state_after_mint(asset_family),
|
||||
expected_state
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn optional_devnet_metaplex_create_mint_campaign_from_env() {
|
||||
if std::env::var("KB_DEVNET_METAPLEX_CREATE_MINT_CAMPAIGN_TEST").ok().as_deref()
|
||||
!= std::option::Option::Some("1")
|
||||
{
|
||||
return;
|
||||
}
|
||||
if std::env::var("KB_DEVNET_METAPLEX_OPERATOR_CONFIRMED").ok().as_deref()
|
||||
!= std::option::Option::Some("1")
|
||||
{
|
||||
panic!("KB_DEVNET_METAPLEX_OPERATOR_CONFIRMED=1 is required for the spending campaign");
|
||||
}
|
||||
let workspace_root = match std::path::Path::new(env!("CARGO_MANIFEST_DIR")).parent() {
|
||||
std::option::Option::Some(value) => value,
|
||||
std::option::Option::None => panic!("workspace root cannot be resolved"),
|
||||
};
|
||||
let config_path = match std::env::var("KB_DEVNET_CONFIG_PATH") {
|
||||
std::result::Result::Ok(value) => {
|
||||
let path = std::path::PathBuf::from(value);
|
||||
if path.is_absolute() { path } else { workspace_root.join(path) }
|
||||
},
|
||||
std::result::Result::Err(_) => workspace_root.join("config/example.config.json"),
|
||||
};
|
||||
let config = ks_config::read_config_json_file_with_environment(
|
||||
config_path.as_path(),
|
||||
workspace_root,
|
||||
)
|
||||
.unwrap_or_else(|error| panic!("Devnet configuration loading failed: {error}"));
|
||||
let profile_name = std::env::var("KB_DEVNET_PROFILE").ok();
|
||||
let mut profile = crate::resolve_demo_devnet_profile(&config, profile_name.as_deref())
|
||||
.unwrap_or_else(|error| panic!("Devnet profile resolution failed: {error}"));
|
||||
let database_url = std::env::var("KB_POSTGRES_TEST_URL")
|
||||
.unwrap_or_else(|error| panic!("KB_POSTGRES_TEST_URL is required: {error}"));
|
||||
profile.database.backend = "postgres".to_string();
|
||||
profile.database.postgres.url = database_url;
|
||||
let pool = ks_onchain_transport::HttpEndpointPool::from_profile(&profile)
|
||||
.unwrap_or_else(|error| panic!("HTTP pool creation failed: {error}"));
|
||||
let store_options = ks_store::PostgresStoreOptions::new(
|
||||
profile.database.postgres.url.clone(),
|
||||
profile.database.postgres.max_connections,
|
||||
profile.database.postgres.connect_timeout_ms,
|
||||
false,
|
||||
)
|
||||
.unwrap_or_else(|error| panic!("PostgreSQL options failed: {error}"));
|
||||
let store = ks_store::PostgresStore::connect(store_options)
|
||||
.await
|
||||
.unwrap_or_else(|error| panic!("PostgreSQL connection failed: {error}"));
|
||||
if let std::result::Result::Err(error) = store.initialize_store_schema().await {
|
||||
panic!("PostgreSQL schema initialization failed: {error}");
|
||||
}
|
||||
let configured_wallet_dir = std::path::PathBuf::from(profile.wallet.wallet_dir.as_str());
|
||||
let wallet_dir = if configured_wallet_dir.is_absolute() {
|
||||
configured_wallet_dir
|
||||
} else {
|
||||
workspace_root.join(configured_wallet_dir)
|
||||
};
|
||||
let family = std::env::var("KB_DEVNET_METAPLEX_CREATE_MINT_FAMILY")
|
||||
.map(|value| return family_from_env(value.as_str()))
|
||||
.unwrap_or(crate::MetaplexTokenMetadataAssetFamily::Nft);
|
||||
let options = crate::MetaplexCreateFixturePreparationOptions::new(wallet_dir)
|
||||
.with_asset_family(family);
|
||||
let summary = crate::execute_devnet_metaplex_create_mint_campaign(
|
||||
&pool,
|
||||
&store,
|
||||
&profile,
|
||||
workspace_root,
|
||||
&options,
|
||||
&crate::NoopSolanaExecutionObserver,
|
||||
)
|
||||
.await
|
||||
.unwrap_or_else(|error| panic!("Metaplex Create -> Mint Devnet campaign failed: {error}"));
|
||||
let create_confirmation = summary
|
||||
.create
|
||||
.confirmation
|
||||
.as_ref()
|
||||
.unwrap_or_else(|| panic!("Create confirmation missing"));
|
||||
let mint_confirmation = summary
|
||||
.mint
|
||||
.confirmation
|
||||
.as_ref()
|
||||
.unwrap_or_else(|| panic!("Mint confirmation missing"));
|
||||
println!(
|
||||
"METAPLEX_CREATE_MINT_FIXTURE family={:?} mint={} metadata={} master_edition={} token_account={} token_record={} preparation_signature={}",
|
||||
summary.asset_family,
|
||||
summary.fixture.mint,
|
||||
summary.fixture.metadata,
|
||||
summary.fixture.master_edition,
|
||||
summary.fixture.token_account,
|
||||
summary.fixture.token_record.as_deref().unwrap_or("none"),
|
||||
summary.fixture.preparation_signature.as_deref().unwrap_or("missing"),
|
||||
);
|
||||
println!(
|
||||
"METAPLEX_CREATE_MINT_STEP operation={} signature={} slot={:?} materializations={}",
|
||||
summary.create.plan.operation_code,
|
||||
create_confirmation.signature.0,
|
||||
create_confirmation.slot,
|
||||
summary.create.materializations.len(),
|
||||
);
|
||||
println!(
|
||||
"METAPLEX_CREATE_MINT_STEP operation={} signature={} slot={:?} materializations={} supply_before={} supply_after={} token_before={} token_after={} token_state_before={} token_state_after={}",
|
||||
summary.mint.plan.operation_code,
|
||||
mint_confirmation.signature.0,
|
||||
mint_confirmation.slot,
|
||||
summary.mint.materializations.len(),
|
||||
summary.token_state.supply_before_mint,
|
||||
summary.token_state.supply_after_mint,
|
||||
summary.token_state.token_amount_before_mint,
|
||||
summary.token_state.token_amount_after_mint,
|
||||
summary.token_state.token_account_state_before_mint,
|
||||
summary.token_state.token_account_state_after_mint,
|
||||
);
|
||||
println!(
|
||||
"METAPLEX_CREATE_MINT_EVIDENCE family={:?} create={} mint={} token_state={}",
|
||||
summary.asset_family,
|
||||
execution_evidence_json(&summary.create),
|
||||
execution_evidence_json(&summary.mint),
|
||||
serde_json::json!({
|
||||
"expectedAmountRaw": summary.token_state.expected_amount_raw,
|
||||
"supplyBeforeMint": summary.token_state.supply_before_mint,
|
||||
"supplyAfterMint": summary.token_state.supply_after_mint,
|
||||
"tokenAmountBeforeMint": summary.token_state.token_amount_before_mint,
|
||||
"tokenAmountAfterMint": summary.token_state.token_amount_after_mint,
|
||||
"tokenAccountStateBeforeMint": summary.token_state.token_account_state_before_mint,
|
||||
"tokenAccountStateAfterMint": summary.token_state.token_account_state_after_mint,
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,730 @@
|
||||
// file: ks-pipeline-demo-scenarios/src/metadata/metaplex_token_metadata/maintenance_campaign.rs
|
||||
// version: 4
|
||||
|
||||
//! Final bounded Devnet qualification campaign for current Metaplex maintenance operations.
|
||||
|
||||
const METAPLEX_FEE_DESTINATION: &str = "2fb1TjRrJQLy9BkYfBjcYgibV7LUsr9cf6QxvyRZyuXn";
|
||||
const METAPLEX_OWNERLESS_CLOSE_DESTINATION: &str = "GxCXYtrnaU6JXeAza8Ugn4EE6QiFinpfn8t3Lo4UkBDX";
|
||||
|
||||
/// Complete evidence produced by the final bounded Metaplex maintenance campaign.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct DevnetMetaplexMaintenanceCampaignSummary {
|
||||
/// Fresh classic NFT used by the campaign.
|
||||
pub fixture: crate::DevnetMetaplexCreateMintCampaignSummary,
|
||||
/// Confirmed `Update` execution evidence.
|
||||
pub update: crate::DevnetMetaplexTokenMetadataExecutionSummary,
|
||||
/// Simulation-only `Resize` evidence for a fresh already-resized NFT.
|
||||
pub resize_probe: crate::DevnetMetaplexTokenMetadataExecutionSummary,
|
||||
/// Simulation-only `Migrate` evidence.
|
||||
pub migrate_probe: crate::DevnetMetaplexTokenMetadataExecutionSummary,
|
||||
/// Simulation-only `Collect` evidence.
|
||||
pub collect_probe: crate::DevnetMetaplexTokenMetadataExecutionSummary,
|
||||
/// Simulation-only `CloseAccounts` evidence.
|
||||
pub close_accounts_probe: crate::DevnetMetaplexTokenMetadataExecutionSummary,
|
||||
/// Primary-sale flag before the current `Update` execution.
|
||||
pub primary_sale_before_update: bool,
|
||||
/// Primary-sale flag after the current `Update` execution.
|
||||
pub primary_sale_after_update: bool,
|
||||
}
|
||||
|
||||
/// Returns the exact ordered operations exercised by the maintenance campaign.
|
||||
pub fn metaplex_maintenance_campaign_operation_names() -> &'static [&'static str; 7] {
|
||||
return &["create", "mint", "update", "resize", "migrate", "collect", "close_accounts"];
|
||||
}
|
||||
|
||||
/// Executes the final bounded Metaplex maintenance qualification campaign on Devnet.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub async fn execute_devnet_metaplex_maintenance_campaign<S, O>(
|
||||
http_pool: &ks_onchain_transport::HttpEndpointPool,
|
||||
store: &S,
|
||||
profile: &ks_config::ProfileConfig,
|
||||
workspace_root: &std::path::Path,
|
||||
options: &crate::MetaplexCreateFixturePreparationOptions,
|
||||
observer: &O,
|
||||
) -> ks_core::Result<crate::DevnetMetaplexMaintenanceCampaignSummary>
|
||||
where
|
||||
S: ks_store::RawTransactionStore
|
||||
+ ks_store::CoreExtractionStore
|
||||
+ ks_store::DecodePipelineStore
|
||||
+ Sync,
|
||||
O: crate::SolanaExecutionObserver,
|
||||
{
|
||||
if options.asset_family != crate::MetaplexTokenMetadataAssetFamily::Nft {
|
||||
return std::result::Result::Err(ks_core::Error::config(
|
||||
"Metaplex maintenance campaign requires the classic NFT fixture family",
|
||||
));
|
||||
}
|
||||
let fixture = match crate::execute_devnet_metaplex_create_mint_campaign(
|
||||
http_pool,
|
||||
store,
|
||||
profile,
|
||||
workspace_root,
|
||||
options,
|
||||
observer,
|
||||
)
|
||||
.await
|
||||
{
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let primary_sale_before_update = match metadata_primary_sale(
|
||||
fixture.mint.after.as_slice(),
|
||||
fixture.fixture.metadata.as_str(),
|
||||
) {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
if primary_sale_before_update {
|
||||
return std::result::Result::Err(ks_core::Error::new(
|
||||
"metaplex_maintenance_initial_primary_sale_invalid",
|
||||
"fresh maintenance NFT already has primary_sale_happened=true",
|
||||
));
|
||||
}
|
||||
let update_operation = match update_operation(&fixture.fixture) {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let update = match execute_confirmed_operation(
|
||||
"update",
|
||||
update_operation,
|
||||
metadata_reads(&fixture.fixture, options.query_role.as_str()),
|
||||
http_pool,
|
||||
store,
|
||||
profile,
|
||||
workspace_root,
|
||||
options,
|
||||
observer,
|
||||
)
|
||||
.await
|
||||
{
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let _update_slot = match validate_confirmed_execution("update", &update) {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let primary_sale_after_update =
|
||||
match metadata_primary_sale(update.after.as_slice(), fixture.fixture.metadata.as_str()) {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
if !primary_sale_after_update {
|
||||
return std::result::Result::Err(ks_core::Error::new(
|
||||
"metaplex_maintenance_update_state_invalid",
|
||||
"confirmed Update did not flip primary_sale_happened to true",
|
||||
));
|
||||
}
|
||||
let resize_operation = ks_lib::ExMetaplexTokenMetadataOperation::Resize {
|
||||
metadata: ks_lib::MdPubkey(fixture.fixture.metadata.clone()),
|
||||
edition: ks_lib::MdPubkey(fixture.fixture.master_edition.clone()),
|
||||
mint: ks_lib::MdPubkey(fixture.fixture.mint.clone()),
|
||||
payer: ks_lib::MdPubkey(fixture.fixture.authority.clone()),
|
||||
authority: std::option::Option::None,
|
||||
token: std::option::Option::Some(ks_lib::MdPubkey(fixture.fixture.token_account.clone())),
|
||||
system_program: ks_lib::MdPubkey(ks_program_ids::SYSTEM_PROGRAM_ID.to_string()),
|
||||
};
|
||||
let resize_probe = match simulate_expected_unavailable(
|
||||
"resize",
|
||||
resize_operation,
|
||||
201,
|
||||
http_pool,
|
||||
profile,
|
||||
workspace_root,
|
||||
options,
|
||||
observer,
|
||||
)
|
||||
.await
|
||||
{
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let migrate_probe = match simulate_expected_unavailable(
|
||||
"migrate",
|
||||
migrate_operation(&fixture.fixture),
|
||||
75,
|
||||
http_pool,
|
||||
profile,
|
||||
workspace_root,
|
||||
options,
|
||||
observer,
|
||||
)
|
||||
.await
|
||||
{
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let collect_probe = match simulate_expected_unavailable(
|
||||
"collect",
|
||||
ks_lib::ExMetaplexTokenMetadataOperation::Collect {
|
||||
authority: ks_lib::MdPubkey(fixture.fixture.authority.clone()),
|
||||
recipient: ks_lib::MdPubkey(METAPLEX_FEE_DESTINATION.to_string()),
|
||||
},
|
||||
7,
|
||||
http_pool,
|
||||
profile,
|
||||
workspace_root,
|
||||
options,
|
||||
observer,
|
||||
)
|
||||
.await
|
||||
{
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let close_accounts_probe = match simulate_expected_unavailable(
|
||||
"close_accounts",
|
||||
ks_lib::ExMetaplexTokenMetadataOperation::CloseAccounts {
|
||||
metadata: ks_lib::MdPubkey(fixture.fixture.metadata.clone()),
|
||||
edition: ks_lib::MdPubkey(fixture.fixture.master_edition.clone()),
|
||||
mint: ks_lib::MdPubkey(fixture.fixture.mint.clone()),
|
||||
authority: ks_lib::MdPubkey(fixture.fixture.authority.clone()),
|
||||
destination: ks_lib::MdPubkey(METAPLEX_OWNERLESS_CLOSE_DESTINATION.to_string()),
|
||||
},
|
||||
188,
|
||||
http_pool,
|
||||
profile,
|
||||
workspace_root,
|
||||
options,
|
||||
observer,
|
||||
)
|
||||
.await
|
||||
{
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
return std::result::Result::Ok(crate::DevnetMetaplexMaintenanceCampaignSummary {
|
||||
fixture,
|
||||
update,
|
||||
resize_probe,
|
||||
migrate_probe,
|
||||
collect_probe,
|
||||
close_accounts_probe,
|
||||
primary_sale_before_update,
|
||||
primary_sale_after_update,
|
||||
});
|
||||
}
|
||||
|
||||
fn update_operation(
|
||||
fixture: &crate::MetaplexCreateFixturePreparationSummary,
|
||||
) -> ks_core::Result<ks_lib::ExMetaplexTokenMetadataOperation> {
|
||||
let value = serde_json::json!({
|
||||
"operation": "update_as_update_authority_v2",
|
||||
"authority": fixture.authority.as_str(),
|
||||
"mint": fixture.mint.as_str(),
|
||||
"metadata": fixture.metadata.as_str(),
|
||||
"edition": fixture.master_edition.as_str(),
|
||||
"token": fixture.token_account.as_str(),
|
||||
"authorization_rules_program": null,
|
||||
"authorization_rules": null,
|
||||
"update_args": {
|
||||
"AsUpdateAuthorityV2": {
|
||||
"new_update_authority": null,
|
||||
"data": null,
|
||||
"primary_sale_happened": true,
|
||||
"is_mutable": null,
|
||||
"collection": "None",
|
||||
"collection_details": "None",
|
||||
"uses": "None",
|
||||
"rule_set": "None",
|
||||
"token_standard": null,
|
||||
"authorization_data": null
|
||||
}
|
||||
}
|
||||
});
|
||||
return match serde_json::from_value::<ks_lib::ExMetaplexTokenMetadataOperation>(value) {
|
||||
std::result::Result::Ok(value) => std::result::Result::Ok(value),
|
||||
std::result::Result::Err(error) => std::result::Result::Err(ks_core::Error::config(
|
||||
format!("Metaplex maintenance Update operation is invalid: {error}"),
|
||||
)),
|
||||
};
|
||||
}
|
||||
|
||||
fn migrate_operation(
|
||||
fixture: &crate::MetaplexCreateFixturePreparationSummary,
|
||||
) -> ks_lib::ExMetaplexTokenMetadataOperation {
|
||||
return ks_lib::ExMetaplexTokenMetadataOperation::Migrate {
|
||||
metadata: ks_lib::MdPubkey(fixture.metadata.clone()),
|
||||
edition: ks_lib::MdPubkey(fixture.master_edition.clone()),
|
||||
token: ks_lib::MdPubkey(fixture.token_account.clone()),
|
||||
token_owner: ks_lib::MdPubkey(fixture.authority.clone()),
|
||||
mint: ks_lib::MdPubkey(fixture.mint.clone()),
|
||||
payer: ks_lib::MdPubkey(fixture.authority.clone()),
|
||||
authority: ks_lib::MdPubkey(fixture.authority.clone()),
|
||||
collection_metadata: ks_lib::MdPubkey(fixture.metadata.clone()),
|
||||
delegate_record: ks_lib::MdPubkey(fixture.metadata.clone()),
|
||||
token_record: ks_lib::MdPubkey(fixture.metadata.clone()),
|
||||
system_program: ks_lib::MdPubkey(ks_program_ids::SYSTEM_PROGRAM_ID.to_string()),
|
||||
sysvar_instructions: ks_lib::MdPubkey(
|
||||
ks_program_ids::SYSVAR_INSTRUCTIONS_PROGRAM_ID.to_string(),
|
||||
),
|
||||
spl_token_program: ks_lib::MdPubkey(ks_program_ids::SPL_TOKEN_PROGRAM_ID.to_string()),
|
||||
authorization_rules_program: std::option::Option::None,
|
||||
authorization_rules: std::option::Option::None,
|
||||
};
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
async fn execute_confirmed_operation<S, O>(
|
||||
label: &str,
|
||||
operation: ks_lib::ExMetaplexTokenMetadataOperation,
|
||||
reads: std::vec::Vec<ks_pipeline::MetaplexTokenMetadataStatefulReadRequest>,
|
||||
http_pool: &ks_onchain_transport::HttpEndpointPool,
|
||||
store: &S,
|
||||
profile: &ks_config::ProfileConfig,
|
||||
workspace_root: &std::path::Path,
|
||||
options: &crate::MetaplexCreateFixturePreparationOptions,
|
||||
observer: &O,
|
||||
) -> ks_core::Result<crate::DevnetMetaplexTokenMetadataExecutionSummary>
|
||||
where
|
||||
S: ks_store::RawTransactionStore
|
||||
+ ks_store::CoreExtractionStore
|
||||
+ ks_store::DecodePipelineStore
|
||||
+ Sync,
|
||||
O: crate::SolanaExecutionObserver,
|
||||
{
|
||||
let decoders: std::vec::Vec<std::sync::Arc<dyn ks_lib::DcApiInstructionDecoder>> =
|
||||
std::vec![std::sync::Arc::new(ks_lib::DcMetadataMetaplexTokenMetadataDecoder)];
|
||||
let materializers: std::vec::Vec<std::sync::Arc<dyn ks_lib::MtApiEventMaterializer>> = std::vec![
|
||||
std::sync::Arc::new(ks_lib::MtAdminMaterializer),
|
||||
std::sync::Arc::new(ks_lib::MtComplianceAuditMaterializer),
|
||||
std::sync::Arc::new(ks_lib::MtLifecycleMaterializer),
|
||||
std::sync::Arc::new(ks_lib::MtMetadataMetaplexTokenMetadataMaterializer),
|
||||
std::sync::Arc::new(ks_lib::MtRiskMaterializer),
|
||||
];
|
||||
let mut request = crate::DevnetMetaplexTokenMetadataExecutionRequest::new(
|
||||
format!("metaplex-maintenance-{label}-{}", uuid::Uuid::new_v4()),
|
||||
operation,
|
||||
);
|
||||
request.query_role = options.query_role.clone();
|
||||
request.transaction_role = options.transaction_role.clone();
|
||||
request.preflight_reads = reads.clone();
|
||||
request.postcondition_reads = reads;
|
||||
request.submit = true;
|
||||
request.operator_confirmed = true;
|
||||
request.materialize_after_confirmation = true;
|
||||
request.post_validation_max_retries = 20;
|
||||
return crate::execute_devnet_metaplex_token_metadata(
|
||||
http_pool,
|
||||
store,
|
||||
profile,
|
||||
workspace_root,
|
||||
&request,
|
||||
decoders.as_slice(),
|
||||
materializers.as_slice(),
|
||||
observer,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
async fn simulate_expected_unavailable<O>(
|
||||
label: &str,
|
||||
operation: ks_lib::ExMetaplexTokenMetadataOperation,
|
||||
expected_custom_error: u64,
|
||||
http_pool: &ks_onchain_transport::HttpEndpointPool,
|
||||
profile: &ks_config::ProfileConfig,
|
||||
workspace_root: &std::path::Path,
|
||||
options: &crate::MetaplexCreateFixturePreparationOptions,
|
||||
observer: &O,
|
||||
) -> ks_core::Result<crate::DevnetMetaplexTokenMetadataExecutionSummary>
|
||||
where
|
||||
O: crate::SolanaExecutionObserver,
|
||||
{
|
||||
let mut request = crate::DevnetMetaplexTokenMetadataExecutionRequest::new(
|
||||
format!("metaplex-maintenance-{label}-probe-{}", uuid::Uuid::new_v4()),
|
||||
operation,
|
||||
);
|
||||
request.query_role = options.query_role.clone();
|
||||
request.transaction_role = options.transaction_role.clone();
|
||||
let summary = match crate::simulate_devnet_metaplex_token_metadata(
|
||||
http_pool,
|
||||
profile,
|
||||
workspace_root,
|
||||
&request,
|
||||
observer,
|
||||
)
|
||||
.await
|
||||
{
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
if summary.simulation.success || summary.send_result.is_some() || summary.confirmation.is_some()
|
||||
{
|
||||
return std::result::Result::Err(ks_core::Error::new(
|
||||
"metaplex_maintenance_unavailable_probe_unexpected_success",
|
||||
format!("Metaplex {label} probe unexpectedly became submit-capable or confirmed"),
|
||||
));
|
||||
}
|
||||
let observed_custom_error = simulation_custom_error(summary.simulation.error.as_deref());
|
||||
if observed_custom_error != std::option::Option::Some(expected_custom_error) {
|
||||
return std::result::Result::Err(ks_core::Error::new(
|
||||
"metaplex_maintenance_unavailable_probe_error_mismatch",
|
||||
format!(
|
||||
"Metaplex {label} probe expected custom error {expected_custom_error}; got custom_error={observed_custom_error:?}, error={:?}, logs={:?}",
|
||||
summary.simulation.error, summary.simulation.logs
|
||||
),
|
||||
));
|
||||
}
|
||||
return std::result::Result::Ok(summary);
|
||||
}
|
||||
|
||||
fn simulation_custom_error(error: std::option::Option<&str>) -> std::option::Option<u64> {
|
||||
let error = match error {
|
||||
std::option::Option::Some(value) => value,
|
||||
std::option::Option::None => return std::option::Option::None,
|
||||
};
|
||||
let value = match serde_json::from_str::<serde_json::Value>(error) {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(_) => return std::option::Option::None,
|
||||
};
|
||||
let instruction_error =
|
||||
match value.get("InstructionError").and_then(serde_json::Value::as_array) {
|
||||
std::option::Option::Some(value) if value.len() == 2 => value,
|
||||
_ => return std::option::Option::None,
|
||||
};
|
||||
let detail = match instruction_error.get(1) {
|
||||
std::option::Option::Some(value) => value,
|
||||
std::option::Option::None => return std::option::Option::None,
|
||||
};
|
||||
return detail.get("Custom").and_then(serde_json::Value::as_u64);
|
||||
}
|
||||
|
||||
fn metadata_reads(
|
||||
fixture: &crate::MetaplexCreateFixturePreparationSummary,
|
||||
query_role: &str,
|
||||
) -> std::vec::Vec<ks_pipeline::MetaplexTokenMetadataStatefulReadRequest> {
|
||||
return std::vec![ks_pipeline::MetaplexTokenMetadataStatefulReadRequest {
|
||||
query_role: query_role.to_string(),
|
||||
account: ks_lib::MdPubkey(fixture.metadata.clone()),
|
||||
kind: ks_pipeline::MetaplexTokenMetadataAccountKind::Metadata,
|
||||
min_context_slot: std::option::Option::None,
|
||||
max_data_bytes: ks_pipeline::MAX_METAPLEX_TOKEN_METADATA_ACCOUNT_BYTES,
|
||||
}];
|
||||
}
|
||||
|
||||
fn metadata_primary_sale(
|
||||
snapshots: &[ks_pipeline::MetaplexTokenMetadataStatefulReadResult],
|
||||
metadata: &str,
|
||||
) -> ks_core::Result<bool> {
|
||||
let snapshot = match snapshots.iter().find(|value| {
|
||||
return value.snapshot.account.0.as_str() == metadata
|
||||
&& value.snapshot.account_kind == "metadata";
|
||||
}) {
|
||||
std::option::Option::Some(value) => value,
|
||||
std::option::Option::None => {
|
||||
return std::result::Result::Err(ks_core::Error::new(
|
||||
"metaplex_maintenance_metadata_snapshot_missing",
|
||||
format!("metadata snapshot {metadata} is missing"),
|
||||
));
|
||||
},
|
||||
};
|
||||
let value = snapshot
|
||||
.snapshot
|
||||
.payload_json
|
||||
.get("primary_sale_happened")
|
||||
.or_else(|| return snapshot.snapshot.payload_json.get("primarySaleHappened"));
|
||||
return match value.and_then(serde_json::Value::as_bool) {
|
||||
std::option::Option::Some(value) => std::result::Result::Ok(value),
|
||||
std::option::Option::None => std::result::Result::Err(ks_core::Error::new(
|
||||
"metaplex_maintenance_primary_sale_missing",
|
||||
"metadata snapshot has no boolean primary-sale field",
|
||||
)),
|
||||
};
|
||||
}
|
||||
|
||||
fn validate_confirmed_execution(
|
||||
label: &str,
|
||||
execution: &crate::DevnetMetaplexTokenMetadataExecutionSummary,
|
||||
) -> ks_core::Result<u64> {
|
||||
let confirmation = match execution.confirmation.as_ref() {
|
||||
std::option::Option::Some(value)
|
||||
if matches!(
|
||||
value.status,
|
||||
ks_lib::ExApiExecutionConfirmationStatus::Confirmed
|
||||
| ks_lib::ExApiExecutionConfirmationStatus::Finalized
|
||||
) =>
|
||||
{
|
||||
value
|
||||
},
|
||||
std::option::Option::Some(value) => {
|
||||
return std::result::Result::Err(ks_core::Error::new(
|
||||
"metaplex_maintenance_confirmation_incomplete",
|
||||
format!("Metaplex {label} stopped at {:?}", value.status),
|
||||
));
|
||||
},
|
||||
std::option::Option::None => {
|
||||
return std::result::Result::Err(ks_core::Error::new(
|
||||
"metaplex_maintenance_confirmation_missing",
|
||||
format!("Metaplex {label} has no confirmation evidence"),
|
||||
));
|
||||
},
|
||||
};
|
||||
let diagnostic = match execution.post_execution.as_ref() {
|
||||
std::option::Option::Some(value) => value,
|
||||
std::option::Option::None => {
|
||||
return std::result::Result::Err(ks_core::Error::new(
|
||||
"metaplex_maintenance_post_execution_missing",
|
||||
format!("Metaplex {label} has no post-execution diagnostic"),
|
||||
));
|
||||
},
|
||||
};
|
||||
if !diagnostic.canonical_inserted
|
||||
|| !diagnostic.core_extracted
|
||||
|| !diagnostic.decode_replayed
|
||||
|| !diagnostic.materialized
|
||||
|| execution.materializations.is_empty()
|
||||
|| execution.materialized_snapshots.is_empty()
|
||||
{
|
||||
return std::result::Result::Err(ks_core::Error::new(
|
||||
"metaplex_maintenance_post_execution_incomplete",
|
||||
format!(
|
||||
"Metaplex {label} post-execution incomplete: signature={}, slot={:?}, canonical_inserted={}, core_extracted={}, decode_replayed={}, materialized={}, materialization_rows={}, materialized_snapshots={}, diagnostics={:?}",
|
||||
confirmation.signature.0,
|
||||
confirmation.slot,
|
||||
diagnostic.canonical_inserted,
|
||||
diagnostic.core_extracted,
|
||||
diagnostic.decode_replayed,
|
||||
diagnostic.materialized,
|
||||
execution.materializations.len(),
|
||||
execution.materialized_snapshots.len(),
|
||||
diagnostic.diagnostics
|
||||
),
|
||||
));
|
||||
}
|
||||
let idempotence = match execution.idempotence_replay.as_ref() {
|
||||
std::option::Option::Some(value) => value,
|
||||
std::option::Option::None => {
|
||||
return std::result::Result::Err(ks_core::Error::new(
|
||||
"metaplex_maintenance_idempotence_missing",
|
||||
format!("Metaplex {label} has no idempotence replay evidence"),
|
||||
));
|
||||
},
|
||||
};
|
||||
if idempotence.failed_inputs != 0
|
||||
|| idempotence.processing_error_inputs != 0
|
||||
|| idempotence.processors.iter().any(|processor| {
|
||||
return processor.failed != 0
|
||||
|| processor.processing_errors != 0
|
||||
|| processor.materialized_outputs != 0
|
||||
|| processor.materialization_refused != 0;
|
||||
})
|
||||
{
|
||||
return std::result::Result::Err(ks_core::Error::new(
|
||||
"metaplex_maintenance_idempotence_failed",
|
||||
format!("Metaplex {label} second replay is not idempotent"),
|
||||
));
|
||||
}
|
||||
return match confirmation.slot {
|
||||
std::option::Option::Some(value) => std::result::Result::Ok(value),
|
||||
std::option::Option::None => std::result::Result::Err(ks_core::Error::new(
|
||||
"metaplex_maintenance_confirmation_slot_missing",
|
||||
format!("Metaplex {label} confirmation has no slot"),
|
||||
)),
|
||||
};
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#[test]
|
||||
fn maintenance_campaign_contract_is_exact() {
|
||||
assert_eq!(
|
||||
crate::metaplex_maintenance_campaign_operation_names(),
|
||||
&["create", "mint", "update", "resize", "migrate", "collect", "close_accounts"],
|
||||
);
|
||||
for custom_error in [201_u64, 75_u64, 7_u64, 188_u64] {
|
||||
let error = format!(r#"{{"InstructionError":[0,{{"Custom":{custom_error}}}]}}"#);
|
||||
assert_eq!(
|
||||
super::simulation_custom_error(std::option::Option::Some(error.as_str())),
|
||||
std::option::Option::Some(custom_error),
|
||||
);
|
||||
}
|
||||
assert_eq!(
|
||||
super::simulation_custom_error(std::option::Option::Some(
|
||||
r#"{"InstructionError":[0,"InvalidInstructionData"]}"#,
|
||||
)),
|
||||
std::option::Option::None,
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn maintenance_reserved_destinations_are_exact() {
|
||||
assert_eq!(super::METAPLEX_FEE_DESTINATION, "2fb1TjRrJQLy9BkYfBjcYgibV7LUsr9cf6QxvyRZyuXn");
|
||||
assert_eq!(
|
||||
super::METAPLEX_OWNERLESS_CLOSE_DESTINATION,
|
||||
"GxCXYtrnaU6JXeAza8Ugn4EE6QiFinpfn8t3Lo4UkBDX",
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn optional_devnet_metaplex_maintenance_campaign_from_env() {
|
||||
if std::env::var("KB_DEVNET_METAPLEX_MAINTENANCE_CAMPAIGN_TEST").ok().as_deref()
|
||||
!= std::option::Option::Some("1")
|
||||
{
|
||||
return;
|
||||
}
|
||||
assert_eq!(
|
||||
std::env::var("KB_DEVNET_METAPLEX_OPERATOR_CONFIRMED").ok().as_deref(),
|
||||
std::option::Option::Some("1"),
|
||||
"set KB_DEVNET_METAPLEX_OPERATOR_CONFIRMED=1 before the maintenance campaign"
|
||||
);
|
||||
let workspace_root = match std::path::Path::new(env!("CARGO_MANIFEST_DIR")).parent() {
|
||||
std::option::Option::Some(value) => value,
|
||||
std::option::Option::None => panic!("workspace root cannot be resolved"),
|
||||
};
|
||||
let config_path = match std::env::var("KB_DEVNET_CONFIG_PATH") {
|
||||
std::result::Result::Ok(value) => {
|
||||
let path = std::path::PathBuf::from(value);
|
||||
if path.is_absolute() { path } else { workspace_root.join(path) }
|
||||
},
|
||||
std::result::Result::Err(_) => workspace_root.join("config/example.config.json"),
|
||||
};
|
||||
let config = ks_config::read_config_json_file_with_environment(
|
||||
config_path.as_path(),
|
||||
workspace_root,
|
||||
)
|
||||
.unwrap_or_else(|error| panic!("Devnet configuration loading failed: {error}"));
|
||||
let profile_name = std::env::var("KB_DEVNET_PROFILE").ok();
|
||||
let mut profile = crate::resolve_demo_devnet_profile(&config, profile_name.as_deref())
|
||||
.unwrap_or_else(|error| panic!("Devnet profile resolution failed: {error}"));
|
||||
let database_url = std::env::var("KB_POSTGRES_TEST_URL")
|
||||
.unwrap_or_else(|error| panic!("KB_POSTGRES_TEST_URL is required: {error}"));
|
||||
profile.database.backend = "postgres".to_string();
|
||||
profile.database.postgres.url = database_url;
|
||||
let http_pool = ks_onchain_transport::HttpEndpointPool::from_profile(&profile)
|
||||
.unwrap_or_else(|error| panic!("HTTP pool initialization failed: {error}"));
|
||||
let store_options = ks_store::PostgresStoreOptions::new(
|
||||
profile.database.postgres.url.clone(),
|
||||
profile.database.postgres.max_connections,
|
||||
profile.database.postgres.connect_timeout_ms,
|
||||
false,
|
||||
)
|
||||
.unwrap_or_else(|error| panic!("PostgreSQL options failed: {error}"));
|
||||
let store = ks_store::PostgresStore::connect(store_options)
|
||||
.await
|
||||
.unwrap_or_else(|error| panic!("PostgreSQL connection failed: {error}"));
|
||||
if let std::result::Result::Err(error) = store.initialize_store_schema().await {
|
||||
panic!("PostgreSQL schema initialization failed: {error}");
|
||||
}
|
||||
let configured_wallet_dir = std::path::PathBuf::from(profile.wallet.wallet_dir.as_str());
|
||||
let wallet_dir = if configured_wallet_dir.is_absolute() {
|
||||
configured_wallet_dir
|
||||
} else {
|
||||
workspace_root.join(configured_wallet_dir)
|
||||
};
|
||||
let options = crate::MetaplexCreateFixturePreparationOptions::new(wallet_dir)
|
||||
.with_asset_family(crate::MetaplexTokenMetadataAssetFamily::Nft);
|
||||
let summary = crate::execute_devnet_metaplex_maintenance_campaign(
|
||||
&http_pool,
|
||||
&store,
|
||||
&profile,
|
||||
workspace_root,
|
||||
&options,
|
||||
&crate::NoopSolanaExecutionObserver,
|
||||
)
|
||||
.await
|
||||
.unwrap_or_else(|error| panic!("Metaplex maintenance Devnet campaign failed: {error}"));
|
||||
println!(
|
||||
"METAPLEX_MAINTENANCE_FIXTURE mint={} metadata={} master_edition={} token={}",
|
||||
summary.fixture.fixture.mint,
|
||||
summary.fixture.fixture.metadata,
|
||||
summary.fixture.fixture.master_edition,
|
||||
summary.fixture.fixture.token_account,
|
||||
);
|
||||
print_confirmed_step("update", &summary.update);
|
||||
println!(
|
||||
"METAPLEX_MAINTENANCE_STATE primary_sale_before={} primary_sale_after={}",
|
||||
summary.primary_sale_before_update, summary.primary_sale_after_update,
|
||||
);
|
||||
print_probe("resize", 201, &summary.resize_probe);
|
||||
print_probe("migrate", 75, &summary.migrate_probe);
|
||||
print_probe("collect", 7, &summary.collect_probe);
|
||||
print_probe("close_accounts", 188, &summary.close_accounts_probe);
|
||||
println!(
|
||||
"METAPLEX_MAINTENANCE_EVIDENCE update={}",
|
||||
execution_evidence_json(&summary.update),
|
||||
);
|
||||
}
|
||||
|
||||
fn print_confirmed_step(
|
||||
label: &str,
|
||||
execution: &crate::DevnetMetaplexTokenMetadataExecutionSummary,
|
||||
) {
|
||||
let confirmation = execution
|
||||
.confirmation
|
||||
.as_ref()
|
||||
.unwrap_or_else(|| panic!("{label} confirmation is missing"));
|
||||
println!(
|
||||
"METAPLEX_MAINTENANCE_STEP label={} operation={} signature={} slot={:?} materializations={}",
|
||||
label,
|
||||
execution.plan.operation_code,
|
||||
confirmation.signature.0,
|
||||
confirmation.slot,
|
||||
execution.materializations.len(),
|
||||
);
|
||||
}
|
||||
|
||||
fn print_probe(
|
||||
label: &str,
|
||||
expected_custom_error: u64,
|
||||
execution: &crate::DevnetMetaplexTokenMetadataExecutionSummary,
|
||||
) {
|
||||
println!(
|
||||
"METAPLEX_MAINTENANCE_PROBE label={} operation={} success={} context_slot={} expected_custom_error={} error={} logs={}",
|
||||
label,
|
||||
execution.plan.operation_code,
|
||||
execution.simulation.success,
|
||||
execution.simulation_context_slot,
|
||||
expected_custom_error,
|
||||
serde_json::to_string(&execution.simulation.error).unwrap_or_else(|error| panic!(
|
||||
"{label} probe error serialization failed: {error}"
|
||||
)),
|
||||
execution.simulation.logs.len(),
|
||||
);
|
||||
println!(
|
||||
"METAPLEX_MAINTENANCE_PROBE_LOGS label={} {}",
|
||||
label,
|
||||
serde_json::to_string(&execution.simulation.logs)
|
||||
.unwrap_or_else(|error| panic!("{label} probe log serialization failed: {error}")),
|
||||
);
|
||||
}
|
||||
|
||||
fn execution_evidence_json(
|
||||
execution: &crate::DevnetMetaplexTokenMetadataExecutionSummary,
|
||||
) -> serde_json::Value {
|
||||
let confirmation = execution.confirmation.as_ref();
|
||||
let post_execution = execution.post_execution.as_ref();
|
||||
let idempotence_clean = execution.idempotence_replay.as_ref().is_some_and(|replay| {
|
||||
return replay.failed_inputs == 0
|
||||
&& replay.processing_error_inputs == 0
|
||||
&& replay.processors.iter().all(|processor| {
|
||||
return processor.failed == 0
|
||||
&& processor.processing_errors == 0
|
||||
&& processor.materialized_outputs == 0
|
||||
&& processor.materialization_refused == 0;
|
||||
});
|
||||
});
|
||||
return serde_json::json!({
|
||||
"operation": execution.plan.operation_code.as_str(),
|
||||
"cluster": execution.cluster,
|
||||
"genesisHash": execution.genesis_hash.as_str(),
|
||||
"simulationContextSlot": execution.simulation_context_slot,
|
||||
"simulationSuccess": execution.simulation.success,
|
||||
"simulationLogCount": execution.simulation.logs.len(),
|
||||
"messageHash": execution.readiness.message_hash.as_str(),
|
||||
"feeLamports": execution.fee.fee_lamports,
|
||||
"signature": confirmation.map(|value| return value.signature.0.clone()),
|
||||
"confirmationStatus": confirmation.map(|value| return format!("{:?}", value.status)),
|
||||
"confirmationSlot": confirmation.and_then(|value| return value.slot),
|
||||
"beforeSnapshots": execution.before.len(),
|
||||
"afterSnapshots": execution.after.len(),
|
||||
"canonicalHydration": post_execution.is_some_and(|value| return value.canonical_inserted),
|
||||
"coreExtraction": post_execution.is_some_and(|value| return value.core_extracted),
|
||||
"decodeReplay": post_execution.is_some_and(|value| return value.decode_replayed),
|
||||
"materialized": post_execution.is_some_and(|value| return value.materialized),
|
||||
"instructionMaterializations": execution.materializations.len(),
|
||||
"materializedSnapshots": execution.materialized_snapshots.len(),
|
||||
"idempotenceReplayClean": idempotence_clean,
|
||||
});
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,404 @@
|
||||
// file: ks-pipeline-demo-scenarios/src/metadata/metaplex_token_metadata/scenarios.rs
|
||||
// version: 9
|
||||
|
||||
//! Coherent Metaplex Token Metadata scenario journeys for tests and Devnet campaigns.
|
||||
|
||||
/// Stable asset family covered by one Metaplex scenario.
|
||||
#[derive(
|
||||
Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd, serde::Deserialize, serde::Serialize,
|
||||
)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum MetaplexTokenMetadataAssetFamily {
|
||||
/// Non-fungible token.
|
||||
Nft,
|
||||
/// Semi-fungible token.
|
||||
Sft,
|
||||
/// Fungible token with Metaplex metadata.
|
||||
Fungible,
|
||||
/// Collection parent and member relationship.
|
||||
Collection,
|
||||
/// Programmable non-fungible token.
|
||||
ProgrammableNft,
|
||||
}
|
||||
|
||||
/// Stable fixture graph required by one scenario journey.
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum MetaplexTokenMetadataFixtureKind {
|
||||
/// Fresh zero-decimal classic SPL mint with profile-owned authorities.
|
||||
ClassicNftMint,
|
||||
/// Fresh SFT mint and token account.
|
||||
SemiFungibleMint,
|
||||
/// Fresh fungible mint and token account.
|
||||
FungibleMint,
|
||||
/// Collection parent plus one unverified member.
|
||||
CollectionParentAndMember,
|
||||
/// Programmable NFT with token record and optional rule set.
|
||||
ProgrammableNftTokenRecord,
|
||||
}
|
||||
|
||||
/// Stable lifecycle state consumed or produced by one scenario journey.
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum MetaplexTokenMetadataFixtureState {
|
||||
/// Mint exists but metadata does not.
|
||||
MintPrepared,
|
||||
/// Metadata exists and remains mutable.
|
||||
MetadataCreated,
|
||||
/// Master edition exists.
|
||||
MasterEditionCreated,
|
||||
/// Metadata is immutable after a confirmed update-authority transition.
|
||||
MetadataImmutable,
|
||||
/// Collection member exists but is not verified.
|
||||
CollectionMemberUnverified,
|
||||
/// Collection member is verified.
|
||||
CollectionMemberVerified,
|
||||
/// Programmable token record exists.
|
||||
ProgrammableTokenRecordCreated,
|
||||
/// Programmable asset is delegated.
|
||||
Delegated,
|
||||
/// Programmable asset is locked.
|
||||
Locked,
|
||||
/// Scenario has reached its terminal validated state.
|
||||
Validated,
|
||||
}
|
||||
|
||||
/// Execution mode accepted by one scenario.
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum MetaplexTokenMetadataScenarioMode {
|
||||
/// Purely deterministic scenario using synthetic accounts and plans.
|
||||
Synthetic,
|
||||
/// Devnet or Testnet simulation without submission.
|
||||
NetworkSimulation,
|
||||
/// Explicitly authorized Devnet or Testnet submission.
|
||||
NetworkSubmission,
|
||||
}
|
||||
|
||||
/// One stable Metaplex journey reusable by automated tests and demo adapters.
|
||||
#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
|
||||
pub struct MetaplexTokenMetadataScenario {
|
||||
/// Stable scenario identifier.
|
||||
pub id: std::string::String,
|
||||
/// Operator-visible scenario label.
|
||||
pub label: std::string::String,
|
||||
/// Asset family exercised by the scenario.
|
||||
pub asset_family: crate::MetaplexTokenMetadataAssetFamily,
|
||||
/// Fixture graph required by the scenario.
|
||||
pub fixture_kind: crate::MetaplexTokenMetadataFixtureKind,
|
||||
/// Required initial lifecycle state.
|
||||
pub initial_state: crate::MetaplexTokenMetadataFixtureState,
|
||||
/// Expected terminal lifecycle state.
|
||||
pub resulting_state: crate::MetaplexTokenMetadataFixtureState,
|
||||
/// Execution mode.
|
||||
pub mode: crate::MetaplexTokenMetadataScenarioMode,
|
||||
/// Stable operation codes executed in order.
|
||||
pub operation_codes: std::vec::Vec<std::string::String>,
|
||||
/// Whether the scenario requires a collection account.
|
||||
pub requires_collection: bool,
|
||||
/// Whether the scenario requires programmable authorization rules.
|
||||
pub requires_programmable_rules: bool,
|
||||
/// Whether a confirmed postcondition is mandatory.
|
||||
pub requires_postcondition: bool,
|
||||
/// Canonical account projections expected when materialization is enabled.
|
||||
pub materialization_targets: std::vec::Vec<std::string::String>,
|
||||
}
|
||||
|
||||
fn scenario(
|
||||
id: &str,
|
||||
label: &str,
|
||||
asset_family: crate::MetaplexTokenMetadataAssetFamily,
|
||||
fixture_kind: crate::MetaplexTokenMetadataFixtureKind,
|
||||
initial_state: crate::MetaplexTokenMetadataFixtureState,
|
||||
resulting_state: crate::MetaplexTokenMetadataFixtureState,
|
||||
operation_codes: std::vec::Vec<std::string::String>,
|
||||
requires_collection: bool,
|
||||
requires_programmable_rules: bool,
|
||||
materialization_targets: &[&str],
|
||||
) -> crate::MetaplexTokenMetadataScenario {
|
||||
return crate::MetaplexTokenMetadataScenario {
|
||||
id: id.to_string(),
|
||||
label: label.to_string(),
|
||||
asset_family,
|
||||
fixture_kind,
|
||||
initial_state,
|
||||
resulting_state,
|
||||
mode: crate::MetaplexTokenMetadataScenarioMode::Synthetic,
|
||||
operation_codes,
|
||||
requires_collection,
|
||||
requires_programmable_rules,
|
||||
requires_postcondition: true,
|
||||
materialization_targets: materialization_targets
|
||||
.iter()
|
||||
.map(|value| return (*value).to_string())
|
||||
.collect(),
|
||||
};
|
||||
}
|
||||
|
||||
/// Returns the complete ordered synthetic Metaplex journey inventory.
|
||||
pub fn metaplex_token_metadata_synthetic_scenarios()
|
||||
-> std::vec::Vec<crate::MetaplexTokenMetadataScenario> {
|
||||
return vec![
|
||||
scenario(
|
||||
"metaplex_nft_create_update",
|
||||
"NFT classique : création, mise à jour et édition maître",
|
||||
crate::MetaplexTokenMetadataAssetFamily::Nft,
|
||||
crate::MetaplexTokenMetadataFixtureKind::ClassicNftMint,
|
||||
crate::MetaplexTokenMetadataFixtureState::MintPrepared,
|
||||
crate::MetaplexTokenMetadataFixtureState::MasterEditionCreated,
|
||||
vec![
|
||||
ks_lib::EX_METAPLEX_TOKEN_METADATA_CREATE_OPERATION.to_string(),
|
||||
ks_lib::EX_METAPLEX_TOKEN_METADATA_UPDATE_OPERATION.to_string(),
|
||||
ks_lib::EX_METAPLEX_TOKEN_METADATA_SET_TOKEN_STANDARD_OPERATION.to_string(),
|
||||
],
|
||||
false,
|
||||
false,
|
||||
&["metadata_state", "edition_state"],
|
||||
),
|
||||
scenario(
|
||||
"metaplex_sft_create_mint",
|
||||
"SFT : création des metadata puis émission de supply",
|
||||
crate::MetaplexTokenMetadataAssetFamily::Sft,
|
||||
crate::MetaplexTokenMetadataFixtureKind::SemiFungibleMint,
|
||||
crate::MetaplexTokenMetadataFixtureState::MintPrepared,
|
||||
crate::MetaplexTokenMetadataFixtureState::Validated,
|
||||
vec![
|
||||
ks_lib::EX_METAPLEX_TOKEN_METADATA_CREATE_OPERATION.to_string(),
|
||||
ks_lib::EX_METAPLEX_TOKEN_METADATA_MINT_OPERATION.to_string(),
|
||||
ks_lib::EX_METAPLEX_TOKEN_METADATA_UPDATE_OPERATION.to_string(),
|
||||
],
|
||||
false,
|
||||
false,
|
||||
&["metadata_state"],
|
||||
),
|
||||
scenario(
|
||||
"metaplex_fungible_create_update",
|
||||
"Fungible : création et transitions d’autorité",
|
||||
crate::MetaplexTokenMetadataAssetFamily::Fungible,
|
||||
crate::MetaplexTokenMetadataFixtureKind::FungibleMint,
|
||||
crate::MetaplexTokenMetadataFixtureState::MintPrepared,
|
||||
crate::MetaplexTokenMetadataFixtureState::Validated,
|
||||
vec![
|
||||
ks_lib::EX_METAPLEX_TOKEN_METADATA_CREATE_OPERATION.to_string(),
|
||||
ks_lib::EX_METAPLEX_TOKEN_METADATA_UPDATE_OPERATION.to_string(),
|
||||
],
|
||||
false,
|
||||
false,
|
||||
&["metadata_state"],
|
||||
),
|
||||
scenario(
|
||||
"metaplex_collection_verify_unverify",
|
||||
"Collection : membre non vérifié, vérification puis révocation",
|
||||
crate::MetaplexTokenMetadataAssetFamily::Collection,
|
||||
crate::MetaplexTokenMetadataFixtureKind::CollectionParentAndMember,
|
||||
crate::MetaplexTokenMetadataFixtureState::CollectionMemberUnverified,
|
||||
crate::MetaplexTokenMetadataFixtureState::CollectionMemberUnverified,
|
||||
vec![
|
||||
ks_lib::EX_METAPLEX_TOKEN_METADATA_VERIFY_OPERATION.to_string(),
|
||||
ks_lib::EX_METAPLEX_TOKEN_METADATA_UNVERIFY_OPERATION.to_string(),
|
||||
],
|
||||
true,
|
||||
false,
|
||||
&["metadata_state", "edition_state"],
|
||||
),
|
||||
scenario(
|
||||
"metaplex_pnft_delegate_transfer_revoke",
|
||||
"pNFT : token record, délégation, verrouillage, transfert et révocation",
|
||||
crate::MetaplexTokenMetadataAssetFamily::ProgrammableNft,
|
||||
crate::MetaplexTokenMetadataFixtureKind::ProgrammableNftTokenRecord,
|
||||
crate::MetaplexTokenMetadataFixtureState::ProgrammableTokenRecordCreated,
|
||||
crate::MetaplexTokenMetadataFixtureState::Validated,
|
||||
vec![
|
||||
ks_lib::EX_METAPLEX_TOKEN_METADATA_DELEGATE_OPERATION.to_string(),
|
||||
ks_lib::EX_METAPLEX_TOKEN_METADATA_LOCK_OPERATION.to_string(),
|
||||
ks_lib::EX_METAPLEX_TOKEN_METADATA_UNLOCK_OPERATION.to_string(),
|
||||
ks_lib::EX_METAPLEX_TOKEN_METADATA_TRANSFER_OPERATION.to_string(),
|
||||
ks_lib::EX_METAPLEX_TOKEN_METADATA_REVOKE_OPERATION.to_string(),
|
||||
],
|
||||
false,
|
||||
true,
|
||||
&["metadata_state", "programmable_token_state", "delegate_admin_state"],
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
/// Returns the ordered Metaplex journeys intended for opt-in Devnet simulation.
|
||||
pub fn metaplex_token_metadata_devnet_scenarios()
|
||||
-> std::vec::Vec<crate::MetaplexTokenMetadataScenario> {
|
||||
let definitions = [
|
||||
(
|
||||
"metaplex_nft_lifecycle_devnet",
|
||||
"NFT classique : création, mise à jour puis immutabilité",
|
||||
crate::MetaplexTokenMetadataAssetFamily::Nft,
|
||||
crate::MetaplexTokenMetadataFixtureKind::ClassicNftMint,
|
||||
false,
|
||||
false,
|
||||
&["metadata_state", "edition_state"][..],
|
||||
),
|
||||
(
|
||||
"metaplex_sft_create_update_devnet",
|
||||
"SFT : création confirmée puis mise à jour",
|
||||
crate::MetaplexTokenMetadataAssetFamily::Sft,
|
||||
crate::MetaplexTokenMetadataFixtureKind::SemiFungibleMint,
|
||||
false,
|
||||
false,
|
||||
&["metadata_state"][..],
|
||||
),
|
||||
(
|
||||
"metaplex_fungible_create_update_devnet",
|
||||
"Fungible : création confirmée puis mise à jour",
|
||||
crate::MetaplexTokenMetadataAssetFamily::Fungible,
|
||||
crate::MetaplexTokenMetadataFixtureKind::FungibleMint,
|
||||
false,
|
||||
false,
|
||||
&["metadata_state"][..],
|
||||
),
|
||||
(
|
||||
"metaplex_collection_create_update_devnet",
|
||||
"Collection parent : création confirmée puis mise à jour",
|
||||
crate::MetaplexTokenMetadataAssetFamily::Collection,
|
||||
crate::MetaplexTokenMetadataFixtureKind::CollectionParentAndMember,
|
||||
true,
|
||||
false,
|
||||
&["metadata_state", "edition_state"][..],
|
||||
),
|
||||
(
|
||||
"metaplex_pnft_create_update_devnet",
|
||||
"pNFT sans rule set : création confirmée puis mise à jour",
|
||||
crate::MetaplexTokenMetadataAssetFamily::ProgrammableNft,
|
||||
crate::MetaplexTokenMetadataFixtureKind::ProgrammableNftTokenRecord,
|
||||
false,
|
||||
false,
|
||||
&["metadata_state", "edition_state"][..],
|
||||
),
|
||||
];
|
||||
return definitions
|
||||
.into_iter()
|
||||
.map(
|
||||
|(
|
||||
id,
|
||||
label,
|
||||
asset_family,
|
||||
fixture_kind,
|
||||
requires_collection,
|
||||
requires_programmable_rules,
|
||||
materialization_targets,
|
||||
)| {
|
||||
let mut value = scenario(
|
||||
id,
|
||||
label,
|
||||
asset_family,
|
||||
fixture_kind,
|
||||
crate::MetaplexTokenMetadataFixtureState::MintPrepared,
|
||||
if asset_family == crate::MetaplexTokenMetadataAssetFamily::Nft {
|
||||
crate::MetaplexTokenMetadataFixtureState::MetadataImmutable
|
||||
} else {
|
||||
crate::MetaplexTokenMetadataFixtureState::MetadataCreated
|
||||
},
|
||||
if asset_family == crate::MetaplexTokenMetadataAssetFamily::Nft {
|
||||
vec![
|
||||
ks_lib::EX_METAPLEX_TOKEN_METADATA_CREATE_OPERATION.to_string(),
|
||||
ks_lib::EX_METAPLEX_TOKEN_METADATA_UPDATE_OPERATION.to_string(),
|
||||
ks_lib::EX_METAPLEX_TOKEN_METADATA_UPDATE_OPERATION.to_string(),
|
||||
]
|
||||
} else {
|
||||
vec![
|
||||
ks_lib::EX_METAPLEX_TOKEN_METADATA_CREATE_OPERATION.to_string(),
|
||||
ks_lib::EX_METAPLEX_TOKEN_METADATA_UPDATE_OPERATION.to_string(),
|
||||
]
|
||||
},
|
||||
requires_collection,
|
||||
requires_programmable_rules,
|
||||
materialization_targets,
|
||||
);
|
||||
value.mode = crate::MetaplexTokenMetadataScenarioMode::NetworkSimulation;
|
||||
return value;
|
||||
},
|
||||
)
|
||||
.collect();
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#[test]
|
||||
fn inventory_is_exact_unique_and_uses_coherent_fixture_graphs() {
|
||||
let scenarios = crate::metaplex_token_metadata_synthetic_scenarios();
|
||||
assert_eq!(scenarios.len(), 5);
|
||||
let ids = scenarios
|
||||
.iter()
|
||||
.map(|scenario| return scenario.id.as_str())
|
||||
.collect::<std::collections::BTreeSet<&str>>();
|
||||
assert_eq!(ids.len(), scenarios.len());
|
||||
for scenario in scenarios {
|
||||
assert!(!scenario.label.trim().is_empty());
|
||||
assert!(!scenario.operation_codes.is_empty());
|
||||
assert!(!scenario.materialization_targets.is_empty());
|
||||
assert!(scenario.requires_postcondition);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn collection_journey_uses_only_current_verify_and_unverify_operations() {
|
||||
let scenarios = crate::metaplex_token_metadata_synthetic_scenarios();
|
||||
let collection = scenarios.iter().find(|scenario| {
|
||||
return scenario.asset_family == crate::MetaplexTokenMetadataAssetFamily::Collection;
|
||||
});
|
||||
assert!(collection.is_some());
|
||||
if let std::option::Option::Some(scenario) = collection {
|
||||
assert_eq!(
|
||||
scenario.operation_codes,
|
||||
vec![
|
||||
ks_lib::EX_METAPLEX_TOKEN_METADATA_VERIFY_OPERATION.to_string(),
|
||||
ks_lib::EX_METAPLEX_TOKEN_METADATA_UNVERIFY_OPERATION.to_string(),
|
||||
]
|
||||
);
|
||||
assert_eq!(
|
||||
scenario.resulting_state,
|
||||
crate::MetaplexTokenMetadataFixtureState::CollectionMemberUnverified
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn collection_and_programmable_journeys_declare_special_requirements() {
|
||||
let scenarios = crate::metaplex_token_metadata_synthetic_scenarios();
|
||||
let collection = scenarios.iter().find(|scenario| {
|
||||
return scenario.asset_family == crate::MetaplexTokenMetadataAssetFamily::Collection;
|
||||
});
|
||||
assert!(collection.is_some_and(|scenario| return scenario.requires_collection));
|
||||
let programmable = scenarios.iter().find(|scenario| {
|
||||
return scenario.asset_family
|
||||
== crate::MetaplexTokenMetadataAssetFamily::ProgrammableNft;
|
||||
});
|
||||
assert!(programmable.is_some_and(|scenario| return scenario.requires_programmable_rules));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn devnet_inventory_exposes_only_fully_preparable_journeys() {
|
||||
let devnet = crate::metaplex_token_metadata_devnet_scenarios();
|
||||
assert_eq!(devnet.len(), 5);
|
||||
let families = devnet
|
||||
.iter()
|
||||
.map(|scenario| return scenario.asset_family)
|
||||
.collect::<std::collections::BTreeSet<_>>();
|
||||
assert_eq!(families.len(), 5);
|
||||
for scenario in devnet {
|
||||
let expected_len =
|
||||
if scenario.asset_family == crate::MetaplexTokenMetadataAssetFamily::Nft {
|
||||
3
|
||||
} else {
|
||||
2
|
||||
};
|
||||
assert_eq!(scenario.operation_codes.len(), expected_len);
|
||||
assert_eq!(
|
||||
scenario.operation_codes[0],
|
||||
ks_lib::EX_METAPLEX_TOKEN_METADATA_CREATE_OPERATION
|
||||
);
|
||||
assert!(
|
||||
scenario.operation_codes[1..].iter().all(|operation| return operation
|
||||
== ks_lib::EX_METAPLEX_TOKEN_METADATA_UPDATE_OPERATION)
|
||||
);
|
||||
assert_eq!(scenario.mode, crate::MetaplexTokenMetadataScenarioMode::NetworkSimulation);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,574 @@
|
||||
// file: ks-pipeline-demo-scenarios/src/metadata/metaplex_token_metadata/use_campaign.rs
|
||||
// version: 2
|
||||
|
||||
//! Bounded Devnet availability probe for the current Metaplex `Use` surface.
|
||||
|
||||
/// Outcome of one bounded Devnet `Use` availability probe.
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub enum DevnetMetaplexUseProbeStatus {
|
||||
/// The current `Use` instruction was confirmed with complete pipeline evidence.
|
||||
Confirmed,
|
||||
/// The exact current `Use` instruction was rejected by Devnet simulation.
|
||||
RuntimeUnavailable,
|
||||
}
|
||||
|
||||
/// Complete evidence produced by one current Metaplex `Use` availability probe.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct DevnetMetaplexUseProbeSummary {
|
||||
/// Fresh classic NFT prepared with two bounded `Multiple` uses.
|
||||
pub fixture: crate::DevnetMetaplexCreateMintCampaignSummary,
|
||||
/// Exact simulation evidence for the current `Use` instruction.
|
||||
pub simulation: crate::DevnetMetaplexTokenMetadataExecutionSummary,
|
||||
/// Confirmed execution evidence when the runtime accepts the current instruction.
|
||||
pub execution: std::option::Option<crate::DevnetMetaplexTokenMetadataExecutionSummary>,
|
||||
/// Runtime availability classification produced by this probe.
|
||||
pub status: crate::DevnetMetaplexUseProbeStatus,
|
||||
/// Remaining uses observed immediately before the `Use` probe.
|
||||
pub uses_before: u64,
|
||||
/// Remaining uses observed after confirmed execution, when available.
|
||||
pub uses_after: std::option::Option<u64>,
|
||||
/// Exact bounded simulation failure diagnostic when runtime-unavailable.
|
||||
pub unavailable_reason: std::option::Option<std::string::String>,
|
||||
}
|
||||
|
||||
/// Returns the exact ordered operations exercised by the bounded `Use` probe.
|
||||
pub fn metaplex_use_probe_operation_names() -> &'static [&'static str; 3] {
|
||||
return &["create", "mint", "use"];
|
||||
}
|
||||
|
||||
/// Executes one fresh classic NFT `Use` availability probe on Devnet.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub async fn execute_devnet_metaplex_use_probe<S, O>(
|
||||
http_pool: &ks_onchain_transport::HttpEndpointPool,
|
||||
store: &S,
|
||||
profile: &ks_config::ProfileConfig,
|
||||
workspace_root: &std::path::Path,
|
||||
options: &crate::MetaplexCreateFixturePreparationOptions,
|
||||
observer: &O,
|
||||
) -> ks_core::Result<crate::DevnetMetaplexUseProbeSummary>
|
||||
where
|
||||
S: ks_store::RawTransactionStore
|
||||
+ ks_store::CoreExtractionStore
|
||||
+ ks_store::DecodePipelineStore
|
||||
+ Sync,
|
||||
O: crate::SolanaExecutionObserver,
|
||||
{
|
||||
if options.asset_family != crate::MetaplexTokenMetadataAssetFamily::Nft {
|
||||
return std::result::Result::Err(ks_core::Error::config(
|
||||
"Metaplex Use probe requires the classic NFT fixture family",
|
||||
));
|
||||
}
|
||||
let fixture_options = options.clone().with_multiple_uses(2);
|
||||
let fixture = match crate::execute_devnet_metaplex_create_mint_campaign(
|
||||
http_pool,
|
||||
store,
|
||||
profile,
|
||||
workspace_root,
|
||||
&fixture_options,
|
||||
observer,
|
||||
)
|
||||
.await
|
||||
{
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let uses_before = match metadata_remaining_multiple_uses(
|
||||
fixture.mint.after.as_slice(),
|
||||
fixture.fixture.metadata.as_str(),
|
||||
2,
|
||||
) {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
if uses_before != 2 {
|
||||
return std::result::Result::Err(ks_core::Error::new(
|
||||
"metaplex_use_probe_initial_uses_invalid",
|
||||
format!("Metaplex Use probe requires remaining=2 before execution; got {uses_before}"),
|
||||
));
|
||||
}
|
||||
let operation = use_operation(&fixture.fixture);
|
||||
let mut simulation_request = crate::DevnetMetaplexTokenMetadataExecutionRequest::new(
|
||||
format!("metaplex-use-probe-simulation-{}", uuid::Uuid::new_v4()),
|
||||
operation.clone(),
|
||||
);
|
||||
simulation_request.query_role = fixture_options.query_role.clone();
|
||||
simulation_request.transaction_role = fixture_options.transaction_role.clone();
|
||||
let simulation = match crate::simulate_devnet_metaplex_token_metadata(
|
||||
http_pool,
|
||||
profile,
|
||||
workspace_root,
|
||||
&simulation_request,
|
||||
observer,
|
||||
)
|
||||
.await
|
||||
{
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
if !simulation.simulation.success {
|
||||
return std::result::Result::Ok(crate::DevnetMetaplexUseProbeSummary {
|
||||
fixture,
|
||||
unavailable_reason: std::option::Option::Some(crate::simulation_failure_message(
|
||||
&simulation.simulation,
|
||||
)),
|
||||
simulation,
|
||||
execution: std::option::Option::None,
|
||||
status: crate::DevnetMetaplexUseProbeStatus::RuntimeUnavailable,
|
||||
uses_before,
|
||||
uses_after: std::option::Option::None,
|
||||
});
|
||||
}
|
||||
let decoders: std::vec::Vec<std::sync::Arc<dyn ks_lib::DcApiInstructionDecoder>> =
|
||||
std::vec![std::sync::Arc::new(ks_lib::DcMetadataMetaplexTokenMetadataDecoder)];
|
||||
let materializers: std::vec::Vec<std::sync::Arc<dyn ks_lib::MtApiEventMaterializer>> = std::vec![
|
||||
std::sync::Arc::new(ks_lib::MtAdminMaterializer),
|
||||
std::sync::Arc::new(ks_lib::MtComplianceAuditMaterializer),
|
||||
std::sync::Arc::new(ks_lib::MtLifecycleMaterializer),
|
||||
std::sync::Arc::new(ks_lib::MtMetadataMetaplexTokenMetadataMaterializer),
|
||||
std::sync::Arc::new(ks_lib::MtRiskMaterializer),
|
||||
];
|
||||
let mut execution_request = crate::DevnetMetaplexTokenMetadataExecutionRequest::new(
|
||||
format!("metaplex-use-probe-submit-{}", uuid::Uuid::new_v4()),
|
||||
operation,
|
||||
);
|
||||
execution_request.query_role = fixture_options.query_role.clone();
|
||||
execution_request.transaction_role = fixture_options.transaction_role.clone();
|
||||
execution_request.submit = true;
|
||||
execution_request.operator_confirmed = true;
|
||||
execution_request.materialize_after_confirmation = true;
|
||||
execution_request.post_validation_max_retries = 20;
|
||||
execution_request.postcondition_reads =
|
||||
metadata_read(fixture.fixture.metadata.as_str(), fixture_options.query_role.as_str());
|
||||
let execution = match crate::execute_devnet_metaplex_token_metadata(
|
||||
http_pool,
|
||||
store,
|
||||
profile,
|
||||
workspace_root,
|
||||
&execution_request,
|
||||
decoders.as_slice(),
|
||||
materializers.as_slice(),
|
||||
observer,
|
||||
)
|
||||
.await
|
||||
{
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let confirmation_slot = match validate_confirmed_use_execution(&execution) {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let uses_after = match metadata_remaining_multiple_uses(
|
||||
execution.after.as_slice(),
|
||||
fixture.fixture.metadata.as_str(),
|
||||
2,
|
||||
) {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
if uses_after != 1 {
|
||||
return std::result::Result::Err(ks_core::Error::new(
|
||||
"metaplex_use_probe_remaining_uses_invalid",
|
||||
format!(
|
||||
"confirmed Metaplex Use must decrement remaining uses from 2 to 1; got {uses_after}"
|
||||
),
|
||||
));
|
||||
}
|
||||
let token_after = match crate::read_token_account_at_or_after(
|
||||
http_pool,
|
||||
fixture_options.query_role.as_str(),
|
||||
fixture.fixture.token_account.as_str(),
|
||||
std::option::Option::Some(confirmation_slot),
|
||||
)
|
||||
.await
|
||||
{
|
||||
std::result::Result::Ok(std::option::Option::Some(value)) => value,
|
||||
std::result::Result::Ok(std::option::Option::None) => {
|
||||
return std::result::Result::Err(ks_core::Error::new(
|
||||
"metaplex_use_probe_token_missing",
|
||||
"classic NFT token account disappeared after confirmed Use",
|
||||
));
|
||||
},
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
if let std::result::Result::Err(error) = crate::validate_classic_fixture_token_account_state(
|
||||
&token_after,
|
||||
fixture.fixture.mint.as_str(),
|
||||
fixture.fixture.authority.as_str(),
|
||||
1,
|
||||
spl_token_interface::state::AccountState::Initialized,
|
||||
) {
|
||||
return std::result::Result::Err(ks_core::Error::new(
|
||||
"metaplex_use_probe_token_state_invalid",
|
||||
format!("confirmed Metaplex Use left invalid NFT token state: {error}"),
|
||||
));
|
||||
}
|
||||
return std::result::Result::Ok(crate::DevnetMetaplexUseProbeSummary {
|
||||
fixture,
|
||||
simulation,
|
||||
execution: std::option::Option::Some(execution),
|
||||
status: crate::DevnetMetaplexUseProbeStatus::Confirmed,
|
||||
uses_before,
|
||||
uses_after: std::option::Option::Some(uses_after),
|
||||
unavailable_reason: std::option::Option::None,
|
||||
});
|
||||
}
|
||||
|
||||
fn use_operation(
|
||||
fixture: &crate::MetaplexCreateFixturePreparationSummary,
|
||||
) -> ks_lib::ExMetaplexTokenMetadataOperation {
|
||||
return ks_lib::ExMetaplexTokenMetadataOperation::Use {
|
||||
authority: ks_lib::MdPubkey(fixture.authority.clone()),
|
||||
delegate_record: std::option::Option::None,
|
||||
token: std::option::Option::Some(ks_lib::MdPubkey(fixture.token_account.clone())),
|
||||
mint: ks_lib::MdPubkey(fixture.mint.clone()),
|
||||
metadata: ks_lib::MdPubkey(fixture.metadata.clone()),
|
||||
edition: std::option::Option::Some(ks_lib::MdPubkey(fixture.master_edition.clone())),
|
||||
payer: ks_lib::MdPubkey(fixture.authority.clone()),
|
||||
system_program: ks_lib::MdPubkey(ks_program_ids::SYSTEM_PROGRAM_ID.to_string()),
|
||||
sysvar_instructions: ks_lib::MdPubkey(
|
||||
ks_program_ids::SYSVAR_INSTRUCTIONS_PROGRAM_ID.to_string(),
|
||||
),
|
||||
spl_token_program: std::option::Option::Some(ks_lib::MdPubkey(
|
||||
ks_program_ids::SPL_TOKEN_PROGRAM_ID.to_string(),
|
||||
)),
|
||||
authorization_rules_program: std::option::Option::None,
|
||||
authorization_rules: std::option::Option::None,
|
||||
args: mpl_token_metadata::types::UseArgs::V1 {
|
||||
authorization_data: std::option::Option::None,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
fn metadata_read(
|
||||
metadata: &str,
|
||||
query_role: &str,
|
||||
) -> std::vec::Vec<ks_pipeline::MetaplexTokenMetadataStatefulReadRequest> {
|
||||
return std::vec![ks_pipeline::MetaplexTokenMetadataStatefulReadRequest {
|
||||
query_role: query_role.to_string(),
|
||||
account: ks_lib::MdPubkey(metadata.to_string()),
|
||||
kind: ks_pipeline::MetaplexTokenMetadataAccountKind::Metadata,
|
||||
min_context_slot: std::option::Option::None,
|
||||
max_data_bytes: ks_pipeline::MAX_METAPLEX_TOKEN_METADATA_ACCOUNT_BYTES,
|
||||
}];
|
||||
}
|
||||
|
||||
fn metadata_remaining_multiple_uses(
|
||||
snapshots: &[ks_pipeline::MetaplexTokenMetadataStatefulReadResult],
|
||||
metadata: &str,
|
||||
expected_total: u64,
|
||||
) -> ks_core::Result<u64> {
|
||||
let snapshot = match snapshots.iter().find(|value| {
|
||||
return value.snapshot.account.0.as_str() == metadata
|
||||
&& value.snapshot.account_kind == "metadata";
|
||||
}) {
|
||||
std::option::Option::Some(value) => value,
|
||||
std::option::Option::None => {
|
||||
return std::result::Result::Err(ks_core::Error::new(
|
||||
"metaplex_use_probe_metadata_snapshot_missing",
|
||||
format!("metadata snapshot {metadata} is missing from the Use probe"),
|
||||
));
|
||||
},
|
||||
};
|
||||
let uses_value = match snapshot.snapshot.payload_json.get("uses") {
|
||||
std::option::Option::Some(value) if !value.is_null() => value.clone(),
|
||||
_ => {
|
||||
return std::result::Result::Err(ks_core::Error::new(
|
||||
"metaplex_use_probe_uses_missing",
|
||||
"metadata snapshot has no Uses state",
|
||||
));
|
||||
},
|
||||
};
|
||||
let uses = match serde_json::from_value::<mpl_token_metadata::types::Uses>(uses_value) {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => {
|
||||
return std::result::Result::Err(ks_core::Error::new(
|
||||
"metaplex_use_probe_uses_decode_failed",
|
||||
format!("metadata Uses projection is invalid: {error}"),
|
||||
));
|
||||
},
|
||||
};
|
||||
if uses.use_method != mpl_token_metadata::types::UseMethod::Multiple
|
||||
|| uses.total != expected_total
|
||||
|| uses.remaining > uses.total
|
||||
{
|
||||
return std::result::Result::Err(ks_core::Error::new(
|
||||
"metaplex_use_probe_uses_state_invalid",
|
||||
format!(
|
||||
"metadata Uses state must remain Multiple with total={expected_total}; got method={:?}, remaining={}, total={}",
|
||||
uses.use_method, uses.remaining, uses.total
|
||||
),
|
||||
));
|
||||
}
|
||||
return std::result::Result::Ok(uses.remaining);
|
||||
}
|
||||
|
||||
fn validate_confirmed_use_execution(
|
||||
execution: &crate::DevnetMetaplexTokenMetadataExecutionSummary,
|
||||
) -> ks_core::Result<u64> {
|
||||
let confirmation = match execution.confirmation.as_ref() {
|
||||
std::option::Option::Some(value)
|
||||
if matches!(
|
||||
value.status,
|
||||
ks_lib::ExApiExecutionConfirmationStatus::Confirmed
|
||||
| ks_lib::ExApiExecutionConfirmationStatus::Finalized
|
||||
) =>
|
||||
{
|
||||
value
|
||||
},
|
||||
std::option::Option::Some(value) => {
|
||||
return std::result::Result::Err(ks_core::Error::new(
|
||||
"metaplex_use_probe_confirmation_incomplete",
|
||||
format!("Metaplex Use stopped at {:?}", value.status),
|
||||
));
|
||||
},
|
||||
std::option::Option::None => {
|
||||
return std::result::Result::Err(ks_core::Error::new(
|
||||
"metaplex_use_probe_confirmation_missing",
|
||||
"Metaplex Use has no confirmation evidence",
|
||||
));
|
||||
},
|
||||
};
|
||||
let diagnostic = match execution.post_execution.as_ref() {
|
||||
std::option::Option::Some(value) => value,
|
||||
std::option::Option::None => {
|
||||
return std::result::Result::Err(ks_core::Error::new(
|
||||
"metaplex_use_probe_post_execution_missing",
|
||||
"Metaplex Use has no post-execution diagnostic",
|
||||
));
|
||||
},
|
||||
};
|
||||
if !diagnostic.canonical_inserted
|
||||
|| !diagnostic.core_extracted
|
||||
|| !diagnostic.decode_replayed
|
||||
|| !diagnostic.materialized
|
||||
|| execution.materializations.is_empty()
|
||||
|| execution.materialized_snapshots.is_empty()
|
||||
{
|
||||
return std::result::Result::Err(ks_core::Error::new(
|
||||
"metaplex_use_probe_post_execution_incomplete",
|
||||
format!(
|
||||
"Metaplex Use post-execution incomplete: signature={}, slot={:?}, canonical_inserted={}, core_extracted={}, decode_replayed={}, materialized={}, materialization_rows={}, materialized_snapshots={}, diagnostics={:?}",
|
||||
confirmation.signature.0,
|
||||
confirmation.slot,
|
||||
diagnostic.canonical_inserted,
|
||||
diagnostic.core_extracted,
|
||||
diagnostic.decode_replayed,
|
||||
diagnostic.materialized,
|
||||
execution.materializations.len(),
|
||||
execution.materialized_snapshots.len(),
|
||||
diagnostic.diagnostics
|
||||
),
|
||||
));
|
||||
}
|
||||
let idempotence = match execution.idempotence_replay.as_ref() {
|
||||
std::option::Option::Some(value) => value,
|
||||
std::option::Option::None => {
|
||||
return std::result::Result::Err(ks_core::Error::new(
|
||||
"metaplex_use_probe_idempotence_missing",
|
||||
"Metaplex Use has no idempotence replay evidence",
|
||||
));
|
||||
},
|
||||
};
|
||||
if idempotence.failed_inputs != 0
|
||||
|| idempotence.processing_error_inputs != 0
|
||||
|| idempotence.processors.iter().any(|processor| {
|
||||
return processor.failed != 0
|
||||
|| processor.processing_errors != 0
|
||||
|| processor.materialized_outputs != 0
|
||||
|| processor.materialization_refused != 0;
|
||||
})
|
||||
{
|
||||
return std::result::Result::Err(ks_core::Error::new(
|
||||
"metaplex_use_probe_idempotence_failed",
|
||||
"Metaplex Use second replay is not idempotent",
|
||||
));
|
||||
}
|
||||
return match confirmation.slot {
|
||||
std::option::Option::Some(value) => std::result::Result::Ok(value),
|
||||
std::option::Option::None => std::result::Result::Err(ks_core::Error::new(
|
||||
"metaplex_use_probe_confirmation_slot_missing",
|
||||
"Metaplex Use confirmation has no slot",
|
||||
)),
|
||||
};
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#[test]
|
||||
fn use_probe_contract_is_exactly_create_mint_then_use() {
|
||||
assert_eq!(crate::metaplex_use_probe_operation_names(), &["create", "mint", "use"]);
|
||||
assert_ne!(
|
||||
crate::DevnetMetaplexUseProbeStatus::Confirmed,
|
||||
crate::DevnetMetaplexUseProbeStatus::RuntimeUnavailable,
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn optional_devnet_metaplex_use_probe_from_env() {
|
||||
if std::env::var("KB_DEVNET_METAPLEX_USE_PROBE_TEST").ok().as_deref()
|
||||
!= std::option::Option::Some("1")
|
||||
{
|
||||
return;
|
||||
}
|
||||
assert_eq!(
|
||||
std::env::var("KB_DEVNET_METAPLEX_OPERATOR_CONFIRMED").ok().as_deref(),
|
||||
std::option::Option::Some("1"),
|
||||
"set KB_DEVNET_METAPLEX_OPERATOR_CONFIRMED=1 before submitting an accepted Use probe"
|
||||
);
|
||||
let workspace_root = match std::path::Path::new(env!("CARGO_MANIFEST_DIR")).parent() {
|
||||
std::option::Option::Some(value) => value,
|
||||
std::option::Option::None => panic!("workspace root cannot be resolved"),
|
||||
};
|
||||
let config_path = match std::env::var("KB_DEVNET_CONFIG_PATH") {
|
||||
std::result::Result::Ok(value) => {
|
||||
let path = std::path::PathBuf::from(value);
|
||||
if path.is_absolute() { path } else { workspace_root.join(path) }
|
||||
},
|
||||
std::result::Result::Err(_) => workspace_root.join("config/example.config.json"),
|
||||
};
|
||||
let config = ks_config::read_config_json_file_with_environment(
|
||||
config_path.as_path(),
|
||||
workspace_root,
|
||||
)
|
||||
.unwrap_or_else(|error| panic!("Devnet configuration loading failed: {error}"));
|
||||
let profile_name = std::env::var("KB_DEVNET_PROFILE").ok();
|
||||
let mut profile = crate::resolve_demo_devnet_profile(&config, profile_name.as_deref())
|
||||
.unwrap_or_else(|error| panic!("Devnet profile resolution failed: {error}"));
|
||||
let database_url = std::env::var("KB_POSTGRES_TEST_URL")
|
||||
.unwrap_or_else(|error| panic!("KB_POSTGRES_TEST_URL is required: {error}"));
|
||||
profile.database.backend = "postgres".to_string();
|
||||
profile.database.postgres.url = database_url;
|
||||
let http_pool = ks_onchain_transport::HttpEndpointPool::from_profile(&profile)
|
||||
.unwrap_or_else(|error| panic!("HTTP pool initialization failed: {error}"));
|
||||
let store_options = ks_store::PostgresStoreOptions::new(
|
||||
profile.database.postgres.url.clone(),
|
||||
profile.database.postgres.max_connections,
|
||||
profile.database.postgres.connect_timeout_ms,
|
||||
false,
|
||||
)
|
||||
.unwrap_or_else(|error| panic!("PostgreSQL options failed: {error}"));
|
||||
let store = ks_store::PostgresStore::connect(store_options)
|
||||
.await
|
||||
.unwrap_or_else(|error| panic!("PostgreSQL connection failed: {error}"));
|
||||
if let std::result::Result::Err(error) = store.initialize_store_schema().await {
|
||||
panic!("PostgreSQL schema initialization failed: {error}");
|
||||
}
|
||||
let configured_wallet_dir = std::path::PathBuf::from(profile.wallet.wallet_dir.as_str());
|
||||
let wallet_dir = if configured_wallet_dir.is_absolute() {
|
||||
configured_wallet_dir
|
||||
} else {
|
||||
workspace_root.join(configured_wallet_dir)
|
||||
};
|
||||
let options = crate::MetaplexCreateFixturePreparationOptions::new(wallet_dir)
|
||||
.with_asset_family(crate::MetaplexTokenMetadataAssetFamily::Nft);
|
||||
let summary = crate::execute_devnet_metaplex_use_probe(
|
||||
&http_pool,
|
||||
&store,
|
||||
&profile,
|
||||
workspace_root,
|
||||
&options,
|
||||
&crate::NoopSolanaExecutionObserver,
|
||||
)
|
||||
.await
|
||||
.unwrap_or_else(|error| panic!("Metaplex Use Devnet probe failed: {error}"));
|
||||
println!(
|
||||
"METAPLEX_USE_PROBE_FIXTURE mint={} metadata={} master_edition={} token={} uses_before={}",
|
||||
summary.fixture.fixture.mint,
|
||||
summary.fixture.fixture.metadata,
|
||||
summary.fixture.fixture.master_edition,
|
||||
summary.fixture.fixture.token_account,
|
||||
summary.uses_before,
|
||||
);
|
||||
println!(
|
||||
"METAPLEX_USE_PROBE_SIMULATION success={} context_slot={} logs={} error={}",
|
||||
summary.simulation.simulation.success,
|
||||
summary.simulation.simulation_context_slot,
|
||||
summary.simulation.simulation.logs.len(),
|
||||
serde_json::to_string(&summary.simulation.simulation.error).unwrap_or_else(
|
||||
|error| panic!("Use simulation error serialization failed: {error}")
|
||||
),
|
||||
);
|
||||
match summary.status {
|
||||
crate::DevnetMetaplexUseProbeStatus::RuntimeUnavailable => {
|
||||
println!(
|
||||
"METAPLEX_USE_PROBE_STATUS status=runtime_unavailable uses_before={} uses_after=null reason={}",
|
||||
summary.uses_before,
|
||||
serde_json::to_string(&summary.unavailable_reason).unwrap_or_else(
|
||||
|error| panic!("Use unavailable reason serialization failed: {error}")
|
||||
),
|
||||
);
|
||||
println!(
|
||||
"METAPLEX_USE_PROBE_LOGS {}",
|
||||
serde_json::to_string(&summary.simulation.simulation.logs).unwrap_or_else(
|
||||
|error| panic!("Use simulation log serialization failed: {error}")
|
||||
),
|
||||
);
|
||||
},
|
||||
crate::DevnetMetaplexUseProbeStatus::Confirmed => {
|
||||
let execution = summary
|
||||
.execution
|
||||
.as_ref()
|
||||
.unwrap_or_else(|| panic!("confirmed Use probe execution is missing"));
|
||||
let confirmation = execution
|
||||
.confirmation
|
||||
.as_ref()
|
||||
.unwrap_or_else(|| panic!("confirmed Use probe confirmation is missing"));
|
||||
println!(
|
||||
"METAPLEX_USE_PROBE_STATUS status=confirmed uses_before={} uses_after={}",
|
||||
summary.uses_before,
|
||||
summary
|
||||
.uses_after
|
||||
.unwrap_or_else(|| panic!("confirmed Use uses_after is missing")),
|
||||
);
|
||||
println!(
|
||||
"METAPLEX_USE_PROBE_STEP operation={} signature={} slot={:?} materializations={}",
|
||||
execution.plan.operation_code,
|
||||
confirmation.signature.0,
|
||||
confirmation.slot,
|
||||
execution.materializations.len(),
|
||||
);
|
||||
println!("METAPLEX_USE_PROBE_EVIDENCE {}", execution_evidence_json(execution),);
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn execution_evidence_json(
|
||||
execution: &crate::DevnetMetaplexTokenMetadataExecutionSummary,
|
||||
) -> serde_json::Value {
|
||||
let confirmation = execution.confirmation.as_ref();
|
||||
let post_execution = execution.post_execution.as_ref();
|
||||
let idempotence_clean = execution.idempotence_replay.as_ref().is_some_and(|replay| {
|
||||
return replay.failed_inputs == 0
|
||||
&& replay.processing_error_inputs == 0
|
||||
&& replay.processors.iter().all(|processor| {
|
||||
return processor.failed == 0
|
||||
&& processor.processing_errors == 0
|
||||
&& processor.materialized_outputs == 0
|
||||
&& processor.materialization_refused == 0;
|
||||
});
|
||||
});
|
||||
return serde_json::json!({
|
||||
"operation": execution.plan.operation_code.as_str(),
|
||||
"cluster": execution.cluster,
|
||||
"genesisHash": execution.genesis_hash.as_str(),
|
||||
"simulationContextSlot": execution.simulation_context_slot,
|
||||
"simulationSuccess": execution.simulation.success,
|
||||
"simulationLogCount": execution.simulation.logs.len(),
|
||||
"messageHash": execution.readiness.message_hash.as_str(),
|
||||
"feeLamports": execution.fee.fee_lamports,
|
||||
"signature": confirmation.map(|value| return value.signature.0.clone()),
|
||||
"confirmationStatus": confirmation.map(|value| return format!("{:?}", value.status)),
|
||||
"confirmationSlot": confirmation.and_then(|value| return value.slot),
|
||||
"beforeSnapshots": execution.before.len(),
|
||||
"afterSnapshots": execution.after.len(),
|
||||
"canonicalHydration": post_execution.is_some_and(|value| return value.canonical_inserted),
|
||||
"coreExtraction": post_execution.is_some_and(|value| return value.core_extracted),
|
||||
"decodeReplay": post_execution.is_some_and(|value| return value.decode_replayed),
|
||||
"materialized": post_execution.is_some_and(|value| return value.materialized),
|
||||
"instructionMaterializations": execution.materializations.len(),
|
||||
"materializedSnapshots": execution.materialized_snapshots.len(),
|
||||
"idempotenceReplayClean": idempotence_clean,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,918 @@
|
||||
// file: ks-pipeline-demo-scenarios/src/metadata/metaplex_token_metadata/validation.rs
|
||||
// version: 18
|
||||
|
||||
//! Machine-readable Metaplex Token Metadata validation contract.
|
||||
|
||||
/// Maximum evidence entries accepted by one Metaplex validation scenario.
|
||||
pub const MAX_METAPLEX_TOKEN_METADATA_VALIDATION_EVIDENCE: usize = 64;
|
||||
|
||||
/// Exact validation status for one Metaplex scenario.
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum MetaplexTokenMetadataValidationStatus {
|
||||
/// Scenario has not been executed.
|
||||
NotRun,
|
||||
/// Scenario passed deterministic synthetic validation.
|
||||
SyntheticValidated,
|
||||
/// Scenario was simulated on Devnet or Testnet.
|
||||
Simulated,
|
||||
/// Scenario was submitted but confirmation evidence is incomplete.
|
||||
Submitted,
|
||||
/// Scenario was confirmed and all postconditions passed.
|
||||
Confirmed,
|
||||
/// Scenario is unavailable in the selected environment.
|
||||
Unavailable,
|
||||
/// Scenario ran and failed.
|
||||
Failed,
|
||||
}
|
||||
|
||||
/// One bounded validation evidence entry.
|
||||
#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
|
||||
pub struct MetaplexTokenMetadataValidationEvidence {
|
||||
/// Stable evidence kind.
|
||||
pub kind: std::string::String,
|
||||
/// Bounded evidence value.
|
||||
pub value: std::string::String,
|
||||
}
|
||||
|
||||
/// One scenario declared by the canonical Metaplex validation matrix.
|
||||
#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct MetaplexTokenMetadataValidationMatrixScenario {
|
||||
/// Stable scenario identifier.
|
||||
pub id: std::string::String,
|
||||
/// Required scenario mode.
|
||||
pub mode: crate::MetaplexTokenMetadataScenarioMode,
|
||||
/// Exact validation status.
|
||||
pub status: crate::MetaplexTokenMetadataValidationStatus,
|
||||
/// Evidence kinds required before confirmation.
|
||||
pub required_evidence: std::vec::Vec<std::string::String>,
|
||||
/// Observed evidence.
|
||||
#[serde(default)]
|
||||
pub evidence: std::vec::Vec<crate::MetaplexTokenMetadataValidationEvidence>,
|
||||
}
|
||||
|
||||
/// Canonical Metaplex validation matrix.
|
||||
#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct MetaplexTokenMetadataValidationMatrix {
|
||||
/// Matrix schema version.
|
||||
pub matrix_version: u32,
|
||||
/// Owning milestone.
|
||||
pub milestone: std::string::String,
|
||||
/// Ordered validation scenarios.
|
||||
pub scenarios: std::vec::Vec<crate::MetaplexTokenMetadataValidationMatrixScenario>,
|
||||
}
|
||||
|
||||
/// Transaction location covered by one cross-validation case.
|
||||
#[derive(
|
||||
Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd, serde::Deserialize, serde::Serialize,
|
||||
)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum MetaplexTokenMetadataTransactionPath {
|
||||
/// Top-level instruction.
|
||||
Outer,
|
||||
/// Cross-program invocation.
|
||||
Cpi,
|
||||
}
|
||||
|
||||
/// Expected transaction outcome for one cross-validation case.
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum MetaplexTokenMetadataTransactionOutcome {
|
||||
/// Successful transaction path.
|
||||
Success,
|
||||
/// Failed or rejected transaction path.
|
||||
Failed,
|
||||
}
|
||||
|
||||
/// Stable failure category exercised by one negative case.
|
||||
#[derive(
|
||||
Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd, serde::Deserialize, serde::Serialize,
|
||||
)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum MetaplexTokenMetadataFailureClass {
|
||||
/// Transaction failed before committed observations could be emitted.
|
||||
TransactionFailed,
|
||||
/// Program ID mismatch.
|
||||
ProgramId,
|
||||
/// Account owner mismatch.
|
||||
Owner,
|
||||
/// Program-derived address mismatch.
|
||||
Pda,
|
||||
/// Positional account or flag mismatch.
|
||||
Accounts,
|
||||
/// Truncated instruction payload.
|
||||
PayloadTruncated,
|
||||
/// Forbidden trailing instruction bytes.
|
||||
PayloadSuffix,
|
||||
/// Unknown instruction discriminant.
|
||||
UnknownDiscriminant,
|
||||
/// Conflicting Metaplex and Token-2022 metadata sources.
|
||||
MetadataSourceConflict,
|
||||
}
|
||||
|
||||
/// One case in the closed Metaplex cross-validation corpus.
|
||||
#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct MetaplexTokenMetadataCrossValidationCase {
|
||||
/// Stable case identifier.
|
||||
pub id: std::string::String,
|
||||
/// Outer or CPI transaction path.
|
||||
pub path: crate::MetaplexTokenMetadataTransactionPath,
|
||||
/// Expected transaction outcome.
|
||||
pub outcome: crate::MetaplexTokenMetadataTransactionOutcome,
|
||||
/// Optional asset family for successful corpus cases.
|
||||
pub asset_family: std::option::Option<crate::MetaplexTokenMetadataAssetFamily>,
|
||||
/// Optional stable negative-case category.
|
||||
pub failure_class: std::option::Option<crate::MetaplexTokenMetadataFailureClass>,
|
||||
/// Whether status changes require actual RPC evidence.
|
||||
pub requires_network_evidence: bool,
|
||||
/// Exact observed validation status.
|
||||
pub status: crate::MetaplexTokenMetadataValidationStatus,
|
||||
/// Evidence kinds required before the declared status is accepted.
|
||||
pub required_evidence: std::vec::Vec<std::string::String>,
|
||||
/// Bounded observed evidence.
|
||||
#[serde(default)]
|
||||
pub evidence: std::vec::Vec<crate::MetaplexTokenMetadataValidationEvidence>,
|
||||
}
|
||||
|
||||
/// Closed Metaplex cross-validation corpus for `0.4.7-pre.010`.
|
||||
#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct MetaplexTokenMetadataCrossValidationMatrix {
|
||||
/// Matrix schema version.
|
||||
pub matrix_version: u32,
|
||||
/// Exact owning prerelease.
|
||||
pub milestone: std::string::String,
|
||||
/// Ordered validation cases.
|
||||
pub cases: std::vec::Vec<crate::MetaplexTokenMetadataCrossValidationCase>,
|
||||
}
|
||||
|
||||
/// Loads and validates the closed Metaplex cross-validation corpus.
|
||||
pub fn load_metaplex_token_metadata_cross_validation_matrix()
|
||||
-> ks_core::Result<crate::MetaplexTokenMetadataCrossValidationMatrix> {
|
||||
let parsed = match serde_json::from_str::<crate::MetaplexTokenMetadataCrossValidationMatrix>(
|
||||
include_str!(
|
||||
"../../../../test-fixtures/contract-matrices/METAPLEX_TOKEN_METADATA_CROSS_VALIDATION_MATRIX.json"
|
||||
),
|
||||
) {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => {
|
||||
return std::result::Result::Err(ks_core::Error::new(
|
||||
"metaplex_token_metadata_cross_validation_matrix_invalid_json",
|
||||
format!("Metaplex cross-validation matrix JSON is invalid: {error}"),
|
||||
));
|
||||
},
|
||||
};
|
||||
if let std::result::Result::Err(error) =
|
||||
validate_metaplex_token_metadata_cross_validation_matrix(&parsed)
|
||||
{
|
||||
return std::result::Result::Err(error);
|
||||
}
|
||||
return std::result::Result::Ok(parsed);
|
||||
}
|
||||
|
||||
/// Validates the closed Metaplex cross-validation corpus and evidence claims.
|
||||
pub fn validate_metaplex_token_metadata_cross_validation_matrix(
|
||||
matrix: &crate::MetaplexTokenMetadataCrossValidationMatrix,
|
||||
) -> ks_core::Result<()> {
|
||||
if matrix.matrix_version != 1 || matrix.milestone != "0.4.7-pre.010" {
|
||||
return std::result::Result::Err(ks_core::Error::new(
|
||||
"metaplex_token_metadata_cross_validation_matrix_contract_mismatch",
|
||||
"Metaplex cross-validation matrix must use version 1 for 0.4.7-pre.010",
|
||||
));
|
||||
}
|
||||
let mut ids = std::collections::BTreeSet::<&str>::new();
|
||||
for case in &matrix.cases {
|
||||
if !ids.insert(case.id.as_str()) || case.required_evidence.is_empty() {
|
||||
return std::result::Result::Err(ks_core::Error::new(
|
||||
"metaplex_token_metadata_cross_validation_case_invalid",
|
||||
"Metaplex cross-validation cases must be unique and declare evidence",
|
||||
));
|
||||
}
|
||||
if case.evidence.len() > crate::MAX_METAPLEX_TOKEN_METADATA_VALIDATION_EVIDENCE {
|
||||
return std::result::Result::Err(ks_core::Error::new(
|
||||
"metaplex_token_metadata_cross_validation_evidence_limit_exceeded",
|
||||
"Metaplex cross-validation evidence exceeds the compiled bound",
|
||||
));
|
||||
}
|
||||
if case.outcome == crate::MetaplexTokenMetadataTransactionOutcome::Success
|
||||
&& case.asset_family.is_none()
|
||||
{
|
||||
return std::result::Result::Err(ks_core::Error::new(
|
||||
"metaplex_token_metadata_cross_validation_success_without_asset_family",
|
||||
"Successful Metaplex corpus cases must identify an asset family",
|
||||
));
|
||||
}
|
||||
if case.outcome == crate::MetaplexTokenMetadataTransactionOutcome::Failed
|
||||
&& case.failure_class.is_none()
|
||||
{
|
||||
return std::result::Result::Err(ks_core::Error::new(
|
||||
"metaplex_token_metadata_cross_validation_failure_without_class",
|
||||
"Failed Metaplex corpus cases must identify a failure class",
|
||||
));
|
||||
}
|
||||
if matches!(
|
||||
case.status,
|
||||
crate::MetaplexTokenMetadataValidationStatus::NotRun
|
||||
| crate::MetaplexTokenMetadataValidationStatus::Unavailable
|
||||
) && !case.evidence.is_empty()
|
||||
{
|
||||
return std::result::Result::Err(ks_core::Error::new(
|
||||
"metaplex_token_metadata_cross_validation_unobserved_with_evidence",
|
||||
"Not-run or unavailable cross-validation cases must not contain evidence",
|
||||
));
|
||||
}
|
||||
if case.requires_network_evidence
|
||||
&& matches!(
|
||||
case.status,
|
||||
crate::MetaplexTokenMetadataValidationStatus::SyntheticValidated
|
||||
)
|
||||
{
|
||||
return std::result::Result::Err(ks_core::Error::new(
|
||||
"metaplex_token_metadata_cross_validation_network_overdeclared",
|
||||
"Network cases cannot be declared validated from synthetic evidence",
|
||||
));
|
||||
}
|
||||
if matches!(
|
||||
case.status,
|
||||
crate::MetaplexTokenMetadataValidationStatus::Simulated
|
||||
| crate::MetaplexTokenMetadataValidationStatus::Submitted
|
||||
| crate::MetaplexTokenMetadataValidationStatus::Confirmed
|
||||
) {
|
||||
let observed = case
|
||||
.evidence
|
||||
.iter()
|
||||
.map(|evidence| return evidence.kind.as_str())
|
||||
.collect::<std::collections::BTreeSet<&str>>();
|
||||
if !case
|
||||
.required_evidence
|
||||
.iter()
|
||||
.all(|kind| return observed.contains(kind.as_str()))
|
||||
{
|
||||
return std::result::Result::Err(ks_core::Error::new(
|
||||
"metaplex_token_metadata_cross_validation_status_without_required_evidence",
|
||||
"Observed Metaplex network status lacks required evidence",
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
return std::result::Result::Ok(());
|
||||
}
|
||||
|
||||
/// Loads and validates the canonical Metaplex validation matrix.
|
||||
pub fn load_metaplex_token_metadata_validation_matrix()
|
||||
-> ks_core::Result<crate::MetaplexTokenMetadataValidationMatrix> {
|
||||
let parsed = match serde_json::from_str::<crate::MetaplexTokenMetadataValidationMatrix>(
|
||||
include_str!(
|
||||
"../../../../test-fixtures/contract-matrices/METAPLEX_TOKEN_METADATA_VALIDATION_MATRIX.json"
|
||||
),
|
||||
) {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => {
|
||||
return std::result::Result::Err(ks_core::Error::new(
|
||||
"metaplex_token_metadata_validation_matrix_invalid_json",
|
||||
format!("Metaplex validation matrix JSON is invalid: {error}"),
|
||||
));
|
||||
},
|
||||
};
|
||||
if let std::result::Result::Err(error) =
|
||||
validate_metaplex_token_metadata_validation_matrix(&parsed)
|
||||
{
|
||||
return std::result::Result::Err(error);
|
||||
}
|
||||
return std::result::Result::Ok(parsed);
|
||||
}
|
||||
|
||||
/// Validates the canonical Metaplex validation matrix.
|
||||
pub fn validate_metaplex_token_metadata_validation_matrix(
|
||||
matrix: &crate::MetaplexTokenMetadataValidationMatrix,
|
||||
) -> ks_core::Result<()> {
|
||||
if matrix.matrix_version != 1 || matrix.milestone != "0.4.7" {
|
||||
return std::result::Result::Err(ks_core::Error::new(
|
||||
"metaplex_token_metadata_validation_matrix_contract_mismatch",
|
||||
"Metaplex validation matrix must use version 1 for milestone 0.4.7",
|
||||
));
|
||||
}
|
||||
let expected = crate::metaplex_token_metadata_synthetic_scenarios()
|
||||
.iter()
|
||||
.map(|scenario| return scenario.id.clone())
|
||||
.collect::<std::vec::Vec<std::string::String>>();
|
||||
let actual = matrix
|
||||
.scenarios
|
||||
.iter()
|
||||
.map(|scenario| return scenario.id.clone())
|
||||
.collect::<std::vec::Vec<std::string::String>>();
|
||||
if actual != expected {
|
||||
return std::result::Result::Err(ks_core::Error::new(
|
||||
"metaplex_token_metadata_validation_matrix_inventory_mismatch",
|
||||
"Metaplex validation matrix inventory differs from the compiled scenario inventory",
|
||||
));
|
||||
}
|
||||
let mut ids = std::collections::BTreeSet::<&str>::new();
|
||||
for scenario in &matrix.scenarios {
|
||||
if !ids.insert(scenario.id.as_str()) || scenario.required_evidence.is_empty() {
|
||||
return std::result::Result::Err(ks_core::Error::new(
|
||||
"metaplex_token_metadata_validation_matrix_scenario_invalid",
|
||||
"Metaplex validation scenarios must be unique and declare required evidence",
|
||||
));
|
||||
}
|
||||
if scenario.evidence.len() > crate::MAX_METAPLEX_TOKEN_METADATA_VALIDATION_EVIDENCE {
|
||||
return std::result::Result::Err(ks_core::Error::new(
|
||||
"metaplex_token_metadata_validation_evidence_limit_exceeded",
|
||||
"Metaplex validation evidence exceeds the compiled bound",
|
||||
));
|
||||
}
|
||||
if matches!(
|
||||
scenario.status,
|
||||
crate::MetaplexTokenMetadataValidationStatus::NotRun
|
||||
| crate::MetaplexTokenMetadataValidationStatus::Unavailable
|
||||
) && !scenario.evidence.is_empty()
|
||||
{
|
||||
return std::result::Result::Err(ks_core::Error::new(
|
||||
"metaplex_token_metadata_validation_unobserved_with_evidence",
|
||||
"Not-run or unavailable Metaplex scenarios must not contain evidence",
|
||||
));
|
||||
}
|
||||
if scenario.status == crate::MetaplexTokenMetadataValidationStatus::Confirmed {
|
||||
let observed = scenario
|
||||
.evidence
|
||||
.iter()
|
||||
.map(|evidence| return evidence.kind.as_str())
|
||||
.collect::<std::collections::BTreeSet<&str>>();
|
||||
if !scenario
|
||||
.required_evidence
|
||||
.iter()
|
||||
.all(|kind| return observed.contains(kind.as_str()))
|
||||
{
|
||||
return std::result::Result::Err(ks_core::Error::new(
|
||||
"metaplex_token_metadata_validation_confirmed_without_required_evidence",
|
||||
"Confirmed Metaplex scenario lacks required evidence",
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
return std::result::Result::Ok(());
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#[test]
|
||||
fn canonical_matrix_is_machine_readable_and_matches_synthetic_inventory() {
|
||||
let result = crate::load_metaplex_token_metadata_validation_matrix();
|
||||
assert!(result.is_ok());
|
||||
if let std::result::Result::Ok(matrix) = result {
|
||||
assert_eq!(matrix.scenarios.len(), 5);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn confirmed_scenario_requires_every_declared_evidence_kind() {
|
||||
let result = crate::load_metaplex_token_metadata_validation_matrix();
|
||||
assert!(result.is_ok());
|
||||
if let std::result::Result::Ok(mut matrix) = result {
|
||||
matrix.scenarios[0].status = crate::MetaplexTokenMetadataValidationStatus::Confirmed;
|
||||
assert!(crate::validate_metaplex_token_metadata_validation_matrix(&matrix).is_err());
|
||||
matrix.scenarios[0].evidence = matrix.scenarios[0]
|
||||
.required_evidence
|
||||
.iter()
|
||||
.map(|kind| {
|
||||
return crate::MetaplexTokenMetadataValidationEvidence {
|
||||
kind: kind.clone(),
|
||||
value: "synthetic-proof".to_string(),
|
||||
};
|
||||
})
|
||||
.collect();
|
||||
assert!(crate::validate_metaplex_token_metadata_validation_matrix(&matrix).is_ok());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cross_validation_corpus_is_closed_and_covers_every_required_dimension() {
|
||||
let result = crate::load_metaplex_token_metadata_cross_validation_matrix();
|
||||
assert!(result.is_ok());
|
||||
if let std::result::Result::Ok(matrix) = result {
|
||||
assert_eq!(matrix.cases.len(), 19);
|
||||
let paths = matrix
|
||||
.cases
|
||||
.iter()
|
||||
.map(|case| return case.path)
|
||||
.collect::<std::collections::BTreeSet<crate::MetaplexTokenMetadataTransactionPath>>(
|
||||
);
|
||||
assert_eq!(paths.len(), 2);
|
||||
let families = matrix
|
||||
.cases
|
||||
.iter()
|
||||
.filter_map(|case| return case.asset_family)
|
||||
.collect::<std::collections::BTreeSet<crate::MetaplexTokenMetadataAssetFamily>>(
|
||||
);
|
||||
assert_eq!(families.len(), 5);
|
||||
let failures = matrix
|
||||
.cases
|
||||
.iter()
|
||||
.filter_map(|case| return case.failure_class)
|
||||
.collect::<std::collections::BTreeSet<crate::MetaplexTokenMetadataFailureClass>>(
|
||||
);
|
||||
assert_eq!(failures.len(), 9);
|
||||
assert_eq!(
|
||||
matrix.cases.iter().filter(|case| return case.requires_network_evidence).count(),
|
||||
3
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn network_cases_cannot_be_promoted_from_synthetic_or_incomplete_evidence() {
|
||||
let result = crate::load_metaplex_token_metadata_cross_validation_matrix();
|
||||
assert!(result.is_ok());
|
||||
if let std::result::Result::Ok(mut matrix) = result {
|
||||
let network =
|
||||
matrix.cases.iter_mut().find(|case| return case.requires_network_evidence);
|
||||
assert!(network.is_some());
|
||||
if let std::option::Option::Some(case) = network {
|
||||
case.status = crate::MetaplexTokenMetadataValidationStatus::SyntheticValidated;
|
||||
}
|
||||
assert!(
|
||||
crate::validate_metaplex_token_metadata_cross_validation_matrix(&matrix).is_err()
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// One family-specific evidence bundle in the closed Devnet execution matrix.
|
||||
#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct MetaplexTokenMetadataDevnetExecutionFamilyEvidence {
|
||||
/// Asset family exercised by this evidence bundle.
|
||||
pub family: crate::MetaplexTokenMetadataAssetFamily,
|
||||
/// Complete evidence observed for this family.
|
||||
pub evidence: std::vec::Vec<crate::MetaplexTokenMetadataValidationEvidence>,
|
||||
}
|
||||
|
||||
/// One current Metaplex operation in the closed Devnet execution matrix.
|
||||
#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct MetaplexTokenMetadataDevnetExecutionOperation {
|
||||
/// Stable instruction discriminator.
|
||||
pub discriminator: u8,
|
||||
/// Official instruction name.
|
||||
pub name: std::string::String,
|
||||
/// Stable executor operation code.
|
||||
pub operation_code: std::string::String,
|
||||
/// Whether the operation is deprecated.
|
||||
pub deprecated: bool,
|
||||
/// Prerelease responsible for the campaign.
|
||||
pub campaign_prerelease: std::string::String,
|
||||
/// Implementation status of the reusable runner.
|
||||
pub runner_status: std::string::String,
|
||||
/// Exact observed network status.
|
||||
pub network_status: std::string::String,
|
||||
/// Asset families on which this operation has completed its declared network status.
|
||||
#[serde(default)]
|
||||
pub observed_families: std::vec::Vec<crate::MetaplexTokenMetadataAssetFamily>,
|
||||
/// Evidence required after simulation.
|
||||
pub required_evidence: std::vec::Vec<std::string::String>,
|
||||
/// Additional evidence required after submission.
|
||||
pub submission_evidence: std::vec::Vec<std::string::String>,
|
||||
/// Operation-level evidence reserved for non-family states such as unavailability.
|
||||
#[serde(default)]
|
||||
pub evidence: std::vec::Vec<crate::MetaplexTokenMetadataValidationEvidence>,
|
||||
/// Family-specific evidence bundles for simulated or confirmed operation paths.
|
||||
#[serde(default)]
|
||||
pub family_evidence: std::vec::Vec<crate::MetaplexTokenMetadataDevnetExecutionFamilyEvidence>,
|
||||
}
|
||||
|
||||
/// Closed inventory of every current Metaplex operation requiring Devnet coverage.
|
||||
#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct MetaplexTokenMetadataDevnetExecutionMatrix {
|
||||
/// Matrix schema version.
|
||||
pub matrix_version: u32,
|
||||
/// Owning prerelease.
|
||||
pub milestone: std::string::String,
|
||||
/// Canonical Token Metadata program ID.
|
||||
pub program_id: std::string::String,
|
||||
/// Ordered current operations.
|
||||
pub operations: std::vec::Vec<crate::MetaplexTokenMetadataDevnetExecutionOperation>,
|
||||
}
|
||||
|
||||
/// Loads and validates the closed Devnet execution matrix.
|
||||
pub fn load_metaplex_token_metadata_devnet_execution_matrix()
|
||||
-> ks_core::Result<crate::MetaplexTokenMetadataDevnetExecutionMatrix> {
|
||||
let matrix = match serde_json::from_str::<crate::MetaplexTokenMetadataDevnetExecutionMatrix>(
|
||||
include_str!(
|
||||
"../../../../test-fixtures/contract-matrices/METAPLEX_TOKEN_METADATA_DEVNET_EXECUTION_MATRIX.json"
|
||||
),
|
||||
) {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => {
|
||||
return std::result::Result::Err(ks_core::Error::new(
|
||||
"metaplex_devnet_execution_matrix_invalid_json",
|
||||
format!("Metaplex Devnet execution matrix JSON is invalid: {error}"),
|
||||
));
|
||||
},
|
||||
};
|
||||
if let std::result::Result::Err(error) =
|
||||
validate_metaplex_token_metadata_devnet_execution_matrix(&matrix)
|
||||
{
|
||||
return std::result::Result::Err(error);
|
||||
}
|
||||
return std::result::Result::Ok(matrix);
|
||||
}
|
||||
|
||||
/// Validates exact current-operation coverage and conservative network statuses.
|
||||
pub fn validate_metaplex_token_metadata_devnet_execution_matrix(
|
||||
matrix: &crate::MetaplexTokenMetadataDevnetExecutionMatrix,
|
||||
) -> ks_core::Result<()> {
|
||||
if matrix.matrix_version != 3
|
||||
|| matrix.milestone != "0.4.8-pre.013"
|
||||
|| matrix.program_id != ks_program_ids::METADATA_METAPLEX_TOKEN_METADATA_PROGRAM_ID
|
||||
|| matrix.operations.len() != 20
|
||||
{
|
||||
return std::result::Result::Err(ks_core::Error::new(
|
||||
"metaplex_devnet_execution_matrix_contract_mismatch",
|
||||
"Metaplex Devnet matrix must use version 3 and declare exactly 20 current operations for 0.4.8-pre.013",
|
||||
));
|
||||
}
|
||||
let expected = [
|
||||
"metadata.metaplex_token_metadata.create_escrow_account",
|
||||
"metadata.metaplex_token_metadata.close_escrow_account",
|
||||
"metadata.metaplex_token_metadata.transfer_out_of_escrow",
|
||||
"metadata.metaplex_token_metadata.burn",
|
||||
"metadata.metaplex_token_metadata.create",
|
||||
"metadata.metaplex_token_metadata.mint",
|
||||
"metadata.metaplex_token_metadata.delegate",
|
||||
"metadata.metaplex_token_metadata.revoke",
|
||||
"metadata.metaplex_token_metadata.lock",
|
||||
"metadata.metaplex_token_metadata.unlock",
|
||||
"metadata.metaplex_token_metadata.migrate",
|
||||
"metadata.metaplex_token_metadata.transfer",
|
||||
"metadata.metaplex_token_metadata.update",
|
||||
"metadata.metaplex_token_metadata.use",
|
||||
"metadata.metaplex_token_metadata.verify",
|
||||
"metadata.metaplex_token_metadata.unverify",
|
||||
"metadata.metaplex_token_metadata.collect",
|
||||
"metadata.metaplex_token_metadata.print",
|
||||
"metadata.metaplex_token_metadata.resize",
|
||||
"metadata.metaplex_token_metadata.close_accounts",
|
||||
]
|
||||
.into_iter()
|
||||
.collect::<std::collections::BTreeSet<&str>>();
|
||||
let observed = matrix
|
||||
.operations
|
||||
.iter()
|
||||
.map(|operation| return operation.operation_code.as_str())
|
||||
.collect::<std::collections::BTreeSet<&str>>();
|
||||
if observed != expected {
|
||||
return std::result::Result::Err(ks_core::Error::new(
|
||||
"metaplex_devnet_execution_matrix_operation_mismatch",
|
||||
"Metaplex Devnet matrix must cover the exact 20 current operation codes",
|
||||
));
|
||||
}
|
||||
let required_evidence = [
|
||||
"cluster",
|
||||
"genesis_hash",
|
||||
"simulation_slot",
|
||||
"simulation_logs",
|
||||
"message_hash",
|
||||
"fee_lamports",
|
||||
]
|
||||
.into_iter()
|
||||
.collect::<std::collections::BTreeSet<&str>>();
|
||||
let submission_evidence = [
|
||||
"signature",
|
||||
"confirmation_status",
|
||||
"confirmation_slot",
|
||||
"before_state",
|
||||
"after_state",
|
||||
"canonical_hydration",
|
||||
"core_extraction",
|
||||
"decode_replay",
|
||||
"materialization",
|
||||
"idempotence_replay",
|
||||
]
|
||||
.into_iter()
|
||||
.collect::<std::collections::BTreeSet<&str>>();
|
||||
for operation in &matrix.operations {
|
||||
let observed_required = operation
|
||||
.required_evidence
|
||||
.iter()
|
||||
.map(std::string::String::as_str)
|
||||
.collect::<std::collections::BTreeSet<&str>>();
|
||||
let observed_submission = operation
|
||||
.submission_evidence
|
||||
.iter()
|
||||
.map(std::string::String::as_str)
|
||||
.collect::<std::collections::BTreeSet<&str>>();
|
||||
if operation.deprecated
|
||||
|| operation.runner_status != "implemented"
|
||||
|| operation.campaign_prerelease != "0.4.8-pre.013"
|
||||
|| observed_required != required_evidence
|
||||
|| observed_submission != submission_evidence
|
||||
|| !matches!(
|
||||
operation.network_status.as_str(),
|
||||
"not_run" | "simulated" | "confirmed" | "unavailable"
|
||||
)
|
||||
|| operation.evidence.len() > crate::MAX_METAPLEX_TOKEN_METADATA_VALIDATION_EVIDENCE
|
||||
{
|
||||
return std::result::Result::Err(ks_core::Error::new(
|
||||
"metaplex_devnet_execution_matrix_entry_invalid",
|
||||
"Metaplex Devnet entries must be current, implemented, evidenced and conservatively classified",
|
||||
));
|
||||
}
|
||||
let observed_families = operation
|
||||
.observed_families
|
||||
.iter()
|
||||
.copied()
|
||||
.collect::<std::collections::BTreeSet<_>>();
|
||||
if observed_families.len() != operation.observed_families.len() {
|
||||
return std::result::Result::Err(ks_core::Error::new(
|
||||
"metaplex_devnet_execution_matrix_duplicate_family",
|
||||
"Metaplex Devnet observed families must be unique",
|
||||
));
|
||||
}
|
||||
let mut observed_evidence = std::collections::BTreeSet::<&str>::new();
|
||||
for evidence in &operation.evidence {
|
||||
if evidence.kind.trim().is_empty()
|
||||
|| evidence.value.trim().is_empty()
|
||||
|| !observed_evidence.insert(evidence.kind.as_str())
|
||||
{
|
||||
return std::result::Result::Err(ks_core::Error::new(
|
||||
"metaplex_devnet_execution_matrix_evidence_invalid",
|
||||
"Metaplex Devnet operation-level evidence kinds and values must be non-empty and unique",
|
||||
));
|
||||
}
|
||||
}
|
||||
let mut observed_family_evidence =
|
||||
std::collections::BTreeSet::<crate::MetaplexTokenMetadataAssetFamily>::new();
|
||||
for family_evidence in &operation.family_evidence {
|
||||
if !observed_family_evidence.insert(family_evidence.family)
|
||||
|| family_evidence.evidence.len()
|
||||
> crate::MAX_METAPLEX_TOKEN_METADATA_VALIDATION_EVIDENCE
|
||||
{
|
||||
return std::result::Result::Err(ks_core::Error::new(
|
||||
"metaplex_devnet_execution_matrix_family_evidence_invalid",
|
||||
"Metaplex Devnet family evidence bundles must be unique and bounded",
|
||||
));
|
||||
}
|
||||
let mut family_kinds = std::collections::BTreeSet::<&str>::new();
|
||||
for evidence in &family_evidence.evidence {
|
||||
if evidence.kind.trim().is_empty()
|
||||
|| evidence.value.trim().is_empty()
|
||||
|| !family_kinds.insert(evidence.kind.as_str())
|
||||
{
|
||||
return std::result::Result::Err(ks_core::Error::new(
|
||||
"metaplex_devnet_execution_matrix_family_evidence_entry_invalid",
|
||||
"Metaplex Devnet family evidence kinds and values must be non-empty and unique",
|
||||
));
|
||||
}
|
||||
}
|
||||
if operation.network_status == "simulated"
|
||||
&& operation
|
||||
.required_evidence
|
||||
.iter()
|
||||
.any(|kind| return !family_kinds.contains(kind.as_str()))
|
||||
{
|
||||
return std::result::Result::Err(ks_core::Error::new(
|
||||
"metaplex_devnet_execution_matrix_family_simulation_evidence_incomplete",
|
||||
"Simulated Metaplex Devnet family evidence must contain every required simulation evidence kind",
|
||||
));
|
||||
}
|
||||
if operation.network_status == "confirmed"
|
||||
&& operation
|
||||
.required_evidence
|
||||
.iter()
|
||||
.chain(operation.submission_evidence.iter())
|
||||
.any(|kind| return !family_kinds.contains(kind.as_str()))
|
||||
{
|
||||
return std::result::Result::Err(ks_core::Error::new(
|
||||
"metaplex_devnet_execution_matrix_family_confirmation_evidence_incomplete",
|
||||
"Confirmed Metaplex Devnet family evidence must contain every simulation and submission evidence kind",
|
||||
));
|
||||
}
|
||||
}
|
||||
match operation.network_status.as_str() {
|
||||
"not_run" => {
|
||||
if !operation.observed_families.is_empty()
|
||||
|| !operation.evidence.is_empty()
|
||||
|| !operation.family_evidence.is_empty()
|
||||
{
|
||||
return std::result::Result::Err(ks_core::Error::new(
|
||||
"metaplex_devnet_execution_matrix_not_run_with_evidence",
|
||||
"Not-run Metaplex Devnet operations cannot claim families or evidence",
|
||||
));
|
||||
}
|
||||
},
|
||||
"simulated" | "confirmed" => {
|
||||
if operation.observed_families.is_empty()
|
||||
|| !operation.evidence.is_empty()
|
||||
|| observed_family_evidence != observed_families
|
||||
{
|
||||
return std::result::Result::Err(ks_core::Error::new(
|
||||
"metaplex_devnet_execution_matrix_family_evidence_mismatch",
|
||||
"Simulated or confirmed Metaplex Devnet operations require exactly one complete evidence bundle per observed family and no aggregate operation evidence",
|
||||
));
|
||||
}
|
||||
},
|
||||
"unavailable" => {
|
||||
if !operation.observed_families.is_empty()
|
||||
|| !operation.family_evidence.is_empty()
|
||||
|| !observed_evidence.contains("unavailable_reason")
|
||||
{
|
||||
return std::result::Result::Err(ks_core::Error::new(
|
||||
"metaplex_devnet_execution_matrix_unavailable_without_reason",
|
||||
"Unavailable Metaplex Devnet operations require an unavailable_reason and no observed family evidence",
|
||||
));
|
||||
}
|
||||
},
|
||||
_ => {},
|
||||
}
|
||||
}
|
||||
return std::result::Result::Ok(());
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod devnet_execution_matrix_tests {
|
||||
#[test]
|
||||
fn devnet_execution_matrix_is_reopened_for_pre_013_and_conservative() {
|
||||
let result = crate::load_metaplex_token_metadata_devnet_execution_matrix();
|
||||
assert!(result.is_ok());
|
||||
let matrix = if let std::result::Result::Ok(value) = result {
|
||||
value
|
||||
} else {
|
||||
return;
|
||||
};
|
||||
assert_eq!(matrix.matrix_version, 3);
|
||||
assert_eq!(matrix.operations.len(), 20);
|
||||
assert!(
|
||||
matrix
|
||||
.operations
|
||||
.iter()
|
||||
.all(|operation| return operation.campaign_prerelease == "0.4.8-pre.013")
|
||||
);
|
||||
assert!(matrix.operations.iter().all(|operation| return !operation.deprecated));
|
||||
assert!(
|
||||
matrix
|
||||
.operations
|
||||
.iter()
|
||||
.all(|operation| return operation.runner_status == "implemented")
|
||||
);
|
||||
let confirmed = matrix
|
||||
.operations
|
||||
.iter()
|
||||
.filter(|operation| return operation.network_status == "confirmed")
|
||||
.collect::<std::vec::Vec<_>>();
|
||||
assert_eq!(confirmed.len(), 15);
|
||||
assert_eq!(
|
||||
confirmed
|
||||
.iter()
|
||||
.map(|operation| return operation.operation_code.as_str())
|
||||
.collect::<std::collections::BTreeSet<_>>(),
|
||||
[
|
||||
"metadata.metaplex_token_metadata.burn",
|
||||
"metadata.metaplex_token_metadata.close_escrow_account",
|
||||
"metadata.metaplex_token_metadata.create",
|
||||
"metadata.metaplex_token_metadata.create_escrow_account",
|
||||
"metadata.metaplex_token_metadata.delegate",
|
||||
"metadata.metaplex_token_metadata.lock",
|
||||
"metadata.metaplex_token_metadata.mint",
|
||||
"metadata.metaplex_token_metadata.print",
|
||||
"metadata.metaplex_token_metadata.revoke",
|
||||
"metadata.metaplex_token_metadata.transfer",
|
||||
"metadata.metaplex_token_metadata.transfer_out_of_escrow",
|
||||
"metadata.metaplex_token_metadata.unlock",
|
||||
"metadata.metaplex_token_metadata.update",
|
||||
"metadata.metaplex_token_metadata.verify",
|
||||
"metadata.metaplex_token_metadata.unverify",
|
||||
]
|
||||
.into_iter()
|
||||
.collect::<std::collections::BTreeSet<_>>()
|
||||
);
|
||||
let create_mint_families = vec![
|
||||
crate::MetaplexTokenMetadataAssetFamily::Nft,
|
||||
crate::MetaplexTokenMetadataAssetFamily::Sft,
|
||||
crate::MetaplexTokenMetadataAssetFamily::Fungible,
|
||||
crate::MetaplexTokenMetadataAssetFamily::Collection,
|
||||
crate::MetaplexTokenMetadataAssetFamily::ProgrammableNft,
|
||||
];
|
||||
for operation in &confirmed {
|
||||
let expected_families = if matches!(
|
||||
operation.operation_code.as_str(),
|
||||
"metadata.metaplex_token_metadata.create" | "metadata.metaplex_token_metadata.mint"
|
||||
) {
|
||||
create_mint_families.as_slice()
|
||||
} else if matches!(
|
||||
operation.operation_code.as_str(),
|
||||
"metadata.metaplex_token_metadata.print"
|
||||
| "metadata.metaplex_token_metadata.burn"
|
||||
| "metadata.metaplex_token_metadata.create_escrow_account"
|
||||
| "metadata.metaplex_token_metadata.close_escrow_account"
|
||||
| "metadata.metaplex_token_metadata.transfer_out_of_escrow"
|
||||
| "metadata.metaplex_token_metadata.update"
|
||||
) {
|
||||
&[crate::MetaplexTokenMetadataAssetFamily::Nft]
|
||||
} else if matches!(
|
||||
operation.operation_code.as_str(),
|
||||
"metadata.metaplex_token_metadata.delegate"
|
||||
| "metadata.metaplex_token_metadata.revoke"
|
||||
| "metadata.metaplex_token_metadata.lock"
|
||||
| "metadata.metaplex_token_metadata.unlock"
|
||||
| "metadata.metaplex_token_metadata.transfer"
|
||||
) {
|
||||
&[crate::MetaplexTokenMetadataAssetFamily::ProgrammableNft]
|
||||
} else {
|
||||
&[crate::MetaplexTokenMetadataAssetFamily::Collection]
|
||||
};
|
||||
assert_eq!(operation.observed_families.as_slice(), expected_families);
|
||||
assert_eq!(
|
||||
operation
|
||||
.family_evidence
|
||||
.iter()
|
||||
.map(|family_evidence| return family_evidence.family)
|
||||
.collect::<std::collections::BTreeSet<_>>(),
|
||||
operation
|
||||
.observed_families
|
||||
.iter()
|
||||
.copied()
|
||||
.collect::<std::collections::BTreeSet<_>>()
|
||||
);
|
||||
}
|
||||
assert_eq!(
|
||||
matrix
|
||||
.operations
|
||||
.iter()
|
||||
.filter(|operation| return operation.network_status == "not_run")
|
||||
.count(),
|
||||
0
|
||||
);
|
||||
let unavailable = matrix
|
||||
.operations
|
||||
.iter()
|
||||
.filter(|operation| return operation.network_status == "unavailable")
|
||||
.collect::<std::vec::Vec<_>>();
|
||||
assert_eq!(unavailable.len(), 5);
|
||||
let use_operation = unavailable.iter().find(|operation| {
|
||||
return operation.operation_code == "metadata.metaplex_token_metadata.use";
|
||||
});
|
||||
assert!(use_operation.is_some());
|
||||
if let std::option::Option::Some(operation) = use_operation {
|
||||
assert!(operation.evidence.iter().any(|evidence| {
|
||||
return evidence.kind == "unavailable_reason"
|
||||
&& evidence.value.contains("InvalidInstructionData");
|
||||
}));
|
||||
}
|
||||
let resize_operation = unavailable.iter().find(|operation| {
|
||||
return operation.operation_code == "metadata.metaplex_token_metadata.resize";
|
||||
});
|
||||
assert!(resize_operation.is_some());
|
||||
if let std::option::Option::Some(operation) = resize_operation {
|
||||
assert!(operation.evidence.iter().any(|evidence| {
|
||||
return evidence.kind == "unavailable_reason"
|
||||
&& evidence.value.contains("AlreadyResized")
|
||||
&& evidence.value.contains("201");
|
||||
}));
|
||||
}
|
||||
for (operation_code, expected_fragment) in [
|
||||
("metadata.metaplex_token_metadata.migrate", "Removed"),
|
||||
("metadata.metaplex_token_metadata.collect", "fee authority"),
|
||||
("metadata.metaplex_token_metadata.close_accounts", "close authority"),
|
||||
] {
|
||||
let operation = unavailable
|
||||
.iter()
|
||||
.find(|operation| return operation.operation_code == operation_code);
|
||||
assert!(operation.is_some());
|
||||
if let std::option::Option::Some(operation) = operation {
|
||||
assert!(operation.evidence.iter().any(|evidence| {
|
||||
return evidence.kind == "unavailable_reason"
|
||||
&& evidence.value.contains(expected_fragment);
|
||||
}));
|
||||
}
|
||||
}
|
||||
let update_operation = confirmed.iter().find(|operation| {
|
||||
return operation.operation_code == "metadata.metaplex_token_metadata.update";
|
||||
});
|
||||
assert!(update_operation.is_some());
|
||||
if let std::option::Option::Some(operation) = update_operation {
|
||||
assert_eq!(
|
||||
operation.observed_families.as_slice(),
|
||||
&[crate::MetaplexTokenMetadataAssetFamily::Nft]
|
||||
);
|
||||
}
|
||||
let mut incomplete = matrix.clone();
|
||||
let create = incomplete.operations.iter_mut().find(|operation| {
|
||||
return operation.operation_code == "metadata.metaplex_token_metadata.create";
|
||||
});
|
||||
assert!(create.is_some());
|
||||
if let std::option::Option::Some(operation) = create {
|
||||
if let std::option::Option::Some(family_evidence) =
|
||||
operation.family_evidence.first_mut()
|
||||
{
|
||||
family_evidence.evidence.clear();
|
||||
}
|
||||
}
|
||||
assert!(
|
||||
crate::validate_metaplex_token_metadata_devnet_execution_matrix(&incomplete).is_err()
|
||||
);
|
||||
}
|
||||
}
|
||||
10
ks-pipeline-demo-scenarios/src/metadata/solana_program.rs
Normal file
10
ks-pipeline-demo-scenarios/src/metadata/solana_program.rs
Normal file
@@ -0,0 +1,10 @@
|
||||
// file: ks-pipeline-demo-scenarios/src/metadata/solana_program.rs
|
||||
// version: 2
|
||||
|
||||
//! Solana Program Metadata demo and Devnet validation scenarios.
|
||||
|
||||
pub(crate) mod campaign;
|
||||
pub(crate) mod devnet_execution;
|
||||
pub(crate) mod fixture;
|
||||
pub(crate) mod scenarios;
|
||||
pub(crate) mod validation;
|
||||
@@ -0,0 +1,144 @@
|
||||
// file: ks-pipeline-demo-scenarios/src/metadata/solana_program/campaign.rs
|
||||
// version: 4
|
||||
|
||||
//! Ordered confirmed Devnet campaign for all Solana Program Metadata operations.
|
||||
|
||||
/// One confirmed step retained by the complete Devnet campaign.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct DevnetSolanaProgramMetadataCampaignStepSummary {
|
||||
/// Stable scenario identifier.
|
||||
pub scenario_id: std::string::String,
|
||||
/// Stable scenario-step identifier.
|
||||
pub step_id: std::string::String,
|
||||
/// Zero-based position inside the scenario.
|
||||
pub step_index: usize,
|
||||
/// Complete simulation, submission and stateful evidence.
|
||||
pub execution: crate::DevnetSolanaProgramMetadataExecutionSummary,
|
||||
}
|
||||
|
||||
/// Complete evidence retained by the two-journey Devnet campaign.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct DevnetSolanaProgramMetadataCampaignSummary {
|
||||
/// Fresh pre-funded fixture used by the campaign.
|
||||
pub fixture: crate::SolanaProgramMetadataFixturePreparationSummary,
|
||||
/// Nine confirmed steps in deterministic journey order.
|
||||
pub steps: std::vec::Vec<crate::DevnetSolanaProgramMetadataCampaignStepSummary>,
|
||||
}
|
||||
|
||||
/// Prepares fresh accounts and executes the nine stable operations on Devnet.
|
||||
///
|
||||
/// The campaign always submits transactions. The caller must explicitly confirm
|
||||
/// both fixture prefunding and all subsequent state mutations.
|
||||
pub async fn execute_devnet_solana_program_metadata_campaign<O>(
|
||||
http_pool: &ks_onchain_transport::HttpEndpointPool,
|
||||
profile: &ks_config::ProfileConfig,
|
||||
workspace_root: &std::path::Path,
|
||||
options: &crate::SolanaProgramMetadataFixturePreparationOptions,
|
||||
observer: &O,
|
||||
) -> ks_core::Result<crate::DevnetSolanaProgramMetadataCampaignSummary>
|
||||
where
|
||||
O: crate::SolanaExecutionObserver,
|
||||
{
|
||||
if !options.operator_confirmed {
|
||||
return std::result::Result::Err(ks_core::Error::config(
|
||||
"Solana Program Metadata Devnet campaign requires explicit operator confirmation",
|
||||
));
|
||||
}
|
||||
let fixture = match crate::prepare_solana_program_metadata_fixture(
|
||||
http_pool,
|
||||
profile,
|
||||
workspace_root,
|
||||
options,
|
||||
)
|
||||
.await
|
||||
{
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let mut steps = std::vec::Vec::with_capacity(fixture.prepared_steps.len());
|
||||
for prepared in &fixture.prepared_steps {
|
||||
let mut request = prepared
|
||||
.execution_request(format!("spm-devnet-{}-{}", prepared.scenario_id, prepared.step_id));
|
||||
request.query_role = options.query_role.clone();
|
||||
request.transaction_role = options.transaction_role.clone();
|
||||
request.submit = true;
|
||||
request.operator_confirmed = true;
|
||||
let execution = match crate::execute_devnet_solana_program_metadata(
|
||||
http_pool,
|
||||
profile,
|
||||
workspace_root,
|
||||
&request,
|
||||
observer,
|
||||
)
|
||||
.await
|
||||
{
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let confirmed = match execution.confirmation.as_ref() {
|
||||
std::option::Option::Some(value) => matches!(
|
||||
value.status,
|
||||
ks_lib::ExApiExecutionConfirmationStatus::Confirmed
|
||||
| ks_lib::ExApiExecutionConfirmationStatus::Finalized
|
||||
),
|
||||
std::option::Option::None => false,
|
||||
};
|
||||
if !confirmed || execution.post_execution.is_none() {
|
||||
return std::result::Result::Err(ks_core::Error::new(
|
||||
"solana_program_metadata_campaign_step_not_confirmed",
|
||||
format!(
|
||||
"campaign step {} did not reach confirmed stateful completion",
|
||||
prepared.step_id
|
||||
),
|
||||
));
|
||||
}
|
||||
if prepared.operation.operation_code() != ks_lib::EX_METADATA_SPM_CLOSE_OPERATION
|
||||
&& execution.materialized_snapshots.is_empty()
|
||||
{
|
||||
return std::result::Result::Err(ks_core::Error::new(
|
||||
"solana_program_metadata_campaign_snapshot_missing",
|
||||
format!(
|
||||
"campaign step {} did not produce its required materialized snapshot",
|
||||
prepared.step_id
|
||||
),
|
||||
));
|
||||
}
|
||||
steps.push(crate::DevnetSolanaProgramMetadataCampaignStepSummary {
|
||||
scenario_id: prepared.scenario_id.clone(),
|
||||
step_id: prepared.step_id.clone(),
|
||||
step_index: prepared.step_index,
|
||||
execution,
|
||||
});
|
||||
}
|
||||
return std::result::Result::Ok(crate::DevnetSolanaProgramMetadataCampaignSummary {
|
||||
fixture,
|
||||
steps,
|
||||
});
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#[test]
|
||||
fn campaign_contract_requires_exactly_nine_ordered_steps() {
|
||||
let scenarios = crate::solana_program_metadata_devnet_scenarios();
|
||||
let step_ids = scenarios
|
||||
.iter()
|
||||
.flat_map(|scenario| return scenario.steps.iter())
|
||||
.map(|step| return step.id.as_str())
|
||||
.collect::<std::vec::Vec<_>>();
|
||||
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",
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,715 @@
|
||||
// file: ks-pipeline-demo-scenarios/src/metadata/solana_program/devnet_execution.rs
|
||||
// version: 3
|
||||
|
||||
//! Real Devnet simulation and controlled submission for Solana Program Metadata.
|
||||
|
||||
/// Complete request for one Solana Program Metadata Devnet execution.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct DevnetSolanaProgramMetadataExecutionRequest {
|
||||
/// Stable caller-provided execution identifier.
|
||||
pub intent_id: std::string::String,
|
||||
/// Endpoint role used for reads, balance, blockhash and fee queries.
|
||||
pub query_role: std::string::String,
|
||||
/// Endpoint role used for simulation, submission and confirmation.
|
||||
pub transaction_role: std::string::String,
|
||||
/// Exact stable Solana Program Metadata operation.
|
||||
pub operation: ks_lib::ExMetadataSpmOperation,
|
||||
/// Bounded state reads required before execution.
|
||||
pub preflight_reads: std::vec::Vec<ks_pipeline::SolanaProgramMetadataStatefulReadRequest>,
|
||||
/// Explicit rent observations required by allocation and growth operations.
|
||||
pub rent_observations: std::vec::Vec<ks_pipeline::SolanaProgramMetadataRentObservation>,
|
||||
/// Bounded state reads repeated after confirmed submission.
|
||||
pub postcondition_reads: std::vec::Vec<ks_pipeline::SolanaProgramMetadataStatefulReadRequest>,
|
||||
/// Explicit approval for overwrite, destructive or irreversible operations.
|
||||
pub allow_destructive_operation: bool,
|
||||
/// Explicitly authorizes signing and submission after successful simulation.
|
||||
pub submit: bool,
|
||||
/// Explicit operator confirmation required for submission.
|
||||
pub operator_confirmed: bool,
|
||||
/// Materializes confirmed account snapshots after submission.
|
||||
pub materialize_after_confirmation: bool,
|
||||
}
|
||||
|
||||
impl crate::DevnetSolanaProgramMetadataExecutionRequest {
|
||||
/// Creates a conservative simulation-only request.
|
||||
pub fn new(
|
||||
intent_id: impl std::convert::Into<std::string::String>,
|
||||
operation: ks_lib::ExMetadataSpmOperation,
|
||||
) -> Self {
|
||||
return Self {
|
||||
intent_id: intent_id.into(),
|
||||
query_role: "http_queries".to_string(),
|
||||
transaction_role: "http_transactions".to_string(),
|
||||
operation,
|
||||
preflight_reads: std::vec::Vec::new(),
|
||||
rent_observations: std::vec::Vec::new(),
|
||||
postcondition_reads: std::vec::Vec::new(),
|
||||
allow_destructive_operation: false,
|
||||
submit: false,
|
||||
operator_confirmed: false,
|
||||
materialize_after_confirmation: false,
|
||||
};
|
||||
}
|
||||
|
||||
/// Creates one conservative request from serialized typed operation JSON.
|
||||
pub fn from_operation_json(
|
||||
intent_id: impl std::convert::Into<std::string::String>,
|
||||
operation_json: &str,
|
||||
) -> ks_core::Result<Self> {
|
||||
if operation_json.contains('<') || operation_json.trim() == "..." {
|
||||
return std::result::Result::Err(ks_core::Error::config(
|
||||
"Solana Program Metadata operation JSON still contains a placeholder",
|
||||
));
|
||||
}
|
||||
let operation = match serde_json::from_str::<ks_lib::ExMetadataSpmOperation>(operation_json)
|
||||
{
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => {
|
||||
return std::result::Result::Err(ks_core::Error::config(format!(
|
||||
"invalid typed Solana Program Metadata operation JSON: {error}"
|
||||
)));
|
||||
},
|
||||
};
|
||||
return std::result::Result::Ok(Self::new(intent_id, operation));
|
||||
}
|
||||
|
||||
/// Validates request-local bounds independently from one profile.
|
||||
pub fn validate(&self) -> ks_core::Result<()> {
|
||||
if self.intent_id.trim().is_empty() {
|
||||
return std::result::Result::Err(ks_core::Error::config(
|
||||
"Solana Program Metadata Devnet intent id must not be empty",
|
||||
));
|
||||
}
|
||||
if self.query_role.trim().is_empty() || self.transaction_role.trim().is_empty() {
|
||||
return std::result::Result::Err(ks_core::Error::config(
|
||||
"Solana Program Metadata endpoint roles must not be empty",
|
||||
));
|
||||
}
|
||||
if self.preflight_reads.is_empty()
|
||||
|| self.preflight_reads.len()
|
||||
> ks_pipeline::MAX_SOLANA_PROGRAM_METADATA_PREFLIGHT_ACCOUNTS
|
||||
|| self.postcondition_reads.len()
|
||||
> ks_pipeline::MAX_SOLANA_PROGRAM_METADATA_PREFLIGHT_ACCOUNTS
|
||||
{
|
||||
return std::result::Result::Err(ks_core::Error::config(
|
||||
"Solana Program Metadata stateful read inventory is empty or above the compiled bound",
|
||||
));
|
||||
}
|
||||
if self.operation.requires_explicit_approval() && !self.allow_destructive_operation {
|
||||
return std::result::Result::Err(ks_core::Error::new(
|
||||
"execution_metadata_spm_destructive_approval_required",
|
||||
"destructive Solana Program Metadata execution requires explicit approval",
|
||||
));
|
||||
}
|
||||
if self.submit && self.postcondition_reads.is_empty() {
|
||||
return std::result::Result::Err(ks_core::Error::config(
|
||||
"submitted Solana Program Metadata execution requires postcondition reads",
|
||||
));
|
||||
}
|
||||
if self.materialize_after_confirmation && self.postcondition_reads.is_empty() {
|
||||
return std::result::Result::Err(ks_core::Error::config(
|
||||
"Solana Program Metadata materialization requires postcondition reads",
|
||||
));
|
||||
}
|
||||
return std::result::Result::Ok(());
|
||||
}
|
||||
}
|
||||
|
||||
/// Complete evidence produced by one Solana Program Metadata Devnet execution.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct DevnetSolanaProgramMetadataExecutionSummary {
|
||||
/// Profile used by the orchestration.
|
||||
pub profile_name: std::string::String,
|
||||
/// Exact classified cluster.
|
||||
pub cluster: ks_lib::ExApiExecutionCluster,
|
||||
/// Genesis hash returned by the selected endpoint.
|
||||
pub genesis_hash: std::string::String,
|
||||
/// Non-secret persistent wallet description.
|
||||
pub wallet: ks_wallet::WalletSummary,
|
||||
/// Wallet balance observed before planning.
|
||||
pub balance_lamports: u64,
|
||||
/// Stateful snapshots observed before simulation.
|
||||
pub before: std::vec::Vec<ks_pipeline::SolanaProgramMetadataStatefulReadResult>,
|
||||
/// Stateful preflight report bound to the exact plan.
|
||||
pub stateful_preflight: ks_pipeline::SolanaProgramMetadataPreflightReport,
|
||||
/// Exact prepared plan.
|
||||
pub plan: ks_lib::ExApiPreparedExecutionPlan,
|
||||
/// Recent blockhash used by the exact transaction.
|
||||
pub latest_blockhash: ks_onchain_transport::LatestBlockhashResult,
|
||||
/// Fee estimate for the exact compiled message.
|
||||
pub fee: ks_onchain_transport::FeeForMessageResult,
|
||||
/// Exact real RPC simulation result.
|
||||
pub simulation: ks_lib::ExApiExecutionSimulationResult,
|
||||
/// Readiness report proving exact-message simulation and signer resolution.
|
||||
pub readiness: ks_pipeline::SolanaProgramMetadataExecutionReadinessReport,
|
||||
/// Submission result when explicitly authorized.
|
||||
pub send_result: std::option::Option<ks_lib::ExApiExecutionSendResult>,
|
||||
/// Confirmation result when submitted.
|
||||
pub confirmation: std::option::Option<ks_lib::ExApiExecutionConfirmationResult>,
|
||||
/// Stateful snapshots observed after confirmed submission.
|
||||
pub after: std::vec::Vec<ks_pipeline::SolanaProgramMetadataStatefulReadResult>,
|
||||
/// Stateful postcondition report after confirmed submission.
|
||||
pub post_execution: std::option::Option<ks_pipeline::SolanaProgramMetadataPostExecutionReport>,
|
||||
/// Canonical projections emitted from confirmed account snapshots.
|
||||
pub materialized_snapshots: std::vec::Vec<ks_lib::MtApiMaterializedOutput>,
|
||||
}
|
||||
|
||||
struct PreparedSolanaProgramMetadataExecution {
|
||||
wallet: ks_wallet::TemporaryWallet,
|
||||
unsigned: ks_lib::ExSolanaUnsignedTransaction,
|
||||
evidence: ks_lib::ExSolanaSimulationEvidence,
|
||||
summary: crate::DevnetSolanaProgramMetadataExecutionSummary,
|
||||
}
|
||||
|
||||
/// Simulates one stable Solana Program Metadata operation against Devnet.
|
||||
pub async fn simulate_devnet_solana_program_metadata<O>(
|
||||
http_pool: &ks_onchain_transport::HttpEndpointPool,
|
||||
profile: &ks_config::ProfileConfig,
|
||||
workspace_root: &std::path::Path,
|
||||
request: &crate::DevnetSolanaProgramMetadataExecutionRequest,
|
||||
observer: &O,
|
||||
) -> ks_core::Result<crate::DevnetSolanaProgramMetadataExecutionSummary>
|
||||
where
|
||||
O: crate::SolanaExecutionObserver,
|
||||
{
|
||||
if request.submit {
|
||||
return std::result::Result::Err(ks_core::Error::config(
|
||||
"simulate_devnet_solana_program_metadata requires submit=false",
|
||||
));
|
||||
}
|
||||
let prepared =
|
||||
match prepare_execution(http_pool, profile, workspace_root, request, observer).await {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
return std::result::Result::Ok(prepared.summary);
|
||||
}
|
||||
|
||||
/// Executes one Solana Program Metadata simulation or authorized submission.
|
||||
pub async fn execute_devnet_solana_program_metadata<O>(
|
||||
http_pool: &ks_onchain_transport::HttpEndpointPool,
|
||||
profile: &ks_config::ProfileConfig,
|
||||
workspace_root: &std::path::Path,
|
||||
request: &crate::DevnetSolanaProgramMetadataExecutionRequest,
|
||||
observer: &O,
|
||||
) -> ks_core::Result<crate::DevnetSolanaProgramMetadataExecutionSummary>
|
||||
where
|
||||
O: crate::SolanaExecutionObserver,
|
||||
{
|
||||
let prepared =
|
||||
match prepare_execution(http_pool, profile, workspace_root, request, observer).await {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
if !request.submit {
|
||||
return std::result::Result::Ok(prepared.summary);
|
||||
}
|
||||
if !prepared.summary.simulation.success {
|
||||
return std::result::Result::Err(ks_core::Error::new(
|
||||
"execution_simulation_failed",
|
||||
crate::simulation_failure_message(&prepared.summary.simulation),
|
||||
));
|
||||
}
|
||||
if let std::result::Result::Err(error) = validate_profile_wallet_signers(
|
||||
prepared.unsigned.required_signer_pubkeys(),
|
||||
prepared.summary.wallet.public_key.as_str(),
|
||||
) {
|
||||
return std::result::Result::Err(error);
|
||||
}
|
||||
let send_evaluation = match ks_lib::ExSafetyChecker
|
||||
.evaluate_send(&prepared.summary.plan, &prepared.summary.simulation)
|
||||
{
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
if send_evaluation.decision == ks_lib::ExSafetyDecision::Deny {
|
||||
return std::result::Result::Err(ks_core::Error::new(
|
||||
"execution_send_denied",
|
||||
crate::violation_message(send_evaluation.violations.as_slice()),
|
||||
));
|
||||
}
|
||||
let signed = match prepared
|
||||
.unsigned
|
||||
.sign_after_simulation(&prepared.evidence, &[prepared.wallet.as_signer()])
|
||||
{
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
if let std::result::Result::Err(error) = signed.verify_signatures() {
|
||||
return std::result::Result::Err(error);
|
||||
}
|
||||
let signature = signed.primary_signature().clone();
|
||||
let mut summary = prepared.summary;
|
||||
let send_config = match ks_onchain_transport::SendTransactionConfig::from_execution_config(
|
||||
&profile.execution,
|
||||
std::option::Option::Some(summary.latest_blockhash.context.slot),
|
||||
) {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let sent = match http_pool
|
||||
.send_transaction_for_role(
|
||||
request.transaction_role.as_str(),
|
||||
signed.transaction_base64().as_str(),
|
||||
&signature,
|
||||
&send_config,
|
||||
)
|
||||
.await
|
||||
{
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
summary.send_result =
|
||||
std::option::Option::Some(sent.to_execution_result(ks_lib::ExApiExecutionCluster::Devnet));
|
||||
let confirmation_config =
|
||||
match ks_onchain_transport::ConfirmTransactionConfig::from_execution_config(
|
||||
&profile.execution,
|
||||
std::option::Option::Some(summary.latest_blockhash.last_valid_block_height),
|
||||
std::option::Option::Some(summary.latest_blockhash.context.slot),
|
||||
) {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let confirmation = match http_pool
|
||||
.confirm_transaction_for_roles(
|
||||
request.transaction_role.as_str(),
|
||||
request.query_role.as_str(),
|
||||
ks_lib::ExApiExecutionCluster::Devnet,
|
||||
&signature,
|
||||
&confirmation_config,
|
||||
)
|
||||
.await
|
||||
{
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let confirmed = matches!(
|
||||
confirmation.status,
|
||||
ks_lib::ExApiExecutionConfirmationStatus::Confirmed
|
||||
| ks_lib::ExApiExecutionConfirmationStatus::Finalized
|
||||
);
|
||||
summary.confirmation = std::option::Option::Some(confirmation);
|
||||
if confirmed {
|
||||
summary.after = match read_snapshots(http_pool, &request.postcondition_reads).await {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let report = match ks_pipeline::inspect_solana_program_metadata_post_execution(
|
||||
&ks_pipeline::SolanaProgramMetadataPostExecutionRequest {
|
||||
operation: request.operation.clone(),
|
||||
before: summary.before.clone(),
|
||||
after: summary.after.clone(),
|
||||
},
|
||||
) {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
if report.status == ks_pipeline::SolanaProgramMetadataPostconditionStatus::Contradicted {
|
||||
return std::result::Result::Err(ks_core::Error::new(
|
||||
"execution_metadata_spm_postcondition_contradicted",
|
||||
"confirmed Solana Program Metadata transaction contradicted its stateful postcondition",
|
||||
));
|
||||
}
|
||||
summary.post_execution = std::option::Option::Some(report);
|
||||
if request.materialize_after_confirmation {
|
||||
summary.materialized_snapshots =
|
||||
materialize_confirmed_snapshots(summary.after.as_slice());
|
||||
}
|
||||
}
|
||||
return std::result::Result::Ok(summary);
|
||||
}
|
||||
|
||||
async fn prepare_execution<O>(
|
||||
http_pool: &ks_onchain_transport::HttpEndpointPool,
|
||||
profile: &ks_config::ProfileConfig,
|
||||
workspace_root: &std::path::Path,
|
||||
request: &crate::DevnetSolanaProgramMetadataExecutionRequest,
|
||||
observer: &O,
|
||||
) -> ks_core::Result<PreparedSolanaProgramMetadataExecution>
|
||||
where
|
||||
O: crate::SolanaExecutionObserver,
|
||||
{
|
||||
if let std::result::Result::Err(error) = request.validate() {
|
||||
return std::result::Result::Err(error);
|
||||
}
|
||||
if let std::result::Result::Err(error) = validate_profile(profile, request) {
|
||||
return std::result::Result::Err(error);
|
||||
}
|
||||
if let std::result::Result::Err(error) =
|
||||
crate::ensure_not_cancelled(observer, "solana_program_metadata_validate")
|
||||
{
|
||||
return std::result::Result::Err(error);
|
||||
}
|
||||
let genesis = match http_pool.get_genesis_hash_for_role(request.query_role.as_str()).await {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
if genesis.classified_cluster
|
||||
!= std::option::Option::Some(ks_lib::ExApiExecutionCluster::Devnet)
|
||||
{
|
||||
return std::result::Result::Err(ks_core::Error::new(
|
||||
"execution_cluster_mismatch",
|
||||
format!(
|
||||
"expected Devnet genesis hash but endpoint returned {} classified as {:?}",
|
||||
genesis.genesis_hash, genesis.classified_cluster
|
||||
),
|
||||
));
|
||||
}
|
||||
let wallet = match crate::load_profile_wallet(profile, workspace_root).await {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let wallet_summary = wallet.summary();
|
||||
let fee_payer = ks_lib::MdPubkey(wallet_summary.public_key.clone());
|
||||
let balance = match http_pool
|
||||
.get_balance_for_role(
|
||||
request.query_role.as_str(),
|
||||
&fee_payer,
|
||||
&ks_onchain_transport::GetBalanceConfig::confirmed(),
|
||||
)
|
||||
.await
|
||||
{
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
if balance.lamports < profile.execution.max_fee_lamports {
|
||||
return std::result::Result::Err(ks_core::Error::new(
|
||||
"execution_balance_insufficient",
|
||||
"Devnet wallet balance is below the configured fee ceiling",
|
||||
));
|
||||
}
|
||||
let intent = build_intent(profile, request, fee_payer.clone());
|
||||
let plan = match ks_lib::ExApiTypedInstructionExecutor::build_prepared_plan(
|
||||
&ks_lib::ExMetadataSolanaProgramMetadataExecutor,
|
||||
&intent,
|
||||
) {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let plan_evaluation = match ks_lib::ExSafetyChecker.evaluate_prepared_plan(&plan) {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
if plan_evaluation.decision == ks_lib::ExSafetyDecision::Deny {
|
||||
return std::result::Result::Err(ks_core::Error::new(
|
||||
"execution_metadata_spm_plan_denied",
|
||||
crate::violation_message(plan_evaluation.violations.as_slice()),
|
||||
));
|
||||
}
|
||||
let before = match read_snapshots(http_pool, &request.preflight_reads).await {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let stateful_preflight = match ks_pipeline::inspect_solana_program_metadata_preflight(
|
||||
&ks_pipeline::SolanaProgramMetadataPreflightRequest {
|
||||
intent: intent.clone(),
|
||||
plan: plan.clone(),
|
||||
before: before.clone(),
|
||||
rent_observations: request.rent_observations.clone(),
|
||||
},
|
||||
) {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let latest_blockhash = match http_pool
|
||||
.get_latest_blockhash_for_role(
|
||||
request.query_role.as_str(),
|
||||
&ks_onchain_transport::GetLatestBlockhashConfig::confirmed(),
|
||||
)
|
||||
.await
|
||||
{
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let unsigned = match ks_lib::executor_solana_build_legacy_transaction(
|
||||
&plan,
|
||||
latest_blockhash.blockhash.as_str(),
|
||||
) {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
if let std::result::Result::Err(error) = validate_profile_wallet_signers(
|
||||
unsigned.required_signer_pubkeys(),
|
||||
wallet_summary.public_key.as_str(),
|
||||
) {
|
||||
return std::result::Result::Err(error);
|
||||
}
|
||||
let fee = match http_pool
|
||||
.get_fee_for_message_for_role(
|
||||
request.query_role.as_str(),
|
||||
unsigned.message_base64().as_str(),
|
||||
&ks_onchain_transport::GetFeeForMessageConfig::new(
|
||||
ks_onchain_transport::RpcCommitmentLevel::Confirmed,
|
||||
std::option::Option::Some(latest_blockhash.context.slot),
|
||||
),
|
||||
)
|
||||
.await
|
||||
{
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let unsigned_base64 = match unsigned.transaction_base64() {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let simulation_config = match ks_onchain_transport::SimulateTransactionConfig::new(
|
||||
ks_onchain_transport::RpcCommitmentLevel::Confirmed,
|
||||
false,
|
||||
false,
|
||||
std::option::Option::Some(latest_blockhash.context.slot),
|
||||
true,
|
||||
std::option::Option::None,
|
||||
) {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
crate::emit(
|
||||
observer,
|
||||
crate::SolanaExecutionProgressLevel::Info,
|
||||
"solana_program_metadata_simulation",
|
||||
format!("simulating exact Solana Program Metadata message {}", unsigned.message_hash()),
|
||||
std::option::Option::None,
|
||||
);
|
||||
let simulation_rpc = match http_pool
|
||||
.simulate_transaction_for_role(
|
||||
request.transaction_role.as_str(),
|
||||
unsigned_base64.as_str(),
|
||||
&simulation_config,
|
||||
)
|
||||
.await
|
||||
{
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let simulation = simulation_rpc.to_execution_result(
|
||||
ks_lib::ExApiExecutionCluster::Devnet,
|
||||
ks_lib::ExApiExecutionBlockhashKind::Latest,
|
||||
std::option::Option::Some(
|
||||
simulation_rpc.context.slot.saturating_sub(latest_blockhash.context.slot),
|
||||
),
|
||||
std::option::Option::None,
|
||||
std::option::Option::None,
|
||||
std::option::Option::Some(&fee),
|
||||
);
|
||||
let readiness = match ks_pipeline::validate_solana_program_metadata_execution_readiness(
|
||||
&ks_pipeline::SolanaProgramMetadataExecutionReadinessRequest {
|
||||
plan: plan.clone(),
|
||||
preflight: stateful_preflight.clone(),
|
||||
message_hash: unsigned.message_hash().to_string(),
|
||||
simulated_message_hash: unsigned.message_hash().to_string(),
|
||||
simulated: true,
|
||||
simulation_succeeded: simulation.success,
|
||||
resolved_signers: vec![fee_payer],
|
||||
submit: request.submit,
|
||||
operator_confirmed: request.operator_confirmed,
|
||||
},
|
||||
) {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => {
|
||||
let simulation_json = match serde_json::to_string_pretty(&simulation) {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(_) => "<simulation serialization unavailable>".to_string(),
|
||||
};
|
||||
return std::result::Result::Err(ks_core::Error::new(
|
||||
error.code(),
|
||||
format!("{}; simulation={simulation_json}", error.message()),
|
||||
));
|
||||
},
|
||||
};
|
||||
let evidence = unsigned.bind_simulation(simulation.clone());
|
||||
return std::result::Result::Ok(PreparedSolanaProgramMetadataExecution {
|
||||
wallet,
|
||||
unsigned,
|
||||
evidence,
|
||||
summary: crate::DevnetSolanaProgramMetadataExecutionSummary {
|
||||
profile_name: profile.name.clone(),
|
||||
cluster: ks_lib::ExApiExecutionCluster::Devnet,
|
||||
genesis_hash: genesis.genesis_hash,
|
||||
wallet: wallet_summary,
|
||||
balance_lamports: balance.lamports,
|
||||
before,
|
||||
stateful_preflight,
|
||||
plan,
|
||||
latest_blockhash,
|
||||
fee,
|
||||
simulation,
|
||||
readiness,
|
||||
send_result: std::option::Option::None,
|
||||
confirmation: std::option::Option::None,
|
||||
after: std::vec::Vec::new(),
|
||||
post_execution: std::option::Option::None,
|
||||
materialized_snapshots: std::vec::Vec::new(),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
fn build_intent(
|
||||
profile: &ks_config::ProfileConfig,
|
||||
request: &crate::DevnetSolanaProgramMetadataExecutionRequest,
|
||||
fee_payer: ks_lib::MdPubkey,
|
||||
) -> ks_lib::ExMetadataSpmExecutionIntent {
|
||||
return ks_lib::ExMetadataSpmExecutionIntent {
|
||||
intent_id: request.intent_id.clone(),
|
||||
fee_payer: fee_payer.clone(),
|
||||
policy: ks_lib::ExApiExecutionPolicy {
|
||||
cluster: ks_lib::ExApiExecutionClusterPolicy {
|
||||
expected_cluster: ks_lib::ExApiExecutionCluster::Devnet,
|
||||
allow_mainnet: false,
|
||||
mainnet_confirmation: false,
|
||||
},
|
||||
simulation: ks_lib::ExApiExecutionSimulationPolicy::Required,
|
||||
blockhash: ks_lib::ExApiExecutionBlockhashPolicy {
|
||||
kind: ks_lib::ExApiExecutionBlockhashKind::Latest,
|
||||
max_age_slots: std::option::Option::Some(
|
||||
profile.execution.recent_blockhash_max_age_slots,
|
||||
),
|
||||
nonce_account: std::option::Option::None,
|
||||
nonce_authority: std::option::Option::None,
|
||||
},
|
||||
cost_limit: ks_lib::ExApiExecutionCostLimit {
|
||||
max_spend_lamports: std::option::Option::Some(
|
||||
profile.execution.devnet_max_spend_lamports,
|
||||
),
|
||||
max_fee_lamports: std::option::Option::Some(profile.execution.max_fee_lamports),
|
||||
max_compute_unit_price_micro_lamports: std::option::Option::Some(
|
||||
profile.execution.max_compute_unit_price_micro_lamports,
|
||||
),
|
||||
},
|
||||
authorized_signers: vec![fee_payer],
|
||||
dry_run: !request.submit,
|
||||
post_execution_validation: ks_lib::ExApiPostExecutionValidationPolicy {
|
||||
canonical_insert_required: true,
|
||||
core_extraction_required: true,
|
||||
decode_replay_required: true,
|
||||
materialization_required: true,
|
||||
},
|
||||
},
|
||||
allow_destructive_operation: request.allow_destructive_operation,
|
||||
operation: request.operation.clone(),
|
||||
};
|
||||
}
|
||||
|
||||
async fn read_snapshots(
|
||||
http_pool: &ks_onchain_transport::HttpEndpointPool,
|
||||
requests: &[ks_pipeline::SolanaProgramMetadataStatefulReadRequest],
|
||||
) -> ks_core::Result<std::vec::Vec<ks_pipeline::SolanaProgramMetadataStatefulReadResult>> {
|
||||
let mut results = std::vec::Vec::with_capacity(requests.len());
|
||||
for request in requests {
|
||||
let value =
|
||||
match ks_pipeline::read_solana_program_metadata_stateful_snapshot(http_pool, request)
|
||||
.await
|
||||
{
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
results.push(value);
|
||||
}
|
||||
return std::result::Result::Ok(results);
|
||||
}
|
||||
|
||||
fn materialize_confirmed_snapshots(
|
||||
snapshots: &[ks_pipeline::SolanaProgramMetadataStatefulReadResult],
|
||||
) -> std::vec::Vec<ks_lib::MtApiMaterializedOutput> {
|
||||
return snapshots
|
||||
.iter()
|
||||
.filter_map(|result| return result.materialized_output.clone())
|
||||
.collect();
|
||||
}
|
||||
|
||||
fn validate_profile(
|
||||
profile: &ks_config::ProfileConfig,
|
||||
request: &crate::DevnetSolanaProgramMetadataExecutionRequest,
|
||||
) -> ks_core::Result<()> {
|
||||
if profile.wallet.cluster != "devnet" {
|
||||
return std::result::Result::Err(ks_core::Error::config(
|
||||
"Solana Program Metadata orchestration requires a Devnet wallet profile",
|
||||
));
|
||||
}
|
||||
if !profile.wallet.temporary_wallet_enabled || !profile.wallet.temporary_wallet_persist {
|
||||
return std::result::Result::Err(ks_core::Error::config(
|
||||
"Solana Program Metadata orchestration requires an enabled persistent temporary wallet",
|
||||
));
|
||||
}
|
||||
if !profile.execution.require_simulation {
|
||||
return std::result::Result::Err(ks_core::Error::config(
|
||||
"Solana Program Metadata orchestration requires simulation",
|
||||
));
|
||||
}
|
||||
if request.submit && !profile.wallet.devnet_send_enabled {
|
||||
return std::result::Result::Err(ks_core::Error::config(
|
||||
"Devnet transaction submission is disabled by the wallet profile",
|
||||
));
|
||||
}
|
||||
if request.submit
|
||||
&& profile.execution.require_operator_confirmation
|
||||
&& !request.operator_confirmed
|
||||
{
|
||||
return std::result::Result::Err(ks_core::Error::config(
|
||||
"Solana Program Metadata submission requires explicit operator confirmation",
|
||||
));
|
||||
}
|
||||
return std::result::Result::Ok(());
|
||||
}
|
||||
|
||||
fn validate_profile_wallet_signers(
|
||||
required_signers: &[std::string::String],
|
||||
wallet_pubkey: &str,
|
||||
) -> ks_core::Result<()> {
|
||||
if required_signers.iter().any(|value| return value.as_str() != wallet_pubkey) {
|
||||
return std::result::Result::Err(ks_core::Error::new(
|
||||
"execution_metadata_spm_external_signer_unavailable",
|
||||
format!(
|
||||
"the Devnet Solana Program Metadata orchestrator can sign only with profile wallet {}; required signers are {}",
|
||||
wallet_pubkey,
|
||||
required_signers.join(",")
|
||||
),
|
||||
));
|
||||
}
|
||||
return std::result::Result::Ok(());
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
fn wallet() -> ks_lib::MdPubkey {
|
||||
return ks_lib::MdPubkey(solana_pubkey::Pubkey::new_from_array([7_u8; 32]).to_string());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn request_rejects_destructive_operations_without_approval() {
|
||||
let wallet = wallet();
|
||||
let mut request = crate::DevnetSolanaProgramMetadataExecutionRequest::new(
|
||||
"write",
|
||||
ks_lib::ExMetadataSpmOperation::Write {
|
||||
buffer: wallet.clone(),
|
||||
authority: wallet,
|
||||
offset: 0,
|
||||
source: ks_lib::ExMetadataSpmWriteSource::Inline { data: vec![1] },
|
||||
},
|
||||
);
|
||||
request
|
||||
.preflight_reads
|
||||
.push(ks_pipeline::SolanaProgramMetadataStatefulReadRequest {
|
||||
query_role: "http_queries".to_string(),
|
||||
account: request.operation_target_for_test(),
|
||||
expected_state: ks_pipeline::SolanaProgramMetadataExpectedAccountState::Buffer,
|
||||
min_context_slot: std::option::Option::None,
|
||||
max_data_bytes: ks_pipeline::MAX_SOLANA_PROGRAM_METADATA_STATEFUL_ACCOUNT_BYTES,
|
||||
});
|
||||
assert!(request.validate().is_err());
|
||||
request.allow_destructive_operation = true;
|
||||
assert!(request.validate().is_ok());
|
||||
}
|
||||
|
||||
trait OperationTargetForTest {
|
||||
fn operation_target_for_test(&self) -> ks_lib::MdPubkey;
|
||||
}
|
||||
|
||||
impl OperationTargetForTest for crate::DevnetSolanaProgramMetadataExecutionRequest {
|
||||
fn operation_target_for_test(&self) -> ks_lib::MdPubkey {
|
||||
return match &self.operation {
|
||||
ks_lib::ExMetadataSpmOperation::Write { buffer, .. } => buffer.clone(),
|
||||
_ => ks_lib::MdPubkey(ks_program_ids::SYSTEM_PROGRAM_ID.to_string()),
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,962 @@
|
||||
// file: ks-pipeline-demo-scenarios/src/metadata/solana_program/fixture.rs
|
||||
// version: 7
|
||||
|
||||
//! Native Solana Program Metadata fixture preparation for Devnet journeys.
|
||||
|
||||
/// Bytes reserved for the public Buffer journey before `Trim`.
|
||||
pub const SOLANA_PROGRAM_METADATA_FIXTURE_BUFFER_DATA_BYTES: u16 = 128;
|
||||
|
||||
/// Options used to prepare one complete Solana Program Metadata fixture.
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub struct SolanaProgramMetadataFixturePreparationOptions {
|
||||
/// Endpoint role used for account, rent, balance, blockhash and fee calls.
|
||||
pub query_role: std::string::String,
|
||||
/// Endpoint role used for simulation, submission and confirmation.
|
||||
pub transaction_role: std::string::String,
|
||||
/// Explicit operator confirmation for the two prefunding transfers.
|
||||
pub operator_confirmed: bool,
|
||||
}
|
||||
|
||||
impl crate::SolanaProgramMetadataFixturePreparationOptions {
|
||||
/// Creates conservative defaults matching the Devnet execution profiles.
|
||||
pub fn new() -> Self {
|
||||
return Self {
|
||||
query_role: "http_queries".to_string(),
|
||||
transaction_role: "http_transactions".to_string(),
|
||||
operator_confirmed: false,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
impl std::default::Default for crate::SolanaProgramMetadataFixturePreparationOptions {
|
||||
fn default() -> Self {
|
||||
return Self::new();
|
||||
}
|
||||
}
|
||||
|
||||
/// One prepared executable step in a Solana Program Metadata journey.
|
||||
#[derive(Clone, Debug, PartialEq, serde::Deserialize, serde::Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct SolanaProgramMetadataPreparedStep {
|
||||
/// Stable scenario identifier.
|
||||
pub scenario_id: std::string::String,
|
||||
/// Stable scenario-step identifier.
|
||||
pub step_id: std::string::String,
|
||||
/// Zero-based step position inside the scenario.
|
||||
pub step_index: usize,
|
||||
/// Exact typed operation.
|
||||
pub operation: ks_lib::ExMetadataSpmOperation,
|
||||
/// Bounded state reads required before execution.
|
||||
pub preflight_reads: std::vec::Vec<ks_pipeline::SolanaProgramMetadataStatefulReadRequest>,
|
||||
/// Explicit rent observations required by the operation.
|
||||
pub rent_observations: std::vec::Vec<ks_pipeline::SolanaProgramMetadataRentObservation>,
|
||||
/// Bounded state reads required after confirmation.
|
||||
pub postcondition_reads: std::vec::Vec<ks_pipeline::SolanaProgramMetadataStatefulReadRequest>,
|
||||
/// Whether explicit destructive approval is required.
|
||||
pub allow_destructive_operation: bool,
|
||||
}
|
||||
|
||||
impl crate::SolanaProgramMetadataPreparedStep {
|
||||
/// Builds one simulation-only execution request from the prepared fixture step.
|
||||
pub fn execution_request(
|
||||
&self,
|
||||
intent_id: impl std::convert::Into<std::string::String>,
|
||||
) -> crate::DevnetSolanaProgramMetadataExecutionRequest {
|
||||
let mut request = crate::DevnetSolanaProgramMetadataExecutionRequest::new(
|
||||
intent_id,
|
||||
self.operation.clone(),
|
||||
);
|
||||
request.preflight_reads = self.preflight_reads.clone();
|
||||
request.rent_observations = self.rent_observations.clone();
|
||||
request.postcondition_reads = self.postcondition_reads.clone();
|
||||
request.allow_destructive_operation = self.allow_destructive_operation;
|
||||
request.materialize_after_confirmation = true;
|
||||
return request;
|
||||
}
|
||||
}
|
||||
|
||||
/// Public fixture values for the two Solana Program Metadata journeys.
|
||||
#[derive(Clone, Debug, PartialEq, serde::Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct SolanaProgramMetadataFixturePreparationSummary {
|
||||
/// Profile used by the fixture preparation.
|
||||
pub profile_name: std::string::String,
|
||||
/// Profile-wallet authority.
|
||||
pub authority: std::string::String,
|
||||
/// Executable program described by the non-canonical metadata PDA.
|
||||
pub described_program: std::string::String,
|
||||
/// Exact 16-byte Buffer seed as UTF-8 text.
|
||||
pub buffer_seed: std::string::String,
|
||||
/// Non-canonical Buffer PDA.
|
||||
pub buffer: std::string::String,
|
||||
/// Lamports transferred to pre-fund the Buffer PDA.
|
||||
pub buffer_prefund_lamports: u64,
|
||||
/// Confirmed Buffer prefunding signature.
|
||||
pub buffer_prefund_signature: std::string::String,
|
||||
/// Exact 16-byte Metadata seed as UTF-8 text.
|
||||
pub metadata_seed: std::string::String,
|
||||
/// Non-canonical Metadata PDA.
|
||||
pub metadata: std::string::String,
|
||||
/// Lamports transferred to pre-fund the Metadata PDA.
|
||||
pub metadata_prefund_lamports: u64,
|
||||
/// Confirmed Metadata prefunding signature.
|
||||
pub metadata_prefund_signature: std::string::String,
|
||||
/// Ordered executable steps for both journeys.
|
||||
pub prepared_steps: std::vec::Vec<crate::SolanaProgramMetadataPreparedStep>,
|
||||
}
|
||||
|
||||
/// Creates two unique pre-funded PDAs and the nine typed journey steps.
|
||||
pub async fn prepare_solana_program_metadata_fixture(
|
||||
http_pool: &ks_onchain_transport::HttpEndpointPool,
|
||||
profile: &ks_config::ProfileConfig,
|
||||
workspace_root: &std::path::Path,
|
||||
options: &crate::SolanaProgramMetadataFixturePreparationOptions,
|
||||
) -> ks_core::Result<crate::SolanaProgramMetadataFixturePreparationSummary> {
|
||||
if options.query_role.trim().is_empty() || options.transaction_role.trim().is_empty() {
|
||||
return std::result::Result::Err(ks_core::Error::config(
|
||||
"Solana Program Metadata fixture endpoint roles must not be empty",
|
||||
));
|
||||
}
|
||||
if profile.wallet.cluster != "devnet"
|
||||
|| !profile.wallet.devnet_send_enabled
|
||||
|| !profile.wallet.temporary_wallet_enabled
|
||||
|| !profile.wallet.temporary_wallet_persist
|
||||
{
|
||||
return std::result::Result::Err(ks_core::Error::config(
|
||||
"Solana Program Metadata fixture requires one persistent send-enabled Devnet profile",
|
||||
));
|
||||
}
|
||||
if !options.operator_confirmed {
|
||||
return std::result::Result::Err(ks_core::Error::config(
|
||||
"Solana Program Metadata fixture prefunding requires explicit operator confirmation",
|
||||
));
|
||||
}
|
||||
let genesis = match http_pool.get_genesis_hash_for_role(options.query_role.as_str()).await {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
if genesis.classified_cluster
|
||||
!= std::option::Option::Some(ks_lib::ExApiExecutionCluster::Devnet)
|
||||
{
|
||||
return std::result::Result::Err(ks_core::Error::new(
|
||||
"execution_cluster_mismatch",
|
||||
"Solana Program Metadata fixture preparation requires a Devnet endpoint",
|
||||
));
|
||||
}
|
||||
let operator = match crate::load_profile_wallet(profile, workspace_root).await {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let authority = ks_lib::MdPubkey(operator.public_key());
|
||||
let described_program = ks_lib::MdPubkey(ks_program_ids::SYSTEM_PROGRAM_ID.to_string());
|
||||
let unique = uuid::Uuid::new_v4().simple().to_string();
|
||||
let buffer_seed = format!("buf{}", &unique[0..13]);
|
||||
let metadata_seed = format!("met{}", &unique[13..26]);
|
||||
let buffer = match ks_lib::executor_metadata_solana_program_metadata_derive_non_canonical_pda(
|
||||
&described_program,
|
||||
&authority,
|
||||
buffer_seed.as_bytes(),
|
||||
) {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let metadata = match ks_lib::executor_metadata_solana_program_metadata_derive_non_canonical_pda(
|
||||
&described_program,
|
||||
&authority,
|
||||
metadata_seed.as_bytes(),
|
||||
) {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
if let std::result::Result::Err(error) = ensure_fixture_addresses_are_missing(
|
||||
http_pool,
|
||||
options,
|
||||
&[buffer.clone(), metadata.clone()],
|
||||
)
|
||||
.await
|
||||
{
|
||||
return std::result::Result::Err(error);
|
||||
}
|
||||
let buffer_header = match u64::try_from(ks_lib::DC_METADATA_SPM_BUFFER_HEADER_BYTES) {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => {
|
||||
return std::result::Result::Err(ks_core::Error::config(error.to_string()));
|
||||
},
|
||||
};
|
||||
let metadata_header = match u64::try_from(ks_lib::DC_METADATA_SPM_METADATA_HEADER_BYTES) {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => {
|
||||
return std::result::Result::Err(ks_core::Error::config(error.to_string()));
|
||||
},
|
||||
};
|
||||
let buffer_target_space = buffer_header
|
||||
.saturating_add(u64::from(crate::SOLANA_PROGRAM_METADATA_FIXTURE_BUFFER_DATA_BYTES));
|
||||
let initial_metadata_bytes = br#"{"name":"Khadhroony Program Metadata","version":1}"#.to_vec();
|
||||
let updated_metadata_bytes =
|
||||
br#"{"name":"Khadhroony Program Metadata","version":2,"validated":true}"#.to_vec();
|
||||
let initial_metadata_data_len = match u64::try_from(initial_metadata_bytes.len()) {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => {
|
||||
return std::result::Result::Err(ks_core::Error::config(error.to_string()));
|
||||
},
|
||||
};
|
||||
let updated_metadata_data_len = match u64::try_from(updated_metadata_bytes.len()) {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => {
|
||||
return std::result::Result::Err(ks_core::Error::config(error.to_string()));
|
||||
},
|
||||
};
|
||||
let initial_metadata_space = metadata_header.saturating_add(initial_metadata_data_len);
|
||||
let updated_metadata_space = metadata_header.saturating_add(updated_metadata_data_len);
|
||||
let buffer_header_rent =
|
||||
match rent_for_space(http_pool, options.query_role.as_str(), buffer_header).await {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let buffer_target_rent =
|
||||
match rent_for_space(http_pool, options.query_role.as_str(), buffer_target_space).await {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let initial_metadata_rent = match rent_for_space(
|
||||
http_pool,
|
||||
options.query_role.as_str(),
|
||||
initial_metadata_space,
|
||||
)
|
||||
.await
|
||||
{
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let updated_metadata_rent = match rent_for_space(
|
||||
http_pool,
|
||||
options.query_role.as_str(),
|
||||
updated_metadata_space,
|
||||
)
|
||||
.await
|
||||
{
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let buffer_prefund_lamports = buffer_target_rent.max(buffer_header_rent);
|
||||
let metadata_prefund_lamports = updated_metadata_rent.max(initial_metadata_rent);
|
||||
let total =
|
||||
match buffer_prefund_lamports
|
||||
.checked_add(metadata_prefund_lamports)
|
||||
.and_then(|value| {
|
||||
return value.checked_add(
|
||||
profile
|
||||
.execution
|
||||
.max_fee_lamports
|
||||
.saturating_mul(crate::SOLANA_PROGRAM_METADATA_CAMPAIGN_TRANSACTION_COUNT),
|
||||
);
|
||||
}) {
|
||||
std::option::Option::Some(value) => value,
|
||||
std::option::Option::None => {
|
||||
return std::result::Result::Err(ks_core::Error::config(
|
||||
"Solana Program Metadata fixture funding requirement overflowed u64",
|
||||
));
|
||||
},
|
||||
};
|
||||
let balance = match http_pool
|
||||
.get_balance_for_role(
|
||||
options.query_role.as_str(),
|
||||
&authority,
|
||||
&ks_onchain_transport::GetBalanceConfig::confirmed(),
|
||||
)
|
||||
.await
|
||||
{
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
if balance.lamports < total {
|
||||
return std::result::Result::Err(ks_core::Error::new(
|
||||
"solana_program_metadata_fixture_balance_insufficient",
|
||||
format!(
|
||||
"Devnet fixture wallet has {} lamports but requires at least {}; fund or airdrop the profile wallet first",
|
||||
balance.lamports, total
|
||||
),
|
||||
));
|
||||
}
|
||||
let buffer_prefund_signature = match submit_prefund_transfer(
|
||||
http_pool,
|
||||
profile,
|
||||
options,
|
||||
&operator,
|
||||
buffer.clone(),
|
||||
buffer_prefund_lamports,
|
||||
"buffer",
|
||||
)
|
||||
.await
|
||||
{
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let metadata_prefund_signature = match submit_prefund_transfer(
|
||||
http_pool,
|
||||
profile,
|
||||
options,
|
||||
&operator,
|
||||
metadata.clone(),
|
||||
metadata_prefund_lamports,
|
||||
"metadata",
|
||||
)
|
||||
.await
|
||||
{
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let prepared_steps = prepared_steps(
|
||||
options.query_role.as_str(),
|
||||
authority.clone(),
|
||||
described_program.clone(),
|
||||
buffer.clone(),
|
||||
buffer_seed.as_bytes(),
|
||||
metadata.clone(),
|
||||
metadata_seed.as_bytes(),
|
||||
initial_metadata_bytes,
|
||||
updated_metadata_bytes,
|
||||
buffer_prefund_lamports,
|
||||
buffer_header,
|
||||
buffer_header_rent,
|
||||
buffer_target_space,
|
||||
buffer_target_rent,
|
||||
metadata_prefund_lamports,
|
||||
initial_metadata_space,
|
||||
initial_metadata_rent,
|
||||
updated_metadata_space,
|
||||
updated_metadata_rent,
|
||||
);
|
||||
tracing::info!(
|
||||
target: crate::TRACING_TARGET,
|
||||
action = "prepare_solana_program_metadata_fixture",
|
||||
profile = profile.name.as_str(),
|
||||
authority = authority.0.as_str(),
|
||||
buffer = buffer.0.as_str(),
|
||||
metadata = metadata.0.as_str(),
|
||||
"prepared pre-funded Solana Program Metadata Devnet fixture"
|
||||
);
|
||||
return std::result::Result::Ok(crate::SolanaProgramMetadataFixturePreparationSummary {
|
||||
profile_name: profile.name.clone(),
|
||||
authority: authority.0,
|
||||
described_program: described_program.0,
|
||||
buffer_seed,
|
||||
buffer: buffer.0,
|
||||
buffer_prefund_lamports,
|
||||
buffer_prefund_signature,
|
||||
metadata_seed,
|
||||
metadata: metadata.0,
|
||||
metadata_prefund_lamports,
|
||||
metadata_prefund_signature,
|
||||
prepared_steps,
|
||||
});
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn prepared_steps(
|
||||
query_role: &str,
|
||||
authority: ks_lib::MdPubkey,
|
||||
described_program: ks_lib::MdPubkey,
|
||||
buffer: ks_lib::MdPubkey,
|
||||
buffer_seed: &[u8],
|
||||
metadata: ks_lib::MdPubkey,
|
||||
metadata_seed: &[u8],
|
||||
initial_metadata_bytes: std::vec::Vec<u8>,
|
||||
updated_metadata_bytes: std::vec::Vec<u8>,
|
||||
buffer_observed_lamports: u64,
|
||||
buffer_header_space: u64,
|
||||
buffer_header_rent: u64,
|
||||
buffer_target_space: u64,
|
||||
buffer_target_rent: u64,
|
||||
metadata_observed_lamports: u64,
|
||||
initial_metadata_space: u64,
|
||||
initial_metadata_rent: u64,
|
||||
updated_metadata_space: u64,
|
||||
updated_metadata_rent: u64,
|
||||
) -> std::vec::Vec<crate::SolanaProgramMetadataPreparedStep> {
|
||||
let context = ks_lib::ExMetadataSpmProgramContext {
|
||||
program: described_program.clone(),
|
||||
program_data: std::option::Option::None,
|
||||
};
|
||||
let buffer_vacant = read(
|
||||
query_role,
|
||||
buffer.clone(),
|
||||
ks_pipeline::SolanaProgramMetadataExpectedAccountState::Vacant,
|
||||
);
|
||||
let buffer_state = read(
|
||||
query_role,
|
||||
buffer.clone(),
|
||||
ks_pipeline::SolanaProgramMetadataExpectedAccountState::Buffer,
|
||||
);
|
||||
let buffer_missing = read(
|
||||
query_role,
|
||||
buffer.clone(),
|
||||
ks_pipeline::SolanaProgramMetadataExpectedAccountState::Missing,
|
||||
);
|
||||
let metadata_vacant = read(
|
||||
query_role,
|
||||
metadata.clone(),
|
||||
ks_pipeline::SolanaProgramMetadataExpectedAccountState::Vacant,
|
||||
);
|
||||
let metadata_state = read(
|
||||
query_role,
|
||||
metadata.clone(),
|
||||
ks_pipeline::SolanaProgramMetadataExpectedAccountState::Metadata,
|
||||
);
|
||||
return vec![
|
||||
prepared_step(
|
||||
"spm_buffer_lifecycle",
|
||||
"spm_buffer_allocate",
|
||||
0,
|
||||
ks_lib::ExMetadataSpmOperation::Allocate {
|
||||
buffer: buffer.clone(),
|
||||
authority: authority.clone(),
|
||||
seed: std::option::Option::Some(buffer_seed.to_vec()),
|
||||
program_context: std::option::Option::Some(context.clone()),
|
||||
canonical: false,
|
||||
allocate_account: true,
|
||||
},
|
||||
vec![buffer_vacant],
|
||||
vec![rent(
|
||||
buffer.clone(),
|
||||
buffer_header_space,
|
||||
buffer_observed_lamports,
|
||||
buffer_header_rent,
|
||||
)],
|
||||
vec![buffer_state.clone()],
|
||||
false,
|
||||
),
|
||||
prepared_step(
|
||||
"spm_buffer_lifecycle",
|
||||
"spm_buffer_extend",
|
||||
1,
|
||||
ks_lib::ExMetadataSpmOperation::Extend {
|
||||
account: buffer.clone(),
|
||||
authority: authority.clone(),
|
||||
length: crate::SOLANA_PROGRAM_METADATA_FIXTURE_BUFFER_DATA_BYTES,
|
||||
program_context: std::option::Option::None,
|
||||
},
|
||||
vec![buffer_state.clone()],
|
||||
vec![rent(
|
||||
buffer.clone(),
|
||||
buffer_target_space,
|
||||
buffer_observed_lamports,
|
||||
buffer_target_rent,
|
||||
)],
|
||||
vec![buffer_state.clone()],
|
||||
false,
|
||||
),
|
||||
prepared_step(
|
||||
"spm_buffer_lifecycle",
|
||||
"spm_buffer_write",
|
||||
2,
|
||||
ks_lib::ExMetadataSpmOperation::Write {
|
||||
buffer: buffer.clone(),
|
||||
authority: authority.clone(),
|
||||
offset: 0,
|
||||
source: ks_lib::ExMetadataSpmWriteSource::Inline {
|
||||
data: b"Khadhroony Solana Program Metadata Buffer fixture".to_vec(),
|
||||
},
|
||||
},
|
||||
vec![buffer_state.clone()],
|
||||
std::vec::Vec::new(),
|
||||
vec![buffer_state.clone()],
|
||||
true,
|
||||
),
|
||||
prepared_step(
|
||||
"spm_buffer_lifecycle",
|
||||
"spm_buffer_set_authority",
|
||||
3,
|
||||
ks_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,
|
||||
ks_lib::ExMetadataSpmOperation::Trim {
|
||||
account: buffer.clone(),
|
||||
authority: authority.clone(),
|
||||
destination: 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_close",
|
||||
5,
|
||||
ks_lib::ExMetadataSpmOperation::Close {
|
||||
account: buffer.clone(),
|
||||
authority: authority.clone(),
|
||||
destination: authority.clone(),
|
||||
program_context: std::option::Option::None,
|
||||
},
|
||||
vec![buffer_state.clone()],
|
||||
std::vec::Vec::new(),
|
||||
vec![buffer_missing],
|
||||
true,
|
||||
),
|
||||
prepared_step(
|
||||
"spm_metadata_lifecycle",
|
||||
"spm_metadata_initialize",
|
||||
0,
|
||||
ks_lib::ExMetadataSpmOperation::Initialize {
|
||||
metadata: metadata.clone(),
|
||||
authority: authority.clone(),
|
||||
program: described_program,
|
||||
program_data: std::option::Option::None,
|
||||
canonical: false,
|
||||
seed: metadata_seed.to_vec(),
|
||||
encoding: ks_lib::ExMetadataSpmEncoding::Utf8,
|
||||
compression: ks_lib::ExMetadataSpmCompression::None,
|
||||
format: ks_lib::ExMetadataSpmFormat::Json,
|
||||
data_source: ks_lib::ExMetadataSpmDataSource::Direct,
|
||||
data: std::option::Option::Some(ks_lib::ExMetadataSpmDataInput::Direct {
|
||||
bytes: initial_metadata_bytes,
|
||||
}),
|
||||
allocate_account: true,
|
||||
},
|
||||
vec![metadata_vacant],
|
||||
vec![rent(
|
||||
metadata.clone(),
|
||||
initial_metadata_space,
|
||||
metadata_observed_lamports,
|
||||
initial_metadata_rent,
|
||||
)],
|
||||
vec![metadata_state.clone()],
|
||||
false,
|
||||
),
|
||||
prepared_step(
|
||||
"spm_metadata_lifecycle",
|
||||
"spm_metadata_set_data",
|
||||
1,
|
||||
ks_lib::ExMetadataSpmOperation::SetData {
|
||||
metadata: metadata.clone(),
|
||||
authority: authority.clone(),
|
||||
encoding: ks_lib::ExMetadataSpmEncoding::Utf8,
|
||||
compression: ks_lib::ExMetadataSpmCompression::None,
|
||||
format: ks_lib::ExMetadataSpmFormat::Json,
|
||||
source: ks_lib::ExMetadataSpmSetDataSource::Inline {
|
||||
data: ks_lib::ExMetadataSpmDataInput::Direct { bytes: updated_metadata_bytes },
|
||||
},
|
||||
program_context: std::option::Option::None,
|
||||
},
|
||||
vec![metadata_state.clone()],
|
||||
vec![rent(
|
||||
metadata.clone(),
|
||||
updated_metadata_space,
|
||||
metadata_observed_lamports,
|
||||
updated_metadata_rent,
|
||||
)],
|
||||
vec![metadata_state.clone()],
|
||||
true,
|
||||
),
|
||||
prepared_step(
|
||||
"spm_metadata_lifecycle",
|
||||
"spm_metadata_set_immutable",
|
||||
2,
|
||||
ks_lib::ExMetadataSpmOperation::SetImmutable {
|
||||
metadata: metadata.clone(),
|
||||
authority,
|
||||
program_context: std::option::Option::None,
|
||||
},
|
||||
vec![metadata_state.clone()],
|
||||
std::vec::Vec::new(),
|
||||
vec![metadata_state],
|
||||
true,
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
fn prepared_step(
|
||||
scenario_id: &str,
|
||||
step_id: &str,
|
||||
step_index: usize,
|
||||
operation: ks_lib::ExMetadataSpmOperation,
|
||||
preflight_reads: std::vec::Vec<ks_pipeline::SolanaProgramMetadataStatefulReadRequest>,
|
||||
rent_observations: std::vec::Vec<ks_pipeline::SolanaProgramMetadataRentObservation>,
|
||||
postcondition_reads: std::vec::Vec<ks_pipeline::SolanaProgramMetadataStatefulReadRequest>,
|
||||
allow_destructive_operation: bool,
|
||||
) -> crate::SolanaProgramMetadataPreparedStep {
|
||||
return crate::SolanaProgramMetadataPreparedStep {
|
||||
scenario_id: scenario_id.to_string(),
|
||||
step_id: step_id.to_string(),
|
||||
step_index,
|
||||
operation,
|
||||
preflight_reads,
|
||||
rent_observations,
|
||||
postcondition_reads,
|
||||
allow_destructive_operation,
|
||||
};
|
||||
}
|
||||
|
||||
fn read(
|
||||
query_role: &str,
|
||||
account: ks_lib::MdPubkey,
|
||||
expected_state: ks_pipeline::SolanaProgramMetadataExpectedAccountState,
|
||||
) -> ks_pipeline::SolanaProgramMetadataStatefulReadRequest {
|
||||
return ks_pipeline::SolanaProgramMetadataStatefulReadRequest {
|
||||
query_role: query_role.to_string(),
|
||||
account,
|
||||
expected_state,
|
||||
min_context_slot: std::option::Option::None,
|
||||
max_data_bytes: ks_pipeline::MAX_SOLANA_PROGRAM_METADATA_STATEFUL_ACCOUNT_BYTES,
|
||||
};
|
||||
}
|
||||
|
||||
fn rent(
|
||||
account: ks_lib::MdPubkey,
|
||||
target_space: u64,
|
||||
observed_lamports: u64,
|
||||
required_lamports: u64,
|
||||
) -> ks_pipeline::SolanaProgramMetadataRentObservation {
|
||||
return ks_pipeline::SolanaProgramMetadataRentObservation {
|
||||
account,
|
||||
target_space,
|
||||
observed_lamports,
|
||||
required_lamports,
|
||||
};
|
||||
}
|
||||
|
||||
async fn ensure_fixture_addresses_are_missing(
|
||||
http_pool: &ks_onchain_transport::HttpEndpointPool,
|
||||
options: &crate::SolanaProgramMetadataFixturePreparationOptions,
|
||||
accounts: &[ks_lib::MdPubkey],
|
||||
) -> ks_core::Result<()> {
|
||||
for account in accounts {
|
||||
let value = match http_pool
|
||||
.get_account_info_for_role(
|
||||
options.query_role.as_str(),
|
||||
account,
|
||||
&ks_onchain_transport::GetAccountInfoConfig::confirmed(),
|
||||
)
|
||||
.await
|
||||
{
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
if value.account.is_some() {
|
||||
return std::result::Result::Err(ks_core::Error::new(
|
||||
"solana_program_metadata_fixture_address_collision",
|
||||
format!("generated fixture address {} already exists", account.0),
|
||||
));
|
||||
}
|
||||
}
|
||||
return std::result::Result::Ok(());
|
||||
}
|
||||
|
||||
async fn rent_for_space(
|
||||
http_pool: &ks_onchain_transport::HttpEndpointPool,
|
||||
query_role: &str,
|
||||
space: u64,
|
||||
) -> ks_core::Result<u64> {
|
||||
let result = match http_pool
|
||||
.get_minimum_balance_for_rent_exemption_for_role(
|
||||
query_role,
|
||||
space,
|
||||
&ks_onchain_transport::GetMinimumBalanceForRentExemptionConfig::confirmed(),
|
||||
)
|
||||
.await
|
||||
{
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
return std::result::Result::Ok(result.minimum_balance_lamports);
|
||||
}
|
||||
|
||||
async fn submit_prefund_transfer(
|
||||
http_pool: &ks_onchain_transport::HttpEndpointPool,
|
||||
profile: &ks_config::ProfileConfig,
|
||||
options: &crate::SolanaProgramMetadataFixturePreparationOptions,
|
||||
operator: &ks_wallet::TemporaryWallet,
|
||||
recipient: ks_lib::MdPubkey,
|
||||
lamports: u64,
|
||||
label: &str,
|
||||
) -> ks_core::Result<std::string::String> {
|
||||
let payer = ks_lib::MdPubkey(operator.public_key());
|
||||
let policy = ks_lib::ExApiExecutionPolicy {
|
||||
cluster: ks_lib::ExApiExecutionClusterPolicy {
|
||||
expected_cluster: ks_lib::ExApiExecutionCluster::Devnet,
|
||||
allow_mainnet: false,
|
||||
mainnet_confirmation: false,
|
||||
},
|
||||
simulation: ks_lib::ExApiExecutionSimulationPolicy::Required,
|
||||
blockhash: ks_lib::ExApiExecutionBlockhashPolicy {
|
||||
kind: ks_lib::ExApiExecutionBlockhashKind::Latest,
|
||||
max_age_slots: std::option::Option::Some(
|
||||
profile.execution.recent_blockhash_max_age_slots,
|
||||
),
|
||||
nonce_account: std::option::Option::None,
|
||||
nonce_authority: std::option::Option::None,
|
||||
},
|
||||
cost_limit: ks_lib::ExApiExecutionCostLimit {
|
||||
max_spend_lamports: std::option::Option::Some(
|
||||
profile.execution.devnet_max_spend_lamports,
|
||||
),
|
||||
max_fee_lamports: std::option::Option::Some(profile.execution.max_fee_lamports),
|
||||
max_compute_unit_price_micro_lamports: std::option::Option::Some(
|
||||
profile.execution.max_compute_unit_price_micro_lamports,
|
||||
),
|
||||
},
|
||||
authorized_signers: vec![payer.clone()],
|
||||
dry_run: false,
|
||||
post_execution_validation: ks_lib::ExApiPostExecutionValidationPolicy {
|
||||
canonical_insert_required: false,
|
||||
core_extraction_required: false,
|
||||
decode_replay_required: false,
|
||||
materialization_required: false,
|
||||
},
|
||||
};
|
||||
let intent = ks_lib::ExSolanaCoreExecutionIntent {
|
||||
intent_id: format!("spm-fixture-prefund-{label}-{}", recipient.0),
|
||||
fee_payer: payer.clone(),
|
||||
policy,
|
||||
operation: ks_lib::ExSolanaCoreOperation::SystemTransfer {
|
||||
from: payer,
|
||||
to: recipient,
|
||||
lamports,
|
||||
},
|
||||
};
|
||||
let plan = match ks_lib::ExApiTypedInstructionExecutor::build_prepared_plan(
|
||||
&ks_lib::ExSolanaCoreExecutor,
|
||||
&intent,
|
||||
) {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let plan_evaluation = match ks_lib::ExSafetyChecker.evaluate_prepared_plan(&plan) {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
if plan_evaluation.decision == ks_lib::ExSafetyDecision::Deny {
|
||||
return std::result::Result::Err(ks_core::Error::new(
|
||||
"solana_program_metadata_fixture_prefund_plan_denied",
|
||||
crate::violation_message(plan_evaluation.violations.as_slice()),
|
||||
));
|
||||
}
|
||||
let latest_blockhash = match http_pool
|
||||
.get_latest_blockhash_for_role(
|
||||
options.query_role.as_str(),
|
||||
&ks_onchain_transport::GetLatestBlockhashConfig::confirmed(),
|
||||
)
|
||||
.await
|
||||
{
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let unsigned = match ks_lib::executor_solana_build_legacy_transaction(
|
||||
&plan,
|
||||
latest_blockhash.blockhash.as_str(),
|
||||
) {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let fee = match http_pool
|
||||
.get_fee_for_message_for_role(
|
||||
options.query_role.as_str(),
|
||||
unsigned.message_base64().as_str(),
|
||||
&ks_onchain_transport::GetFeeForMessageConfig::new(
|
||||
ks_onchain_transport::RpcCommitmentLevel::Confirmed,
|
||||
std::option::Option::Some(latest_blockhash.context.slot),
|
||||
),
|
||||
)
|
||||
.await
|
||||
{
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let unsigned_base64 = match unsigned.transaction_base64() {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let simulation_config = match ks_onchain_transport::SimulateTransactionConfig::new(
|
||||
ks_onchain_transport::RpcCommitmentLevel::Confirmed,
|
||||
false,
|
||||
false,
|
||||
std::option::Option::Some(latest_blockhash.context.slot),
|
||||
true,
|
||||
std::option::Option::None,
|
||||
) {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let simulation_rpc = match http_pool
|
||||
.simulate_transaction_for_role(
|
||||
options.transaction_role.as_str(),
|
||||
unsigned_base64.as_str(),
|
||||
&simulation_config,
|
||||
)
|
||||
.await
|
||||
{
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let simulation = simulation_rpc.to_execution_result(
|
||||
ks_lib::ExApiExecutionCluster::Devnet,
|
||||
ks_lib::ExApiExecutionBlockhashKind::Latest,
|
||||
std::option::Option::Some(
|
||||
simulation_rpc.context.slot.saturating_sub(latest_blockhash.context.slot),
|
||||
),
|
||||
std::option::Option::None,
|
||||
std::option::Option::None,
|
||||
std::option::Option::Some(&fee),
|
||||
);
|
||||
if !simulation.success {
|
||||
return std::result::Result::Err(ks_core::Error::new(
|
||||
"solana_program_metadata_fixture_prefund_simulation_failed",
|
||||
crate::simulation_failure_message(&simulation),
|
||||
));
|
||||
}
|
||||
let send_evaluation = match ks_lib::ExSafetyChecker.evaluate_send(&plan, &simulation) {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
if send_evaluation.decision == ks_lib::ExSafetyDecision::Deny {
|
||||
return std::result::Result::Err(ks_core::Error::new(
|
||||
"solana_program_metadata_fixture_prefund_send_denied",
|
||||
crate::violation_message(send_evaluation.violations.as_slice()),
|
||||
));
|
||||
}
|
||||
let evidence = unsigned.bind_simulation(simulation.clone());
|
||||
let signed = match unsigned.sign_after_simulation(&evidence, &[operator.as_signer()]) {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
if let std::result::Result::Err(error) = signed.verify_signatures() {
|
||||
return std::result::Result::Err(error);
|
||||
}
|
||||
let signature = signed.primary_signature().clone();
|
||||
let send_config = match ks_onchain_transport::SendTransactionConfig::from_execution_config(
|
||||
&profile.execution,
|
||||
std::option::Option::Some(latest_blockhash.context.slot),
|
||||
) {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
if let std::result::Result::Err(error) = http_pool
|
||||
.send_transaction_for_role(
|
||||
options.transaction_role.as_str(),
|
||||
signed.transaction_base64().as_str(),
|
||||
&signature,
|
||||
&send_config,
|
||||
)
|
||||
.await
|
||||
{
|
||||
return std::result::Result::Err(error);
|
||||
}
|
||||
let confirmation_config =
|
||||
match ks_onchain_transport::ConfirmTransactionConfig::from_execution_config(
|
||||
&profile.execution,
|
||||
std::option::Option::Some(latest_blockhash.last_valid_block_height),
|
||||
std::option::Option::Some(latest_blockhash.context.slot),
|
||||
) {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let confirmation = match http_pool
|
||||
.confirm_transaction_for_roles(
|
||||
options.transaction_role.as_str(),
|
||||
options.query_role.as_str(),
|
||||
ks_lib::ExApiExecutionCluster::Devnet,
|
||||
&signature,
|
||||
&confirmation_config,
|
||||
)
|
||||
.await
|
||||
{
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
if !matches!(
|
||||
confirmation.status,
|
||||
ks_lib::ExApiExecutionConfirmationStatus::Confirmed
|
||||
| ks_lib::ExApiExecutionConfirmationStatus::Finalized
|
||||
) {
|
||||
return std::result::Result::Err(ks_core::Error::new(
|
||||
"solana_program_metadata_fixture_prefund_confirmation_failed",
|
||||
format!("prefunding transaction stopped at {:?}", confirmation.status),
|
||||
));
|
||||
}
|
||||
return std::result::Result::Ok(signature.0);
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#[test]
|
||||
fn fixture_seed_shapes_and_journey_inventory_are_exact() {
|
||||
let unique = uuid::Uuid::nil().simple().to_string();
|
||||
let buffer_seed = format!("buf{}", &unique[0..13]);
|
||||
let metadata_seed = format!("met{}", &unique[13..26]);
|
||||
assert_eq!(buffer_seed.len(), ks_lib::DC_METADATA_SPM_SEED_BYTES);
|
||||
assert_eq!(metadata_seed.len(), ks_lib::DC_METADATA_SPM_SEED_BYTES);
|
||||
let authority =
|
||||
ks_lib::MdPubkey(solana_pubkey::Pubkey::new_from_array([1_u8; 32]).to_string());
|
||||
let buffer =
|
||||
ks_lib::MdPubkey(solana_pubkey::Pubkey::new_from_array([2_u8; 32]).to_string());
|
||||
let metadata =
|
||||
ks_lib::MdPubkey(solana_pubkey::Pubkey::new_from_array([3_u8; 32]).to_string());
|
||||
let steps = super::prepared_steps(
|
||||
"custom_queries",
|
||||
authority,
|
||||
ks_lib::MdPubkey(ks_program_ids::SYSTEM_PROGRAM_ID.to_string()),
|
||||
buffer,
|
||||
buffer_seed.as_bytes(),
|
||||
metadata,
|
||||
metadata_seed.as_bytes(),
|
||||
br#"{"version":1}"#.to_vec(),
|
||||
br#"{"version":2}"#.to_vec(),
|
||||
1_000_000,
|
||||
96,
|
||||
1_000_000,
|
||||
224,
|
||||
1_000_000,
|
||||
1_000_000,
|
||||
109,
|
||||
1_000_000,
|
||||
109,
|
||||
1_000_000,
|
||||
);
|
||||
let actual = steps
|
||||
.iter()
|
||||
.map(|step| return step.operation.operation_code())
|
||||
.collect::<std::vec::Vec<_>>();
|
||||
let scenarios = crate::solana_program_metadata_devnet_scenarios();
|
||||
let expected = scenarios
|
||||
.iter()
|
||||
.flat_map(|scenario| return scenario.steps.iter())
|
||||
.map(|step| return step.operation_code.as_str())
|
||||
.collect::<std::vec::Vec<_>>();
|
||||
assert_eq!(actual, expected);
|
||||
let expected_buffer =
|
||||
ks_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()
|
||||
== ks_lib::EX_METADATA_SPM_SET_AUTHORITY_OPERATION;
|
||||
});
|
||||
assert!(matches!(
|
||||
set_authority.map(|step| return &step.operation),
|
||||
std::option::Option::Some(ks_lib::ExMetadataSpmOperation::SetAuthority {
|
||||
account,
|
||||
..
|
||||
}) if account == &expected_buffer
|
||||
));
|
||||
assert!(steps.iter().all(|step| {
|
||||
return step
|
||||
.preflight_reads
|
||||
.iter()
|
||||
.chain(step.postcondition_reads.iter())
|
||||
.all(|read| return read.query_role == "custom_queries");
|
||||
}));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,237 @@
|
||||
// file: ks-pipeline-demo-scenarios/src/metadata/solana_program/scenarios.rs
|
||||
// version: 5
|
||||
|
||||
//! Coherent Solana Program Metadata journeys for Devnet validation.
|
||||
|
||||
/// Stable lifecycle state consumed or produced by one scenario step.
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum SolanaProgramMetadataFixtureState {
|
||||
/// Pre-funded but uninitialized PDA.
|
||||
Prefunded,
|
||||
/// Initialized Buffer account.
|
||||
Buffer,
|
||||
/// Initialized mutable Metadata account.
|
||||
MutableMetadata,
|
||||
/// Initialized immutable Metadata account.
|
||||
ImmutableMetadata,
|
||||
/// Closed account.
|
||||
Closed,
|
||||
}
|
||||
|
||||
/// One stable step in a Solana Program Metadata Devnet journey.
|
||||
#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct SolanaProgramMetadataScenarioStep {
|
||||
/// 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 the operation.
|
||||
pub initial_state: crate::SolanaProgramMetadataFixtureState,
|
||||
/// Expected state after confirmed execution.
|
||||
pub resulting_state: crate::SolanaProgramMetadataFixtureState,
|
||||
/// Whether the operation requires explicit destructive approval.
|
||||
pub requires_explicit_approval: bool,
|
||||
/// Required evidence kinds.
|
||||
pub required_evidence: std::vec::Vec<std::string::String>,
|
||||
}
|
||||
|
||||
/// One ordered multi-step Solana Program Metadata journey.
|
||||
#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct SolanaProgramMetadataScenario {
|
||||
/// 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 scenario steps.
|
||||
pub steps: std::vec::Vec<crate::SolanaProgramMetadataScenarioStep>,
|
||||
}
|
||||
|
||||
/// Returns the two ordered Devnet journeys covering all nine stable operations.
|
||||
pub fn solana_program_metadata_devnet_scenarios()
|
||||
-> std::vec::Vec<crate::SolanaProgramMetadataScenario> {
|
||||
return vec![
|
||||
crate::SolanaProgramMetadataScenario {
|
||||
id: "spm_buffer_lifecycle".to_string(),
|
||||
label: "Buffer : allocation, extension, écriture, autorité, trim et fermeture"
|
||||
.to_string(),
|
||||
fixture_account: "buffer".to_string(),
|
||||
steps: vec![
|
||||
step(
|
||||
"spm_buffer_allocate",
|
||||
"Allouer le Buffer préfinancé",
|
||||
ks_lib::EX_METADATA_SPM_ALLOCATE_OPERATION,
|
||||
crate::SolanaProgramMetadataFixtureState::Prefunded,
|
||||
crate::SolanaProgramMetadataFixtureState::Buffer,
|
||||
false,
|
||||
),
|
||||
step(
|
||||
"spm_buffer_extend",
|
||||
"Étendre la capacité du Buffer",
|
||||
ks_lib::EX_METADATA_SPM_EXTEND_OPERATION,
|
||||
crate::SolanaProgramMetadataFixtureState::Buffer,
|
||||
crate::SolanaProgramMetadataFixtureState::Buffer,
|
||||
false,
|
||||
),
|
||||
step(
|
||||
"spm_buffer_write",
|
||||
"Écrire les données inline",
|
||||
ks_lib::EX_METADATA_SPM_WRITE_OPERATION,
|
||||
crate::SolanaProgramMetadataFixtureState::Buffer,
|
||||
crate::SolanaProgramMetadataFixtureState::Buffer,
|
||||
true,
|
||||
),
|
||||
step(
|
||||
"spm_buffer_set_authority",
|
||||
"Réaffirmer l’autorité du Buffer non canonique",
|
||||
ks_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",
|
||||
ks_lib::EX_METADATA_SPM_TRIM_OPERATION,
|
||||
crate::SolanaProgramMetadataFixtureState::Buffer,
|
||||
crate::SolanaProgramMetadataFixtureState::Buffer,
|
||||
true,
|
||||
),
|
||||
step(
|
||||
"spm_buffer_close",
|
||||
"Fermer le Buffer",
|
||||
ks_lib::EX_METADATA_SPM_CLOSE_OPERATION,
|
||||
crate::SolanaProgramMetadataFixtureState::Buffer,
|
||||
crate::SolanaProgramMetadataFixtureState::Closed,
|
||||
true,
|
||||
),
|
||||
],
|
||||
},
|
||||
crate::SolanaProgramMetadataScenario {
|
||||
id: "spm_metadata_lifecycle".to_string(),
|
||||
label: "Metadata : initialisation, données et immutabilité".to_string(),
|
||||
fixture_account: "metadata".to_string(),
|
||||
steps: vec![
|
||||
step(
|
||||
"spm_metadata_initialize",
|
||||
"Initialiser les metadata non-canoniques",
|
||||
ks_lib::EX_METADATA_SPM_INITIALIZE_OPERATION,
|
||||
crate::SolanaProgramMetadataFixtureState::Prefunded,
|
||||
crate::SolanaProgramMetadataFixtureState::MutableMetadata,
|
||||
false,
|
||||
),
|
||||
step(
|
||||
"spm_metadata_set_data",
|
||||
"Mettre à jour les données JSON",
|
||||
ks_lib::EX_METADATA_SPM_SET_DATA_OPERATION,
|
||||
crate::SolanaProgramMetadataFixtureState::MutableMetadata,
|
||||
crate::SolanaProgramMetadataFixtureState::MutableMetadata,
|
||||
true,
|
||||
),
|
||||
step(
|
||||
"spm_metadata_set_immutable",
|
||||
"Rendre les metadata immuables",
|
||||
ks_lib::EX_METADATA_SPM_SET_IMMUTABLE_OPERATION,
|
||||
crate::SolanaProgramMetadataFixtureState::MutableMetadata,
|
||||
crate::SolanaProgramMetadataFixtureState::ImmutableMetadata,
|
||||
true,
|
||||
),
|
||||
],
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
fn step(
|
||||
id: &str,
|
||||
label: &str,
|
||||
operation_code: &str,
|
||||
initial_state: crate::SolanaProgramMetadataFixtureState,
|
||||
resulting_state: crate::SolanaProgramMetadataFixtureState,
|
||||
requires_explicit_approval: bool,
|
||||
) -> crate::SolanaProgramMetadataScenarioStep {
|
||||
return crate::SolanaProgramMetadataScenarioStep {
|
||||
id: id.to_string(),
|
||||
label: label.to_string(),
|
||||
operation_code: operation_code.to_string(),
|
||||
initial_state,
|
||||
resulting_state,
|
||||
requires_explicit_approval,
|
||||
required_evidence: required_evidence(resulting_state),
|
||||
};
|
||||
}
|
||||
|
||||
fn required_evidence(
|
||||
resulting_state: crate::SolanaProgramMetadataFixtureState,
|
||||
) -> std::vec::Vec<std::string::String> {
|
||||
let terminal_evidence = match resulting_state {
|
||||
crate::SolanaProgramMetadataFixtureState::Closed => "account_absence",
|
||||
_ => "materialized_snapshot",
|
||||
};
|
||||
return vec![
|
||||
"stateful_preflight".to_string(),
|
||||
"rpc_simulation".to_string(),
|
||||
"confirmed_signature".to_string(),
|
||||
"stateful_postcondition".to_string(),
|
||||
terminal_evidence.to_string(),
|
||||
];
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#[test]
|
||||
fn journeys_cover_each_stable_operation_exactly_once() {
|
||||
let scenarios = crate::solana_program_metadata_devnet_scenarios();
|
||||
assert_eq!(scenarios.len(), 2);
|
||||
let operations = scenarios
|
||||
.iter()
|
||||
.flat_map(|scenario| return scenario.steps.iter())
|
||||
.map(|step| return step.operation_code.as_str())
|
||||
.collect::<std::vec::Vec<_>>();
|
||||
assert_eq!(operations.len(), ks_lib::EX_METADATA_SPM_SUPPORTED_OPERATION_CODES.len());
|
||||
for operation in ks_lib::EX_METADATA_SPM_SUPPORTED_OPERATION_CODES {
|
||||
assert_eq!(
|
||||
operations.iter().filter(|candidate| return **candidate == *operation).count(),
|
||||
1
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[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 == ks_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() {
|
||||
for step in scenario.steps {
|
||||
assert_eq!(
|
||||
step.requires_explicit_approval,
|
||||
ks_lib::EX_METADATA_SPM_DESTRUCTIVE_OPERATION_CODES
|
||||
.contains(&step.operation_code.as_str())
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
// file: ks-pipeline-demo-scenarios/src/metadata/solana_program/validation.rs
|
||||
// version: 5
|
||||
|
||||
//! Conservative Solana Program Metadata validation status and evidence contracts.
|
||||
|
||||
/// Maximum evidence entries accepted by one operation validation row.
|
||||
pub const MAX_SOLANA_PROGRAM_METADATA_VALIDATION_EVIDENCE: usize = 16;
|
||||
|
||||
/// Exact network validation status.
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum SolanaProgramMetadataValidationStatus {
|
||||
/// No network execution was attempted.
|
||||
NotRun,
|
||||
/// Exact transaction simulated successfully.
|
||||
Simulated,
|
||||
/// Transaction submitted but not yet confirmed.
|
||||
Submitted,
|
||||
/// Transaction confirmed and postconditions validated.
|
||||
Confirmed,
|
||||
/// Required network capability was unavailable.
|
||||
Unavailable,
|
||||
/// Validation failed with retained diagnostics.
|
||||
Failed,
|
||||
}
|
||||
|
||||
/// One bounded evidence record.
|
||||
#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct SolanaProgramMetadataValidationEvidence {
|
||||
/// Stable evidence kind.
|
||||
pub kind: std::string::String,
|
||||
/// Non-secret evidence value.
|
||||
pub value: std::string::String,
|
||||
}
|
||||
|
||||
/// One operation row in the closed Devnet validation matrix.
|
||||
#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct SolanaProgramMetadataValidationOperation {
|
||||
/// Stable operation code.
|
||||
pub operation_code: std::string::String,
|
||||
/// Scenario step that exercises the operation.
|
||||
pub scenario_step_id: std::string::String,
|
||||
/// Exact current validation status.
|
||||
pub status: crate::SolanaProgramMetadataValidationStatus,
|
||||
/// Whether a destructive approval is required.
|
||||
pub requires_explicit_approval: bool,
|
||||
/// Evidence required for confirmed status.
|
||||
pub required_evidence: std::vec::Vec<std::string::String>,
|
||||
/// Bounded observed evidence.
|
||||
#[serde(default)]
|
||||
pub evidence: std::vec::Vec<crate::SolanaProgramMetadataValidationEvidence>,
|
||||
}
|
||||
|
||||
/// Closed validation matrix for all nine stable operations.
|
||||
#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct SolanaProgramMetadataValidationMatrix {
|
||||
/// Matrix schema version.
|
||||
pub matrix_version: u32,
|
||||
/// Exact owning prerelease.
|
||||
pub milestone: std::string::String,
|
||||
/// Ordered stable operations.
|
||||
pub operations: std::vec::Vec<crate::SolanaProgramMetadataValidationOperation>,
|
||||
}
|
||||
|
||||
/// Loads and validates the canonical Solana Program Metadata Devnet matrix.
|
||||
pub fn load_solana_program_metadata_validation_matrix()
|
||||
-> ks_core::Result<crate::SolanaProgramMetadataValidationMatrix> {
|
||||
let parsed = match serde_json::from_str::<crate::SolanaProgramMetadataValidationMatrix>(
|
||||
include_str!(
|
||||
"../../../../test-fixtures/contract-matrices/SOLANA_PROGRAM_METADATA_DEVNET_VALIDATION_MATRIX.json"
|
||||
),
|
||||
) {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => {
|
||||
return std::result::Result::Err(ks_core::Error::new(
|
||||
"solana_program_metadata_validation_matrix_invalid_json",
|
||||
format!("Solana Program Metadata validation matrix is invalid: {error}"),
|
||||
));
|
||||
},
|
||||
};
|
||||
if let std::result::Result::Err(error) =
|
||||
validate_solana_program_metadata_validation_matrix(&parsed)
|
||||
{
|
||||
return std::result::Result::Err(error);
|
||||
}
|
||||
return std::result::Result::Ok(parsed);
|
||||
}
|
||||
|
||||
/// Validates exact inventory, order and conservative evidence claims.
|
||||
pub fn validate_solana_program_metadata_validation_matrix(
|
||||
matrix: &crate::SolanaProgramMetadataValidationMatrix,
|
||||
) -> ks_core::Result<()> {
|
||||
if matrix.matrix_version != 1 || matrix.milestone != "0.4.8-pre.009" {
|
||||
return std::result::Result::Err(ks_core::Error::new(
|
||||
"solana_program_metadata_validation_matrix_contract_mismatch",
|
||||
"Solana Program Metadata validation matrix must use version 1 for 0.4.8-pre.009",
|
||||
));
|
||||
}
|
||||
let compiled_steps = crate::solana_program_metadata_devnet_scenarios()
|
||||
.into_iter()
|
||||
.flat_map(|scenario| return scenario.steps)
|
||||
.collect::<std::vec::Vec<_>>();
|
||||
let expected = compiled_steps
|
||||
.iter()
|
||||
.map(|step| return step.operation_code.as_str())
|
||||
.collect::<std::vec::Vec<_>>();
|
||||
let actual = matrix
|
||||
.operations
|
||||
.iter()
|
||||
.map(|operation| return operation.operation_code.as_str())
|
||||
.collect::<std::vec::Vec<_>>();
|
||||
if actual != expected {
|
||||
return std::result::Result::Err(ks_core::Error::new(
|
||||
"solana_program_metadata_validation_matrix_inventory_mismatch",
|
||||
"Solana Program Metadata validation matrix differs from the compiled journey order",
|
||||
));
|
||||
}
|
||||
for (operation, step) in matrix.operations.iter().zip(compiled_steps.iter()) {
|
||||
if operation.scenario_step_id != step.id
|
||||
|| operation.requires_explicit_approval != step.requires_explicit_approval
|
||||
|| operation.required_evidence != step.required_evidence
|
||||
|| operation.evidence.len() > crate::MAX_SOLANA_PROGRAM_METADATA_VALIDATION_EVIDENCE
|
||||
{
|
||||
return std::result::Result::Err(ks_core::Error::new(
|
||||
"solana_program_metadata_validation_matrix_row_invalid",
|
||||
"Solana Program Metadata validation row differs from the compiled scenario contract",
|
||||
));
|
||||
}
|
||||
if matches!(
|
||||
operation.status,
|
||||
crate::SolanaProgramMetadataValidationStatus::NotRun
|
||||
| crate::SolanaProgramMetadataValidationStatus::Unavailable
|
||||
) && !operation.evidence.is_empty()
|
||||
{
|
||||
return std::result::Result::Err(ks_core::Error::new(
|
||||
"solana_program_metadata_validation_unobserved_with_evidence",
|
||||
"not-run or unavailable validation rows must not contain observed evidence",
|
||||
));
|
||||
}
|
||||
if operation.status == crate::SolanaProgramMetadataValidationStatus::Confirmed {
|
||||
let observed = operation
|
||||
.evidence
|
||||
.iter()
|
||||
.map(|evidence| return evidence.kind.as_str())
|
||||
.collect::<std::collections::BTreeSet<_>>();
|
||||
if !operation
|
||||
.required_evidence
|
||||
.iter()
|
||||
.all(|kind| return observed.contains(kind.as_str()))
|
||||
{
|
||||
return std::result::Result::Err(ks_core::Error::new(
|
||||
"solana_program_metadata_validation_confirmed_without_evidence",
|
||||
"confirmed validation row lacks required evidence",
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
return std::result::Result::Ok(());
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#[test]
|
||||
fn canonical_matrix_matches_the_compiled_two_journey_inventory() {
|
||||
let matrix = match crate::load_solana_program_metadata_validation_matrix() {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => panic!("matrix load failed: {error}"),
|
||||
};
|
||||
assert_eq!(matrix.operations.len(), 9);
|
||||
assert!(matrix.operations.iter().all(|operation| {
|
||||
return operation.status == crate::SolanaProgramMetadataValidationStatus::Confirmed
|
||||
&& operation.evidence.len() == 10;
|
||||
}));
|
||||
}
|
||||
}
|
||||
1433
ks-pipeline-demo-scenarios/src/solana.rs
Normal file
1433
ks-pipeline-demo-scenarios/src/solana.rs
Normal file
File diff suppressed because it is too large
Load Diff
9
ks-pipeline-demo-scenarios/src/spl.rs
Normal file
9
ks-pipeline-demo-scenarios/src/spl.rs
Normal file
@@ -0,0 +1,9 @@
|
||||
// file: ks-pipeline-demo-scenarios/src/spl.rs
|
||||
// version: 2
|
||||
|
||||
//! SPL-oriented reusable demo and Devnet validation scenarios.
|
||||
|
||||
pub(crate) mod associated_token_account;
|
||||
pub(crate) mod memo;
|
||||
pub(crate) mod token;
|
||||
pub(crate) mod token_2022;
|
||||
1208
ks-pipeline-demo-scenarios/src/spl/associated_token_account.rs
Normal file
1208
ks-pipeline-demo-scenarios/src/spl/associated_token_account.rs
Normal file
File diff suppressed because it is too large
Load Diff
908
ks-pipeline-demo-scenarios/src/spl/memo.rs
Normal file
908
ks-pipeline-demo-scenarios/src/spl/memo.rs
Normal file
@@ -0,0 +1,908 @@
|
||||
// file: ks-pipeline-demo-scenarios/src/spl/memo.rs
|
||||
// version: 7
|
||||
|
||||
//! Devnet SPL Memo v4 execution with canonical post-validation.
|
||||
|
||||
/// Complete request for one SPL Memo v4 Devnet execution.
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub struct DevnetMemoExecutionRequest {
|
||||
/// Stable caller-provided execution identifier.
|
||||
pub intent_id: std::string::String,
|
||||
/// Endpoint role used for cluster, balance, blockhash, fee and hydration calls.
|
||||
pub query_role: std::string::String,
|
||||
/// Endpoint role used for simulation, submission and confirmation polling.
|
||||
pub transaction_role: std::string::String,
|
||||
/// Exact UTF-8 Memo payload.
|
||||
pub message: std::string::String,
|
||||
/// Supplies the persistent Devnet fee payer as a Memo signer account.
|
||||
pub include_wallet_as_memo_signer: bool,
|
||||
/// Explicitly authorizes signing and submission after successful simulation.
|
||||
pub submit: bool,
|
||||
/// Explicit operator confirmation required by the active profile.
|
||||
pub operator_confirmed: bool,
|
||||
/// Number of `getTransaction` retries after the first hydration attempt.
|
||||
pub post_validation_max_retries: u32,
|
||||
/// Replaces existing core and decode outputs for the submitted signature.
|
||||
pub force_post_validation_replay: bool,
|
||||
}
|
||||
|
||||
impl DevnetMemoExecutionRequest {
|
||||
/// Creates a conservative simulation-only Memo v4 request.
|
||||
pub fn new(
|
||||
intent_id: impl std::convert::Into<std::string::String>,
|
||||
message: impl std::convert::Into<std::string::String>,
|
||||
) -> Self {
|
||||
return Self {
|
||||
intent_id: intent_id.into(),
|
||||
query_role: "http_queries".to_string(),
|
||||
transaction_role: "http_transactions".to_string(),
|
||||
message: message.into(),
|
||||
include_wallet_as_memo_signer: true,
|
||||
submit: false,
|
||||
operator_confirmed: false,
|
||||
post_validation_max_retries: 10,
|
||||
force_post_validation_replay: false,
|
||||
};
|
||||
}
|
||||
|
||||
/// Validates request-local bounds independently from one profile.
|
||||
pub fn validate(&self) -> ks_core::Result<()> {
|
||||
if self.intent_id.trim().is_empty() {
|
||||
return std::result::Result::Err(ks_core::Error::config(
|
||||
"Devnet Memo execution intent id must not be empty",
|
||||
));
|
||||
}
|
||||
if self.query_role.trim().is_empty() || self.transaction_role.trim().is_empty() {
|
||||
return std::result::Result::Err(ks_core::Error::config(
|
||||
"Devnet Memo execution endpoint roles must not be empty",
|
||||
));
|
||||
}
|
||||
if self.message.len() > ks_lib::EX_SPL_MEMO_MAX_MESSAGE_BYTES {
|
||||
return std::result::Result::Err(ks_core::Error::config(format!(
|
||||
"Devnet Memo payload length {} exceeds the executor limit {}",
|
||||
self.message.len(),
|
||||
ks_lib::EX_SPL_MEMO_MAX_MESSAGE_BYTES
|
||||
)));
|
||||
}
|
||||
if self.post_validation_max_retries > 20 {
|
||||
return std::result::Result::Err(ks_core::Error::config(
|
||||
"post-execution getTransaction retries must not exceed 20",
|
||||
));
|
||||
}
|
||||
return std::result::Result::Ok(());
|
||||
}
|
||||
}
|
||||
|
||||
/// Complete result of one SPL Memo v4 Devnet execution.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct DevnetMemoExecutionSummary {
|
||||
/// Profile used by the orchestration.
|
||||
pub profile_name: std::string::String,
|
||||
/// Exact classified cluster.
|
||||
pub cluster: ks_lib::ExApiExecutionCluster,
|
||||
/// Genesis hash returned by the selected endpoint.
|
||||
pub genesis_hash: std::string::String,
|
||||
/// Non-secret persistent wallet description.
|
||||
pub wallet: ks_wallet::WalletSummary,
|
||||
/// Wallet balance observed before planning.
|
||||
pub balance_lamports: u64,
|
||||
/// Exact prepared Memo plan.
|
||||
pub plan: ks_lib::ExApiPreparedExecutionPlan,
|
||||
/// Recent blockhash used by the exact transaction.
|
||||
pub latest_blockhash: ks_onchain_transport::LatestBlockhashResult,
|
||||
/// Fee estimate for the exact compiled message.
|
||||
pub fee: ks_onchain_transport::FeeForMessageResult,
|
||||
/// Exact simulation result bound to the compiled message.
|
||||
pub simulation: ks_lib::ExApiExecutionSimulationResult,
|
||||
/// Submission result when explicitly authorized.
|
||||
pub send_result: std::option::Option<ks_lib::ExApiExecutionSendResult>,
|
||||
/// Confirmation result when submitted.
|
||||
pub confirmation: std::option::Option<ks_lib::ExApiExecutionConfirmationResult>,
|
||||
/// Canonical hydration result for the exact signature.
|
||||
pub backfill: std::option::Option<ks_pipeline::BackfillSummary>,
|
||||
/// Core extraction result for the exact signature.
|
||||
pub core_extraction: std::option::Option<ks_pipeline::CoreExtractionSummary>,
|
||||
/// First Memo decode and materialization replay.
|
||||
pub decode_replay: std::option::Option<ks_pipeline::DecodeReplaySummary>,
|
||||
/// Second replay proving that the same decoder version and input are idempotent.
|
||||
pub idempotence_replay: std::option::Option<ks_pipeline::DecodeReplaySummary>,
|
||||
/// Exact persisted transaction annotation rows for the submitted signature.
|
||||
pub annotations: std::vec::Vec<ks_store::MaterializedEventQueryRow>,
|
||||
/// Aggregated post-execution validation diagnostic.
|
||||
pub post_execution: std::option::Option<ks_lib::ExApiPostExecutionDiagnostic>,
|
||||
}
|
||||
|
||||
/// Executes one Memo v4 Devnet simulation or explicitly authorized submission.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub async fn execute_devnet_memo<S, O>(
|
||||
http_pool: &ks_onchain_transport::HttpEndpointPool,
|
||||
store: &S,
|
||||
profile: &ks_config::ProfileConfig,
|
||||
workspace_root: &std::path::Path,
|
||||
request: &crate::DevnetMemoExecutionRequest,
|
||||
decoders: &[std::sync::Arc<dyn ks_lib::DcApiInstructionDecoder>],
|
||||
materializers: &[std::sync::Arc<dyn ks_lib::MtApiEventMaterializer>],
|
||||
observer: &O,
|
||||
) -> ks_core::Result<crate::DevnetMemoExecutionSummary>
|
||||
where
|
||||
S: ks_store::RawTransactionStore
|
||||
+ ks_store::CoreExtractionStore
|
||||
+ ks_store::DecodePipelineStore
|
||||
+ Sync,
|
||||
O: crate::SolanaExecutionObserver,
|
||||
{
|
||||
if let std::result::Result::Err(error) = request.validate() {
|
||||
return std::result::Result::Err(error);
|
||||
}
|
||||
if let std::result::Result::Err(error) = validate_profile(profile, request) {
|
||||
return std::result::Result::Err(error);
|
||||
}
|
||||
if let std::result::Result::Err(error) = crate::ensure_not_cancelled(observer, "validate") {
|
||||
return std::result::Result::Err(error);
|
||||
}
|
||||
let genesis = match http_pool.get_genesis_hash_for_role(request.query_role.as_str()).await {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
if genesis.classified_cluster
|
||||
!= std::option::Option::Some(ks_lib::ExApiExecutionCluster::Devnet)
|
||||
{
|
||||
return std::result::Result::Err(ks_core::Error::new(
|
||||
"execution_cluster_mismatch",
|
||||
format!(
|
||||
"expected Devnet genesis hash but endpoint returned {} classified as {:?}",
|
||||
genesis.genesis_hash, genesis.classified_cluster
|
||||
),
|
||||
));
|
||||
}
|
||||
let wallet = match crate::load_profile_wallet(profile, workspace_root).await {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let wallet_summary = wallet.summary();
|
||||
let fee_payer = ks_lib::MdPubkey(wallet_summary.public_key.clone());
|
||||
let balance = match http_pool
|
||||
.get_balance_for_role(
|
||||
request.query_role.as_str(),
|
||||
&fee_payer,
|
||||
&ks_onchain_transport::GetBalanceConfig::confirmed(),
|
||||
)
|
||||
.await
|
||||
{
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
if balance.lamports < profile.execution.max_fee_lamports {
|
||||
return std::result::Result::Err(ks_core::Error::new(
|
||||
"execution_balance_insufficient",
|
||||
format!(
|
||||
"Devnet wallet balance {} is below the configured fee ceiling {}",
|
||||
balance.lamports, profile.execution.max_fee_lamports
|
||||
),
|
||||
));
|
||||
}
|
||||
let plan = match build_plan(profile, request, fee_payer.clone()) {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let plan_evaluation = match ks_lib::ExSafetyChecker.evaluate_prepared_plan(&plan) {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
if plan_evaluation.decision == ks_lib::ExSafetyDecision::Deny {
|
||||
return std::result::Result::Err(ks_core::Error::new(
|
||||
"execution_plan_denied",
|
||||
crate::violation_message(plan_evaluation.violations.as_slice()),
|
||||
));
|
||||
}
|
||||
let latest_blockhash = match http_pool
|
||||
.get_latest_blockhash_for_role(
|
||||
request.query_role.as_str(),
|
||||
&ks_onchain_transport::GetLatestBlockhashConfig::confirmed(),
|
||||
)
|
||||
.await
|
||||
{
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let unsigned = match ks_lib::executor_solana_build_legacy_transaction(
|
||||
&plan,
|
||||
latest_blockhash.blockhash.as_str(),
|
||||
) {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let message_base64 = unsigned.message_base64();
|
||||
let fee = match http_pool
|
||||
.get_fee_for_message_for_role(
|
||||
request.query_role.as_str(),
|
||||
message_base64.as_str(),
|
||||
&ks_onchain_transport::GetFeeForMessageConfig::new(
|
||||
ks_onchain_transport::RpcCommitmentLevel::Confirmed,
|
||||
std::option::Option::Some(latest_blockhash.context.slot),
|
||||
),
|
||||
)
|
||||
.await
|
||||
{
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
if fee.fee_lamports.is_none() {
|
||||
return std::result::Result::Err(ks_core::Error::new(
|
||||
"execution_fee_unavailable",
|
||||
"getFeeForMessage returned null for the selected recent blockhash",
|
||||
));
|
||||
}
|
||||
let unsigned_base64 = match unsigned.transaction_base64() {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let simulation_config = match ks_onchain_transport::SimulateTransactionConfig::new(
|
||||
ks_onchain_transport::RpcCommitmentLevel::Confirmed,
|
||||
false,
|
||||
false,
|
||||
std::option::Option::Some(latest_blockhash.context.slot),
|
||||
true,
|
||||
std::option::Option::None,
|
||||
) {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
crate::emit(
|
||||
observer,
|
||||
crate::SolanaExecutionProgressLevel::Info,
|
||||
"memo_simulation",
|
||||
format!("simulating exact Memo message {}", unsigned.message_hash()),
|
||||
std::option::Option::None,
|
||||
);
|
||||
let simulation_rpc = match http_pool
|
||||
.simulate_transaction_for_role(
|
||||
request.transaction_role.as_str(),
|
||||
unsigned_base64.as_str(),
|
||||
&simulation_config,
|
||||
)
|
||||
.await
|
||||
{
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let simulation = simulation_rpc.to_execution_result(
|
||||
ks_lib::ExApiExecutionCluster::Devnet,
|
||||
ks_lib::ExApiExecutionBlockhashKind::Latest,
|
||||
std::option::Option::Some(
|
||||
simulation_rpc.context.slot.saturating_sub(latest_blockhash.context.slot),
|
||||
),
|
||||
std::option::Option::None,
|
||||
std::option::Option::None,
|
||||
std::option::Option::Some(&fee),
|
||||
);
|
||||
let evidence = unsigned.bind_simulation(simulation.clone());
|
||||
let mut summary = crate::DevnetMemoExecutionSummary {
|
||||
profile_name: profile.name.clone(),
|
||||
cluster: ks_lib::ExApiExecutionCluster::Devnet,
|
||||
genesis_hash: genesis.genesis_hash,
|
||||
wallet: wallet_summary,
|
||||
balance_lamports: balance.lamports,
|
||||
plan,
|
||||
latest_blockhash,
|
||||
fee,
|
||||
simulation,
|
||||
send_result: std::option::Option::None,
|
||||
confirmation: std::option::Option::None,
|
||||
backfill: std::option::Option::None,
|
||||
core_extraction: std::option::Option::None,
|
||||
decode_replay: std::option::Option::None,
|
||||
idempotence_replay: std::option::Option::None,
|
||||
annotations: std::vec::Vec::new(),
|
||||
post_execution: std::option::Option::None,
|
||||
};
|
||||
if !request.submit {
|
||||
return std::result::Result::Ok(summary);
|
||||
}
|
||||
if !summary.simulation.success {
|
||||
return std::result::Result::Err(ks_core::Error::new(
|
||||
"execution_simulation_failed",
|
||||
crate::simulation_failure_message(&summary.simulation),
|
||||
));
|
||||
}
|
||||
let send_evaluation =
|
||||
match ks_lib::ExSafetyChecker.evaluate_send(&summary.plan, &summary.simulation) {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
if send_evaluation.decision == ks_lib::ExSafetyDecision::Deny {
|
||||
return std::result::Result::Err(ks_core::Error::new(
|
||||
"execution_send_denied",
|
||||
crate::violation_message(send_evaluation.violations.as_slice()),
|
||||
));
|
||||
}
|
||||
let signed = match unsigned.sign_after_simulation(&evidence, &[wallet.as_signer()]) {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
if let std::result::Result::Err(error) = signed.verify_signatures() {
|
||||
return std::result::Result::Err(error);
|
||||
}
|
||||
let signature = signed.primary_signature().clone();
|
||||
let mut diagnostic = ks_lib::ExApiPostExecutionDiagnostic {
|
||||
signature: signature.clone(),
|
||||
canonical_inserted: false,
|
||||
core_extracted: false,
|
||||
decode_replayed: false,
|
||||
materialized: false,
|
||||
diagnostics: std::vec::Vec::new(),
|
||||
};
|
||||
let send_config = match ks_onchain_transport::SendTransactionConfig::from_execution_config(
|
||||
&profile.execution,
|
||||
std::option::Option::Some(summary.latest_blockhash.context.slot),
|
||||
) {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let signed_base64 = signed.transaction_base64();
|
||||
let sent = match http_pool
|
||||
.send_transaction_for_role(
|
||||
request.transaction_role.as_str(),
|
||||
signed_base64.as_str(),
|
||||
&signature,
|
||||
&send_config,
|
||||
)
|
||||
.await
|
||||
{
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => {
|
||||
diagnostic.diagnostics.push(format!("Memo submission failed: {error}"));
|
||||
summary.post_execution = std::option::Option::Some(diagnostic);
|
||||
return std::result::Result::Ok(summary);
|
||||
},
|
||||
};
|
||||
summary.send_result =
|
||||
std::option::Option::Some(sent.to_execution_result(ks_lib::ExApiExecutionCluster::Devnet));
|
||||
let confirmation_config =
|
||||
match ks_onchain_transport::ConfirmTransactionConfig::from_execution_config(
|
||||
&profile.execution,
|
||||
std::option::Option::Some(summary.latest_blockhash.last_valid_block_height),
|
||||
std::option::Option::Some(summary.latest_blockhash.context.slot),
|
||||
) {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let confirmation = match http_pool
|
||||
.confirm_transaction_for_roles(
|
||||
request.transaction_role.as_str(),
|
||||
request.query_role.as_str(),
|
||||
ks_lib::ExApiExecutionCluster::Devnet,
|
||||
&signature,
|
||||
&confirmation_config,
|
||||
)
|
||||
.await
|
||||
{
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => {
|
||||
diagnostic.diagnostics.push(format!("Memo confirmation failed: {error}"));
|
||||
summary.post_execution = std::option::Option::Some(diagnostic);
|
||||
return std::result::Result::Ok(summary);
|
||||
},
|
||||
};
|
||||
let confirmation_status = confirmation.status;
|
||||
summary.confirmation = std::option::Option::Some(confirmation);
|
||||
if !matches!(
|
||||
confirmation_status,
|
||||
ks_lib::ExApiExecutionConfirmationStatus::Confirmed
|
||||
| ks_lib::ExApiExecutionConfirmationStatus::Finalized
|
||||
) {
|
||||
diagnostic.diagnostics.push(format!(
|
||||
"Memo post-validation stopped at confirmation status {confirmation_status:?}"
|
||||
));
|
||||
summary.post_execution = std::option::Option::Some(diagnostic);
|
||||
return std::result::Result::Ok(summary);
|
||||
}
|
||||
let backfill =
|
||||
match hydrate_signature(http_pool, store, profile, request, observer, &signature).await {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => {
|
||||
diagnostic.diagnostics.push(format!("Memo hydration failed: {error}"));
|
||||
summary.post_execution = std::option::Option::Some(diagnostic);
|
||||
return std::result::Result::Ok(summary);
|
||||
},
|
||||
};
|
||||
diagnostic.canonical_inserted = canonical_available(&backfill);
|
||||
summary.backfill = std::option::Option::Some(backfill);
|
||||
if !diagnostic.canonical_inserted {
|
||||
diagnostic
|
||||
.diagnostics
|
||||
.push("confirmed Memo transaction was unavailable for canonical hydration".to_string());
|
||||
summary.post_execution = std::option::Option::Some(diagnostic);
|
||||
return std::result::Result::Ok(summary);
|
||||
}
|
||||
let extraction = match ks_pipeline::execute_core_extraction(
|
||||
store,
|
||||
&ks_pipeline::CoreExtractionRequest {
|
||||
source: ks_pipeline::CoreExtractionSource::Signatures(std::vec![signature.0.clone()]),
|
||||
limit: 1,
|
||||
max_concurrent_extractions: 1,
|
||||
force_replay: request.force_post_validation_replay,
|
||||
},
|
||||
observer,
|
||||
)
|
||||
.await
|
||||
{
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => {
|
||||
diagnostic.diagnostics.push(format!("Memo core extraction failed: {error}"));
|
||||
summary.post_execution = std::option::Option::Some(diagnostic);
|
||||
return std::result::Result::Ok(summary);
|
||||
},
|
||||
};
|
||||
diagnostic.core_extracted = extraction.failed == 0
|
||||
&& !extraction.cancelled
|
||||
&& extraction.selected == 1
|
||||
&& extraction.extracted.saturating_add(extraction.skipped) >= 1;
|
||||
summary.core_extraction = std::option::Option::Some(extraction);
|
||||
if !diagnostic.core_extracted {
|
||||
diagnostic.diagnostics.push("Memo core extraction did not complete".to_string());
|
||||
summary.post_execution = std::option::Option::Some(diagnostic);
|
||||
return std::result::Result::Ok(summary);
|
||||
}
|
||||
let first_replay =
|
||||
match replay_memo(store, request, &signature, false, decoders, materializers, observer)
|
||||
.await
|
||||
{
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => {
|
||||
diagnostic.diagnostics.push(format!("Memo decode replay failed: {error}"));
|
||||
summary.post_execution = std::option::Option::Some(diagnostic);
|
||||
return std::result::Result::Ok(summary);
|
||||
},
|
||||
};
|
||||
diagnostic.decode_replayed = decode_completed(&first_replay);
|
||||
summary.decode_replay = std::option::Option::Some(first_replay);
|
||||
let filter = match ks_store::MaterializedEventFilter::new(
|
||||
std::option::Option::Some("materializer.transaction.annotations".to_string()),
|
||||
std::option::Option::Some("transaction_annotation".to_string()),
|
||||
std::option::Option::Some(signature.0.clone()),
|
||||
8,
|
||||
) {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
summary.annotations =
|
||||
match ks_store::DecodePipelineStore::list_materialized_events(store, &filter).await {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => {
|
||||
diagnostic.diagnostics.push(format!("Memo annotation query failed: {error}"));
|
||||
summary.post_execution = std::option::Option::Some(diagnostic);
|
||||
return std::result::Result::Ok(summary);
|
||||
},
|
||||
};
|
||||
diagnostic.materialized = diagnostic.decode_replayed
|
||||
&& summary
|
||||
.annotations
|
||||
.iter()
|
||||
.any(|row| return row.signature.as_str() == signature.0.as_str());
|
||||
let second_replay = match replay_memo(
|
||||
store,
|
||||
request,
|
||||
&signature,
|
||||
true,
|
||||
decoders,
|
||||
materializers,
|
||||
observer,
|
||||
)
|
||||
.await
|
||||
{
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => {
|
||||
diagnostic.diagnostics.push(format!("Memo idempotence replay failed: {error}"));
|
||||
summary.post_execution = std::option::Option::Some(diagnostic);
|
||||
return std::result::Result::Ok(summary);
|
||||
},
|
||||
};
|
||||
let idempotent = second_replay.failed_inputs == 0
|
||||
&& second_replay.processing_error_inputs == 0
|
||||
&& second_replay.processors.iter().all(|processor| {
|
||||
return processor.failed == 0
|
||||
&& processor.processing_errors == 0
|
||||
&& processor.materialized_outputs == 0
|
||||
&& processor.materialization_refused == 0;
|
||||
});
|
||||
summary.idempotence_replay = std::option::Option::Some(second_replay);
|
||||
if !idempotent {
|
||||
diagnostic
|
||||
.diagnostics
|
||||
.push("second Memo replay did not prove a clean idempotent skip".to_string());
|
||||
} else if diagnostic.canonical_inserted
|
||||
&& diagnostic.core_extracted
|
||||
&& diagnostic.decode_replayed
|
||||
&& diagnostic.materialized
|
||||
{
|
||||
diagnostic.diagnostics.push(
|
||||
"Memo completed canonical hydration, core extraction, decode, annotation projection and idempotence validation"
|
||||
.to_string(),
|
||||
);
|
||||
}
|
||||
summary.post_execution = std::option::Option::Some(diagnostic);
|
||||
return std::result::Result::Ok(summary);
|
||||
}
|
||||
|
||||
fn validate_profile(
|
||||
profile: &ks_config::ProfileConfig,
|
||||
request: &crate::DevnetMemoExecutionRequest,
|
||||
) -> ks_core::Result<()> {
|
||||
if profile.wallet.cluster != "devnet" {
|
||||
return std::result::Result::Err(ks_core::Error::config(
|
||||
"Memo Devnet orchestration requires a Devnet wallet profile",
|
||||
));
|
||||
}
|
||||
if !profile.wallet.temporary_wallet_enabled || !profile.wallet.temporary_wallet_persist {
|
||||
return std::result::Result::Err(ks_core::Error::config(
|
||||
"Memo Devnet orchestration requires an enabled persistent temporary wallet",
|
||||
));
|
||||
}
|
||||
if !profile.execution.require_simulation {
|
||||
return std::result::Result::Err(ks_core::Error::config(
|
||||
"Memo Devnet orchestration requires simulation",
|
||||
));
|
||||
}
|
||||
if request.submit && !profile.wallet.devnet_send_enabled {
|
||||
return std::result::Result::Err(ks_core::Error::config(
|
||||
"Devnet transaction submission is disabled by the wallet profile",
|
||||
));
|
||||
}
|
||||
if request.submit
|
||||
&& profile.execution.require_operator_confirmation
|
||||
&& !request.operator_confirmed
|
||||
{
|
||||
return std::result::Result::Err(ks_core::Error::config(
|
||||
"Memo Devnet submission requires explicit operator confirmation",
|
||||
));
|
||||
}
|
||||
return std::result::Result::Ok(());
|
||||
}
|
||||
|
||||
fn build_plan(
|
||||
profile: &ks_config::ProfileConfig,
|
||||
request: &crate::DevnetMemoExecutionRequest,
|
||||
fee_payer: ks_lib::MdPubkey,
|
||||
) -> ks_core::Result<ks_lib::ExApiPreparedExecutionPlan> {
|
||||
let signers = if request.include_wallet_as_memo_signer {
|
||||
std::vec![ks_lib::ExSplMemoSigner { pubkey: fee_payer.clone() }]
|
||||
} else {
|
||||
std::vec::Vec::new()
|
||||
};
|
||||
let intent = ks_lib::ExSplMemoExecutionIntent {
|
||||
intent_id: request.intent_id.clone(),
|
||||
fee_payer: fee_payer.clone(),
|
||||
policy: ks_lib::ExApiExecutionPolicy {
|
||||
cluster: ks_lib::ExApiExecutionClusterPolicy {
|
||||
expected_cluster: ks_lib::ExApiExecutionCluster::Devnet,
|
||||
allow_mainnet: false,
|
||||
mainnet_confirmation: false,
|
||||
},
|
||||
simulation: ks_lib::ExApiExecutionSimulationPolicy::Required,
|
||||
blockhash: ks_lib::ExApiExecutionBlockhashPolicy {
|
||||
kind: ks_lib::ExApiExecutionBlockhashKind::Latest,
|
||||
max_age_slots: std::option::Option::Some(
|
||||
profile.execution.recent_blockhash_max_age_slots,
|
||||
),
|
||||
nonce_account: std::option::Option::None,
|
||||
nonce_authority: std::option::Option::None,
|
||||
},
|
||||
cost_limit: ks_lib::ExApiExecutionCostLimit {
|
||||
max_spend_lamports: std::option::Option::Some(0),
|
||||
max_fee_lamports: std::option::Option::Some(profile.execution.max_fee_lamports),
|
||||
max_compute_unit_price_micro_lamports: std::option::Option::Some(
|
||||
profile.execution.max_compute_unit_price_micro_lamports,
|
||||
),
|
||||
},
|
||||
authorized_signers: std::vec![fee_payer.clone()],
|
||||
dry_run: !request.submit,
|
||||
post_execution_validation: ks_lib::ExApiPostExecutionValidationPolicy {
|
||||
canonical_insert_required: true,
|
||||
core_extraction_required: true,
|
||||
decode_replay_required: true,
|
||||
materialization_required: true,
|
||||
},
|
||||
},
|
||||
operation: ks_lib::ExSplMemoOperation::AddMemo {
|
||||
generation: ks_lib::ExSplMemoGeneration::V4,
|
||||
message: request.message.clone(),
|
||||
signers,
|
||||
},
|
||||
};
|
||||
return ks_lib::ExApiTypedInstructionExecutor::build_prepared_plan(
|
||||
&ks_lib::ExSplMemoExecutor,
|
||||
&intent,
|
||||
);
|
||||
}
|
||||
|
||||
async fn hydrate_signature<S, O>(
|
||||
http_pool: &ks_onchain_transport::HttpEndpointPool,
|
||||
store: &S,
|
||||
profile: &ks_config::ProfileConfig,
|
||||
request: &crate::DevnetMemoExecutionRequest,
|
||||
observer: &O,
|
||||
signature: &ks_lib::MdSignature,
|
||||
) -> ks_core::Result<ks_pipeline::BackfillSummary>
|
||||
where
|
||||
S: ks_store::RawTransactionStore + Sync,
|
||||
O: crate::SolanaExecutionObserver,
|
||||
{
|
||||
let mut retry = 0_u32;
|
||||
loop {
|
||||
let result = match ks_pipeline::execute_http_backfill(
|
||||
http_pool,
|
||||
store,
|
||||
&ks_pipeline::BackfillRequest {
|
||||
role: request.query_role.clone(),
|
||||
commitment: "confirmed".to_string(),
|
||||
source: ks_pipeline::BackfillSource::ExplicitSignatures(std::vec![
|
||||
signature.0.clone()
|
||||
]),
|
||||
page_size: 1,
|
||||
max_pages: 1,
|
||||
max_concurrent_requests: 1,
|
||||
max_retries: 0,
|
||||
},
|
||||
observer,
|
||||
)
|
||||
.await
|
||||
{
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
if canonical_available(&result)
|
||||
|| retry >= request.post_validation_max_retries
|
||||
|| observer.is_execution_cancelled()
|
||||
{
|
||||
return std::result::Result::Ok(result);
|
||||
}
|
||||
retry = retry.saturating_add(1);
|
||||
tokio::time::sleep(std::time::Duration::from_millis(std::cmp::max(
|
||||
profile.execution.confirmation_poll_interval_ms,
|
||||
500,
|
||||
)))
|
||||
.await;
|
||||
}
|
||||
}
|
||||
|
||||
fn canonical_available(summary: &ks_pipeline::BackfillSummary) -> bool {
|
||||
return summary.failed == 0
|
||||
&& summary.missing == 0
|
||||
&& summary.candidates_completed == 1
|
||||
&& summary.candidates_cancelled == 0
|
||||
&& summary.candidates_not_started == 0
|
||||
&& summary
|
||||
.canonical_inserted
|
||||
.saturating_add(summary.canonical_skipped)
|
||||
.saturating_add(summary.existing_skipped)
|
||||
>= 1;
|
||||
}
|
||||
|
||||
async fn replay_memo<S, O>(
|
||||
store: &S,
|
||||
request: &crate::DevnetMemoExecutionRequest,
|
||||
signature: &ks_lib::MdSignature,
|
||||
include_materialized_state: bool,
|
||||
decoders: &[std::sync::Arc<dyn ks_lib::DcApiInstructionDecoder>],
|
||||
materializers: &[std::sync::Arc<dyn ks_lib::MtApiEventMaterializer>],
|
||||
observer: &O,
|
||||
) -> ks_core::Result<ks_pipeline::DecodeReplaySummary>
|
||||
where
|
||||
S: ks_store::DecodePipelineStore + Sync,
|
||||
O: crate::SolanaExecutionObserver,
|
||||
{
|
||||
let mut states = std::vec![
|
||||
ks_store::CoreInstructionProcessingState::Pending,
|
||||
ks_store::CoreInstructionProcessingState::Failed,
|
||||
ks_store::CoreInstructionProcessingState::ReplayRequested,
|
||||
];
|
||||
if include_materialized_state {
|
||||
states.push(ks_store::CoreInstructionProcessingState::Materialized);
|
||||
}
|
||||
let selection = match ks_store::DecodeSelectionFilter::new(
|
||||
std::vec![signature.0.clone()],
|
||||
states,
|
||||
std::option::Option::None,
|
||||
std::option::Option::None,
|
||||
std::vec![ks_program_ids::SPL_MEMO_V4_PROGRAM_ID.to_string()],
|
||||
std::vec::Vec::new(),
|
||||
false,
|
||||
8,
|
||||
) {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
return ks_pipeline::execute_decode_replay(
|
||||
store,
|
||||
&ks_pipeline::DecodeReplayRequest {
|
||||
campaign_id: ks_pipeline::new_decode_campaign_id(),
|
||||
selection,
|
||||
decoder_names: std::vec::Vec::new(),
|
||||
dispatch_policy: ks_pipeline::DecodeDispatchPolicy::HighestPriority,
|
||||
max_concurrent_inputs: 1,
|
||||
force_replay: if include_materialized_state {
|
||||
false
|
||||
} else {
|
||||
request.force_post_validation_replay
|
||||
},
|
||||
force_replay_all_matching: false,
|
||||
materialize_after_decode: true,
|
||||
},
|
||||
decoders,
|
||||
materializers,
|
||||
observer,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
fn decode_completed(summary: &ks_pipeline::DecodeReplaySummary) -> bool {
|
||||
return summary.failed_inputs == 0
|
||||
&& summary.processing_error_inputs == 0
|
||||
&& summary.unmatched == 0
|
||||
&& !summary.cancelled
|
||||
&& summary.completed >= 1
|
||||
&& summary.processors.iter().all(|processor| {
|
||||
return processor.failed == 0
|
||||
&& processor.processing_errors == 0
|
||||
&& processor.unsupported == 0
|
||||
&& processor.materialization_refused == 0;
|
||||
})
|
||||
&& summary.processors.iter().map(|processor| return processor.decoded).sum::<u64>() >= 1;
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
fn example_devnet_profile() -> ks_config::ProfileConfig {
|
||||
let config =
|
||||
match ks_config::parse_config_json(include_str!("../../../config/example.config.json"))
|
||||
{
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => panic!("example config parse failed: {error}"),
|
||||
};
|
||||
return match crate::resolve_demo_devnet_profile(&config, std::option::Option::None) {
|
||||
std::result::Result::Ok(profile) => profile,
|
||||
std::result::Result::Err(error) => panic!("Devnet profile resolution failed: {error}"),
|
||||
};
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn request_is_simulation_only_and_bounded_by_default() {
|
||||
let request = crate::DevnetMemoExecutionRequest::new("memo-1", "audit annotation");
|
||||
assert!(!request.submit);
|
||||
assert!(request.include_wallet_as_memo_signer);
|
||||
assert!(request.validate().is_ok());
|
||||
let oversized = "x".repeat(ks_lib::EX_SPL_MEMO_MAX_MESSAGE_BYTES + 1);
|
||||
assert!(crate::DevnetMemoExecutionRequest::new("memo-2", oversized).validate().is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exact_v4_plan_has_zero_spend_and_wallet_signer() {
|
||||
let profile = example_devnet_profile();
|
||||
let fee_payer = ks_lib::MdPubkey(ks_program_ids::SYSTEM_PROGRAM_ID.to_string());
|
||||
let mut request = crate::DevnetMemoExecutionRequest::new("memo-3", "hello");
|
||||
request.submit = true;
|
||||
request.operator_confirmed = true;
|
||||
let plan = match super::build_plan(&profile, &request, fee_payer.clone()) {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => panic!("Memo plan failed: {error}"),
|
||||
};
|
||||
assert_eq!(plan.requested_spend_lamports, 0);
|
||||
assert_eq!(plan.fee_payer, fee_payer);
|
||||
assert_eq!(plan.instructions.len(), 1);
|
||||
assert_eq!(plan.instructions[0].program_id.0, ks_program_ids::SPL_MEMO_V4_PROGRAM_ID);
|
||||
assert_eq!(plan.instructions[0].accounts.len(), 1);
|
||||
assert!(plan.instructions[0].accounts[0].is_signer);
|
||||
assert!(!plan.instructions[0].accounts[0].is_writable);
|
||||
assert!(ks_lib::ExSafetyChecker.evaluate_prepared_plan(&plan).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn submission_requires_profile_enablement_and_confirmation() {
|
||||
let profile = example_devnet_profile();
|
||||
let mut request = crate::DevnetMemoExecutionRequest::new("memo-4", "hello");
|
||||
assert!(super::validate_profile(&profile, &request).is_ok());
|
||||
request.submit = true;
|
||||
assert!(super::validate_profile(&profile, &request).is_err());
|
||||
request.operator_confirmed = true;
|
||||
assert!(super::validate_profile(&profile, &request).is_ok());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn optional_devnet_memo_execution_from_env() {
|
||||
if std::env::var("KB_DEVNET_MEMO_EXECUTION_TEST").ok().as_deref()
|
||||
!= std::option::Option::Some("1")
|
||||
{
|
||||
return;
|
||||
}
|
||||
let database_url = match std::env::var("KB_POSTGRES_TEST_URL") {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => {
|
||||
panic!("KB_POSTGRES_TEST_URL is required: {error}");
|
||||
},
|
||||
};
|
||||
let mut profile = example_devnet_profile();
|
||||
profile.database.backend = "postgres".to_string();
|
||||
profile.database.postgres.url = database_url;
|
||||
if let std::result::Result::Ok(directory) = std::env::var("KB_DEVNET_WALLET_DIR") {
|
||||
profile.wallet.wallet_dir = directory;
|
||||
}
|
||||
let pool = match ks_onchain_transport::HttpEndpointPool::from_profile(&profile) {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => panic!("HTTP pool creation failed: {error}"),
|
||||
};
|
||||
let store_options = match ks_store::PostgresStoreOptions::new(
|
||||
profile.database.postgres.url.clone(),
|
||||
profile.database.postgres.max_connections,
|
||||
profile.database.postgres.connect_timeout_ms,
|
||||
false,
|
||||
) {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => panic!("PostgreSQL options failed: {error}"),
|
||||
};
|
||||
let store = match ks_store::PostgresStore::connect(store_options).await {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => panic!("PostgreSQL connection failed: {error}"),
|
||||
};
|
||||
if let std::result::Result::Err(error) = store.initialize_store_schema().await {
|
||||
panic!("PostgreSQL schema initialization failed: {error}");
|
||||
}
|
||||
let mut request = crate::DevnetMemoExecutionRequest::new(
|
||||
format!("devnet-memo-test-{}", uuid::Uuid::new_v4()),
|
||||
format!("khadhroony-bot3 memo validation {}", uuid::Uuid::new_v4()),
|
||||
);
|
||||
request.post_validation_max_retries = 20;
|
||||
if std::env::var("KB_DEVNET_MEMO_SUBMIT").ok().as_deref() == std::option::Option::Some("1")
|
||||
{
|
||||
request.submit = true;
|
||||
request.operator_confirmed = true;
|
||||
}
|
||||
let decoders: std::vec::Vec<std::sync::Arc<dyn ks_lib::DcApiInstructionDecoder>> =
|
||||
std::vec![std::sync::Arc::new(ks_lib::DcSplMemoDecoder)];
|
||||
let materializers: std::vec::Vec<std::sync::Arc<dyn ks_lib::MtApiEventMaterializer>> =
|
||||
std::vec![std::sync::Arc::new(ks_lib::MtTransactionAnnotationMaterializer,)];
|
||||
let workspace_root = match std::path::Path::new(env!("CARGO_MANIFEST_DIR")).parent() {
|
||||
std::option::Option::Some(value) => value,
|
||||
std::option::Option::None => panic!("workspace root cannot be resolved"),
|
||||
};
|
||||
let summary = match crate::execute_devnet_memo(
|
||||
&pool,
|
||||
&store,
|
||||
&profile,
|
||||
workspace_root,
|
||||
&request,
|
||||
decoders.as_slice(),
|
||||
materializers.as_slice(),
|
||||
&crate::NoopSolanaExecutionObserver,
|
||||
)
|
||||
.await
|
||||
{
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => panic!("Devnet Memo execution failed: {error}"),
|
||||
};
|
||||
assert!(summary.simulation.success);
|
||||
if request.submit {
|
||||
let post_execution = match summary.post_execution {
|
||||
std::option::Option::Some(value) => value,
|
||||
std::option::Option::None => panic!("Memo post-execution diagnostic missing"),
|
||||
};
|
||||
assert!(post_execution.canonical_inserted);
|
||||
assert!(post_execution.core_extracted);
|
||||
assert!(post_execution.decode_replayed);
|
||||
assert!(post_execution.materialized);
|
||||
assert!(!summary.annotations.is_empty());
|
||||
let idempotence = match summary.idempotence_replay {
|
||||
std::option::Option::Some(value) => value,
|
||||
std::option::Option::None => panic!("Memo idempotence replay missing"),
|
||||
};
|
||||
assert_eq!(
|
||||
idempotence
|
||||
.processors
|
||||
.iter()
|
||||
.map(|processor| return processor.materialized_outputs)
|
||||
.sum::<u64>(),
|
||||
0
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
7
ks-pipeline-demo-scenarios/src/spl/token.rs
Normal file
7
ks-pipeline-demo-scenarios/src/spl/token.rs
Normal file
@@ -0,0 +1,7 @@
|
||||
// file: ks-pipeline-demo-scenarios/src/spl/token.rs
|
||||
// version: 2
|
||||
|
||||
//! Classic SPL Token demo and Devnet validation scenarios.
|
||||
|
||||
pub(crate) mod execution;
|
||||
pub(crate) mod lifecycle;
|
||||
1354
ks-pipeline-demo-scenarios/src/spl/token/execution.rs
Normal file
1354
ks-pipeline-demo-scenarios/src/spl/token/execution.rs
Normal file
File diff suppressed because it is too large
Load Diff
1742
ks-pipeline-demo-scenarios/src/spl/token/lifecycle.rs
Normal file
1742
ks-pipeline-demo-scenarios/src/spl/token/lifecycle.rs
Normal file
File diff suppressed because it is too large
Load Diff
10
ks-pipeline-demo-scenarios/src/spl/token_2022.rs
Normal file
10
ks-pipeline-demo-scenarios/src/spl/token_2022.rs
Normal file
@@ -0,0 +1,10 @@
|
||||
// file: ks-pipeline-demo-scenarios/src/spl/token_2022.rs
|
||||
// version: 2
|
||||
|
||||
//! SPL Token-2022 demo and Devnet validation scenarios.
|
||||
|
||||
pub(crate) mod devnet_execution;
|
||||
pub(crate) mod devnet_scenarios;
|
||||
pub(crate) mod fixture;
|
||||
pub(crate) mod metadata;
|
||||
pub(crate) mod validation;
|
||||
1045
ks-pipeline-demo-scenarios/src/spl/token_2022/devnet_execution.rs
Normal file
1045
ks-pipeline-demo-scenarios/src/spl/token_2022/devnet_execution.rs
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,180 @@
|
||||
// file: ks-pipeline-demo-scenarios/src/spl/token_2022/devnet_scenarios.rs
|
||||
// version: 9
|
||||
|
||||
//! Stable Devnet validation scenarios required to close milestone 0.4.6.
|
||||
|
||||
/// Stable category of one independent Devnet validation scenario.
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum DevnetSplValidationFamily {
|
||||
/// Public Token-2022 state mutations without cryptographic proofs.
|
||||
Token2022Public,
|
||||
/// ElGamal Registry lifecycle kept technically separate from Token-2022.
|
||||
ElGamalRegistry,
|
||||
/// Token-2022 confidential operations requiring proof material.
|
||||
Token2022Confidential,
|
||||
}
|
||||
|
||||
/// Stable status of one scenario in the application validation workflow.
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum DevnetSplValidationImplementationStatus {
|
||||
/// The scenario can be simulated and submitted through the application.
|
||||
Executable,
|
||||
/// The typed backend exists but the application execution path remains to be connected.
|
||||
BackendReady,
|
||||
/// The scenario requires externally prepared proof material before execution.
|
||||
ProofFixtureRequired,
|
||||
}
|
||||
|
||||
/// One independent Devnet validation scenario exposed to applications.
|
||||
#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
|
||||
pub struct DevnetSplValidationScenario {
|
||||
/// Stable identifier used by scripts, tests and the frontend.
|
||||
pub id: std::string::String,
|
||||
/// Operator-visible label.
|
||||
pub label: std::string::String,
|
||||
/// Scenario family.
|
||||
pub family: crate::DevnetSplValidationFamily,
|
||||
/// Stable executor operation code.
|
||||
pub operation_code: std::string::String,
|
||||
/// Current implementation status.
|
||||
pub implementation_status: crate::DevnetSplValidationImplementationStatus,
|
||||
/// Whether this scenario mutates on-chain state.
|
||||
pub destructive: bool,
|
||||
/// Whether explicit operator confirmation is mandatory before submission.
|
||||
pub operator_confirmation_required: bool,
|
||||
/// Whether cryptographic proof material is required.
|
||||
pub proof_required: bool,
|
||||
/// Ordered fixture variables required by the scenario.
|
||||
pub required_fixture_variables: std::vec::Vec<std::string::String>,
|
||||
}
|
||||
|
||||
/// Returns the complete ordered Devnet scenario inventory for milestone 0.4.6.
|
||||
pub fn devnet_spl_validation_scenarios() -> std::vec::Vec<crate::DevnetSplValidationScenario> {
|
||||
return vec![
|
||||
public_scenario("token_2022_mint_to_checked", "Token-2022 MintToChecked", ks_lib::EX_SPL_TOKEN_2022_MINT_TO_CHECKED_OPERATION, &["TOKEN_2022_MINT", "TOKEN_2022_SOURCE", "TOKEN_2022_AUTHORITY", "TOKEN_2022_DECIMALS", "TOKEN_2022_MINT_AMOUNT_RAW"]),
|
||||
public_scenario("token_2022_transfer_checked", "Token-2022 TransferChecked", ks_lib::EX_SPL_TOKEN_2022_TRANSFER_CHECKED_OPERATION, &["TOKEN_2022_SOURCE", "TOKEN_2022_MINT", "TOKEN_2022_DESTINATION", "TOKEN_2022_AUTHORITY", "TOKEN_2022_DECIMALS", "TOKEN_2022_TRANSFER_AMOUNT_RAW"]),
|
||||
public_scenario("token_2022_approve_checked", "Token-2022 ApproveChecked", ks_lib::EX_SPL_TOKEN_2022_APPROVE_CHECKED_OPERATION, &["TOKEN_2022_SOURCE", "TOKEN_2022_MINT", "TOKEN_2022_DELEGATE", "TOKEN_2022_AUTHORITY", "TOKEN_2022_DECIMALS", "TOKEN_2022_APPROVE_AMOUNT_RAW"]),
|
||||
public_scenario("token_2022_revoke", "Token-2022 Revoke", ks_lib::EX_SPL_TOKEN_2022_REVOKE_OPERATION, &["TOKEN_2022_SOURCE", "TOKEN_2022_AUTHORITY"]),
|
||||
public_scenario("token_2022_burn_checked", "Token-2022 BurnChecked", ks_lib::EX_SPL_TOKEN_2022_BURN_CHECKED_OPERATION, &["TOKEN_2022_SOURCE", "TOKEN_2022_MINT", "TOKEN_2022_AUTHORITY", "TOKEN_2022_DECIMALS", "TOKEN_2022_BURN_AMOUNT_RAW"]),
|
||||
public_scenario("token_2022_freeze_account", "Token-2022 FreezeAccount", ks_lib::EX_SPL_TOKEN_2022_FREEZE_ACCOUNT_OPERATION, &["TOKEN_2022_SOURCE", "TOKEN_2022_MINT", "TOKEN_2022_FREEZE_AUTHORITY"]),
|
||||
public_scenario("token_2022_thaw_account", "Token-2022 ThawAccount", ks_lib::EX_SPL_TOKEN_2022_THAW_ACCOUNT_OPERATION, &["TOKEN_2022_SOURCE", "TOKEN_2022_MINT", "TOKEN_2022_FREEZE_AUTHORITY"]),
|
||||
public_scenario("token_2022_close_destination", "Token-2022 CloseAccount destination", ks_lib::EX_SPL_TOKEN_2022_CLOSE_ACCOUNT_OPERATION, &["TOKEN_2022_DESTINATION", "KB_DEVNET_WALLET_ADDRESS", "TOKEN_2022_AUTHORITY"]),
|
||||
registry_scenario("elgamal_registry_create", "ElGamal Registry CreateRegistry", "spl.elgamal_registry.create_registry", &["ELGAMAL_REGISTRY_ADDRESS", "ELGAMAL_PUBKEY_BASE64", "PUBKEY_VALIDITY_PROOF_CONTEXT"]),
|
||||
registry_scenario("elgamal_registry_update", "ElGamal Registry UpdateRegistry", "spl.elgamal_registry.update_registry", &["ELGAMAL_REGISTRY_ADDRESS", "ELGAMAL_PUBKEY_BASE64", "PUBKEY_VALIDITY_PROOF_CONTEXT"]),
|
||||
confidential_scenario("token_2022_configure_confidential_account", "Token-2022 ConfigureAccountWithRegistry", ks_lib::EX_SPL_TOKEN_2022_CONFIGURE_CONFIDENTIAL_TRANSFER_ACCOUNT_WITH_REGISTRY_OPERATION, &["TOKEN_2022_SOURCE", "TOKEN_2022_MINT", "ELGAMAL_REGISTRY_ADDRESS", "PUBKEY_VALIDITY_PROOF_CONTEXT"]),
|
||||
];
|
||||
}
|
||||
|
||||
fn public_scenario(
|
||||
id: &str,
|
||||
label: &str,
|
||||
operation_code: &str,
|
||||
variables: &[&str],
|
||||
) -> crate::DevnetSplValidationScenario {
|
||||
return scenario(
|
||||
id,
|
||||
label,
|
||||
crate::DevnetSplValidationFamily::Token2022Public,
|
||||
operation_code,
|
||||
crate::DevnetSplValidationImplementationStatus::Executable,
|
||||
true,
|
||||
false,
|
||||
variables,
|
||||
);
|
||||
}
|
||||
|
||||
fn registry_scenario(
|
||||
id: &str,
|
||||
label: &str,
|
||||
operation_code: &str,
|
||||
variables: &[&str],
|
||||
) -> crate::DevnetSplValidationScenario {
|
||||
return scenario(
|
||||
id,
|
||||
label,
|
||||
crate::DevnetSplValidationFamily::ElGamalRegistry,
|
||||
operation_code,
|
||||
crate::DevnetSplValidationImplementationStatus::ProofFixtureRequired,
|
||||
true,
|
||||
true,
|
||||
variables,
|
||||
);
|
||||
}
|
||||
|
||||
fn confidential_scenario(
|
||||
id: &str,
|
||||
label: &str,
|
||||
operation_code: &str,
|
||||
variables: &[&str],
|
||||
) -> crate::DevnetSplValidationScenario {
|
||||
return scenario(
|
||||
id,
|
||||
label,
|
||||
crate::DevnetSplValidationFamily::Token2022Confidential,
|
||||
operation_code,
|
||||
crate::DevnetSplValidationImplementationStatus::ProofFixtureRequired,
|
||||
true,
|
||||
true,
|
||||
variables,
|
||||
);
|
||||
}
|
||||
|
||||
fn scenario(
|
||||
id: &str,
|
||||
label: &str,
|
||||
family: crate::DevnetSplValidationFamily,
|
||||
operation_code: &str,
|
||||
implementation_status: crate::DevnetSplValidationImplementationStatus,
|
||||
destructive: bool,
|
||||
proof_required: bool,
|
||||
variables: &[&str],
|
||||
) -> crate::DevnetSplValidationScenario {
|
||||
return crate::DevnetSplValidationScenario {
|
||||
id: id.to_string(),
|
||||
label: label.to_string(),
|
||||
family,
|
||||
operation_code: operation_code.to_string(),
|
||||
implementation_status,
|
||||
destructive,
|
||||
operator_confirmation_required: destructive,
|
||||
proof_required,
|
||||
required_fixture_variables: variables
|
||||
.iter()
|
||||
.map(|value| return (*value).to_string())
|
||||
.collect(),
|
||||
};
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#[test]
|
||||
fn milestone_inventory_is_stable_unique_and_keeps_registry_separate() {
|
||||
let scenarios = crate::devnet_spl_validation_scenarios();
|
||||
assert_eq!(scenarios.len(), 11);
|
||||
let ids = scenarios
|
||||
.iter()
|
||||
.map(|scenario| return scenario.id.as_str())
|
||||
.collect::<std::collections::BTreeSet<&str>>();
|
||||
assert_eq!(ids.len(), scenarios.len());
|
||||
assert!(
|
||||
scenarios.iter().any(|scenario| return scenario.family
|
||||
== crate::DevnetSplValidationFamily::ElGamalRegistry)
|
||||
);
|
||||
assert!(
|
||||
scenarios
|
||||
.iter()
|
||||
.filter(|scenario| return scenario.family
|
||||
== crate::DevnetSplValidationFamily::Token2022Public)
|
||||
.all(|scenario| return !scenario.proof_required)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_mutating_scenario_requires_operator_confirmation() {
|
||||
assert!(crate::devnet_spl_validation_scenarios().iter().all(|scenario| {
|
||||
return !scenario.destructive || scenario.operator_confirmation_required;
|
||||
}));
|
||||
}
|
||||
}
|
||||
462
ks-pipeline-demo-scenarios/src/spl/token_2022/fixture.rs
Normal file
462
ks-pipeline-demo-scenarios/src/spl/token_2022/fixture.rs
Normal file
@@ -0,0 +1,462 @@
|
||||
// file: ks-pipeline-demo-scenarios/src/spl/token_2022/fixture.rs
|
||||
// version: 4
|
||||
|
||||
//! Idempotent Token-2022 public scenario fixture preparation.
|
||||
|
||||
/// Default raw amount minted by the public MintToChecked scenario.
|
||||
pub const DEFAULT_TOKEN_2022_MINT_AMOUNT_RAW: u64 = 10_000_000_000;
|
||||
/// Default raw amount transferred by the public TransferChecked scenario.
|
||||
pub const DEFAULT_TOKEN_2022_TRANSFER_AMOUNT_RAW: u64 = 1_000_000_000;
|
||||
/// Default raw amount delegated by the public ApproveChecked scenario.
|
||||
pub const DEFAULT_TOKEN_2022_APPROVE_AMOUNT_RAW: u64 = 2_000_000_000;
|
||||
/// Default raw amount burned by the public BurnChecked scenario.
|
||||
pub const DEFAULT_TOKEN_2022_BURN_AMOUNT_RAW: u64 = 1_000_000_000;
|
||||
|
||||
macro_rules! ks_try {
|
||||
($expression:expr) => {
|
||||
match $expression {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/// Command-line options for one Token-2022 fixture preparation.
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub struct Token2022FixturePreparationOptions {
|
||||
/// Devnet RPC URL used by Solana CLI commands.
|
||||
pub rpc_url: std::string::String,
|
||||
/// Persistent operator wallet keypair path.
|
||||
pub wallet_path: std::path::PathBuf,
|
||||
/// Wallet directory receiving public fixture files and temporary keypairs.
|
||||
pub wallet_dir: std::path::PathBuf,
|
||||
/// Mint decimals used by the public scenarios.
|
||||
pub decimals: u8,
|
||||
}
|
||||
|
||||
/// Public values written to one Token-2022 fixture file.
|
||||
#[derive(Clone, Debug, Eq, PartialEq, serde::Serialize)]
|
||||
pub struct Token2022FixturePreparationSummary {
|
||||
/// Generated fixture path.
|
||||
pub fixture_path: std::path::PathBuf,
|
||||
/// Canonical Token-2022 Program ID.
|
||||
pub program_id: std::string::String,
|
||||
/// Token-2022 mint address.
|
||||
pub mint: std::string::String,
|
||||
/// Source Token-2022 account address.
|
||||
pub source: std::string::String,
|
||||
/// Destination Token-2022 account address.
|
||||
pub destination: std::string::String,
|
||||
/// Empty Token-2022 account reserved for CloseAccount.
|
||||
pub close_account: std::string::String,
|
||||
/// Delegate public key.
|
||||
pub delegate: std::string::String,
|
||||
/// Wallet authority public key.
|
||||
pub authority: std::string::String,
|
||||
/// Freeze authority public key.
|
||||
pub freeze_authority: std::string::String,
|
||||
/// Mint decimals.
|
||||
pub decimals: u8,
|
||||
/// Whether the fixture file was replaced during this invocation.
|
||||
pub fixture_written: bool,
|
||||
}
|
||||
|
||||
/// Creates or reuses one complete Token-2022 public scenario fixture.
|
||||
pub async fn prepare_token_2022_fixture(
|
||||
options: &crate::Token2022FixturePreparationOptions,
|
||||
) -> ks_core::Result<crate::Token2022FixturePreparationSummary> {
|
||||
ks_try!(validate_options(options));
|
||||
let fixture_dir = options.wallet_dir.join("spl_token_2022_validation");
|
||||
if let std::result::Result::Err(error) = tokio::fs::create_dir_all(&fixture_dir).await {
|
||||
return std::result::Result::Err(ks_core::Error::io(format!(
|
||||
"unable to create Token-2022 fixture directory {}: {error}",
|
||||
fixture_dir.display()
|
||||
)));
|
||||
}
|
||||
let wallet_path = ks_try!(path_text(&options.wallet_path));
|
||||
let authority =
|
||||
ks_try!(command_stdout("solana-keygen", &["pubkey", wallet_path.as_str()]).await);
|
||||
let mint_keypair = fixture_dir.join("mint.json");
|
||||
let source_keypair = fixture_dir.join("source-account.json");
|
||||
let destination_keypair = fixture_dir.join("destination-account.json");
|
||||
let close_account_keypair = fixture_dir.join("close-account.json");
|
||||
let delegate_keypair = fixture_dir.join("delegate.json");
|
||||
ks_try!(ensure_keypair(&mint_keypair).await);
|
||||
ks_try!(ensure_keypair(&source_keypair).await);
|
||||
ks_try!(ensure_keypair(&destination_keypair).await);
|
||||
ks_try!(ensure_keypair(&close_account_keypair).await);
|
||||
ks_try!(ensure_keypair(&delegate_keypair).await);
|
||||
let mint = ks_try!(keypair_pubkey(&mint_keypair).await);
|
||||
let source = ks_try!(keypair_pubkey(&source_keypair).await);
|
||||
let destination = ks_try!(keypair_pubkey(&destination_keypair).await);
|
||||
let close_account = ks_try!(keypair_pubkey(&close_account_keypair).await);
|
||||
let delegate = ks_try!(keypair_pubkey(&delegate_keypair).await);
|
||||
let mint_exists = ks_try!(account_exists(options.rpc_url.as_str(), mint.as_str()).await);
|
||||
if !mint_exists {
|
||||
let decimals = options.decimals.to_string();
|
||||
let wallet_path = ks_try!(path_text(&options.wallet_path));
|
||||
let mint_keypair_path = ks_try!(path_text(&mint_keypair));
|
||||
ks_try!(
|
||||
run_command(
|
||||
"spl-token",
|
||||
&[
|
||||
"--url",
|
||||
options.rpc_url.as_str(),
|
||||
"--program-id",
|
||||
ks_program_ids::SPL_TOKEN_2022_PROGRAM_ID,
|
||||
"create-token",
|
||||
"--enable-freeze",
|
||||
"--decimals",
|
||||
decimals.as_str(),
|
||||
"--fee-payer",
|
||||
wallet_path.as_str(),
|
||||
"--mint-authority",
|
||||
authority.as_str(),
|
||||
mint_keypair_path.as_str()
|
||||
]
|
||||
)
|
||||
.await
|
||||
);
|
||||
}
|
||||
ks_try!(
|
||||
ensure_token_account(
|
||||
options,
|
||||
mint.as_str(),
|
||||
authority.as_str(),
|
||||
&source_keypair,
|
||||
source.as_str()
|
||||
)
|
||||
.await
|
||||
);
|
||||
ks_try!(
|
||||
ensure_token_account(
|
||||
options,
|
||||
mint.as_str(),
|
||||
authority.as_str(),
|
||||
&destination_keypair,
|
||||
destination.as_str()
|
||||
)
|
||||
.await
|
||||
);
|
||||
ks_try!(
|
||||
ensure_token_account(
|
||||
options,
|
||||
mint.as_str(),
|
||||
authority.as_str(),
|
||||
&close_account_keypair,
|
||||
close_account.as_str()
|
||||
)
|
||||
.await
|
||||
);
|
||||
let fixture_path = fixture_dir.join("fixture.env");
|
||||
let summary = crate::Token2022FixturePreparationSummary {
|
||||
fixture_path: fixture_path.clone(),
|
||||
program_id: ks_program_ids::SPL_TOKEN_2022_PROGRAM_ID.to_string(),
|
||||
mint,
|
||||
source,
|
||||
destination,
|
||||
close_account,
|
||||
delegate,
|
||||
authority: authority.clone(),
|
||||
freeze_authority: authority,
|
||||
decimals: options.decimals,
|
||||
fixture_written: true,
|
||||
};
|
||||
let contents = render_fixture(&summary);
|
||||
let fixture_written =
|
||||
ks_try!(write_fixture_atomically(&fixture_path, contents.as_bytes()).await);
|
||||
return std::result::Result::Ok(crate::Token2022FixturePreparationSummary {
|
||||
fixture_path,
|
||||
program_id: summary.program_id,
|
||||
mint: summary.mint,
|
||||
source: summary.source,
|
||||
destination: summary.destination,
|
||||
close_account: summary.close_account,
|
||||
delegate: summary.delegate,
|
||||
authority: summary.authority,
|
||||
freeze_authority: summary.freeze_authority,
|
||||
decimals: summary.decimals,
|
||||
fixture_written,
|
||||
});
|
||||
}
|
||||
|
||||
fn validate_options(options: &crate::Token2022FixturePreparationOptions) -> ks_core::Result<()> {
|
||||
if options.rpc_url.trim().is_empty() {
|
||||
return std::result::Result::Err(ks_core::Error::config(
|
||||
"Token-2022 fixture RPC URL must not be empty",
|
||||
));
|
||||
}
|
||||
if !options.wallet_path.is_file() {
|
||||
return std::result::Result::Err(ks_core::Error::config(format!(
|
||||
"Token-2022 fixture wallet does not exist: {}",
|
||||
options.wallet_path.display()
|
||||
)));
|
||||
}
|
||||
if options.decimals > 18 {
|
||||
return std::result::Result::Err(ks_core::Error::config(
|
||||
"Token-2022 fixture decimals must be at most 18",
|
||||
));
|
||||
}
|
||||
return std::result::Result::Ok(());
|
||||
}
|
||||
|
||||
async fn ensure_keypair(path: &std::path::Path) -> ks_core::Result<()> {
|
||||
if path.is_file() {
|
||||
return std::result::Result::Ok(());
|
||||
}
|
||||
let keypair_path = ks_try!(path_text(path));
|
||||
ks_try!(
|
||||
run_command(
|
||||
"solana-keygen",
|
||||
&[
|
||||
"new",
|
||||
"--no-bip39-passphrase",
|
||||
"--force",
|
||||
"--silent",
|
||||
"--outfile",
|
||||
keypair_path.as_str()
|
||||
]
|
||||
)
|
||||
.await
|
||||
);
|
||||
ks_try!(set_private_permissions(path).await);
|
||||
return std::result::Result::Ok(());
|
||||
}
|
||||
|
||||
async fn set_private_permissions(path: &std::path::Path) -> ks_core::Result<()> {
|
||||
#[cfg(unix)]
|
||||
{
|
||||
let metadata = match tokio::fs::metadata(path).await {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => {
|
||||
return std::result::Result::Err(ks_core::Error::io(format!(
|
||||
"unable to read keypair metadata {}: {error}",
|
||||
path.display()
|
||||
)));
|
||||
},
|
||||
};
|
||||
let mut permissions = metadata.permissions();
|
||||
std::os::unix::fs::PermissionsExt::set_mode(&mut permissions, 0o600);
|
||||
if let std::result::Result::Err(error) = tokio::fs::set_permissions(path, permissions).await
|
||||
{
|
||||
return std::result::Result::Err(ks_core::Error::io(format!(
|
||||
"unable to protect keypair {}: {error}",
|
||||
path.display()
|
||||
)));
|
||||
}
|
||||
}
|
||||
return std::result::Result::Ok(());
|
||||
}
|
||||
|
||||
async fn keypair_pubkey(path: &std::path::Path) -> ks_core::Result<std::string::String> {
|
||||
let keypair_path = ks_try!(path_text(path));
|
||||
return command_stdout("solana-keygen", &["pubkey", keypair_path.as_str()]).await;
|
||||
}
|
||||
|
||||
async fn account_exists(rpc_url: &str, address: &str) -> ks_core::Result<bool> {
|
||||
let output = match tokio::process::Command::new("solana")
|
||||
.args(["account", address, "--url", rpc_url, "--output", "json"])
|
||||
.output()
|
||||
.await
|
||||
{
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => {
|
||||
return std::result::Result::Err(ks_core::Error::io(format!(
|
||||
"unable to execute solana account: {error}"
|
||||
)));
|
||||
},
|
||||
};
|
||||
return std::result::Result::Ok(output.status.success());
|
||||
}
|
||||
|
||||
async fn ensure_token_account(
|
||||
options: &crate::Token2022FixturePreparationOptions,
|
||||
mint: &str,
|
||||
authority: &str,
|
||||
keypair_path: &std::path::Path,
|
||||
address: &str,
|
||||
) -> ks_core::Result<()> {
|
||||
let exists = ks_try!(account_exists(options.rpc_url.as_str(), address).await);
|
||||
if exists {
|
||||
return std::result::Result::Ok(());
|
||||
}
|
||||
let token_account_keypair = ks_try!(path_text(keypair_path));
|
||||
let wallet_path = ks_try!(path_text(&options.wallet_path));
|
||||
ks_try!(
|
||||
run_command(
|
||||
"spl-token",
|
||||
&[
|
||||
"--url",
|
||||
options.rpc_url.as_str(),
|
||||
"--program-id",
|
||||
ks_program_ids::SPL_TOKEN_2022_PROGRAM_ID,
|
||||
"create-account",
|
||||
mint,
|
||||
token_account_keypair.as_str(),
|
||||
"--owner",
|
||||
authority,
|
||||
"--fee-payer",
|
||||
wallet_path.as_str()
|
||||
]
|
||||
)
|
||||
.await
|
||||
);
|
||||
return std::result::Result::Ok(());
|
||||
}
|
||||
|
||||
async fn command_stdout(program: &str, arguments: &[&str]) -> ks_core::Result<std::string::String> {
|
||||
let output = match tokio::process::Command::new(program).args(arguments).output().await {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => {
|
||||
return std::result::Result::Err(ks_core::Error::io(format!(
|
||||
"unable to execute {program}: {error}"
|
||||
)));
|
||||
},
|
||||
};
|
||||
if !output.status.success() {
|
||||
return std::result::Result::Err(command_error(program, arguments, &output));
|
||||
}
|
||||
let value = match std::string::String::from_utf8(output.stdout) {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => {
|
||||
return std::result::Result::Err(ks_core::Error::invalid_state(format!(
|
||||
"{program} returned non-UTF-8 output: {error}"
|
||||
)));
|
||||
},
|
||||
};
|
||||
return std::result::Result::Ok(value.trim().to_string());
|
||||
}
|
||||
|
||||
async fn run_command(program: &str, arguments: &[&str]) -> ks_core::Result<()> {
|
||||
let output = match tokio::process::Command::new(program).args(arguments).output().await {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => {
|
||||
return std::result::Result::Err(ks_core::Error::io(format!(
|
||||
"unable to execute {program}: {error}"
|
||||
)));
|
||||
},
|
||||
};
|
||||
if !output.status.success() {
|
||||
return std::result::Result::Err(command_error(program, arguments, &output));
|
||||
}
|
||||
return std::result::Result::Ok(());
|
||||
}
|
||||
|
||||
fn command_error(
|
||||
program: &str,
|
||||
arguments: &[&str],
|
||||
output: &std::process::Output,
|
||||
) -> ks_core::Error {
|
||||
let stderr = std::string::String::from_utf8_lossy(output.stderr.as_slice())
|
||||
.trim()
|
||||
.to_string();
|
||||
let stdout = std::string::String::from_utf8_lossy(output.stdout.as_slice())
|
||||
.trim()
|
||||
.to_string();
|
||||
let detail = if stderr.is_empty() { stdout } else { stderr };
|
||||
return ks_core::Error::new(
|
||||
"token_2022_fixture_command_failed",
|
||||
format!(
|
||||
"command {program} {} failed with status {}: {detail}",
|
||||
arguments.join(" "),
|
||||
output.status
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
fn path_text(path: &std::path::Path) -> ks_core::Result<std::string::String> {
|
||||
return match path.to_str() {
|
||||
std::option::Option::Some(value) => std::result::Result::Ok(value.to_string()),
|
||||
std::option::Option::None => std::result::Result::Err(ks_core::Error::config(format!(
|
||||
"path is not valid UTF-8: {}",
|
||||
path.display()
|
||||
))),
|
||||
};
|
||||
}
|
||||
|
||||
fn render_fixture(summary: &crate::Token2022FixturePreparationSummary) -> std::string::String {
|
||||
return format!(
|
||||
"export TOKEN_2022_PROGRAM={}\nexport TOKEN_2022_MINT={}\nexport TOKEN_2022_SOURCE={}\nexport TOKEN_2022_DESTINATION={}\nexport TOKEN_2022_CLOSE_ACCOUNT={}\nexport TOKEN_2022_DELEGATE={}\nexport TOKEN_2022_AUTHORITY={}\nexport TOKEN_2022_FREEZE_AUTHORITY={}\nexport TOKEN_2022_DECIMALS={}\nexport TOKEN_2022_MINT_AMOUNT_RAW={}\nexport TOKEN_2022_TRANSFER_AMOUNT_RAW={}\nexport TOKEN_2022_APPROVE_AMOUNT_RAW={}\nexport TOKEN_2022_BURN_AMOUNT_RAW={}\nexport KB_DEVNET_WALLET_ADDRESS={}\n",
|
||||
summary.program_id,
|
||||
summary.mint,
|
||||
summary.source,
|
||||
summary.destination,
|
||||
summary.close_account,
|
||||
summary.delegate,
|
||||
summary.authority,
|
||||
summary.freeze_authority,
|
||||
summary.decimals,
|
||||
crate::DEFAULT_TOKEN_2022_MINT_AMOUNT_RAW,
|
||||
crate::DEFAULT_TOKEN_2022_TRANSFER_AMOUNT_RAW,
|
||||
crate::DEFAULT_TOKEN_2022_APPROVE_AMOUNT_RAW,
|
||||
crate::DEFAULT_TOKEN_2022_BURN_AMOUNT_RAW,
|
||||
summary.authority
|
||||
);
|
||||
}
|
||||
|
||||
async fn write_fixture_atomically(
|
||||
path: &std::path::Path,
|
||||
contents: &[u8],
|
||||
) -> ks_core::Result<bool> {
|
||||
if let std::result::Result::Ok(existing) = tokio::fs::read(path).await {
|
||||
if existing == contents {
|
||||
return std::result::Result::Ok(false);
|
||||
}
|
||||
}
|
||||
let temporary = path.with_extension("env.tmp");
|
||||
if let std::result::Result::Err(error) = tokio::fs::write(&temporary, contents).await {
|
||||
return std::result::Result::Err(ks_core::Error::io(format!(
|
||||
"unable to write temporary Token-2022 fixture {}: {error}",
|
||||
temporary.display()
|
||||
)));
|
||||
}
|
||||
if let std::result::Result::Err(error) = tokio::fs::rename(&temporary, path).await {
|
||||
return std::result::Result::Err(ks_core::Error::io(format!(
|
||||
"unable to replace Token-2022 fixture {}: {error}",
|
||||
path.display()
|
||||
)));
|
||||
}
|
||||
return std::result::Result::Ok(true);
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#[test]
|
||||
fn rendered_fixture_contains_every_public_scenario_variable() {
|
||||
let summary = super::Token2022FixturePreparationSummary {
|
||||
fixture_path: std::path::PathBuf::from("fixture.env"),
|
||||
program_id: ks_program_ids::SPL_TOKEN_2022_PROGRAM_ID.to_string(),
|
||||
mint: "mint".to_string(),
|
||||
source: "source".to_string(),
|
||||
destination: "destination".to_string(),
|
||||
close_account: "close".to_string(),
|
||||
delegate: "delegate".to_string(),
|
||||
authority: "authority".to_string(),
|
||||
freeze_authority: "authority".to_string(),
|
||||
decimals: 9,
|
||||
fixture_written: true,
|
||||
};
|
||||
let fixture = super::render_fixture(&summary);
|
||||
for scenario in crate::devnet_spl_validation_scenarios().into_iter().filter(|scenario| {
|
||||
return scenario.family == crate::DevnetSplValidationFamily::Token2022Public;
|
||||
}) {
|
||||
for variable in scenario.required_fixture_variables {
|
||||
assert!(fixture.lines().any(|line| return line.starts_with(format!("export {variable}=").as_str())), "missing {variable}");
|
||||
}
|
||||
}
|
||||
assert!(fixture.contains("export TOKEN_2022_CLOSE_ACCOUNT=close\n"));
|
||||
assert!(fixture.contains("export KB_DEVNET_WALLET_ADDRESS=authority\n"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn options_reject_excessive_decimals() {
|
||||
let options = super::Token2022FixturePreparationOptions {
|
||||
rpc_url: "https://api.devnet.solana.com".to_string(),
|
||||
wallet_path: std::path::PathBuf::from("missing.json"),
|
||||
wallet_dir: std::path::PathBuf::from("wallets"),
|
||||
decimals: 19,
|
||||
};
|
||||
let error = super::validate_options(&options);
|
||||
assert!(error.is_err());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
// file: ks-pipeline-demo-scenarios/src/spl/token_2022/metadata.rs
|
||||
// version: 2
|
||||
|
||||
//! Token-2022 embedded Token Metadata fixtures, campaigns and validation.
|
||||
|
||||
pub(crate) mod campaign;
|
||||
pub(crate) mod fixture;
|
||||
pub(crate) mod validation;
|
||||
@@ -0,0 +1,586 @@
|
||||
// file: ks-pipeline-demo-scenarios/src/spl/token_2022/metadata/campaign.rs
|
||||
// version: 5
|
||||
|
||||
//! Ordered confirmed Devnet campaign for the five Token-2022 Token Metadata instructions.
|
||||
|
||||
/// One confirmed Token Metadata campaign step and its authoritative postcondition.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct DevnetToken2022MetadataCampaignStepSummary {
|
||||
/// Stable step identifier.
|
||||
pub step_id: std::string::String,
|
||||
/// Canonical operation code built by `ks-lib`.
|
||||
pub operation_code: std::string::String,
|
||||
/// Complete simulation, submission, replay and instruction-materialization evidence.
|
||||
pub execution: crate::DevnetSplToken2022ExecutionSummary,
|
||||
/// Authoritative mint snapshot read at or after the confirmed transaction slot.
|
||||
pub stateful_snapshot: ks_pipeline::Token2022StatefulReadResult,
|
||||
/// Validated return-data evidence for the `Emit` step only.
|
||||
pub emit_evidence: std::option::Option<ks_pipeline::Token2022MetadataEmitEvidence>,
|
||||
/// Explicit stateful or return-data postcondition.
|
||||
pub postcondition: ks_pipeline::Token2022ExecutionPostcondition,
|
||||
}
|
||||
|
||||
/// Complete evidence retained by the five-step Token Metadata campaign.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct DevnetToken2022MetadataCampaignSummary {
|
||||
/// Fresh Token-2022 mint prepared for this campaign.
|
||||
pub fixture: crate::Token2022MetadataFixturePreparationSummary,
|
||||
/// Five confirmed steps in interface order.
|
||||
pub steps: std::vec::Vec<crate::DevnetToken2022MetadataCampaignStepSummary>,
|
||||
}
|
||||
|
||||
/// Returns the exact ordered operation names exercised by the Devnet campaign.
|
||||
pub fn token_2022_metadata_campaign_operation_names() -> &'static [&'static str; 5] {
|
||||
return &[
|
||||
"initialize_token_metadata",
|
||||
"update_token_metadata_field",
|
||||
"emit_token_metadata",
|
||||
"remove_token_metadata_key",
|
||||
"update_token_metadata_authority",
|
||||
];
|
||||
}
|
||||
|
||||
/// Prepares a fresh mint and executes all five Token Metadata interface instructions on Devnet.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub async fn execute_devnet_token_2022_metadata_campaign<S, O>(
|
||||
http_pool: &ks_onchain_transport::HttpEndpointPool,
|
||||
store: &S,
|
||||
profile: &ks_config::ProfileConfig,
|
||||
workspace_root: &std::path::Path,
|
||||
options: &crate::Token2022MetadataFixturePreparationOptions,
|
||||
observer: &O,
|
||||
) -> ks_core::Result<crate::DevnetToken2022MetadataCampaignSummary>
|
||||
where
|
||||
S: ks_store::RawTransactionStore
|
||||
+ ks_store::CoreExtractionStore
|
||||
+ ks_store::DecodePipelineStore
|
||||
+ Sync,
|
||||
O: crate::SolanaExecutionObserver,
|
||||
{
|
||||
if !options.operator_confirmed {
|
||||
return std::result::Result::Err(ks_core::Error::config(
|
||||
"Token-2022 Token Metadata Devnet campaign requires explicit operator confirmation",
|
||||
));
|
||||
}
|
||||
let fixture = match crate::prepare_token_2022_metadata_fixture(
|
||||
http_pool,
|
||||
profile,
|
||||
workspace_root,
|
||||
options,
|
||||
)
|
||||
.await
|
||||
{
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let operations = campaign_operations(&fixture);
|
||||
let decoders: std::vec::Vec<std::sync::Arc<dyn ks_lib::DcApiInstructionDecoder>> =
|
||||
std::vec![std::sync::Arc::new(ks_lib::DcSplToken2022Decoder)];
|
||||
let materializers: std::vec::Vec<std::sync::Arc<dyn ks_lib::MtApiEventMaterializer>> =
|
||||
std::vec![std::sync::Arc::new(ks_lib::MtMetadataToken2022Materializer)];
|
||||
let mut steps = std::vec::Vec::with_capacity(operations.len());
|
||||
for (index, (step_id, operation)) in operations.into_iter().enumerate() {
|
||||
let operation_code = operation.operation_code().to_string();
|
||||
let mut request = crate::DevnetSplToken2022ExecutionRequest::new(
|
||||
format!("token-2022-metadata-devnet-{step_id}-{}", uuid::Uuid::new_v4()),
|
||||
operation.clone(),
|
||||
);
|
||||
request.query_role = options.query_role.clone();
|
||||
request.transaction_role = options.transaction_role.clone();
|
||||
request.submit = true;
|
||||
request.operator_confirmed = true;
|
||||
request.post_validation_max_retries = 20;
|
||||
let execution = match crate::execute_devnet_spl_token_2022(
|
||||
http_pool,
|
||||
store,
|
||||
profile,
|
||||
workspace_root,
|
||||
&request,
|
||||
decoders.as_slice(),
|
||||
materializers.as_slice(),
|
||||
observer,
|
||||
)
|
||||
.await
|
||||
{
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let confirmation = match execution.confirmation.as_ref() {
|
||||
std::option::Option::Some(value)
|
||||
if matches!(
|
||||
value.status,
|
||||
ks_lib::ExApiExecutionConfirmationStatus::Confirmed
|
||||
| ks_lib::ExApiExecutionConfirmationStatus::Finalized
|
||||
) =>
|
||||
{
|
||||
value
|
||||
},
|
||||
std::option::Option::Some(value) => {
|
||||
return std::result::Result::Err(ks_core::Error::new(
|
||||
"token_2022_metadata_campaign_step_not_confirmed",
|
||||
format!("campaign step {step_id} stopped at {:?}", value.status),
|
||||
));
|
||||
},
|
||||
std::option::Option::None => {
|
||||
return std::result::Result::Err(ks_core::Error::new(
|
||||
"token_2022_metadata_campaign_confirmation_missing",
|
||||
format!("campaign step {step_id} has no confirmation evidence"),
|
||||
));
|
||||
},
|
||||
};
|
||||
let diagnostic = match execution.post_execution.as_ref() {
|
||||
std::option::Option::Some(value) => value,
|
||||
std::option::Option::None => {
|
||||
return std::result::Result::Err(ks_core::Error::new(
|
||||
"token_2022_metadata_campaign_post_execution_missing",
|
||||
format!("campaign step {step_id} has no post-execution diagnostic"),
|
||||
));
|
||||
},
|
||||
};
|
||||
if !execution.simulation.success
|
||||
|| !diagnostic.canonical_inserted
|
||||
|| !diagnostic.core_extracted
|
||||
|| !diagnostic.decode_replayed
|
||||
{
|
||||
return std::result::Result::Err(ks_core::Error::new(
|
||||
"token_2022_metadata_campaign_replay_incomplete",
|
||||
format!(
|
||||
"campaign step {step_id} did not complete simulation and canonical decode replay"
|
||||
),
|
||||
));
|
||||
}
|
||||
let idempotence_replay = match execution.idempotence_replay.as_ref() {
|
||||
std::option::Option::Some(value) => value,
|
||||
std::option::Option::None => {
|
||||
return std::result::Result::Err(ks_core::Error::new(
|
||||
"token_2022_metadata_campaign_idempotence_missing",
|
||||
format!("campaign step {step_id} has no idempotence replay evidence"),
|
||||
));
|
||||
},
|
||||
};
|
||||
if idempotence_replay.failed_inputs != 0
|
||||
|| idempotence_replay.processing_error_inputs != 0
|
||||
|| idempotence_replay.processors.iter().any(|processor| {
|
||||
return processor.failed != 0
|
||||
|| processor.processing_errors != 0
|
||||
|| processor.materialized_outputs != 0
|
||||
|| processor.materialization_refused != 0;
|
||||
})
|
||||
{
|
||||
return std::result::Result::Err(ks_core::Error::new(
|
||||
"token_2022_metadata_campaign_idempotence_failed",
|
||||
format!("campaign step {step_id} did not produce a clean second replay"),
|
||||
));
|
||||
}
|
||||
if !execution.plan.policy.post_execution_validation.materialization_required
|
||||
&& operation_code != ks_lib::EX_SPL_TOKEN_2022_EMIT_TOKEN_METADATA_OPERATION
|
||||
{
|
||||
return std::result::Result::Err(ks_core::Error::new(
|
||||
"token_2022_metadata_campaign_materialization_policy_invalid",
|
||||
format!("campaign step {step_id} unexpectedly disabled materialization"),
|
||||
));
|
||||
}
|
||||
if execution.plan.policy.post_execution_validation.materialization_required
|
||||
&& execution.materializations.is_empty()
|
||||
{
|
||||
return std::result::Result::Err(ks_core::Error::new(
|
||||
"token_2022_metadata_campaign_materialization_missing",
|
||||
format!("campaign step {step_id} produced no metadata materialization"),
|
||||
));
|
||||
}
|
||||
let stateful_snapshot = match ks_pipeline::read_token_2022_stateful_snapshot(
|
||||
http_pool,
|
||||
&ks_pipeline::Token2022StatefulReadRequest {
|
||||
query_role: options.query_role.clone(),
|
||||
account: fixture.mint.clone(),
|
||||
kind: ks_lib::DcToken2022StateKind::Mint,
|
||||
min_context_slot: confirmation.slot,
|
||||
max_data_bytes: ks_pipeline::MAX_TOKEN_2022_STATEFUL_ACCOUNT_BYTES,
|
||||
context: ks_pipeline::Token2022StatefulContext::default(),
|
||||
},
|
||||
)
|
||||
.await
|
||||
{
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let emit_evidence = match &operation {
|
||||
ks_lib::ExSplToken2022Operation::Instruction { value } => match value.as_ref() {
|
||||
ks_lib::ExSplTokenSingleOperation::EmitTokenMetadata { start, end, .. } => {
|
||||
match ks_pipeline::inspect_token_2022_metadata_emit_simulation(
|
||||
&execution.simulation,
|
||||
*start,
|
||||
*end,
|
||||
) {
|
||||
std::result::Result::Ok(value) => std::option::Option::Some(value),
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
}
|
||||
},
|
||||
_ => std::option::Option::None,
|
||||
},
|
||||
ks_lib::ExSplToken2022Operation::Batch { instructions: _ } => std::option::Option::None,
|
||||
};
|
||||
let postcondition = campaign_postcondition(
|
||||
index,
|
||||
&fixture,
|
||||
&stateful_snapshot.snapshot,
|
||||
emit_evidence.as_ref(),
|
||||
);
|
||||
if postcondition.status != ks_pipeline::Token2022ExecutionPostconditionStatus::Confirmed {
|
||||
return std::result::Result::Err(ks_core::Error::new(
|
||||
"token_2022_metadata_campaign_postcondition_failed",
|
||||
format!("campaign step {step_id}: {}", postcondition.diagnostic),
|
||||
));
|
||||
}
|
||||
steps.push(crate::DevnetToken2022MetadataCampaignStepSummary {
|
||||
step_id: step_id.to_string(),
|
||||
operation_code,
|
||||
execution,
|
||||
stateful_snapshot,
|
||||
emit_evidence,
|
||||
postcondition,
|
||||
});
|
||||
}
|
||||
return std::result::Result::Ok(crate::DevnetToken2022MetadataCampaignSummary {
|
||||
fixture,
|
||||
steps,
|
||||
});
|
||||
}
|
||||
|
||||
fn campaign_operations(
|
||||
fixture: &crate::Token2022MetadataFixturePreparationSummary,
|
||||
) -> std::vec::Vec<(&'static str, ks_lib::ExSplToken2022Operation)> {
|
||||
let metadata = fixture.mint.clone();
|
||||
let authority = fixture.initial_authority.clone();
|
||||
return std::vec![
|
||||
(
|
||||
"token_2022_metadata_initialize",
|
||||
instruction(ks_lib::ExSplTokenSingleOperation::InitializeTokenMetadata {
|
||||
metadata: metadata.clone(),
|
||||
update_authority: authority.clone(),
|
||||
mint: metadata.clone(),
|
||||
mint_authority: authority.clone(),
|
||||
name: crate::TOKEN_2022_METADATA_CAMPAIGN_NAME.to_string(),
|
||||
symbol: crate::TOKEN_2022_METADATA_CAMPAIGN_SYMBOL.to_string(),
|
||||
uri: crate::TOKEN_2022_METADATA_CAMPAIGN_URI.to_string(),
|
||||
}),
|
||||
),
|
||||
(
|
||||
"token_2022_metadata_update_field",
|
||||
instruction(ks_lib::ExSplTokenSingleOperation::UpdateTokenMetadataField {
|
||||
metadata: metadata.clone(),
|
||||
update_authority: authority.clone(),
|
||||
field: ks_lib::ExSplTokenMetadataField::Key(
|
||||
crate::TOKEN_2022_METADATA_CAMPAIGN_KEY.to_string(),
|
||||
),
|
||||
value: crate::TOKEN_2022_METADATA_CAMPAIGN_VALUE.to_string(),
|
||||
}),
|
||||
),
|
||||
(
|
||||
"token_2022_metadata_emit",
|
||||
instruction(ks_lib::ExSplTokenSingleOperation::EmitTokenMetadata {
|
||||
metadata: metadata.clone(),
|
||||
start: std::option::Option::None,
|
||||
end: std::option::Option::None,
|
||||
}),
|
||||
),
|
||||
(
|
||||
"token_2022_metadata_remove_key",
|
||||
instruction(ks_lib::ExSplTokenSingleOperation::RemoveTokenMetadataKey {
|
||||
metadata: metadata.clone(),
|
||||
update_authority: authority.clone(),
|
||||
key: crate::TOKEN_2022_METADATA_CAMPAIGN_KEY.to_string(),
|
||||
idempotent: false,
|
||||
}),
|
||||
),
|
||||
(
|
||||
"token_2022_metadata_update_authority",
|
||||
instruction(ks_lib::ExSplTokenSingleOperation::UpdateTokenMetadataAuthority {
|
||||
metadata,
|
||||
current_authority: authority,
|
||||
new_authority: std::option::Option::Some(fixture.final_authority.clone()),
|
||||
}),
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
fn instruction(value: ks_lib::ExSplTokenSingleOperation) -> ks_lib::ExSplToken2022Operation {
|
||||
return ks_lib::ExSplToken2022Operation::Instruction { value: std::boxed::Box::new(value) };
|
||||
}
|
||||
|
||||
fn campaign_postcondition(
|
||||
step_index: usize,
|
||||
fixture: &crate::Token2022MetadataFixturePreparationSummary,
|
||||
snapshot: &ks_pipeline::Token2022StatefulSnapshotBundle,
|
||||
emit_evidence: std::option::Option<&ks_pipeline::Token2022MetadataEmitEvidence>,
|
||||
) -> ks_pipeline::Token2022ExecutionPostcondition {
|
||||
if step_index == 4 {
|
||||
return ks_pipeline::inspect_token_2022_metadata_authority_postcondition(
|
||||
&fixture.mint,
|
||||
std::option::Option::Some(&fixture.final_authority),
|
||||
snapshot,
|
||||
);
|
||||
}
|
||||
let value_fields = metadata_value_fields(snapshot);
|
||||
let confirmed = match step_index {
|
||||
0 => value_fields.is_some_and(|value| {
|
||||
return required_fields_match(value, fixture.initial_authority.0.as_str())
|
||||
&& !contains_campaign_pair(value);
|
||||
}),
|
||||
1 => value_fields.is_some_and(|value| {
|
||||
return required_fields_match(value, fixture.initial_authority.0.as_str())
|
||||
&& contains_campaign_pair(value);
|
||||
}),
|
||||
2 => {
|
||||
let snapshot_matches = value_fields.is_some_and(|value| {
|
||||
return required_fields_match(value, fixture.initial_authority.0.as_str())
|
||||
&& contains_campaign_pair(value);
|
||||
});
|
||||
let emit_matches = emit_evidence
|
||||
.and_then(|evidence| return evidence.decoded_metadata.as_ref())
|
||||
.is_some_and(|value| {
|
||||
return required_fields_match(value, fixture.initial_authority.0.as_str())
|
||||
&& contains_campaign_pair(value);
|
||||
});
|
||||
snapshot_matches && emit_matches
|
||||
},
|
||||
3 => value_fields.is_some_and(|value| {
|
||||
return required_fields_match(value, fixture.initial_authority.0.as_str())
|
||||
&& !contains_campaign_pair(value);
|
||||
}),
|
||||
_ => false,
|
||||
};
|
||||
return ks_pipeline::Token2022ExecutionPostcondition {
|
||||
role: "metadata".to_string(),
|
||||
account: fixture.mint.clone(),
|
||||
status: if confirmed {
|
||||
ks_pipeline::Token2022ExecutionPostconditionStatus::Confirmed
|
||||
} else {
|
||||
ks_pipeline::Token2022ExecutionPostconditionStatus::Contradicted
|
||||
},
|
||||
diagnostic: if confirmed {
|
||||
format!("Token-2022 metadata campaign postcondition {step_index} is confirmed")
|
||||
} else {
|
||||
format!("Token-2022 metadata campaign postcondition {step_index} is contradicted")
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
fn metadata_value_fields(
|
||||
snapshot: &ks_pipeline::Token2022StatefulSnapshotBundle,
|
||||
) -> std::option::Option<&serde_json::Value> {
|
||||
return snapshot.outputs.iter().find_map(|output| {
|
||||
if output.family != ks_lib::MdMaterializedEventFamily::Metadata
|
||||
|| output.payload_json.get("projectionKind").and_then(serde_json::Value::as_str)
|
||||
!= std::option::Option::Some("token_metadata")
|
||||
{
|
||||
return std::option::Option::None;
|
||||
}
|
||||
return output.payload_json.get("valueFields");
|
||||
});
|
||||
}
|
||||
|
||||
fn required_fields_match(value: &serde_json::Value, authority: &str) -> bool {
|
||||
return value.get("updateAuthority").and_then(serde_json::Value::as_str)
|
||||
== std::option::Option::Some(authority)
|
||||
&& value.get("name").and_then(serde_json::Value::as_str)
|
||||
== std::option::Option::Some(crate::TOKEN_2022_METADATA_CAMPAIGN_NAME)
|
||||
&& value.get("symbol").and_then(serde_json::Value::as_str)
|
||||
== std::option::Option::Some(crate::TOKEN_2022_METADATA_CAMPAIGN_SYMBOL)
|
||||
&& value.get("uri").and_then(serde_json::Value::as_str)
|
||||
== std::option::Option::Some(crate::TOKEN_2022_METADATA_CAMPAIGN_URI);
|
||||
}
|
||||
|
||||
fn contains_campaign_pair(value: &serde_json::Value) -> bool {
|
||||
return value
|
||||
.get("additionalMetadata")
|
||||
.and_then(serde_json::Value::as_array)
|
||||
.is_some_and(|pairs| {
|
||||
return pairs.iter().any(|pair| {
|
||||
return pair.get("key").and_then(serde_json::Value::as_str)
|
||||
== std::option::Option::Some(crate::TOKEN_2022_METADATA_CAMPAIGN_KEY)
|
||||
&& pair.get("value").and_then(serde_json::Value::as_str)
|
||||
== std::option::Option::Some(crate::TOKEN_2022_METADATA_CAMPAIGN_VALUE);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
fn pubkey(byte: u8) -> ks_lib::MdPubkey {
|
||||
return ks_lib::MdPubkey(bs58::encode([byte; 32]).into_string());
|
||||
}
|
||||
|
||||
fn fixture() -> crate::Token2022MetadataFixturePreparationSummary {
|
||||
return crate::Token2022MetadataFixturePreparationSummary {
|
||||
mint_keypair_path: std::path::PathBuf::from("mint.json"),
|
||||
final_authority_keypair_path: std::path::PathBuf::from("authority.json"),
|
||||
mint: pubkey(1),
|
||||
initial_authority: pubkey(2),
|
||||
final_authority: pubkey(3),
|
||||
mint_space: 234,
|
||||
rent_budget_space: 512,
|
||||
rent_reserve_lamports: 1,
|
||||
preparation_signature: "signature".to_string(),
|
||||
initial_snapshot: ks_pipeline::Token2022StatefulReadResult {
|
||||
commitment: "confirmed".to_string(),
|
||||
context_slot: 1,
|
||||
snapshot: ks_pipeline::Token2022StatefulSnapshotBundle {
|
||||
account_key: pubkey(1).0,
|
||||
slot: 1,
|
||||
state_kind: "mint".to_string(),
|
||||
extension_names: vec!["metadata_pointer".to_string()],
|
||||
outputs: std::vec::Vec::new(),
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn campaign_contract_covers_five_interface_operations_once_in_order() {
|
||||
let operations = super::campaign_operations(&fixture());
|
||||
assert_eq!(
|
||||
operations
|
||||
.iter()
|
||||
.map(|(_, operation)| return operation.operation_code())
|
||||
.collect::<std::vec::Vec<_>>(),
|
||||
vec![
|
||||
ks_lib::EX_SPL_TOKEN_2022_INITIALIZE_TOKEN_METADATA_OPERATION,
|
||||
ks_lib::EX_SPL_TOKEN_2022_UPDATE_TOKEN_METADATA_FIELD_OPERATION,
|
||||
ks_lib::EX_SPL_TOKEN_2022_EMIT_TOKEN_METADATA_OPERATION,
|
||||
ks_lib::EX_SPL_TOKEN_2022_REMOVE_TOKEN_METADATA_KEY_OPERATION,
|
||||
ks_lib::EX_SPL_TOKEN_2022_UPDATE_TOKEN_METADATA_AUTHORITY_OPERATION,
|
||||
]
|
||||
);
|
||||
assert_eq!(
|
||||
crate::token_2022_metadata_campaign_operation_names(),
|
||||
&[
|
||||
"initialize_token_metadata",
|
||||
"update_token_metadata_field",
|
||||
"emit_token_metadata",
|
||||
"remove_token_metadata_key",
|
||||
"update_token_metadata_authority",
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn campaign_pair_detection_is_exact_and_removal_is_observable() {
|
||||
let with_pair = serde_json::json!({
|
||||
"additionalMetadata": [{
|
||||
"key": crate::TOKEN_2022_METADATA_CAMPAIGN_KEY,
|
||||
"value": crate::TOKEN_2022_METADATA_CAMPAIGN_VALUE
|
||||
}]
|
||||
});
|
||||
let without_pair = serde_json::json!({"additionalMetadata": []});
|
||||
assert!(super::contains_campaign_pair(&with_pair));
|
||||
assert!(!super::contains_campaign_pair(&without_pair));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn optional_devnet_token_2022_metadata_campaign_from_env() {
|
||||
if std::env::var("KB_DEVNET_TOKEN_2022_METADATA_CAMPAIGN_TEST").ok().as_deref()
|
||||
!= std::option::Option::Some("1")
|
||||
{
|
||||
return;
|
||||
}
|
||||
if std::env::var("KB_DEVNET_TOKEN_2022_METADATA_OPERATOR_CONFIRMED")
|
||||
.ok()
|
||||
.as_deref()
|
||||
!= std::option::Option::Some("1")
|
||||
{
|
||||
panic!(
|
||||
"KB_DEVNET_TOKEN_2022_METADATA_OPERATOR_CONFIRMED=1 is required for the spending campaign"
|
||||
);
|
||||
}
|
||||
let workspace_root = match std::path::Path::new(env!("CARGO_MANIFEST_DIR")).parent() {
|
||||
std::option::Option::Some(value) => value,
|
||||
std::option::Option::None => panic!("workspace root cannot be resolved"),
|
||||
};
|
||||
let config_path = match std::env::var("KB_DEVNET_CONFIG_PATH") {
|
||||
std::result::Result::Ok(value) => {
|
||||
let path = std::path::PathBuf::from(value);
|
||||
if path.is_absolute() { path } else { workspace_root.join(path) }
|
||||
},
|
||||
std::result::Result::Err(_) => workspace_root.join("config/example.config.json"),
|
||||
};
|
||||
let config = ks_config::read_config_json_file_with_environment(
|
||||
config_path.as_path(),
|
||||
workspace_root,
|
||||
)
|
||||
.unwrap_or_else(|error| panic!("Devnet configuration loading failed: {error}"));
|
||||
let profile_name = std::env::var("KB_DEVNET_PROFILE").ok();
|
||||
let mut profile = crate::resolve_demo_devnet_profile(&config, profile_name.as_deref())
|
||||
.unwrap_or_else(|error| panic!("Devnet profile resolution failed: {error}"));
|
||||
let database_url = std::env::var("KB_POSTGRES_TEST_URL")
|
||||
.unwrap_or_else(|error| panic!("KB_POSTGRES_TEST_URL is required: {error}"));
|
||||
profile.database.backend = "postgres".to_string();
|
||||
profile.database.postgres.url = database_url;
|
||||
let pool = ks_onchain_transport::HttpEndpointPool::from_profile(&profile)
|
||||
.unwrap_or_else(|error| panic!("HTTP pool creation failed: {error}"));
|
||||
let store_options = ks_store::PostgresStoreOptions::new(
|
||||
profile.database.postgres.url.clone(),
|
||||
profile.database.postgres.max_connections,
|
||||
profile.database.postgres.connect_timeout_ms,
|
||||
false,
|
||||
)
|
||||
.unwrap_or_else(|error| panic!("PostgreSQL options failed: {error}"));
|
||||
let store = ks_store::PostgresStore::connect(store_options)
|
||||
.await
|
||||
.unwrap_or_else(|error| panic!("PostgreSQL connection failed: {error}"));
|
||||
if let std::result::Result::Err(error) = store.initialize_store_schema().await {
|
||||
panic!("PostgreSQL schema initialization failed: {error}");
|
||||
}
|
||||
let configured_wallet_dir = std::path::PathBuf::from(profile.wallet.wallet_dir.as_str());
|
||||
let wallet_dir = if configured_wallet_dir.is_absolute() {
|
||||
configured_wallet_dir
|
||||
} else {
|
||||
workspace_root.join(configured_wallet_dir)
|
||||
};
|
||||
let mut options = crate::Token2022MetadataFixturePreparationOptions::new(wallet_dir);
|
||||
options.operator_confirmed = true;
|
||||
let summary = crate::execute_devnet_token_2022_metadata_campaign(
|
||||
&pool,
|
||||
&store,
|
||||
&profile,
|
||||
workspace_root,
|
||||
&options,
|
||||
&crate::NoopSolanaExecutionObserver,
|
||||
)
|
||||
.await
|
||||
.unwrap_or_else(|error| {
|
||||
panic!("Token-2022 Token Metadata Devnet campaign failed: {error}")
|
||||
});
|
||||
assert_eq!(summary.steps.len(), 5);
|
||||
assert!(summary.steps.iter().all(|step| {
|
||||
return step.postcondition.status
|
||||
== ks_pipeline::Token2022ExecutionPostconditionStatus::Confirmed;
|
||||
}));
|
||||
println!(
|
||||
"TOKEN_2022_METADATA_FIXTURE mint={} preparation_signature={} initial_authority={} final_authority={}",
|
||||
summary.fixture.mint.0,
|
||||
summary.fixture.preparation_signature,
|
||||
summary.fixture.initial_authority.0,
|
||||
summary.fixture.final_authority.0,
|
||||
);
|
||||
for step in &summary.steps {
|
||||
let signature = step
|
||||
.execution
|
||||
.confirmation
|
||||
.as_ref()
|
||||
.map(|value| return value.signature.0.as_str())
|
||||
.unwrap_or("missing");
|
||||
let slot = step.execution.confirmation.as_ref().and_then(|value| return value.slot);
|
||||
println!(
|
||||
"TOKEN_2022_METADATA_STEP id={} operation={} signature={} slot={:?} postcondition={:?} materializations={} emit_bytes={}",
|
||||
step.step_id,
|
||||
step.operation_code,
|
||||
signature,
|
||||
slot,
|
||||
step.postcondition.status,
|
||||
step.execution.materializations.len(),
|
||||
step.emit_evidence.as_ref().map(|value| return value.data.len()).unwrap_or(0),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,661 @@
|
||||
// file: ks-pipeline-demo-scenarios/src/spl/token_2022/metadata/fixture.rs
|
||||
// version: 4
|
||||
|
||||
//! Native Token-2022 mint preparation for the embedded Token Metadata Devnet campaign.
|
||||
|
||||
/// Stable metadata name used by the `0.4.8-pre.012` campaign.
|
||||
pub const TOKEN_2022_METADATA_CAMPAIGN_NAME: &str = "Khadhroony Token-2022 Metadata";
|
||||
/// Stable metadata symbol used by the `0.4.8-pre.012` campaign.
|
||||
pub const TOKEN_2022_METADATA_CAMPAIGN_SYMBOL: &str = "KBTM";
|
||||
/// Opaque URI stored on-chain by the `0.4.8-pre.012` campaign.
|
||||
pub const TOKEN_2022_METADATA_CAMPAIGN_URI: &str =
|
||||
"https://example.invalid/khadhroony/token-2022-metadata.json";
|
||||
/// Additional metadata key created and removed by the campaign.
|
||||
pub const TOKEN_2022_METADATA_CAMPAIGN_KEY: &str = "campaign";
|
||||
/// Additional metadata value created and removed by the campaign.
|
||||
pub const TOKEN_2022_METADATA_CAMPAIGN_VALUE: &str = "0.4.8-pre.012";
|
||||
|
||||
const TOKEN_2022_TLV_HEADER_BYTES: usize = 4;
|
||||
|
||||
/// Options used to prepare one fresh Token-2022 mint for the metadata campaign.
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub struct Token2022MetadataFixturePreparationOptions {
|
||||
/// Endpoint role used for state, rent, balance, blockhash and fee calls.
|
||||
pub query_role: std::string::String,
|
||||
/// Endpoint role used for simulation, submission and confirmation polling.
|
||||
pub transaction_role: std::string::String,
|
||||
/// Directory receiving persistent fixture keypairs.
|
||||
pub wallet_dir: std::path::PathBuf,
|
||||
/// Explicit operator authorization for the lamport-spending fixture transaction.
|
||||
pub operator_confirmed: bool,
|
||||
}
|
||||
|
||||
impl crate::Token2022MetadataFixturePreparationOptions {
|
||||
/// Creates conservative defaults matching Devnet execution profiles.
|
||||
pub fn new(wallet_dir: std::path::PathBuf) -> Self {
|
||||
return Self {
|
||||
query_role: "http_queries".to_string(),
|
||||
transaction_role: "http_transactions".to_string(),
|
||||
wallet_dir,
|
||||
operator_confirmed: false,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// Fresh Token-2022 mint and authority identities used by the metadata campaign.
|
||||
#[derive(Clone, Debug, PartialEq, serde::Serialize)]
|
||||
pub struct Token2022MetadataFixturePreparationSummary {
|
||||
/// Persistent fixture mint keypair path.
|
||||
pub mint_keypair_path: std::path::PathBuf,
|
||||
/// Persistent final metadata-authority keypair path.
|
||||
pub final_authority_keypair_path: std::path::PathBuf,
|
||||
/// Fresh Token-2022 mint address. Embedded metadata uses this same account.
|
||||
pub mint: ks_lib::MdPubkey,
|
||||
/// Profile wallet used as mint and initial metadata update authority.
|
||||
pub initial_authority: ks_lib::MdPubkey,
|
||||
/// Fresh public key installed by the final `UpdateAuthority` campaign step.
|
||||
pub final_authority: ks_lib::MdPubkey,
|
||||
/// Initial mint account bytes containing only the fixed Metadata Pointer extension.
|
||||
pub mint_space: usize,
|
||||
/// Account-size budget used to pre-fund rent for the largest campaign metadata state.
|
||||
pub rent_budget_space: usize,
|
||||
/// Rent reserve deposited when the mint is created.
|
||||
pub rent_reserve_lamports: u64,
|
||||
/// Confirmed signature that created the Token-2022 mint fixture.
|
||||
pub preparation_signature: std::string::String,
|
||||
/// Authoritative snapshot after mint creation and before Token Metadata initialization.
|
||||
pub initial_snapshot: ks_pipeline::Token2022StatefulReadResult,
|
||||
}
|
||||
|
||||
/// Creates a fresh Token-2022 mint with Metadata Pointer initialized to the mint itself.
|
||||
///
|
||||
/// The mint account is created with the fixed Metadata Pointer size but is pre-funded
|
||||
/// for the largest state reached by this campaign so Token Metadata reallocations
|
||||
/// remain rent-exempt without introducing an additional payer instruction.
|
||||
pub async fn prepare_token_2022_metadata_fixture(
|
||||
http_pool: &ks_onchain_transport::HttpEndpointPool,
|
||||
profile: &ks_config::ProfileConfig,
|
||||
workspace_root: &std::path::Path,
|
||||
options: &crate::Token2022MetadataFixturePreparationOptions,
|
||||
) -> ks_core::Result<crate::Token2022MetadataFixturePreparationSummary> {
|
||||
if options.query_role.trim().is_empty() || options.transaction_role.trim().is_empty() {
|
||||
return std::result::Result::Err(ks_core::Error::config(
|
||||
"Token-2022 metadata fixture endpoint roles must not be empty",
|
||||
));
|
||||
}
|
||||
if profile.wallet.cluster != "devnet"
|
||||
|| !profile.wallet.temporary_wallet_enabled
|
||||
|| !profile.wallet.temporary_wallet_persist
|
||||
{
|
||||
return std::result::Result::Err(ks_core::Error::config(
|
||||
"Token-2022 metadata fixture requires a persistent Devnet wallet profile",
|
||||
));
|
||||
}
|
||||
if !profile.wallet.devnet_send_enabled || !options.operator_confirmed {
|
||||
return std::result::Result::Err(ks_core::Error::config(
|
||||
"Token-2022 metadata fixture requires enabled Devnet submission and explicit operator confirmation",
|
||||
));
|
||||
}
|
||||
let operator = match crate::load_profile_wallet(profile, workspace_root).await {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let fixture_store = match ks_wallet::TemporaryWalletStore::new(
|
||||
options.wallet_dir.join("token_2022_metadata_validation"),
|
||||
) {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let fixture_epoch = match std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH) {
|
||||
std::result::Result::Ok(value) => value.as_millis(),
|
||||
std::result::Result::Err(error) => {
|
||||
return std::result::Result::Err(ks_core::Error::config(format!(
|
||||
"unable to create a unique Token-2022 metadata fixture identifier: {error}"
|
||||
)));
|
||||
},
|
||||
};
|
||||
let mint_alias = match ks_wallet::WalletAlias::parse(
|
||||
format!("token-2022-metadata-mint-{fixture_epoch}").as_str(),
|
||||
) {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let final_authority_alias = match ks_wallet::WalletAlias::parse(
|
||||
format!("token-2022-metadata-authority-{fixture_epoch}").as_str(),
|
||||
) {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let mint_wallet = match fixture_store.load_or_create(mint_alias.clone()).await {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let final_authority_wallet =
|
||||
match fixture_store.load_or_create(final_authority_alias.clone()).await {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let mint = ks_lib::MdPubkey(mint_wallet.public_key());
|
||||
let initial_authority = ks_lib::MdPubkey(operator.public_key());
|
||||
let final_authority = ks_lib::MdPubkey(final_authority_wallet.public_key());
|
||||
let mint_space = match token_2022_metadata_pointer_mint_space() {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let rent_budget_space = metadata_campaign_rent_budget_space(mint_space);
|
||||
let preparation_signature = match create_native_token_2022_metadata_mint(
|
||||
http_pool,
|
||||
profile,
|
||||
options,
|
||||
&operator,
|
||||
&mint_wallet,
|
||||
mint_space,
|
||||
rent_budget_space,
|
||||
)
|
||||
.await
|
||||
{
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let initial_snapshot = match ks_pipeline::read_token_2022_stateful_snapshot(
|
||||
http_pool,
|
||||
&ks_pipeline::Token2022StatefulReadRequest {
|
||||
query_role: options.query_role.clone(),
|
||||
account: mint.clone(),
|
||||
kind: ks_lib::DcToken2022StateKind::Mint,
|
||||
min_context_slot: std::option::Option::None,
|
||||
max_data_bytes: ks_pipeline::MAX_TOKEN_2022_STATEFUL_ACCOUNT_BYTES,
|
||||
context: ks_pipeline::Token2022StatefulContext::default(),
|
||||
},
|
||||
)
|
||||
.await
|
||||
{
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
if !initial_snapshot
|
||||
.snapshot
|
||||
.extension_names
|
||||
.iter()
|
||||
.any(|value| return value == "metadata_pointer")
|
||||
|| initial_snapshot
|
||||
.snapshot
|
||||
.extension_names
|
||||
.iter()
|
||||
.any(|value| return value == "token_metadata")
|
||||
|| !metadata_pointer_snapshot_matches(&initial_snapshot.snapshot, &mint, &initial_authority)
|
||||
{
|
||||
return std::result::Result::Err(ks_core::Error::new(
|
||||
"token_2022_metadata_fixture_state_invalid",
|
||||
"fresh metadata fixture must contain the expected self-referential Metadata Pointer and no Token Metadata extension",
|
||||
));
|
||||
}
|
||||
let rent = match http_pool
|
||||
.get_minimum_balance_for_rent_exemption_for_role(
|
||||
options.query_role.as_str(),
|
||||
match u64::try_from(rent_budget_space) {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => {
|
||||
return std::result::Result::Err(ks_core::Error::config(format!(
|
||||
"Token-2022 metadata rent budget does not fit u64: {error}"
|
||||
)));
|
||||
},
|
||||
},
|
||||
&ks_onchain_transport::GetMinimumBalanceForRentExemptionConfig::confirmed(),
|
||||
)
|
||||
.await
|
||||
{
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
return std::result::Result::Ok(crate::Token2022MetadataFixturePreparationSummary {
|
||||
mint_keypair_path: fixture_store.wallet_path(&mint_alias),
|
||||
final_authority_keypair_path: fixture_store.wallet_path(&final_authority_alias),
|
||||
mint,
|
||||
initial_authority,
|
||||
final_authority,
|
||||
mint_space,
|
||||
rent_budget_space,
|
||||
rent_reserve_lamports: rent.minimum_balance_lamports,
|
||||
preparation_signature,
|
||||
initial_snapshot,
|
||||
});
|
||||
}
|
||||
|
||||
fn metadata_pointer_snapshot_matches(
|
||||
snapshot: &ks_pipeline::Token2022StatefulSnapshotBundle,
|
||||
mint: &ks_lib::MdPubkey,
|
||||
authority: &ks_lib::MdPubkey,
|
||||
) -> bool {
|
||||
return snapshot.outputs.iter().any(|output| {
|
||||
return output.family == ks_lib::MdMaterializedEventFamily::Admin
|
||||
&& output.payload_json.get("extensionName").and_then(serde_json::Value::as_str)
|
||||
== std::option::Option::Some("metadata_pointer")
|
||||
&& output
|
||||
.payload_json
|
||||
.get("valueFields")
|
||||
.and_then(|value| return value.get("address"))
|
||||
.and_then(serde_json::Value::as_str)
|
||||
== std::option::Option::Some(mint.0.as_str())
|
||||
&& output
|
||||
.payload_json
|
||||
.get("valueFields")
|
||||
.and_then(|value| return value.get("authority"))
|
||||
.and_then(serde_json::Value::as_str)
|
||||
== std::option::Option::Some(authority.0.as_str());
|
||||
});
|
||||
}
|
||||
|
||||
fn token_2022_metadata_pointer_mint_space() -> ks_core::Result<usize> {
|
||||
return match spl_token_2022_interface::extension::ExtensionType::try_calculate_account_len::<
|
||||
spl_token_2022_interface::pod::PodMint,
|
||||
>(&[spl_token_2022_interface::extension::ExtensionType::MetadataPointer])
|
||||
{
|
||||
std::result::Result::Ok(value) => std::result::Result::Ok(value),
|
||||
std::result::Result::Err(error) => std::result::Result::Err(ks_core::Error::new(
|
||||
"token_2022_metadata_fixture_space_failed",
|
||||
format!("unable to calculate Metadata Pointer mint space: {error}"),
|
||||
)),
|
||||
};
|
||||
}
|
||||
|
||||
fn metadata_campaign_rent_budget_space(mint_space: usize) -> usize {
|
||||
return mint_space
|
||||
.saturating_add(TOKEN_2022_TLV_HEADER_BYTES)
|
||||
.saturating_add(metadata_campaign_max_value_bytes());
|
||||
}
|
||||
|
||||
fn metadata_campaign_max_value_bytes() -> usize {
|
||||
return 64_usize
|
||||
.saturating_add(4)
|
||||
.saturating_add(TOKEN_2022_METADATA_CAMPAIGN_NAME.len())
|
||||
.saturating_add(4)
|
||||
.saturating_add(TOKEN_2022_METADATA_CAMPAIGN_SYMBOL.len())
|
||||
.saturating_add(4)
|
||||
.saturating_add(TOKEN_2022_METADATA_CAMPAIGN_URI.len())
|
||||
.saturating_add(4)
|
||||
.saturating_add(4)
|
||||
.saturating_add(TOKEN_2022_METADATA_CAMPAIGN_KEY.len())
|
||||
.saturating_add(4)
|
||||
.saturating_add(TOKEN_2022_METADATA_CAMPAIGN_VALUE.len());
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
async fn create_native_token_2022_metadata_mint(
|
||||
http_pool: &ks_onchain_transport::HttpEndpointPool,
|
||||
profile: &ks_config::ProfileConfig,
|
||||
options: &crate::Token2022MetadataFixturePreparationOptions,
|
||||
operator: &ks_wallet::TemporaryWallet,
|
||||
mint_wallet: &ks_wallet::TemporaryWallet,
|
||||
mint_space: usize,
|
||||
rent_budget_space: usize,
|
||||
) -> ks_core::Result<std::string::String> {
|
||||
let rent_space = match u64::try_from(rent_budget_space) {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => {
|
||||
return std::result::Result::Err(ks_core::Error::config(format!(
|
||||
"Token-2022 metadata rent budget does not fit u64: {error}"
|
||||
)));
|
||||
},
|
||||
};
|
||||
let account_space = match u64::try_from(mint_space) {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => {
|
||||
return std::result::Result::Err(ks_core::Error::config(format!(
|
||||
"Token-2022 Metadata Pointer mint space does not fit u64: {error}"
|
||||
)));
|
||||
},
|
||||
};
|
||||
let rent = match http_pool
|
||||
.get_minimum_balance_for_rent_exemption_for_role(
|
||||
options.query_role.as_str(),
|
||||
rent_space,
|
||||
&ks_onchain_transport::GetMinimumBalanceForRentExemptionConfig::confirmed(),
|
||||
)
|
||||
.await
|
||||
{
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let payer = ks_lib::MdPubkey(operator.public_key());
|
||||
let mint = ks_lib::MdPubkey(mint_wallet.public_key());
|
||||
let policy =
|
||||
fixture_policy(profile, payer.clone(), mint.clone(), rent.minimum_balance_lamports);
|
||||
let system_intent = ks_lib::ExSolanaCoreExecutionIntent {
|
||||
intent_id: format!("token-2022-metadata-fixture-create-account-{}", mint.0),
|
||||
fee_payer: payer.clone(),
|
||||
policy: policy.clone(),
|
||||
operation: ks_lib::ExSolanaCoreOperation::SystemCreateAccount {
|
||||
from: payer.clone(),
|
||||
new_account: mint.clone(),
|
||||
lamports: rent.minimum_balance_lamports,
|
||||
space: account_space,
|
||||
owner: ks_lib::MdPubkey(ks_program_ids::SPL_TOKEN_2022_PROGRAM_ID.to_string()),
|
||||
},
|
||||
};
|
||||
let system_plan = match ks_lib::ExApiTypedInstructionExecutor::build_prepared_plan(
|
||||
&ks_lib::ExSolanaCoreExecutor,
|
||||
&system_intent,
|
||||
) {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let token_program = match <solana_pubkey::Pubkey as std::str::FromStr>::from_str(
|
||||
ks_program_ids::SPL_TOKEN_2022_PROGRAM_ID,
|
||||
) {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => {
|
||||
return std::result::Result::Err(ks_core::Error::config(format!(
|
||||
"invalid Token-2022 Program ID: {error}"
|
||||
)));
|
||||
},
|
||||
};
|
||||
let mint_pubkey = match <solana_pubkey::Pubkey as std::str::FromStr>::from_str(mint.0.as_str())
|
||||
{
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => {
|
||||
return std::result::Result::Err(ks_core::Error::config(format!(
|
||||
"invalid Token-2022 metadata fixture mint: {error}"
|
||||
)));
|
||||
},
|
||||
};
|
||||
let authority_pubkey =
|
||||
match <solana_pubkey::Pubkey as std::str::FromStr>::from_str(payer.0.as_str()) {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => {
|
||||
return std::result::Result::Err(ks_core::Error::config(format!(
|
||||
"invalid Token-2022 metadata fixture authority: {error}"
|
||||
)));
|
||||
},
|
||||
};
|
||||
let pointer_instruction =
|
||||
match spl_token_2022_interface::extension::metadata_pointer::instruction::initialize(
|
||||
&token_program,
|
||||
&mint_pubkey,
|
||||
std::option::Option::Some(authority_pubkey),
|
||||
std::option::Option::Some(mint_pubkey),
|
||||
) {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => {
|
||||
return std::result::Result::Err(ks_core::Error::new(
|
||||
"token_2022_metadata_fixture_pointer_build_failed",
|
||||
error.to_string(),
|
||||
));
|
||||
},
|
||||
};
|
||||
let initialize_mint = match spl_token_2022_interface::instruction::initialize_mint2(
|
||||
&token_program,
|
||||
&mint_pubkey,
|
||||
&authority_pubkey,
|
||||
std::option::Option::Some(&authority_pubkey),
|
||||
0,
|
||||
) {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => {
|
||||
return std::result::Result::Err(ks_core::Error::new(
|
||||
"token_2022_metadata_fixture_initialize_mint_build_failed",
|
||||
error.to_string(),
|
||||
));
|
||||
},
|
||||
};
|
||||
let plan = merge_fixture_instructions(
|
||||
system_plan,
|
||||
std::vec![
|
||||
planned_instruction("spl.token_2022.initialize_metadata_pointer", &pointer_instruction,),
|
||||
planned_instruction("spl.token_2022.initialize_mint2", &initialize_mint),
|
||||
],
|
||||
policy,
|
||||
rent.minimum_balance_lamports,
|
||||
);
|
||||
let plan_evaluation = match ks_lib::ExSafetyChecker.evaluate_prepared_plan(&plan) {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
if plan_evaluation.decision == ks_lib::ExSafetyDecision::Deny {
|
||||
return std::result::Result::Err(ks_core::Error::new(
|
||||
"token_2022_metadata_fixture_plan_denied",
|
||||
crate::violation_message(plan_evaluation.violations.as_slice()),
|
||||
));
|
||||
}
|
||||
let latest_blockhash = match http_pool
|
||||
.get_latest_blockhash_for_role(
|
||||
options.query_role.as_str(),
|
||||
&ks_onchain_transport::GetLatestBlockhashConfig::confirmed(),
|
||||
)
|
||||
.await
|
||||
{
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let unsigned = match ks_lib::executor_solana_build_legacy_transaction(
|
||||
&plan,
|
||||
latest_blockhash.blockhash.as_str(),
|
||||
) {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let fee = match http_pool
|
||||
.get_fee_for_message_for_role(
|
||||
options.query_role.as_str(),
|
||||
unsigned.message_base64().as_str(),
|
||||
&ks_onchain_transport::GetFeeForMessageConfig::new(
|
||||
ks_onchain_transport::RpcCommitmentLevel::Confirmed,
|
||||
std::option::Option::Some(latest_blockhash.context.slot),
|
||||
),
|
||||
)
|
||||
.await
|
||||
{
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let unsigned_base64 = match unsigned.transaction_base64() {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let simulation_config = match ks_onchain_transport::SimulateTransactionConfig::new(
|
||||
ks_onchain_transport::RpcCommitmentLevel::Confirmed,
|
||||
false,
|
||||
false,
|
||||
std::option::Option::Some(latest_blockhash.context.slot),
|
||||
true,
|
||||
std::option::Option::None,
|
||||
) {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let simulation_rpc = match http_pool
|
||||
.simulate_transaction_for_role(
|
||||
options.transaction_role.as_str(),
|
||||
unsigned_base64.as_str(),
|
||||
&simulation_config,
|
||||
)
|
||||
.await
|
||||
{
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let simulation = simulation_rpc.to_execution_result(
|
||||
ks_lib::ExApiExecutionCluster::Devnet,
|
||||
ks_lib::ExApiExecutionBlockhashKind::Latest,
|
||||
std::option::Option::Some(
|
||||
simulation_rpc.context.slot.saturating_sub(latest_blockhash.context.slot),
|
||||
),
|
||||
std::option::Option::None,
|
||||
std::option::Option::None,
|
||||
std::option::Option::Some(&fee),
|
||||
);
|
||||
if !simulation.success {
|
||||
return std::result::Result::Err(ks_core::Error::new(
|
||||
"token_2022_metadata_fixture_simulation_failed",
|
||||
crate::simulation_failure_message(&simulation),
|
||||
));
|
||||
}
|
||||
let send_evaluation = match ks_lib::ExSafetyChecker.evaluate_send(&plan, &simulation) {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
if send_evaluation.decision == ks_lib::ExSafetyDecision::Deny {
|
||||
return std::result::Result::Err(ks_core::Error::new(
|
||||
"token_2022_metadata_fixture_send_denied",
|
||||
crate::violation_message(send_evaluation.violations.as_slice()),
|
||||
));
|
||||
}
|
||||
let evidence = unsigned.bind_simulation(simulation);
|
||||
let signed = match unsigned
|
||||
.sign_after_simulation(&evidence, &[operator.as_signer(), mint_wallet.as_signer()])
|
||||
{
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
if let std::result::Result::Err(error) = signed.verify_signatures() {
|
||||
return std::result::Result::Err(error);
|
||||
}
|
||||
let signature = signed.primary_signature().clone();
|
||||
let send_config = match ks_onchain_transport::SendTransactionConfig::from_execution_config(
|
||||
&profile.execution,
|
||||
std::option::Option::Some(latest_blockhash.context.slot),
|
||||
) {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
if let std::result::Result::Err(error) = http_pool
|
||||
.send_transaction_for_role(
|
||||
options.transaction_role.as_str(),
|
||||
signed.transaction_base64().as_str(),
|
||||
&signature,
|
||||
&send_config,
|
||||
)
|
||||
.await
|
||||
{
|
||||
return std::result::Result::Err(error);
|
||||
}
|
||||
let confirmation_config =
|
||||
match ks_onchain_transport::ConfirmTransactionConfig::from_execution_config(
|
||||
&profile.execution,
|
||||
std::option::Option::Some(latest_blockhash.last_valid_block_height),
|
||||
std::option::Option::Some(latest_blockhash.context.slot),
|
||||
) {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let confirmation = match http_pool
|
||||
.confirm_transaction_for_roles(
|
||||
options.transaction_role.as_str(),
|
||||
options.query_role.as_str(),
|
||||
ks_lib::ExApiExecutionCluster::Devnet,
|
||||
&signature,
|
||||
&confirmation_config,
|
||||
)
|
||||
.await
|
||||
{
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
if !matches!(
|
||||
confirmation.status,
|
||||
ks_lib::ExApiExecutionConfirmationStatus::Confirmed
|
||||
| ks_lib::ExApiExecutionConfirmationStatus::Finalized
|
||||
) {
|
||||
return std::result::Result::Err(ks_core::Error::new(
|
||||
"token_2022_metadata_fixture_confirmation_failed",
|
||||
format!("Token-2022 metadata mint preparation stopped at {:?}", confirmation.status),
|
||||
));
|
||||
}
|
||||
return std::result::Result::Ok(signature.0);
|
||||
}
|
||||
|
||||
fn planned_instruction(
|
||||
operation_code: &str,
|
||||
instruction: &solana_instruction::Instruction,
|
||||
) -> ks_lib::ExApiPlannedInstruction {
|
||||
return ks_lib::ExApiPlannedInstruction {
|
||||
program_id: ks_lib::MdProgramId(instruction.program_id.to_string()),
|
||||
operation_code: operation_code.to_string(),
|
||||
accounts: instruction
|
||||
.accounts
|
||||
.iter()
|
||||
.map(|account| {
|
||||
return ks_lib::ExApiPlannedAccount {
|
||||
pubkey: ks_lib::MdPubkey(account.pubkey.to_string()),
|
||||
is_signer: account.is_signer,
|
||||
is_writable: account.is_writable,
|
||||
};
|
||||
})
|
||||
.collect(),
|
||||
data: instruction.data.clone(),
|
||||
};
|
||||
}
|
||||
|
||||
fn fixture_policy(
|
||||
profile: &ks_config::ProfileConfig,
|
||||
payer: ks_lib::MdPubkey,
|
||||
mint: ks_lib::MdPubkey,
|
||||
_rent_lamports: u64,
|
||||
) -> ks_lib::ExApiExecutionPolicy {
|
||||
return ks_lib::ExApiExecutionPolicy {
|
||||
cluster: ks_lib::ExApiExecutionClusterPolicy {
|
||||
expected_cluster: ks_lib::ExApiExecutionCluster::Devnet,
|
||||
allow_mainnet: false,
|
||||
mainnet_confirmation: false,
|
||||
},
|
||||
simulation: ks_lib::ExApiExecutionSimulationPolicy::Required,
|
||||
blockhash: ks_lib::ExApiExecutionBlockhashPolicy {
|
||||
kind: ks_lib::ExApiExecutionBlockhashKind::Latest,
|
||||
max_age_slots: std::option::Option::Some(
|
||||
profile.execution.recent_blockhash_max_age_slots,
|
||||
),
|
||||
nonce_account: std::option::Option::None,
|
||||
nonce_authority: std::option::Option::None,
|
||||
},
|
||||
cost_limit: ks_lib::ExApiExecutionCostLimit {
|
||||
max_spend_lamports: std::option::Option::Some(
|
||||
profile.execution.devnet_max_spend_lamports,
|
||||
),
|
||||
max_fee_lamports: std::option::Option::Some(profile.execution.max_fee_lamports),
|
||||
max_compute_unit_price_micro_lamports: std::option::Option::Some(
|
||||
profile.execution.max_compute_unit_price_micro_lamports,
|
||||
),
|
||||
},
|
||||
authorized_signers: std::vec![payer, mint],
|
||||
dry_run: false,
|
||||
post_execution_validation: ks_lib::ExApiPostExecutionValidationPolicy {
|
||||
canonical_insert_required: false,
|
||||
core_extraction_required: false,
|
||||
decode_replay_required: false,
|
||||
materialization_required: false,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
fn merge_fixture_instructions(
|
||||
mut system_plan: ks_lib::ExApiPreparedExecutionPlan,
|
||||
token_instructions: std::vec::Vec<ks_lib::ExApiPlannedInstruction>,
|
||||
policy: ks_lib::ExApiExecutionPolicy,
|
||||
rent_lamports: u64,
|
||||
) -> ks_lib::ExApiPreparedExecutionPlan {
|
||||
system_plan.instructions.extend(token_instructions);
|
||||
system_plan.executor_name =
|
||||
"ks-pipeline-demo-scenarios.token_2022_metadata_fixture".to_string();
|
||||
system_plan.operation_code = "spl.token_2022.metadata.prepare_mint".to_string();
|
||||
system_plan.policy = policy;
|
||||
system_plan.requested_spend_lamports = rent_lamports;
|
||||
return system_plan;
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#[test]
|
||||
fn metadata_rent_budget_covers_pointer_and_largest_campaign_value() {
|
||||
let mint_space = super::token_2022_metadata_pointer_mint_space()
|
||||
.unwrap_or_else(|error| panic!("metadata pointer mint size failed: {error}"));
|
||||
assert!(mint_space > 82);
|
||||
let budget = super::metadata_campaign_rent_budget_space(mint_space);
|
||||
assert_eq!(
|
||||
budget,
|
||||
mint_space
|
||||
+ super::TOKEN_2022_TLV_HEADER_BYTES
|
||||
+ super::metadata_campaign_max_value_bytes()
|
||||
);
|
||||
assert!(budget > mint_space);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,210 @@
|
||||
// file: ks-pipeline-demo-scenarios/src/spl/token_2022/metadata/validation.rs
|
||||
// version: 4
|
||||
|
||||
//! Conservative validation matrix for the `0.4.8-pre.012` Token Metadata campaign.
|
||||
|
||||
/// Maximum evidence items retained by one campaign scenario.
|
||||
pub const MAX_TOKEN_2022_METADATA_VALIDATION_EVIDENCE: usize = 16;
|
||||
|
||||
/// Exact network-validation status for one Token Metadata scenario.
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum Token2022MetadataValidationStatus {
|
||||
/// No real Devnet execution has been recorded.
|
||||
NotRun,
|
||||
/// The scenario completed with all declared evidence.
|
||||
Confirmed,
|
||||
/// The scenario is unavailable for an explicit recorded reason.
|
||||
Unavailable,
|
||||
}
|
||||
|
||||
/// One bounded evidence item retained by the validation matrix.
|
||||
#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Token2022MetadataValidationEvidence {
|
||||
/// Stable evidence kind.
|
||||
pub kind: std::string::String,
|
||||
/// Bounded evidence value such as a signature, slot or diagnostic.
|
||||
pub value: std::string::String,
|
||||
}
|
||||
|
||||
/// One ordered Token Metadata scenario in the canonical Devnet matrix.
|
||||
#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Token2022MetadataValidationScenario {
|
||||
/// Stable campaign step identifier.
|
||||
pub id: std::string::String,
|
||||
/// Short Token Metadata interface operation name.
|
||||
pub operation: std::string::String,
|
||||
/// Current evidence-qualified network status.
|
||||
pub status: crate::Token2022MetadataValidationStatus,
|
||||
/// Evidence kinds required before promotion to `confirmed`.
|
||||
pub required_evidence: std::vec::Vec<std::string::String>,
|
||||
/// Bounded observed evidence.
|
||||
pub evidence: std::vec::Vec<crate::Token2022MetadataValidationEvidence>,
|
||||
}
|
||||
|
||||
/// Canonical `0.4.8-pre.012` Token Metadata Devnet validation matrix.
|
||||
#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Token2022MetadataValidationMatrix {
|
||||
/// Matrix schema version.
|
||||
pub matrix_version: u32,
|
||||
/// Milestone owning the campaign.
|
||||
pub milestone: std::string::String,
|
||||
/// Token-2022 program ID.
|
||||
pub program_id: std::string::String,
|
||||
/// Five scenarios in campaign order.
|
||||
pub scenarios: std::vec::Vec<crate::Token2022MetadataValidationScenario>,
|
||||
}
|
||||
|
||||
/// Loads and validates the canonical Token Metadata Devnet matrix.
|
||||
pub fn load_token_2022_metadata_validation_matrix()
|
||||
-> ks_core::Result<crate::Token2022MetadataValidationMatrix> {
|
||||
let matrix = match serde_json::from_str::<crate::Token2022MetadataValidationMatrix>(
|
||||
include_str!(
|
||||
"../../../../../test-fixtures/contract-matrices/SPL_TOKEN_2022_METADATA_DEVNET_VALIDATION_MATRIX.json"
|
||||
),
|
||||
) {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => {
|
||||
return std::result::Result::Err(ks_core::Error::new(
|
||||
"token_2022_metadata_validation_matrix_invalid_json",
|
||||
error.to_string(),
|
||||
));
|
||||
},
|
||||
};
|
||||
return match crate::validate_token_2022_metadata_validation_matrix(&matrix) {
|
||||
std::result::Result::Ok(()) => std::result::Result::Ok(matrix),
|
||||
std::result::Result::Err(error) => std::result::Result::Err(error),
|
||||
};
|
||||
}
|
||||
|
||||
/// Validates campaign coverage and prevents promotion without complete evidence.
|
||||
pub fn validate_token_2022_metadata_validation_matrix(
|
||||
matrix: &crate::Token2022MetadataValidationMatrix,
|
||||
) -> ks_core::Result<()> {
|
||||
if matrix.matrix_version != 1
|
||||
|| matrix.milestone != "0.4.8-pre.012"
|
||||
|| matrix.program_id != ks_program_ids::SPL_TOKEN_2022_PROGRAM_ID
|
||||
{
|
||||
return std::result::Result::Err(ks_core::Error::config(
|
||||
"Token-2022 metadata validation matrix identity is invalid",
|
||||
));
|
||||
}
|
||||
let expected = crate::token_2022_metadata_campaign_operation_names();
|
||||
if matrix.scenarios.len() != expected.len() {
|
||||
return std::result::Result::Err(ks_core::Error::config(format!(
|
||||
"Token-2022 metadata validation matrix requires exactly {} scenarios",
|
||||
expected.len()
|
||||
)));
|
||||
}
|
||||
let mut ids = std::collections::BTreeSet::new();
|
||||
for (index, scenario) in matrix.scenarios.iter().enumerate() {
|
||||
if !ids.insert(scenario.id.as_str()) {
|
||||
return std::result::Result::Err(ks_core::Error::config(format!(
|
||||
"duplicate Token-2022 metadata validation scenario {}",
|
||||
scenario.id
|
||||
)));
|
||||
}
|
||||
if scenario.operation != expected[index] {
|
||||
return std::result::Result::Err(ks_core::Error::config(format!(
|
||||
"Token-2022 metadata scenario {} expected operation {}, got {}",
|
||||
scenario.id, expected[index], scenario.operation
|
||||
)));
|
||||
}
|
||||
if scenario.required_evidence.is_empty()
|
||||
|| scenario.required_evidence.len() > MAX_TOKEN_2022_METADATA_VALIDATION_EVIDENCE
|
||||
|| scenario.evidence.len() > MAX_TOKEN_2022_METADATA_VALIDATION_EVIDENCE
|
||||
{
|
||||
return std::result::Result::Err(ks_core::Error::config(format!(
|
||||
"Token-2022 metadata scenario {} has invalid evidence bounds",
|
||||
scenario.id
|
||||
)));
|
||||
}
|
||||
let mut required = std::collections::BTreeSet::new();
|
||||
if scenario
|
||||
.required_evidence
|
||||
.iter()
|
||||
.any(|kind| return kind.trim().is_empty() || !required.insert(kind.as_str()))
|
||||
{
|
||||
return std::result::Result::Err(ks_core::Error::config(format!(
|
||||
"Token-2022 metadata scenario {} has empty or duplicate required evidence",
|
||||
scenario.id
|
||||
)));
|
||||
}
|
||||
let mut observed = std::collections::BTreeSet::new();
|
||||
for evidence in &scenario.evidence {
|
||||
if evidence.kind.trim().is_empty()
|
||||
|| evidence.value.trim().is_empty()
|
||||
|| !observed.insert(evidence.kind.as_str())
|
||||
{
|
||||
return std::result::Result::Err(ks_core::Error::config(format!(
|
||||
"Token-2022 metadata scenario {} has invalid observed evidence",
|
||||
scenario.id
|
||||
)));
|
||||
}
|
||||
}
|
||||
match scenario.status {
|
||||
crate::Token2022MetadataValidationStatus::NotRun => {
|
||||
if !scenario.evidence.is_empty() {
|
||||
return std::result::Result::Err(ks_core::Error::config(format!(
|
||||
"not-run Token-2022 metadata scenario {} cannot claim evidence",
|
||||
scenario.id
|
||||
)));
|
||||
}
|
||||
},
|
||||
crate::Token2022MetadataValidationStatus::Confirmed => {
|
||||
if scenario
|
||||
.required_evidence
|
||||
.iter()
|
||||
.any(|kind| return !observed.contains(kind.as_str()))
|
||||
{
|
||||
return std::result::Result::Err(ks_core::Error::config(format!(
|
||||
"confirmed Token-2022 metadata scenario {} is missing required evidence",
|
||||
scenario.id
|
||||
)));
|
||||
}
|
||||
},
|
||||
crate::Token2022MetadataValidationStatus::Unavailable => {
|
||||
if !observed.contains("unavailable_reason") {
|
||||
return std::result::Result::Err(ks_core::Error::config(format!(
|
||||
"unavailable Token-2022 metadata scenario {} requires unavailable_reason",
|
||||
scenario.id
|
||||
)));
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
return std::result::Result::Ok(());
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#[test]
|
||||
fn canonical_matrix_is_closed_ordered_and_confirmed_after_network_execution() {
|
||||
let matrix = crate::load_token_2022_metadata_validation_matrix()
|
||||
.unwrap_or_else(|error| panic!("Token-2022 metadata matrix failed: {error}"));
|
||||
assert_eq!(matrix.scenarios.len(), 5);
|
||||
assert!(matrix.scenarios.iter().all(|scenario| {
|
||||
let observed = scenario
|
||||
.evidence
|
||||
.iter()
|
||||
.map(|evidence| return evidence.kind.as_str())
|
||||
.collect::<std::collections::BTreeSet<_>>();
|
||||
return scenario.status == crate::Token2022MetadataValidationStatus::Confirmed
|
||||
&& scenario
|
||||
.required_evidence
|
||||
.iter()
|
||||
.all(|kind| return observed.contains(kind.as_str()));
|
||||
}));
|
||||
assert_eq!(
|
||||
matrix
|
||||
.scenarios
|
||||
.iter()
|
||||
.map(|scenario| return scenario.operation.as_str())
|
||||
.collect::<std::vec::Vec<_>>(),
|
||||
crate::token_2022_metadata_campaign_operation_names().as_slice()
|
||||
);
|
||||
}
|
||||
}
|
||||
440
ks-pipeline-demo-scenarios/src/spl/token_2022/validation.rs
Normal file
440
ks-pipeline-demo-scenarios/src/spl/token_2022/validation.rs
Normal file
@@ -0,0 +1,440 @@
|
||||
// file: ks-pipeline-demo-scenarios/src/spl/token_2022/validation.rs
|
||||
// version: 8
|
||||
|
||||
//! Machine-readable validation evidence contract for the Token-2022 milestone.
|
||||
|
||||
/// Maximum number of evidence records accepted in one validation report.
|
||||
pub const MAX_TOKEN_2022_VALIDATION_EVIDENCE: usize = 64;
|
||||
|
||||
/// Required validation environment for one scenario.
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum Token2022ValidationEnvironment {
|
||||
/// Deterministic offline fixtures and builder comparisons.
|
||||
Offline,
|
||||
/// A local validator under operator control.
|
||||
Localnet,
|
||||
/// Solana Devnet.
|
||||
Devnet,
|
||||
/// Scenario may run on Localnet or Devnet according to deployment availability.
|
||||
LocalnetOrDevnet,
|
||||
/// Mainnet observations without mutable execution.
|
||||
MainnetObservation,
|
||||
/// PostgreSQL persistence and replay validation.
|
||||
Postgres,
|
||||
/// Tauri application smoke validation.
|
||||
Tauri,
|
||||
}
|
||||
|
||||
/// Exact status of one validation scenario.
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum Token2022ValidationStatus {
|
||||
/// Scenario is declared but has not been run.
|
||||
NotRun,
|
||||
/// Scenario was simulated without submission.
|
||||
Simulated,
|
||||
/// Scenario was submitted but confirmation evidence is incomplete.
|
||||
Submitted,
|
||||
/// Scenario was confirmed and its required postconditions passed.
|
||||
Confirmed,
|
||||
/// Scenario is not available in the selected environment.
|
||||
Unavailable,
|
||||
/// Scenario ran and failed.
|
||||
Failed,
|
||||
}
|
||||
|
||||
/// One bounded proof attached to a validation scenario.
|
||||
#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
|
||||
pub struct Token2022ValidationEvidence {
|
||||
/// Stable evidence kind such as `signature`, `test_suite`, or `replay`.
|
||||
pub kind: std::string::String,
|
||||
/// Bounded evidence value.
|
||||
pub value: std::string::String,
|
||||
}
|
||||
|
||||
/// One declared Token-2022 validation scenario and its observed evidence.
|
||||
#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
|
||||
pub struct Token2022ValidationScenario {
|
||||
/// Stable scenario identifier.
|
||||
pub id: std::string::String,
|
||||
/// Validation environment.
|
||||
pub environment: crate::Token2022ValidationEnvironment,
|
||||
/// Exact observed status.
|
||||
pub status: crate::Token2022ValidationStatus,
|
||||
/// Whether canonical hydration is required.
|
||||
pub requires_canonical_hydration: bool,
|
||||
/// Whether core extraction is required.
|
||||
pub requires_core_extraction: bool,
|
||||
/// Whether decode replay is required.
|
||||
pub requires_decode_replay: bool,
|
||||
/// Whether materialization is required.
|
||||
pub requires_materialization: bool,
|
||||
/// Whether a second idempotent replay is required.
|
||||
pub requires_second_replay: bool,
|
||||
/// Ordered evidence records.
|
||||
pub evidence: std::vec::Vec<crate::Token2022ValidationEvidence>,
|
||||
}
|
||||
|
||||
/// Machine-readable validation matrix loaded from the canonical JSON document.
|
||||
#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Token2022ValidationMatrix {
|
||||
/// Matrix schema version.
|
||||
pub matrix_version: u32,
|
||||
/// Milestone owning this matrix.
|
||||
pub milestone: std::string::String,
|
||||
/// Aggregate matrix status.
|
||||
pub status: std::string::String,
|
||||
/// Exact accepted status vocabulary.
|
||||
pub status_vocabulary: std::vec::Vec<std::string::String>,
|
||||
/// Scenarios in stable roadmap order.
|
||||
pub scenarios: std::vec::Vec<crate::Token2022ValidationMatrixScenario>,
|
||||
}
|
||||
|
||||
/// One scenario declared by the canonical validation matrix.
|
||||
#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Token2022ValidationMatrixScenario {
|
||||
/// Stable scenario identifier.
|
||||
pub id: std::string::String,
|
||||
/// Required execution environment.
|
||||
pub environment: crate::Token2022ValidationEnvironment,
|
||||
/// Exact observed status.
|
||||
pub status: crate::Token2022ValidationStatus,
|
||||
/// Evidence kinds required before this scenario may be confirmed.
|
||||
pub required_evidence: std::vec::Vec<std::string::String>,
|
||||
/// Observed bounded evidence.
|
||||
#[serde(default)]
|
||||
pub evidence: std::vec::Vec<crate::Token2022ValidationEvidence>,
|
||||
}
|
||||
|
||||
/// Loads and validates the canonical Token-2022 validation matrix.
|
||||
pub fn load_token_2022_validation_matrix() -> ks_core::Result<crate::Token2022ValidationMatrix> {
|
||||
let parsed = match serde_json::from_str::<crate::Token2022ValidationMatrix>(include_str!(
|
||||
"../../../../test-fixtures/contract-matrices/SPL_TOKEN_2022_VALIDATION_MATRIX.json"
|
||||
)) {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => {
|
||||
return std::result::Result::Err(ks_core::Error::new(
|
||||
"token_2022_validation_matrix_invalid_json",
|
||||
format!("Token-2022 validation matrix JSON is invalid: {error}"),
|
||||
));
|
||||
},
|
||||
};
|
||||
if let std::result::Result::Err(error) = validate_token_2022_validation_matrix(&parsed) {
|
||||
return std::result::Result::Err(error);
|
||||
}
|
||||
return std::result::Result::Ok(parsed);
|
||||
}
|
||||
|
||||
/// Validates schema, scenario inventory, statuses, and observed evidence.
|
||||
pub fn validate_token_2022_validation_matrix(
|
||||
matrix: &crate::Token2022ValidationMatrix,
|
||||
) -> ks_core::Result<()> {
|
||||
if matrix.matrix_version != 2 || matrix.milestone != "0.4.6" {
|
||||
return std::result::Result::Err(ks_core::Error::new(
|
||||
"token_2022_validation_matrix_contract_mismatch",
|
||||
"Token-2022 validation matrix must use version 2 for milestone 0.4.6",
|
||||
));
|
||||
}
|
||||
let expected_statuses =
|
||||
["not_run", "simulated", "submitted", "confirmed", "unavailable", "failed"];
|
||||
let actual_statuses = matrix
|
||||
.status_vocabulary
|
||||
.iter()
|
||||
.map(|status| return status.as_str())
|
||||
.collect::<std::vec::Vec<&str>>();
|
||||
if actual_statuses.as_slice() != expected_statuses {
|
||||
return std::result::Result::Err(ks_core::Error::new(
|
||||
"token_2022_validation_matrix_status_vocabulary_mismatch",
|
||||
"Token-2022 validation status vocabulary differs from the compiled contract",
|
||||
));
|
||||
}
|
||||
let expected_ids = [
|
||||
"offline_full_regression",
|
||||
"token_2022_public_devnet",
|
||||
"elgamal_registry_devnet",
|
||||
"confidential_transfer_localnet_or_devnet",
|
||||
"confidential_mint_burn_localnet_or_devnet",
|
||||
"permissioned_confidential_burn_localnet_or_devnet",
|
||||
"mainnet_observation_corpus",
|
||||
"postgres_double_replay",
|
||||
"tauri_smoke",
|
||||
];
|
||||
let actual_ids = matrix
|
||||
.scenarios
|
||||
.iter()
|
||||
.map(|scenario| return scenario.id.as_str())
|
||||
.collect::<std::vec::Vec<&str>>();
|
||||
if actual_ids.as_slice() != expected_ids {
|
||||
return std::result::Result::Err(ks_core::Error::new(
|
||||
"token_2022_validation_matrix_scenario_inventory_mismatch",
|
||||
"Token-2022 validation scenario inventory or order differs from the compiled contract",
|
||||
));
|
||||
}
|
||||
for scenario in &matrix.scenarios {
|
||||
if scenario.required_evidence.is_empty()
|
||||
|| scenario.required_evidence.len() > crate::MAX_TOKEN_2022_VALIDATION_EVIDENCE
|
||||
{
|
||||
return std::result::Result::Err(ks_core::Error::new(
|
||||
"token_2022_validation_matrix_required_evidence_invalid",
|
||||
"Every Token-2022 validation scenario must declare bounded required evidence",
|
||||
));
|
||||
}
|
||||
let mut required = std::collections::BTreeSet::<&str>::new();
|
||||
for evidence_kind in &scenario.required_evidence {
|
||||
if evidence_kind.trim().is_empty() || !required.insert(evidence_kind.as_str()) {
|
||||
return std::result::Result::Err(ks_core::Error::new(
|
||||
"token_2022_validation_matrix_required_evidence_invalid",
|
||||
"Required Token-2022 validation evidence must be non-empty and unique",
|
||||
));
|
||||
}
|
||||
}
|
||||
if scenario.status == crate::Token2022ValidationStatus::Confirmed {
|
||||
let observed = scenario
|
||||
.evidence
|
||||
.iter()
|
||||
.map(|evidence| return evidence.kind.as_str())
|
||||
.collect::<std::collections::BTreeSet<&str>>();
|
||||
if !required.iter().all(|kind| return observed.contains(kind)) {
|
||||
return std::result::Result::Err(ks_core::Error::new(
|
||||
"token_2022_validation_matrix_confirmed_without_required_evidence",
|
||||
format!(
|
||||
"Confirmed Token-2022 validation scenario {} lacks required evidence",
|
||||
scenario.id
|
||||
),
|
||||
));
|
||||
}
|
||||
} else if matches!(
|
||||
scenario.status,
|
||||
crate::Token2022ValidationStatus::NotRun
|
||||
| crate::Token2022ValidationStatus::Unavailable
|
||||
) && !scenario.evidence.is_empty()
|
||||
{
|
||||
return std::result::Result::Err(ks_core::Error::new(
|
||||
"token_2022_validation_matrix_unobserved_with_evidence",
|
||||
"Not-run or unavailable Token-2022 scenarios must not retain observed evidence",
|
||||
));
|
||||
}
|
||||
}
|
||||
return std::result::Result::Ok(());
|
||||
}
|
||||
|
||||
/// Complete validation report checked before milestone closure.
|
||||
#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
|
||||
pub struct Token2022ValidationReport {
|
||||
/// Scenarios in stable roadmap order.
|
||||
pub scenarios: std::vec::Vec<crate::Token2022ValidationScenario>,
|
||||
}
|
||||
|
||||
/// Validates that a milestone report is bounded, unique, and does not overclaim evidence.
|
||||
pub fn validate_token_2022_validation_report(
|
||||
report: &crate::Token2022ValidationReport,
|
||||
) -> ks_core::Result<()> {
|
||||
if report.scenarios.is_empty() {
|
||||
return std::result::Result::Err(ks_core::Error::config(
|
||||
"Token-2022 validation report must contain at least one scenario",
|
||||
));
|
||||
}
|
||||
let mut ids = std::collections::BTreeSet::<std::string::String>::new();
|
||||
for scenario in &report.scenarios {
|
||||
if scenario.id.trim().is_empty() || !ids.insert(scenario.id.clone()) {
|
||||
return std::result::Result::Err(ks_core::Error::new(
|
||||
"token_2022_validation_scenario_identity_invalid",
|
||||
"Token-2022 validation scenario ids must be non-empty and unique",
|
||||
));
|
||||
}
|
||||
if scenario.evidence.len() > crate::MAX_TOKEN_2022_VALIDATION_EVIDENCE {
|
||||
return std::result::Result::Err(ks_core::Error::new(
|
||||
"token_2022_validation_evidence_limit_exceeded",
|
||||
format!(
|
||||
"Token-2022 validation accepts at most {} evidence records per scenario",
|
||||
crate::MAX_TOKEN_2022_VALIDATION_EVIDENCE
|
||||
),
|
||||
));
|
||||
}
|
||||
for evidence in &scenario.evidence {
|
||||
if evidence.kind.trim().is_empty() || evidence.value.trim().is_empty() {
|
||||
return std::result::Result::Err(ks_core::Error::new(
|
||||
"token_2022_validation_evidence_invalid",
|
||||
"Token-2022 validation evidence kind and value must be non-empty",
|
||||
));
|
||||
}
|
||||
}
|
||||
let completed = matches!(
|
||||
scenario.status,
|
||||
crate::Token2022ValidationStatus::Simulated
|
||||
| crate::Token2022ValidationStatus::Submitted
|
||||
| crate::Token2022ValidationStatus::Confirmed
|
||||
| crate::Token2022ValidationStatus::Failed
|
||||
);
|
||||
if completed && scenario.evidence.is_empty() {
|
||||
return std::result::Result::Err(ks_core::Error::new(
|
||||
"token_2022_validation_completed_without_evidence",
|
||||
"A completed Token-2022 validation scenario must retain evidence",
|
||||
));
|
||||
}
|
||||
if scenario.status == crate::Token2022ValidationStatus::Confirmed
|
||||
&& (scenario.requires_canonical_hydration
|
||||
|| scenario.requires_core_extraction
|
||||
|| scenario.requires_decode_replay
|
||||
|| scenario.requires_materialization
|
||||
|| scenario.requires_second_replay)
|
||||
&& !has_pipeline_evidence(scenario)
|
||||
{
|
||||
return std::result::Result::Err(ks_core::Error::new(
|
||||
"token_2022_validation_confirmed_without_pipeline_evidence",
|
||||
"A confirmed end-to-end Token-2022 scenario must retain hydration, extraction, replay, materialization, and idempotence evidence",
|
||||
));
|
||||
}
|
||||
}
|
||||
return std::result::Result::Ok(());
|
||||
}
|
||||
|
||||
fn has_pipeline_evidence(scenario: &crate::Token2022ValidationScenario) -> bool {
|
||||
let kinds = scenario
|
||||
.evidence
|
||||
.iter()
|
||||
.map(|evidence| return evidence.kind.as_str())
|
||||
.collect::<std::collections::BTreeSet<&str>>();
|
||||
if scenario.requires_canonical_hydration && !kinds.contains("canonical_hydration") {
|
||||
return false;
|
||||
}
|
||||
if scenario.requires_core_extraction && !kinds.contains("core_extraction") {
|
||||
return false;
|
||||
}
|
||||
if scenario.requires_decode_replay && !kinds.contains("decode_replay") {
|
||||
return false;
|
||||
}
|
||||
if scenario.requires_materialization && !kinds.contains("materialization") {
|
||||
return false;
|
||||
}
|
||||
if scenario.requires_second_replay && !kinds.contains("second_replay") {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
fn scenario(status: crate::Token2022ValidationStatus) -> crate::Token2022ValidationScenario {
|
||||
return crate::Token2022ValidationScenario {
|
||||
id: "confidential_transfer_devnet".to_string(),
|
||||
environment: crate::Token2022ValidationEnvironment::Devnet,
|
||||
status,
|
||||
requires_canonical_hydration: true,
|
||||
requires_core_extraction: true,
|
||||
requires_decode_replay: true,
|
||||
requires_materialization: true,
|
||||
requires_second_replay: true,
|
||||
evidence: vec![
|
||||
crate::Token2022ValidationEvidence {
|
||||
kind: "signature".to_string(),
|
||||
value: "signature".to_string(),
|
||||
},
|
||||
crate::Token2022ValidationEvidence {
|
||||
kind: "canonical_hydration".to_string(),
|
||||
value: "1".to_string(),
|
||||
},
|
||||
crate::Token2022ValidationEvidence {
|
||||
kind: "core_extraction".to_string(),
|
||||
value: "1".to_string(),
|
||||
},
|
||||
crate::Token2022ValidationEvidence {
|
||||
kind: "decode_replay".to_string(),
|
||||
value: "1".to_string(),
|
||||
},
|
||||
crate::Token2022ValidationEvidence {
|
||||
kind: "materialization".to_string(),
|
||||
value: "1".to_string(),
|
||||
},
|
||||
crate::Token2022ValidationEvidence {
|
||||
kind: "second_replay".to_string(),
|
||||
value: "0_new_outputs".to_string(),
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn confirmed_end_to_end_scenario_requires_complete_pipeline_evidence() {
|
||||
let report = crate::Token2022ValidationReport {
|
||||
scenarios: vec![scenario(crate::Token2022ValidationStatus::Confirmed)],
|
||||
};
|
||||
assert!(crate::validate_token_2022_validation_report(&report).is_ok());
|
||||
let mut incomplete = scenario(crate::Token2022ValidationStatus::Confirmed);
|
||||
incomplete.evidence.retain(|evidence| return evidence.kind != "second_replay");
|
||||
assert!(
|
||||
crate::validate_token_2022_validation_report(&crate::Token2022ValidationReport {
|
||||
scenarios: vec![incomplete]
|
||||
})
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn not_run_and_unavailable_scenarios_do_not_invent_evidence() {
|
||||
let mut not_run = scenario(crate::Token2022ValidationStatus::NotRun);
|
||||
not_run.evidence.clear();
|
||||
let mut unavailable = scenario(crate::Token2022ValidationStatus::Unavailable);
|
||||
unavailable.id = "permissioned_burn_devnet".to_string();
|
||||
unavailable.evidence.clear();
|
||||
assert!(
|
||||
crate::validate_token_2022_validation_report(&crate::Token2022ValidationReport {
|
||||
scenarios: vec![not_run, unavailable]
|
||||
})
|
||||
.is_ok()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn duplicate_ids_empty_evidence_and_completed_without_evidence_fail_closed() {
|
||||
let first = scenario(crate::Token2022ValidationStatus::Confirmed);
|
||||
let duplicate = first.clone();
|
||||
assert!(
|
||||
crate::validate_token_2022_validation_report(&crate::Token2022ValidationReport {
|
||||
scenarios: vec![first, duplicate]
|
||||
})
|
||||
.is_err()
|
||||
);
|
||||
let mut failed = scenario(crate::Token2022ValidationStatus::Failed);
|
||||
failed.evidence.clear();
|
||||
assert!(
|
||||
crate::validate_token_2022_validation_report(&crate::Token2022ValidationReport {
|
||||
scenarios: vec![failed]
|
||||
})
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn canonical_matrix_matches_compiled_inventory_and_observed_offline_evidence() {
|
||||
let matrix = crate::load_token_2022_validation_matrix();
|
||||
assert!(matrix.is_ok());
|
||||
let matrix = match matrix {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => panic!("unexpected matrix error: {error}"),
|
||||
};
|
||||
assert_eq!(matrix.scenarios.len(), 9);
|
||||
assert_eq!(matrix.scenarios[0].status, crate::Token2022ValidationStatus::Confirmed);
|
||||
assert_eq!(matrix.scenarios[0].evidence.len(), 4);
|
||||
assert!(
|
||||
matrix.scenarios[1..]
|
||||
.iter()
|
||||
.all(|scenario| return scenario.status == crate::Token2022ValidationStatus::NotRun)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn confirmed_matrix_scenario_requires_every_declared_evidence_kind() {
|
||||
let matrix = crate::load_token_2022_validation_matrix();
|
||||
assert!(matrix.is_ok());
|
||||
let mut matrix = match matrix {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => panic!("unexpected matrix error: {error}"),
|
||||
};
|
||||
matrix.scenarios[0].evidence.retain(|evidence| return evidence.kind != "clippy");
|
||||
assert!(crate::validate_token_2022_validation_matrix(&matrix).is_err());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
// file: ks-pipeline-demo-scenarios/tests/external_metadata_solana_program_scenarios_api.rs
|
||||
// version: 3
|
||||
|
||||
//! Public API regression test for Solana Program Metadata Devnet scenarios.
|
||||
|
||||
#[test]
|
||||
fn external_consumers_use_only_public_solana_program_metadata_scenario_exports() {
|
||||
let scenarios = ks_pipeline_demo_scenarios::solana_program_metadata_devnet_scenarios();
|
||||
assert_eq!(scenarios.len(), 2);
|
||||
let matrix = match ks_pipeline_demo_scenarios::load_solana_program_metadata_validation_matrix()
|
||||
{
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => {
|
||||
panic!("unable to load Solana Program Metadata validation matrix: {error}");
|
||||
},
|
||||
};
|
||||
assert_eq!(matrix.operations.len(), 9);
|
||||
let wallet = ks_lib::MdPubkey(solana_pubkey::Pubkey::new_from_array([3_u8; 32]).to_string());
|
||||
let operation = ks_lib::ExMetadataSpmOperation::Extend {
|
||||
account: wallet.clone(),
|
||||
authority: wallet.clone(),
|
||||
length: 1,
|
||||
program_context: std::option::Option::None,
|
||||
};
|
||||
let mut request = ks_pipeline_demo_scenarios::DevnetSolanaProgramMetadataExecutionRequest::new(
|
||||
"external-spm",
|
||||
operation.clone(),
|
||||
);
|
||||
request
|
||||
.preflight_reads
|
||||
.push(ks_pipeline::SolanaProgramMetadataStatefulReadRequest {
|
||||
query_role: "http_queries".to_string(),
|
||||
account: wallet.clone(),
|
||||
expected_state: ks_pipeline::SolanaProgramMetadataExpectedAccountState::Buffer,
|
||||
min_context_slot: std::option::Option::None,
|
||||
max_data_bytes: ks_pipeline::MAX_SOLANA_PROGRAM_METADATA_STATEFUL_ACCOUNT_BYTES,
|
||||
});
|
||||
assert!(request.validate().is_ok());
|
||||
let step = ks_pipeline_demo_scenarios::SolanaProgramMetadataPreparedStep {
|
||||
scenario_id: "external".to_string(),
|
||||
step_id: "extend".to_string(),
|
||||
step_index: 0,
|
||||
operation,
|
||||
preflight_reads: request.preflight_reads.clone(),
|
||||
rent_observations: std::vec::Vec::new(),
|
||||
postcondition_reads: std::vec::Vec::new(),
|
||||
allow_destructive_operation: false,
|
||||
};
|
||||
assert_eq!(step.execution_request("prepared").intent_id, "prepared");
|
||||
let fixture_options =
|
||||
ks_pipeline_demo_scenarios::SolanaProgramMetadataFixturePreparationOptions::new();
|
||||
assert_eq!(fixture_options.query_role, "http_queries");
|
||||
assert_eq!(
|
||||
ks_pipeline_demo_scenarios::SOLANA_PROGRAM_METADATA_FIXTURE_BUFFER_DATA_BYTES,
|
||||
128
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user