diff --git a/README.md b/README.md index fc808de..b11fafc 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,5 @@ - + # Khadhroony Bot3 @@ -53,4 +53,6 @@ cargo clippy --workspace --all-targets ## Décodeurs consolidés -`0.1.0-pre.004` porte le décodeur Solana Core dans `kb-lib`. `0.1.0-pre.005` ajoute SPL Memo v1, v3 et v4. `0.1.0-pre.006` ajoute le décodeur SPL Token classique avec ses 28 tags publiés, son wire borné, ses formes d’autorité et son `Batch` structuré. `0.1.0-pre.007` ajoute le programme SPL Associated Token Account avec ses trois instructions, sa forme historique `Create` vide et la validation canonique des PDA pour SPL Token classique et Token‑2022. Tous utilisent les mêmes contrats contextualisés, la même façade publique et aucune dépendance inverse vers `kb-store`. +`0.1.0-pre.004` porte le décodeur Solana Core dans `kb-lib`. `0.1.0-pre.005` ajoute SPL Memo v1, v3 et v4. `0.1.0-pre.006` ajoute le décodeur SPL Token classique avec ses 28 tags publiés, son wire borné, ses formes d’autorité et son `Batch` structuré. `0.1.0-pre.007` ajoute le programme SPL Associated Token Account avec ses trois instructions, sa forme historique `Create` vide et la validation canonique des PDA pour SPL Token classique et Token‑2022. + +`0.1.0-pre.008` porte le décodeur Token‑2022 maximal, son parseur d’états Mint/Account/Multisig et de TLV, ainsi que le programme indépendant du registre ElGamal. Les deux composants gardent des frontières et des targets de tracing distincts. Tous les décodeurs migrés utilisent les mêmes contrats contextualisés, la même façade publique et aucune dépendance inverse vers `kb-store`. diff --git a/ROADMAP.md b/ROADMAP.md index 2c2e4e7..c92c6f6 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -1,5 +1,5 @@ - + # ROADMAP — khadhroony-bot3 @@ -83,5 +83,18 @@ - [x] Distinguer explicitement SPL Token classique et Token‑2022 sans décoder leurs états ni extensions. - [x] Conserver les dix tests différentiels et la matrice machine-readable ATA. - [x] Porter le target de tracing vers `kb-lib.decoder.spl.associated_token_account`. -- [ ] Valider `kb-program-ids`, les 196 tests de `kb-lib`, Clippy et le workspace avec Cargo sur la machine de développement. -- [ ] Poursuivre avec les décodeurs Token‑2022 et registre ElGamal après validation. +- [x] Valider `kb-program-ids`, les 196 tests de `kb-lib`, Clippy et le workspace avec Cargo sur la machine de développement. +- [x] Poursuivre avec les décodeurs Token‑2022 et registre ElGamal après validation. + +### 0.1.0-pre.008 — Décodeurs Token‑2022 et registre ElGamal dans `kb-lib` + +- [x] Remplacer les scaffolds `token_2022` et `elgamal_registry` par les décodeurs exacts validés de bot2. +- [x] Conserver les 48 enveloppes Token‑2022, les sous-instructions d’extensions identifiables et les interfaces Metadata/Group incorporées. +- [x] Porter le parseur borné des états Mint, Account et Multisig avec leurs entrées TLV ordonnées. +- [x] Maintenir le registre ElGamal comme programme indépendant avec ses deux instructions et son état de compte exact de 64 octets. +- [x] Ajouter le Program ID du registre ElGamal à `kb-program-ids` sans le classer comme surface native. +- [x] Déplacer `ProgramIdEntry` et les fonctions du registre dans `kb-program-ids/src/programs.rs`, avec `lib.rs` limité aux réexports. +- [x] Porter les targets de tracing vers `kb-lib.decoder.spl.token_2022` et `kb-lib.decoder.spl.elgamal_registry`. +- [x] Conserver les 55 tests Token‑2022/ElGamal et leurs matrices machine-readable. +- [ ] Valider `kb-program-ids`, les 251 tests de `kb-lib`, Clippy et le workspace avec Cargo sur la machine de développement. +- [ ] Poursuivre avec le décodeur Metaplex Token Metadata après validation. diff --git a/config/example.config.json b/config/example.config.json index 5e5a2c8..2943cc1 100644 --- a/config/example.config.json +++ b/config/example.config.json @@ -25,10 +25,10 @@ "kb_decoder_metadata_metaplex_token_metadata", "kb-lib.decoder.solana.core", "kb-lib.decoder.spl.associated_token_account", - "kb_decoder_spl_elgamal_registry", + "kb-lib.decoder.spl.elgamal_registry", "kb-lib.decoder.spl.memo", "kb-lib.decoder.spl.token", - "kb_decoder_spl_token_2022", + "kb-lib.decoder.spl.token_2022", "kb_execution_solana", "kb_executor_metadata_metaplex_token_metadata", "kb_executor_metadata_spl_name_service", @@ -1122,42 +1122,42 @@ ] }, { - "name": "file_kb_decoder_spl_token_2022_debug", + "name": "file_kb_lib_decoder_spl_token_2022_debug", "enabled": true, "sink": "file", "level": "debug", - "path": "logs/devnet/kb_decoder_spl_token_2022/debug.log", + "path": "logs/devnet/kb_lib_decoder_spl_token_2022/debug.log", "rotation": "daily", "format": "human", "ansi": false, "targets": [ - "kb_decoder_spl_token_2022" + "kb-lib.decoder.spl.token_2022" ] }, { - "name": "file_kb_decoder_spl_token_2022_info", + "name": "file_kb_lib_decoder_spl_token_2022_info", "enabled": true, "sink": "file", "level": "info", - "path": "logs/devnet/kb_decoder_spl_token_2022/info.log", + "path": "logs/devnet/kb_lib_decoder_spl_token_2022/info.log", "rotation": "daily", "format": "human", "ansi": false, "targets": [ - "kb_decoder_spl_token_2022" + "kb-lib.decoder.spl.token_2022" ] }, { - "name": "file_kb_decoder_spl_token_2022_error", + "name": "file_kb_lib_decoder_spl_token_2022_error", "enabled": true, "sink": "file", "level": "error", - "path": "logs/devnet/kb_decoder_spl_token_2022/error.jsonl", + "path": "logs/devnet/kb_lib_decoder_spl_token_2022/error.jsonl", "rotation": "daily", "format": "json", "ansi": false, "targets": [ - "kb_decoder_spl_token_2022" + "kb-lib.decoder.spl.token_2022" ] }, { @@ -1200,42 +1200,42 @@ ] }, { - "name": "file_kb_decoder_spl_elgamal_registry_debug", + "name": "file_kb_lib_decoder_spl_elgamal_registry_debug", "enabled": true, "sink": "file", "level": "debug", - "path": "logs/devnet/kb_decoder_spl_elgamal_registry/debug.log", + "path": "logs/devnet/kb_lib_decoder_spl_elgamal_registry/debug.log", "rotation": "daily", "format": "human", "ansi": false, "targets": [ - "kb_decoder_spl_elgamal_registry" + "kb-lib.decoder.spl.elgamal_registry" ] }, { - "name": "file_kb_decoder_spl_elgamal_registry_info", + "name": "file_kb_lib_decoder_spl_elgamal_registry_info", "enabled": true, "sink": "file", "level": "info", - "path": "logs/devnet/kb_decoder_spl_elgamal_registry/info.log", + "path": "logs/devnet/kb_lib_decoder_spl_elgamal_registry/info.log", "rotation": "daily", "format": "human", "ansi": false, "targets": [ - "kb_decoder_spl_elgamal_registry" + "kb-lib.decoder.spl.elgamal_registry" ] }, { - "name": "file_kb_decoder_spl_elgamal_registry_error", + "name": "file_kb_lib_decoder_spl_elgamal_registry_error", "enabled": true, "sink": "file", "level": "error", - "path": "logs/devnet/kb_decoder_spl_elgamal_registry/error.jsonl", + "path": "logs/devnet/kb_lib_decoder_spl_elgamal_registry/error.jsonl", "rotation": "daily", "format": "json", "ansi": false, "targets": [ - "kb_decoder_spl_elgamal_registry" + "kb-lib.decoder.spl.elgamal_registry" ] }, { @@ -1627,10 +1627,10 @@ "kb_decoder_metadata_metaplex_token_metadata", "kb-lib.decoder.solana.core", "kb-lib.decoder.spl.associated_token_account", - "kb_decoder_spl_elgamal_registry", + "kb-lib.decoder.spl.elgamal_registry", "kb-lib.decoder.spl.memo", "kb-lib.decoder.spl.token", - "kb_decoder_spl_token_2022", + "kb-lib.decoder.spl.token_2022", "kb_execution_solana", "kb_executor_metadata_metaplex_token_metadata", "kb_executor_metadata_spl_name_service", @@ -2724,42 +2724,42 @@ ] }, { - "name": "file_kb_decoder_spl_token_2022_debug", + "name": "file_kb_lib_decoder_spl_token_2022_debug", "enabled": true, "sink": "file", "level": "debug", - "path": "logs/mainnet_research/kb_decoder_spl_token_2022/debug.log", + "path": "logs/mainnet_research/kb_lib_decoder_spl_token_2022/debug.log", "rotation": "daily", "format": "human", "ansi": false, "targets": [ - "kb_decoder_spl_token_2022" + "kb-lib.decoder.spl.token_2022" ] }, { - "name": "file_kb_decoder_spl_token_2022_info", + "name": "file_kb_lib_decoder_spl_token_2022_info", "enabled": true, "sink": "file", "level": "info", - "path": "logs/mainnet_research/kb_decoder_spl_token_2022/info.log", + "path": "logs/mainnet_research/kb_lib_decoder_spl_token_2022/info.log", "rotation": "daily", "format": "human", "ansi": false, "targets": [ - "kb_decoder_spl_token_2022" + "kb-lib.decoder.spl.token_2022" ] }, { - "name": "file_kb_decoder_spl_token_2022_error", + "name": "file_kb_lib_decoder_spl_token_2022_error", "enabled": true, "sink": "file", "level": "error", - "path": "logs/mainnet_research/kb_decoder_spl_token_2022/error.jsonl", + "path": "logs/mainnet_research/kb_lib_decoder_spl_token_2022/error.jsonl", "rotation": "daily", "format": "json", "ansi": false, "targets": [ - "kb_decoder_spl_token_2022" + "kb-lib.decoder.spl.token_2022" ] }, { @@ -2802,42 +2802,42 @@ ] }, { - "name": "file_kb_decoder_spl_elgamal_registry_debug", + "name": "file_kb_lib_decoder_spl_elgamal_registry_debug", "enabled": true, "sink": "file", "level": "debug", - "path": "logs/mainnet_research/kb_decoder_spl_elgamal_registry/debug.log", + "path": "logs/mainnet_research/kb_lib_decoder_spl_elgamal_registry/debug.log", "rotation": "daily", "format": "human", "ansi": false, "targets": [ - "kb_decoder_spl_elgamal_registry" + "kb-lib.decoder.spl.elgamal_registry" ] }, { - "name": "file_kb_decoder_spl_elgamal_registry_info", + "name": "file_kb_lib_decoder_spl_elgamal_registry_info", "enabled": true, "sink": "file", "level": "info", - "path": "logs/mainnet_research/kb_decoder_spl_elgamal_registry/info.log", + "path": "logs/mainnet_research/kb_lib_decoder_spl_elgamal_registry/info.log", "rotation": "daily", "format": "human", "ansi": false, "targets": [ - "kb_decoder_spl_elgamal_registry" + "kb-lib.decoder.spl.elgamal_registry" ] }, { - "name": "file_kb_decoder_spl_elgamal_registry_error", + "name": "file_kb_lib_decoder_spl_elgamal_registry_error", "enabled": true, "sink": "file", "level": "error", - "path": "logs/mainnet_research/kb_decoder_spl_elgamal_registry/error.jsonl", + "path": "logs/mainnet_research/kb_lib_decoder_spl_elgamal_registry/error.jsonl", "rotation": "daily", "format": "json", "ansi": false, "targets": [ - "kb_decoder_spl_elgamal_registry" + "kb-lib.decoder.spl.elgamal_registry" ] }, { @@ -3429,10 +3429,10 @@ "kb_decoder_metadata_metaplex_token_metadata", "kb-lib.decoder.solana.core", "kb-lib.decoder.spl.associated_token_account", - "kb_decoder_spl_elgamal_registry", + "kb-lib.decoder.spl.elgamal_registry", "kb-lib.decoder.spl.memo", "kb-lib.decoder.spl.token", - "kb_decoder_spl_token_2022", + "kb-lib.decoder.spl.token_2022", "kb_execution_solana", "kb_executor_metadata_metaplex_token_metadata", "kb_executor_metadata_spl_name_service", @@ -4526,42 +4526,42 @@ ] }, { - "name": "file_kb_decoder_spl_token_2022_debug", + "name": "file_kb_lib_decoder_spl_token_2022_debug", "enabled": true, "sink": "file", "level": "debug", - "path": "logs/mainnet/kb_decoder_spl_token_2022/debug.log", + "path": "logs/mainnet/kb_lib_decoder_spl_token_2022/debug.log", "rotation": "daily", "format": "human", "ansi": false, "targets": [ - "kb_decoder_spl_token_2022" + "kb-lib.decoder.spl.token_2022" ] }, { - "name": "file_kb_decoder_spl_token_2022_info", + "name": "file_kb_lib_decoder_spl_token_2022_info", "enabled": true, "sink": "file", "level": "info", - "path": "logs/mainnet/kb_decoder_spl_token_2022/info.log", + "path": "logs/mainnet/kb_lib_decoder_spl_token_2022/info.log", "rotation": "daily", "format": "human", "ansi": false, "targets": [ - "kb_decoder_spl_token_2022" + "kb-lib.decoder.spl.token_2022" ] }, { - "name": "file_kb_decoder_spl_token_2022_error", + "name": "file_kb_lib_decoder_spl_token_2022_error", "enabled": true, "sink": "file", "level": "error", - "path": "logs/mainnet/kb_decoder_spl_token_2022/error.jsonl", + "path": "logs/mainnet/kb_lib_decoder_spl_token_2022/error.jsonl", "rotation": "daily", "format": "json", "ansi": false, "targets": [ - "kb_decoder_spl_token_2022" + "kb-lib.decoder.spl.token_2022" ] }, { @@ -4604,42 +4604,42 @@ ] }, { - "name": "file_kb_decoder_spl_elgamal_registry_debug", + "name": "file_kb_lib_decoder_spl_elgamal_registry_debug", "enabled": true, "sink": "file", "level": "debug", - "path": "logs/mainnet/kb_decoder_spl_elgamal_registry/debug.log", + "path": "logs/mainnet/kb_lib_decoder_spl_elgamal_registry/debug.log", "rotation": "daily", "format": "human", "ansi": false, "targets": [ - "kb_decoder_spl_elgamal_registry" + "kb-lib.decoder.spl.elgamal_registry" ] }, { - "name": "file_kb_decoder_spl_elgamal_registry_info", + "name": "file_kb_lib_decoder_spl_elgamal_registry_info", "enabled": true, "sink": "file", "level": "info", - "path": "logs/mainnet/kb_decoder_spl_elgamal_registry/info.log", + "path": "logs/mainnet/kb_lib_decoder_spl_elgamal_registry/info.log", "rotation": "daily", "format": "human", "ansi": false, "targets": [ - "kb_decoder_spl_elgamal_registry" + "kb-lib.decoder.spl.elgamal_registry" ] }, { - "name": "file_kb_decoder_spl_elgamal_registry_error", + "name": "file_kb_lib_decoder_spl_elgamal_registry_error", "enabled": true, "sink": "file", "level": "error", - "path": "logs/mainnet/kb_decoder_spl_elgamal_registry/error.jsonl", + "path": "logs/mainnet/kb_lib_decoder_spl_elgamal_registry/error.jsonl", "rotation": "daily", "format": "json", "ansi": false, "targets": [ - "kb_decoder_spl_elgamal_registry" + "kb-lib.decoder.spl.elgamal_registry" ] }, { diff --git a/docs/SOLANA_INTERFACE_DEPENDENCIES.md b/docs/SOLANA_INTERFACE_DEPENDENCIES.md index a840d8b..fd69344 100644 --- a/docs/SOLANA_INTERFACE_DEPENDENCIES.md +++ b/docs/SOLANA_INTERFACE_DEPENDENCIES.md @@ -1,5 +1,5 @@ - + # Dépendances d’interfaces Solana et SPL @@ -182,9 +182,11 @@ instructions et extensions Token-2022 reste réservé à `0.4.6`. Le catalogue workspace déclare `spl-token-2022-interface ^3.1` avec `serde` et `spl-elgamal-registry-interface ^0.2` sans feature additionnelle. La résolution opérateur confirme -respectivement `3.1.1` avec `default + serde` et `0.2.1` avec `default`, consommées par -`kb_decoder_spl_token_2022` pour poursuivre l'audit. La matrice inventorie 48 tags de premier -niveau et 29 types TLV de production, dont le récent `PermissionedBurnExtension` `46` et son TLV +respectivement `3.1.1` avec `default + serde` et `0.2.1` avec `default`. Dans bot3, `kb-lib` +les consomme comme dépendances de développement pour les tests différentiels ; le code de +production utilise les parseurs locaux bornés déjà prouvés contre ces interfaces. La matrice +inventorie 48 tags de premier niveau et 29 types TLV de production, dont le récent +`PermissionedBurnExtension` `46` et son TLV `PermissionedBurn` `28`. Les sous-discriminants, comptes, layouts, tailles, builders et processors restent explicitement @@ -211,6 +213,12 @@ ni discriminant, ni builder : elle ajoute seulement, avant Borsh, une validation longueurs de chaînes de `Initialize`, `UpdateField` et `RemoveKey`. Cette garde plus sûre devient normative pour le décodeur ; `UpdateAuthority` et `Emit` restent sans chaîne. +La migration `0.1.0-pre.008` conserve ces contrats dans +`kb_lib::decoder::spl::token_2022` et isole le registre dans +`kb_lib::decoder::spl::elgamal_registry`. Les targets de tracing, matrices et Program IDs restent +distincts ; aucun pointeur TLV n’est interprété comme une preuve de l’état du programme externe +pointé. + ### ZK ElGamal Proof `kb_decoder_solana_core` consomme directement `solana-zk-elgamal-proof-interface ^0.1`. L’enum `ProofInstruction` fournit les treize discriminants et les types de `proof_data` fournissent les tailles `Pod` exactes pour séparer le contexte du corps de preuve inline. Le runtime Agave reste la source normative pour la sélection entre preuve inline et preuve stockée dans un compte, les positions de comptes optionnels et l’acceptation des octets suffixes de `CloseContextState`. diff --git a/docs/SPL_ELGAMAL_REGISTRY_MATRIX.json b/docs/SPL_ELGAMAL_REGISTRY_MATRIX.json index 2320573..1252165 100644 --- a/docs/SPL_ELGAMAL_REGISTRY_MATRIX.json +++ b/docs/SPL_ELGAMAL_REGISTRY_MATRIX.json @@ -1,6 +1,6 @@ { "file": "docs/SPL_ELGAMAL_REGISTRY_MATRIX.json", - "version": 1, + "version": "0.1.0-pre.008", "programId": "regVYJW7tcT8zipN5YiBvHsvR5jXW1uLFxaHSbugABg", "interface": { "crate": "spl-elgamal-registry-interface", @@ -112,7 +112,7 @@ "publishedInlineBuilderOffset": 1 }, "decoder": { - "crate": "kb_decoder_spl_elgamal_registry", + "component": "kb-lib.decoder.spl.elgamal_registry", "coverageEqualityRequired": true, "unknownTagPolicy": "failed", "trailingBytesPolicy": "failed", diff --git a/docs/SPL_TOKEN_2022_MATRIX.json b/docs/SPL_TOKEN_2022_MATRIX.json index 2639640..e492d21 100644 --- a/docs/SPL_TOKEN_2022_MATRIX.json +++ b/docs/SPL_TOKEN_2022_MATRIX.json @@ -1,6 +1,6 @@ { "file": "docs/SPL_TOKEN_2022_MATRIX.json", - "version": "0.4.6-pre.026", + "version": "0.1.0-pre.008", "schemaVersion": 1, "matrixStatus": "exact_decoder_leaf_coverage_proven_pending_materialization_executor_preflight_and_cluster_corpus_completion", "programId": "TokenzQdBNbLqP5VEhdkAS6EPFLC1PHnBqCXEpPxuEb", @@ -30,7 +30,7 @@ "default", "serde" ], - "leafConsumer": "kb_decoder_spl_token_2022", + "leafConsumer": "kb-lib.decoder.spl.token_2022", "operatorEvidence": "cargo tree -i spl-token-2022-interface -e features", "publishedCandidateAudited": "3.1.1", "publishedCandidateStatus": "matches_confirmed_workspace_resolution", @@ -66,12 +66,11 @@ "metadataVersionDriftStatus": "unpack_hardening_only_wire_and_builders_unchanged_by_source_diff" }, "operatorCommandsRequired": [ - "cargo tree -p kb_decoder_spl_token_2022 -e features", - "cargo tree -p kb_executor_spl_token_2022 -e features", + "cargo tree -p kb-lib -e features", "cargo tree -i spl-token-2022-interface -e features", "cargo tree -i spl-elgamal-registry-interface -e features", "cargo metadata --format-version 1 --locked", - "cargo test -p kb_decoder_spl_token_2022", + "cargo test -p kb-lib", "cargo clippy --all-targets" ], "sourceCommit": "e18f9c6f9bf6044b934f48e3090e8e59e4820f02", @@ -2079,13 +2078,13 @@ "default" ], "resolutionStatus": "confirmed_by_operator_cargo_tree_2026-07-16", - "leafConsumer": "kb_decoder_spl_token_2022_for_audit_only_pending_dedicated_crate_decision", + "leafConsumer": "kb-lib.decoder.spl.elgamal_registry", "operatorEvidence": "cargo tree -i spl-elgamal-registry-interface -e features", "programId": "regVYJW7tcT8zipN5YiBvHsvR5jXW1uLFxaHSbugABg", "programIdEvidence": "spl-elgamal-registry-interface 0.2.1 declare_id", "registryAddressSeedUtf8": "elgamal-registry", - "status": "identity_confirmed_separate_instruction_processor_and_cluster_audit_pending", - "matrix": "separate_matrix_required", + "status": "identity_and_exact_two_instruction_decoder_confirmed_separate_stateful_and_cluster_validation_pending", + "matrix": "docs/SPL_ELGAMAL_REGISTRY_MATRIX.json", "sharedInstructionEnum": false, "sharedDecoder": false, "sharedExecutor": false, diff --git a/docs/TRACING_CONTRACT.md b/docs/TRACING_CONTRACT.md index c2ef94e..3022916 100644 --- a/docs/TRACING_CONTRACT.md +++ b/docs/TRACING_CONTRACT.md @@ -1,5 +1,5 @@ - + # Contrat de tracing par crate et composant @@ -58,8 +58,10 @@ Les targets déjà migrés vers les noms canoniques bot3 sont : ```text kb-lib.decoder.solana.core kb-lib.decoder.spl.associated_token_account +kb-lib.decoder.spl.elgamal_registry kb-lib.decoder.spl.memo kb-lib.decoder.spl.token +kb-lib.decoder.spl.token_2022 kb-logging kb-store ``` diff --git a/kb-lib/Cargo.toml b/kb-lib/Cargo.toml index e4290e3..6ce8264 100644 --- a/kb-lib/Cargo.toml +++ b/kb-lib/Cargo.toml @@ -1,5 +1,5 @@ # file: kb-lib/Cargo.toml -# version: 6 +# version: 7 [package] name = "kb-lib" @@ -31,7 +31,9 @@ ts-rs.workspace = true wincode.workspace = true [dev-dependencies] +spl-elgamal-registry-interface.workspace = true spl-memo-interface.workspace = true +spl-token-2022-interface.workspace = true spl-token-interface.workspace = true [lints] diff --git a/kb-lib/README.md b/kb-lib/README.md index 29146f0..54f715a 100644 --- a/kb-lib/README.md +++ b/kb-lib/README.md @@ -1,5 +1,5 @@ - + # kb-lib @@ -56,12 +56,39 @@ puis expose tout écart comme diagnostic. SPL Token classique et Token‑2022 so Program ID sans reconstruire leur état ni interpréter leurs extensions. La matrice normative est `docs/SPL_ASSOCIATED_TOKEN_ACCOUNT_MATRIX.json`. +## Décodeur SPL Token‑2022 + +`SplToken2022Decoder` couvre exclusivement +`TokenzQdBNbLqP5VEhdkAS6EPFLC1PHnBqCXEpPxuEb`. Il conserve les 48 enveloppes de premier niveau, +les sous-instructions d’extensions dont le wire est officiellement identifiable, les interfaces +Token Metadata et Token Group exécutées directement par Token‑2022, ainsi que `Batch` sans batch +imbriqué. + +Le décodeur borne les payloads, les chaînes de metadata, le nombre d’enfants et les comptes +cumulés. Les comptes, autorités, offsets de preuve, suffixes et chemins outer/inner restent +ordonnés et exacts. Une transaction échouée produit une intention non commitée. Le parseur public +`parse_token_2022_state()` distingue Mint, Account et Multisig, conserve la base exacte et expose +les entrées TLV ordonnées sans inventer l’état d’un programme externe pointé. La matrice normative +est `docs/SPL_TOKEN_2022_MATRIX.json`. + +## Décodeur du registre ElGamal + +`SplElgamalRegistryDecoder` couvre le programme indépendant +`regVYJW7tcT8zipN5YiBvHsvR5jXW1uLFxaHSbugABg`. Il décode exactement `CreateRegistry` et +`UpdateRegistry`, conserve l’offset relatif de preuve et ne prétend jamais vérifier la preuve ZK. +`parse_elgamal_registry_state()` exige exactement 64 octets et restitue le wallet propriétaire, la +clé publique ElGamal et le wire complet. La matrice normative est +`docs/SPL_ELGAMAL_REGISTRY_MATRIX.json`. + ## API publique utile - `SolanaCoreDecoder` : décodeur concret natif ; - `SplMemoDecoder` : décodeur exact des trois générations SPL Memo ; - `SplTokenDecoder` : décodeur exact du programme SPL Token classique ; - `SplAssociatedTokenAccountDecoder` : décodeur exact du programme Associated Token Account ; +- `SplToken2022Decoder` : décodeur exact des instructions Token‑2022 ; +- `Token2022State`, `Token2022StateKind`, `Token2022TlvEntry` et `parse_token_2022_state()` : API publique de lecture d’état Token‑2022 ; +- `SplElgamalRegistryDecoder`, `ElGamalRegistryState` et `parse_elgamal_registry_state()` : API publique du registre ElGamal ; - `InstructionDecoder` : contrat de reconnaissance, couverture et décodage contextualisé ; - `ProtocolDecoder` : contrat de compatibilité avec les observations historiques ; - `CoreInstructionReplayInput` : input source-neutral produit par l’extraction core ; diff --git a/kb-lib/src/decoder.rs b/kb-lib/src/decoder.rs index 7b392f9..9d9c3ae 100644 --- a/kb-lib/src/decoder.rs +++ b/kb-lib/src/decoder.rs @@ -1,5 +1,5 @@ // file: kb-lib/src/decoder.rs -// version: 4 +// version: 5 //! Consolidated decoder modules. @@ -33,6 +33,8 @@ pub mod vesting; pub mod wallet; pub mod weighted; +/// One parsed SPL ElGamal registry instruction. +pub(crate) use self::spl::ParsedRegistryInstruction; /// Current stable Associated Token Account decoded event contract version. pub(crate) use self::spl::SPL_ASSOCIATED_TOKEN_ACCOUNT_EVENT_VERSION; /// Exact Associated Token Account instructions published by the official interface. @@ -51,6 +53,14 @@ pub(crate) use self::spl::SPL_ASSOCIATED_TOKEN_ACCOUNT_MAX_TRANSACTION_KEYS; pub(crate) use self::spl::SPL_ASSOCIATED_TOKEN_ACCOUNT_SURFACE_CODE; /// Canonical tracing target for the Associated Token Account decoder. pub(crate) use self::spl::SPL_ASSOCIATED_TOKEN_ACCOUNT_TRACING_TARGET; +/// Current stable SPL ElGamal registry event contract version. +pub(crate) use self::spl::SPL_ELGAMAL_REGISTRY_EVENT_VERSION; +/// Maximum retained SPL ElGamal registry instruction payload size. +pub(crate) use self::spl::SPL_ELGAMAL_REGISTRY_MAX_INSTRUCTION_BYTES; +/// Stable SPL ElGamal registry surface code. +pub(crate) use self::spl::SPL_ELGAMAL_REGISTRY_SURFACE_CODE; +/// Canonical tracing target for the SPL ElGamal registry decoder. +pub(crate) use self::spl::SPL_ELGAMAL_REGISTRY_TRACING_TARGET; /// Maximum payload prefix retained for bounded diagnostics. pub(crate) use self::spl::SPL_MEMO_DIAGNOSTIC_PREFIX_BYTES; /// Current stable Memo decoded event contract version. @@ -67,6 +77,24 @@ pub(crate) use self::spl::SPL_MEMO_V1_SURFACE_CODE; pub(crate) use self::spl::SPL_MEMO_V3_SURFACE_CODE; /// Stable Memo v4 surface code. pub(crate) use self::spl::SPL_MEMO_V4_SURFACE_CODE; +/// Maximum prefix retained in bounded Token-2022 diagnostics. +pub(crate) use self::spl::SPL_TOKEN_2022_DIAGNOSTIC_PREFIX_BYTES; +/// Current stable Token-2022 decoded event contract version. +pub(crate) use self::spl::SPL_TOKEN_2022_EVENT_VERSION; +/// Exact top-level instruction inventory published by the resolved Token-2022 interface. +pub(crate) use self::spl::SPL_TOKEN_2022_INSTRUCTION_ENTRIES; +/// Maximum total account metas consumed by one Token-2022 batch. +pub(crate) use self::spl::SPL_TOKEN_2022_MAX_BATCH_ACCOUNTS; +/// Maximum decoded sub-instructions in one Token-2022 batch. +pub(crate) use self::spl::SPL_TOKEN_2022_MAX_BATCH_INSTRUCTIONS; +/// Maximum retained Token-2022 instruction payload size. +pub(crate) use self::spl::SPL_TOKEN_2022_MAX_INSTRUCTION_BYTES; +/// Maximum retained UTF-8 string size for embedded Token Metadata instructions. +pub(crate) use self::spl::SPL_TOKEN_2022_MAX_METADATA_STRING_BYTES; +/// Stable Token-2022 surface code. +pub(crate) use self::spl::SPL_TOKEN_2022_SURFACE_CODE; +/// Canonical tracing target for the Token-2022 decoder. +pub(crate) use self::spl::SPL_TOKEN_2022_TRACING_TARGET; /// Maximum prefix retained in bounded classic SPL Token diagnostics. pub(crate) use self::spl::SPL_TOKEN_DIAGNOSTIC_PREFIX_BYTES; /// Current stable classic SPL Token decoded event contract version. @@ -89,8 +117,20 @@ pub(crate) use self::spl::spl_associated_token_account_decode; pub(crate) use self::spl::spl_associated_token_account_entry; /// Reads one bounded Associated Token Account payload. pub(crate) use self::spl::spl_associated_token_account_payload; +/// Decodes one bounded SPL ElGamal registry instruction. +pub(crate) use self::spl::spl_elgamal_registry_decode; +/// Decodes one bounded SPL ElGamal registry instruction payload. +pub(crate) use self::spl::spl_elgamal_registry_decode_payload; +/// Parses one exact SPL ElGamal registry instruction. +pub(crate) use self::spl::spl_elgamal_registry_parse; /// Decodes one bounded SPL Memo instruction. pub(crate) use self::spl::spl_memo_decode; +/// Decodes one bounded Token-2022 instruction. +pub(crate) use self::spl::spl_token_2022_decode; +/// Returns the exact entry matching one Token-2022 tag. +pub(crate) use self::spl::spl_token_2022_entry_for_tag; +/// Returns the first byte of one retained Token-2022 payload. +pub(crate) use self::spl::spl_token_2022_payload_tag; /// Decodes one bounded classic SPL Token instruction. pub(crate) use self::spl::spl_token_decode; /// Returns the published entry matching one classic SPL Token tag. @@ -98,9 +138,27 @@ pub(crate) use self::spl::spl_token_entry_for_tag; /// Returns the first byte of one retained classic SPL Token payload. pub(crate) use self::spl::spl_token_payload_tag; +/// Exact byte length of one SPL ElGamal registry account. +pub use self::spl::ELGAMAL_REGISTRY_ACCOUNT_LEN; +/// Parsed SPL ElGamal public-key registry account. +pub use self::spl::ElGamalRegistryState; /// Exact decoder for the SPL Associated Token Account program. pub use self::spl::SplAssociatedTokenAccountDecoder; +/// Exact decoder for the SPL ElGamal registry program. +pub use self::spl::SplElgamalRegistryDecoder; /// Exact decoder for the three registered SPL Memo generations. pub use self::spl::SplMemoDecoder; +/// Exact decoder for the Token-2022 program. +pub use self::spl::SplToken2022Decoder; /// Exact decoder for the classic SPL Token program. pub use self::spl::SplTokenDecoder; +/// Parsed Token-2022 Mint, Account, or Multisig state. +pub use self::spl::Token2022State; +/// Exact Token-2022 base account state kind. +pub use self::spl::Token2022StateKind; +/// One exact Token-2022 TLV entry. +pub use self::spl::Token2022TlvEntry; +/// Parses one exact SPL ElGamal registry account. +pub use self::spl::parse_elgamal_registry_state; +/// Parses one Token-2022 Mint, Account, or Multisig state. +pub use self::spl::parse_token_2022_state; diff --git a/kb-lib/src/decoder/spl.rs b/kb-lib/src/decoder/spl.rs index ad09232..aed2830 100644 --- a/kb-lib/src/decoder/spl.rs +++ b/kb-lib/src/decoder/spl.rs @@ -1,5 +1,5 @@ // file: kb-lib/src/decoder/spl.rs -// version: 4 +// version: 5 //! `spl` decoder family. @@ -37,6 +37,22 @@ pub(crate) use self::associated_token_account::spl_associated_token_account_deco pub(crate) use self::associated_token_account::spl_associated_token_account_entry; /// Reads one bounded Associated Token Account payload. pub(crate) use self::associated_token_account::spl_associated_token_account_payload; +/// One parsed SPL ElGamal registry instruction. +pub(crate) use self::elgamal_registry::ParsedRegistryInstruction; +/// Current stable SPL ElGamal registry event contract version. +pub(crate) use self::elgamal_registry::SPL_ELGAMAL_REGISTRY_EVENT_VERSION; +/// Maximum retained SPL ElGamal registry instruction payload size. +pub(crate) use self::elgamal_registry::SPL_ELGAMAL_REGISTRY_MAX_INSTRUCTION_BYTES; +/// Stable SPL ElGamal registry surface code. +pub(crate) use self::elgamal_registry::SPL_ELGAMAL_REGISTRY_SURFACE_CODE; +/// Canonical tracing target for the SPL ElGamal registry decoder. +pub(crate) use self::elgamal_registry::SPL_ELGAMAL_REGISTRY_TRACING_TARGET; +/// Decodes one bounded SPL ElGamal registry instruction. +pub(crate) use self::elgamal_registry::spl_elgamal_registry_decode; +/// Decodes one bounded SPL ElGamal registry instruction payload. +pub(crate) use self::elgamal_registry::spl_elgamal_registry_decode_payload; +/// Parses one exact SPL ElGamal registry instruction. +pub(crate) use self::elgamal_registry::spl_elgamal_registry_parse; /// Maximum payload prefix retained for bounded diagnostics. pub(crate) use self::memo::SPL_MEMO_DIAGNOSTIC_PREFIX_BYTES; /// Current stable Memo decoded event contract version. @@ -77,10 +93,52 @@ pub(crate) use self::token::spl_token_decode; pub(crate) use self::token::spl_token_entry_for_tag; /// Returns the first byte of one retained classic SPL Token payload. pub(crate) use self::token::spl_token_payload_tag; +/// Maximum prefix retained in bounded Token-2022 diagnostics. +pub(crate) use self::token_2022::SPL_TOKEN_2022_DIAGNOSTIC_PREFIX_BYTES; +/// Current stable Token-2022 decoded event contract version. +pub(crate) use self::token_2022::SPL_TOKEN_2022_EVENT_VERSION; +/// Exact top-level instruction inventory published by the resolved Token-2022 interface. +pub(crate) use self::token_2022::SPL_TOKEN_2022_INSTRUCTION_ENTRIES; +/// Maximum total account metas consumed by one Token-2022 batch. +pub(crate) use self::token_2022::SPL_TOKEN_2022_MAX_BATCH_ACCOUNTS; +/// Maximum decoded sub-instructions in one Token-2022 batch. +pub(crate) use self::token_2022::SPL_TOKEN_2022_MAX_BATCH_INSTRUCTIONS; +/// Maximum retained Token-2022 instruction payload size. +pub(crate) use self::token_2022::SPL_TOKEN_2022_MAX_INSTRUCTION_BYTES; +/// Maximum retained UTF-8 string size for embedded Token Metadata instructions. +pub(crate) use self::token_2022::SPL_TOKEN_2022_MAX_METADATA_STRING_BYTES; +/// Stable Token-2022 surface code. +pub(crate) use self::token_2022::SPL_TOKEN_2022_SURFACE_CODE; +/// Canonical tracing target for the Token-2022 decoder. +pub(crate) use self::token_2022::SPL_TOKEN_2022_TRACING_TARGET; +/// Decodes one bounded Token-2022 instruction. +pub(crate) use self::token_2022::spl_token_2022_decode; +/// Returns the exact entry matching one Token-2022 tag. +pub(crate) use self::token_2022::spl_token_2022_entry_for_tag; +/// Returns the first byte of one retained Token-2022 payload. +pub(crate) use self::token_2022::spl_token_2022_payload_tag; /// Exact decoder for the SPL Associated Token Account program. pub use self::associated_token_account::SplAssociatedTokenAccountDecoder; +/// Exact byte length of one SPL ElGamal registry account. +pub use self::elgamal_registry::ELGAMAL_REGISTRY_ACCOUNT_LEN; +/// Parsed SPL ElGamal public-key registry account. +pub use self::elgamal_registry::ElGamalRegistryState; +/// Exact decoder for the SPL ElGamal registry program. +pub use self::elgamal_registry::SplElgamalRegistryDecoder; +/// Parses one exact SPL ElGamal registry account. +pub use self::elgamal_registry::parse_elgamal_registry_state; /// Exact decoder for the three registered SPL Memo generations. pub use self::memo::SplMemoDecoder; /// Exact decoder for the classic SPL Token program. pub use self::token::SplTokenDecoder; +/// Exact decoder for the Token-2022 program. +pub use self::token_2022::SplToken2022Decoder; +/// Parsed Token-2022 Mint, Account, or Multisig state. +pub use self::token_2022::Token2022State; +/// Exact Token-2022 base account state kind. +pub use self::token_2022::Token2022StateKind; +/// One exact Token-2022 TLV entry. +pub use self::token_2022::Token2022TlvEntry; +/// Parses one Token-2022 Mint, Account, or Multisig state. +pub use self::token_2022::parse_token_2022_state; diff --git a/kb-lib/src/decoder/spl/elgamal_registry.rs b/kb-lib/src/decoder/spl/elgamal_registry.rs index df4f465..ef115c3 100644 --- a/kb-lib/src/decoder/spl/elgamal_registry.rs +++ b/kb-lib/src/decoder/spl/elgamal_registry.rs @@ -1,10 +1,35 @@ // file: kb-lib/src/decoder/spl/elgamal_registry.rs -// version: 1 +// version: 2 -//! Migration boundary for legacy crate `kb_decoder_spl_elgamal_registry`. +//! Exact SPL Token-2022 ElGamal public-key registry decoder component. -/// Legacy crate name retained for migration and compatibility tracking. -pub const LEGACY_CRATE: &str = "kb_decoder_spl_elgamal_registry"; +mod constants; +mod decoder; +mod state; +mod wire; -/// Current porting status. -pub const MIGRATION_STATUS: &str = "source-preserved-pending-port"; +/// Current stable SPL ElGamal registry event contract version. +pub(crate) use self::constants::SPL_ELGAMAL_REGISTRY_EVENT_VERSION; +/// Maximum retained SPL ElGamal registry instruction payload size. +pub(crate) use self::constants::SPL_ELGAMAL_REGISTRY_MAX_INSTRUCTION_BYTES; +/// Stable SPL ElGamal registry surface code. +pub(crate) use self::constants::SPL_ELGAMAL_REGISTRY_SURFACE_CODE; +/// Canonical tracing target for the SPL ElGamal registry decoder. +pub(crate) use self::constants::SPL_ELGAMAL_REGISTRY_TRACING_TARGET; +/// One parsed SPL ElGamal registry instruction. +pub(crate) use self::wire::ParsedRegistryInstruction; +/// Decodes one bounded SPL ElGamal registry instruction. +pub(crate) use self::wire::spl_elgamal_registry_decode; +/// Decodes one bounded SPL ElGamal registry instruction payload. +pub(crate) use self::wire::spl_elgamal_registry_decode_payload; +/// Parses one exact SPL ElGamal registry instruction. +pub(crate) use self::wire::spl_elgamal_registry_parse; + +/// Exact decoder for the SPL ElGamal registry program. +pub use self::decoder::SplElgamalRegistryDecoder; +/// Exact byte length of one SPL ElGamal registry account. +pub use self::state::ELGAMAL_REGISTRY_ACCOUNT_LEN; +/// Parsed SPL ElGamal public-key registry account. +pub use self::state::ElGamalRegistryState; +/// Parses one exact SPL ElGamal registry account. +pub use self::state::parse_elgamal_registry_state; diff --git a/kb-lib/src/decoder/spl/elgamal_registry/constants.rs b/kb-lib/src/decoder/spl/elgamal_registry/constants.rs new file mode 100644 index 0000000..eaee3ba --- /dev/null +++ b/kb-lib/src/decoder/spl/elgamal_registry/constants.rs @@ -0,0 +1,11 @@ +// file: kb-lib/src/decoder/spl/elgamal_registry/constants.rs +// version: 1 + +//! Constants for the SPL ElGamal registry decoder. + +pub(crate) const SPL_ELGAMAL_REGISTRY_SURFACE_CODE: &str = "spl_elgamal_registry"; +pub(crate) const SPL_ELGAMAL_REGISTRY_EVENT_VERSION: u32 = 1; +pub(crate) const SPL_ELGAMAL_REGISTRY_MAX_INSTRUCTION_BYTES: usize = 16; + +/// Canonical tracing target for this crate. +pub(crate) const SPL_ELGAMAL_REGISTRY_TRACING_TARGET: &str = "kb-lib.decoder.spl.elgamal_registry"; diff --git a/kb-lib/src/decoder/spl/elgamal_registry/decoder.rs b/kb-lib/src/decoder/spl/elgamal_registry/decoder.rs new file mode 100644 index 0000000..a8657bc --- /dev/null +++ b/kb-lib/src/decoder/spl/elgamal_registry/decoder.rs @@ -0,0 +1,246 @@ +// file: kb-lib/src/decoder/spl/elgamal_registry/decoder.rs +// version: 1 + +//! Exact dispatch for the SPL ElGamal registry program. + +const SURFACES: &[crate::DecoderSurface] = &[crate::DecoderSurface { + program_id: kb_program_ids::SPL_TOKEN_2022_ELGAMAL_REGISTRY_PROGRAM_ID, + surface_code: crate::SPL_ELGAMAL_REGISTRY_SURFACE_CODE, + priority: 100, +}]; + +const PROGRAM_IDS: &[&str] = &[kb_program_ids::SPL_TOKEN_2022_ELGAMAL_REGISTRY_PROGRAM_ID]; + +/// Decoder for the dedicated Token-2022 ElGamal public-key registry program. +#[derive(Clone, Debug, Default)] +pub struct SplElgamalRegistryDecoder; + +impl crate::ProtocolDecoder for crate::SplElgamalRegistryDecoder { + fn decoder_name(&self) -> &'static str { + return "kb_decoder_spl_elgamal_registry"; + } + + fn decoder_version(&self) -> &'static str { + return env!("CARGO_PKG_VERSION"); + } + + fn program_ids(&self) -> &'static [&'static str] { + return PROGRAM_IDS; + } + + fn supports_observation( + &self, + observation: &crate::ProgramObservation, + ) -> crate::DecoderSupport { + return if crate::ProtocolDecoder::handles_program_id(self, &observation.program_id) { + crate::DecoderSupport::Yes + } else { + crate::DecoderSupport::No + }; + } + + fn decode_observation( + &self, + _observation: &crate::ProgramObservation, + ) -> kb_core::Result> { + return std::result::Result::Ok(std::vec::Vec::new()); + } +} + +impl crate::InstructionDecoder for crate::SplElgamalRegistryDecoder { + fn identity(&self) -> crate::DecoderIdentity { + return crate::DecoderIdentity { + name: "spl_elgamal_registry".to_string(), + version: env!("CARGO_PKG_VERSION").to_string(), + }; + } + + fn surfaces(&self) -> &'static [crate::DecoderSurface] { + return SURFACES; + } + + fn coverage(&self) -> std::vec::Vec { + return [(0_u8, "create_registry"), (1_u8, "update_registry")] + .into_iter() + .map(|(tag, code)| { + return crate::DecoderCoverageDeclaration { + program_id: kb_program_ids::SPL_TOKEN_2022_ELGAMAL_REGISTRY_PROGRAM_ID + .to_string(), + surface_code: std::option::Option::Some( + crate::SPL_ELGAMAL_REGISTRY_SURFACE_CODE.to_string(), + ), + entry_kind: crate::DecoderCoverageEntryKind::Instruction, + entry_code: code.to_string(), + discriminator_hex: std::option::Option::Some(format!("{tag:02x}")), + historical: false, + }; + }) + .collect(); + } + + fn recognize(&self, input: &crate::CoreInstructionReplayInput) -> crate::DecoderRecognition { + if input.program_id != kb_program_ids::SPL_TOKEN_2022_ELGAMAL_REGISTRY_PROGRAM_ID { + return crate::DecoderRecognition::incompatible(); + } + let entry = input + .instruction_payload_json + .as_ref() + .and_then(|payload| return payload.get("dataBase64")) + .and_then(serde_json::Value::as_str) + .and_then(|encoded| { + use base64::Engine; // rust-rules: trait-import + return base64::engine::general_purpose::STANDARD.decode(encoded.as_bytes()).ok(); + }) + .and_then(|bytes| return bytes.first().copied()) + .and_then(|tag| { + return match tag { + 0 => std::option::Option::Some("create_registry".to_string()), + 1 => std::option::Option::Some("update_registry".to_string()), + _ => std::option::Option::None, + }; + }); + return crate::DecoderRecognition::compatible( + entry.is_some(), + 100, + std::option::Option::Some(crate::SPL_ELGAMAL_REGISTRY_SURFACE_CODE.to_string()), + entry, + std::option::Option::None, + ); + } + + fn decode(&self, input: &crate::CoreInstructionReplayInput) -> crate::DecoderExecutionResult { + if input.program_id != kb_program_ids::SPL_TOKEN_2022_ELGAMAL_REGISTRY_PROGRAM_ID { + return crate::DecoderExecutionResult::unsupported(std::option::Option::None); + } + let result = crate::spl_elgamal_registry_decode(input); + if matches!( + result.status, + crate::DecoderOutcomeStatus::Failed | crate::DecoderOutcomeStatus::Unsupported + ) { + tracing::error!( + target: crate::SPL_ELGAMAL_REGISTRY_TRACING_TARGET, + action = "decode_failure", + signature = %input.signature, + slot = input.slot, + instruction_path = %input.instruction_path, + program_id = %input.program_id, + processor_name = "spl_elgamal_registry", + processor_version = env!("CARGO_PKG_VERSION"), + input_key = %input.replay_input_key, + payload_hash = ?input.instruction_payload_hash, + transaction_failed = input.transaction_failed, + result_status = ?result.status, + diagnostics = ?result.diagnostics, + "SPL ElGamal registry instruction was not decoded successfully" + ); + } + tracing::debug!( + target: crate::SPL_ELGAMAL_REGISTRY_TRACING_TARGET, + action = "decode", + signature = %input.signature, + instruction_path = %input.instruction_path, + program_id = %input.program_id, + transaction_failed = input.transaction_failed, + result_status = ?result.status, + observation_count = result.observations.len(), + "SPL ElGamal registry instruction decode completed" + ); + return result; + } +} + +#[cfg(test)] +mod tests { + use base64::Engine; // rust-rules: trait-import + + fn input( + program_id: &str, + payload: &[u8], + failed: bool, + path: &str, + ) -> crate::CoreInstructionReplayInput { + let result = crate::CoreInstructionReplayInput::new( + format!("signature:{path}"), + "signature", + 42, + path, + program_id, + failed, + if failed { + std::option::Option::Some(serde_json::json!({"InstructionError":[0,"Custom"]})) + } else { + std::option::Option::None + }, + serde_json::json!([]), + serde_json::json!([]), + std::option::Option::Some(serde_json::json!({ + "dataBase64": base64::engine::general_purpose::STANDARD.encode(payload) + })), + std::option::Option::None, + serde_json::json!([]), + serde_json::json!([]), + serde_json::json!([]), + serde_json::json!([]), + ); + return match result { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => panic!("registry replay input failed: {error}"), + }; + } + + #[test] + fn exact_program_boundary_and_interface_id_are_preserved() { + assert_eq!( + spl_elgamal_registry_interface::id().to_string(), + kb_program_ids::SPL_TOKEN_2022_ELGAMAL_REGISTRY_PROGRAM_ID + ); + let decoder = crate::SplElgamalRegistryDecoder; + assert_eq!(crate::InstructionDecoder::coverage(&decoder).len(), 2); + let foreign = input(kb_program_ids::SPL_TOKEN_2022_PROGRAM_ID, &[0, 1], false, "0"); + assert_eq!( + crate::InstructionDecoder::decode(&decoder, &foreign).status, + crate::DecoderOutcomeStatus::Unsupported + ); + } + + #[test] + fn create_and_update_decode_exact_offsets_and_commit_state() { + for (tag, code, offset, failed, path) in [ + (0_u8, "create_registry", 1_i8, false, "0"), + (1_u8, "update_registry", -1_i8, true, "1/0"), + (1_u8, "update_registry", 0_i8, false, "2"), + ] { + let input = input( + kb_program_ids::SPL_TOKEN_2022_ELGAMAL_REGISTRY_PROGRAM_ID, + &[tag, offset as u8], + failed, + path, + ); + let result = + crate::InstructionDecoder::decode(&crate::SplElgamalRegistryDecoder, &input); + assert_eq!(result.status, crate::DecoderOutcomeStatus::Decoded); + assert_eq!(result.recognized_entry_code.as_deref(), std::option::Option::Some(code)); + assert_eq!( + result.observations[0].payload_json["proofInstructionOffset"], + serde_json::json!(offset) + ); + assert_eq!(result.observations[0].observation_committed, !failed); + } + } + + #[test] + fn malformed_unknown_and_suffixed_wire_fail_closed() { + for payload in [std::vec::Vec::new(), vec![0], vec![2, 0], vec![0, 1, 2]] { + let input = input( + kb_program_ids::SPL_TOKEN_2022_ELGAMAL_REGISTRY_PROGRAM_ID, + payload.as_slice(), + false, + "0", + ); + let result = + crate::InstructionDecoder::decode(&crate::SplElgamalRegistryDecoder, &input); + assert_eq!(result.status, crate::DecoderOutcomeStatus::Failed); + assert!(result.observations.is_empty()); + } + } +} diff --git a/kb-lib/src/decoder/spl/elgamal_registry/state.rs b/kb-lib/src/decoder/spl/elgamal_registry/state.rs new file mode 100644 index 0000000..1fe9d64 --- /dev/null +++ b/kb-lib/src/decoder/spl/elgamal_registry/state.rs @@ -0,0 +1,76 @@ +// file: kb-lib/src/decoder/spl/elgamal_registry/state.rs +// version: 1 + +//! Strict SPL ElGamal registry account-state parsing. + +use base64::Engine; // rust-rules: trait-import + +/// Exact byte length of one SPL ElGamal registry account. +pub const ELGAMAL_REGISTRY_ACCOUNT_LEN: usize = 64; + +/// Public state retained by one SPL ElGamal registry account. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct ElGamalRegistryState { + /// Wallet address associated with this registry. + pub owner: String, + /// Exact 32-byte ElGamal public key encoded as standard base64. + pub elgamal_pubkey_base64: String, + /// Exact account bytes encoded as lowercase hexadecimal. + pub wire_hex: String, +} + +/// Parse one exact SPL ElGamal registry account. +pub fn parse_elgamal_registry_state( + data: &[u8], +) -> std::result::Result { + if data.len() != ELGAMAL_REGISTRY_ACCOUNT_LEN { + return std::result::Result::Err(format!( + "SPL ElGamal registry account requires exactly {ELGAMAL_REGISTRY_ACCOUNT_LEN} bytes; received {}", + data.len() + )); + } + return std::result::Result::Ok(ElGamalRegistryState { + owner: bs58::encode(&data[..32]).into_string(), + elgamal_pubkey_base64: base64::engine::general_purpose::STANDARD.encode(&data[32..64]), + wire_hex: lower_hex(data), + }); +} + +fn lower_hex(bytes: &[u8]) -> String { + let mut output = String::with_capacity(bytes.len() * 2); + for byte in bytes { + output.push_str(&format!("{byte:02x}")); + } + return output; +} + +#[cfg(test)] +mod tests { + use base64::Engine; // rust-rules: trait-import + + #[test] + fn exact_registry_state_preserves_owner_key_and_wire() { + let mut data = [0u8; crate::ELGAMAL_REGISTRY_ACCOUNT_LEN]; + data[..32].fill(7); + data[32..].fill(9); + let parsed = crate::parse_elgamal_registry_state(&data); + assert_eq!( + parsed.as_ref().map(|state| return state.owner.clone()), + std::result::Result::Ok(bs58::encode([7u8; 32]).into_string()) + ); + assert_eq!( + parsed.as_ref().map(|state| return state.elgamal_pubkey_base64.clone()), + std::result::Result::Ok(base64::engine::general_purpose::STANDARD.encode([9u8; 32])) + ); + assert_eq!( + parsed.as_ref().map(|state| return state.wire_hex.len()), + std::result::Result::Ok(128) + ); + } + + #[test] + fn registry_state_rejects_every_non_exact_length() { + assert!(crate::parse_elgamal_registry_state(&[0; 63]).is_err()); + assert!(crate::parse_elgamal_registry_state(&[0; 65]).is_err()); + } +} diff --git a/kb-lib/src/decoder/spl/elgamal_registry/wire.rs b/kb-lib/src/decoder/spl/elgamal_registry/wire.rs new file mode 100644 index 0000000..5d2671b --- /dev/null +++ b/kb-lib/src/decoder/spl/elgamal_registry/wire.rs @@ -0,0 +1,195 @@ +// file: kb-lib/src/decoder/spl/elgamal_registry/wire.rs +// version: 1 + +//! Exact two-byte SPL ElGamal registry instruction decoding. + +use base64::Engine; // rust-rules: trait-import +use sha2::Digest; // rust-rules: trait-import + +#[derive(Clone, Debug)] +pub(crate) struct ParsedRegistryInstruction { + pub(crate) code: &'static str, + pub(crate) tag: u8, + pub(crate) proof_instruction_offset: i8, +} + +pub(crate) fn spl_elgamal_registry_decode_payload( + input: &crate::CoreInstructionReplayInput, +) -> std::result::Result, std::string::String> { + let payload = match input.instruction_payload_json.as_ref() { + std::option::Option::Some(value) => value, + std::option::Option::None => { + return std::result::Result::Err( + "ElGamal registry instruction payload is not retained".to_string(), + ); + }, + }; + let encoded = match payload.get("dataBase64").and_then(serde_json::Value::as_str) { + std::option::Option::Some(value) => value, + std::option::Option::None => { + return std::result::Result::Err( + "ElGamal registry payload does not contain dataBase64".to_string(), + ); + }, + }; + if encoded.len() > 32 { + return std::result::Result::Err( + "ElGamal registry base64 payload is too large".to_string(), + ); + } + let bytes = match base64::engine::general_purpose::STANDARD.decode(encoded.as_bytes()) { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => { + return std::result::Result::Err(format!( + "ElGamal registry payload is not valid base64: {error}" + )); + }, + }; + if bytes.len() > crate::SPL_ELGAMAL_REGISTRY_MAX_INSTRUCTION_BYTES { + return std::result::Result::Err(format!( + "ElGamal registry payload exceeds {} bytes", + crate::SPL_ELGAMAL_REGISTRY_MAX_INSTRUCTION_BYTES + )); + } + return std::result::Result::Ok(bytes); +} + +pub(crate) fn spl_elgamal_registry_parse( + bytes: &[u8], +) -> std::result::Result { + if bytes.len() != 2 { + return std::result::Result::Err(format!( + "ElGamal registry instruction requires exactly 2 bytes; received {}", + bytes.len() + )); + } + let code = match bytes[0] { + 0 => "create_registry", + 1 => "update_registry", + tag => return std::result::Result::Err(format!("unknown ElGamal registry tag {tag}")), + }; + return std::result::Result::Ok(crate::ParsedRegistryInstruction { + code, + tag: bytes[0], + proof_instruction_offset: bytes[1] as i8, + }); +} + +pub(crate) fn spl_elgamal_registry_decode( + input: &crate::CoreInstructionReplayInput, +) -> crate::DecoderExecutionResult { + let bytes = match crate::spl_elgamal_registry_decode_payload(input) { + std::result::Result::Ok(value) => value, + std::result::Result::Err(message) => { + return crate::DecoderExecutionResult { + status: crate::DecoderOutcomeStatus::Failed, + recognized_entry_code: std::option::Option::None, + observations: std::vec::Vec::new(), + diagnostics: std::vec![crate::DecoderDiagnostic { + code: "elgamal_registry_payload_invalid".to_string(), + message, + retriable: false, + }], + }; + }, + }; + let parsed = match crate::spl_elgamal_registry_parse(bytes.as_slice()) { + std::result::Result::Ok(value) => value, + std::result::Result::Err(message) => { + return crate::DecoderExecutionResult { + status: crate::DecoderOutcomeStatus::Failed, + recognized_entry_code: bytes.first().and_then(|tag| { + return match tag { + 0 => std::option::Option::Some("create_registry".to_string()), + 1 => std::option::Option::Some("update_registry".to_string()), + _ => std::option::Option::None, + }; + }), + observations: std::vec::Vec::new(), + diagnostics: std::vec![crate::DecoderDiagnostic { + code: "elgamal_registry_wire_invalid".to_string(), + message, + retriable: false, + }], + }; + }, + }; + let committed = !input.transaction_failed; + let wire_hash = hash(bytes.as_slice()); + let event = crate::DecodedProtocolEvent { + signature: crate::Signature(input.signature.clone()), + slot: crate::Slot(input.slot), + instruction_path: crate::InstructionPath(input.instruction_path.clone()), + program_id: crate::ProgramId(input.program_id.clone()), + protocol_code: crate::ProtocolCode(crate::SPL_ELGAMAL_REGISTRY_SURFACE_CODE.to_string()), + surface_code: crate::SurfaceCode(crate::SPL_ELGAMAL_REGISTRY_SURFACE_CODE.to_string()), + event_code: crate::EventCode(format!( + "{}.{}", + crate::SPL_ELGAMAL_REGISTRY_SURFACE_CODE, + parsed.code + )), + event_name: crate::EventName(parsed.code.to_string()), + event_family: crate::EventFamily::Admin, + source_kind: if input.instruction_path.contains('/') { + crate::EventSourceKind::InnerInstruction + } else { + crate::EventSourceKind::Instruction + }, + confidence: crate::DecoderConfidence::ManualExact, + }; + let observation = crate::DecodedObservation { + event_key: format!("elgamal_registry:{}:0", parsed.code), + event, + payload_json: serde_json::json!({ + "eventVersion": crate::SPL_ELGAMAL_REGISTRY_EVENT_VERSION, + "instruction": parsed.code, + "wireTag": parsed.tag, + "proofInstructionOffset": parsed.proof_instruction_offset, + "proofLocationKind": if parsed.proof_instruction_offset == 0 { "context_state_account" } else { "relative_instruction_offset" }, + "instructionPath": input.instruction_path, + "instructionLocation": if input.instruction_path.contains('/') { "inner" } else { "outer" }, + "transactionSucceeded": !input.transaction_failed, + "committed": committed, + "accounts": input.instruction_accounts_json, + "wire": { + "lengthBytes": bytes.len(), + "sha256": wire_hash, + "prefixHex": hex(bytes.as_slice()), + "complete": true, + }, + "programBoundary": { + "registryProgramId": kb_program_ids::SPL_TOKEN_2022_ELGAMAL_REGISTRY_PROGRAM_ID, + "token2022ProgramId": kb_program_ids::SPL_TOKEN_2022_PROGRAM_ID, + "zkProofProgramId": kb_program_ids::ZK_ELGAMAL_PROOF_PROGRAM_ID, + "proofDecodedHere": false, + } + }), + transaction_failed: input.transaction_failed, + transaction_error: input.transaction_err_json.clone(), + observation_committed: committed, + proof: crate::DecoderProof { + kind: crate::DecoderProofKind::Manual, + confidence: crate::DecoderConfidence::ManualExact, + evidence: std::vec![ + "spl_elgamal_registry_interface_0_2_1_registry_instruction_unpack".to_string(), + format!("wire_tag:{}", parsed.tag), + format!("wire_sha256:{wire_hash}"), + ], + }, + }; + return crate::DecoderExecutionResult { + status: crate::DecoderOutcomeStatus::Decoded, + recognized_entry_code: std::option::Option::Some(parsed.code.to_string()), + observations: std::vec![observation], + diagnostics: std::vec::Vec::new(), + }; +} + +fn hash(bytes: &[u8]) -> std::string::String { + let digest = sha2::Sha256::digest(bytes); + return digest.iter().map(|byte| return format!("{byte:02x}")).collect(); +} + +fn hex(bytes: &[u8]) -> std::string::String { + return bytes.iter().map(|byte| return format!("{byte:02x}")).collect(); +} diff --git a/kb-lib/src/decoder/spl/token_2022.rs b/kb-lib/src/decoder/spl/token_2022.rs index 7cc08be..7a50e46 100644 --- a/kb-lib/src/decoder/spl/token_2022.rs +++ b/kb-lib/src/decoder/spl/token_2022.rs @@ -1,10 +1,45 @@ // file: kb-lib/src/decoder/spl/token_2022.rs -// version: 1 +// version: 2 -//! Migration boundary for legacy crate `kb_decoder_spl_token_2022`. +//! Exact Token-2022 instruction and state decoder component. -/// Legacy crate name retained for migration and compatibility tracking. -pub const LEGACY_CRATE: &str = "kb_decoder_spl_token_2022"; +mod constants; +mod decoder; +mod state; +mod wire; -/// Current porting status. -pub const MIGRATION_STATUS: &str = "source-preserved-pending-port"; +/// Maximum prefix retained in bounded Token-2022 diagnostics. +pub(crate) use self::constants::SPL_TOKEN_2022_DIAGNOSTIC_PREFIX_BYTES; +/// Current stable Token-2022 decoded event contract version. +pub(crate) use self::constants::SPL_TOKEN_2022_EVENT_VERSION; +/// Exact top-level instruction inventory published by the resolved Token-2022 interface. +pub(crate) use self::constants::SPL_TOKEN_2022_INSTRUCTION_ENTRIES; +/// Maximum total account metas consumed by one Token-2022 batch. +pub(crate) use self::constants::SPL_TOKEN_2022_MAX_BATCH_ACCOUNTS; +/// Maximum decoded sub-instructions in one Token-2022 batch. +pub(crate) use self::constants::SPL_TOKEN_2022_MAX_BATCH_INSTRUCTIONS; +/// Maximum retained Token-2022 instruction payload size. +pub(crate) use self::constants::SPL_TOKEN_2022_MAX_INSTRUCTION_BYTES; +/// Maximum retained UTF-8 string size for embedded Token Metadata instructions. +pub(crate) use self::constants::SPL_TOKEN_2022_MAX_METADATA_STRING_BYTES; +/// Stable Token-2022 surface code. +pub(crate) use self::constants::SPL_TOKEN_2022_SURFACE_CODE; +/// Canonical tracing target for the Token-2022 decoder. +pub(crate) use self::constants::SPL_TOKEN_2022_TRACING_TARGET; +/// Decodes one bounded Token-2022 instruction. +pub(crate) use self::wire::spl_token_2022_decode; +/// Returns the exact entry matching one Token-2022 tag. +pub(crate) use self::wire::spl_token_2022_entry_for_tag; +/// Returns the first byte of one retained Token-2022 payload. +pub(crate) use self::wire::spl_token_2022_payload_tag; + +/// Exact decoder for the Token-2022 program. +pub use self::decoder::SplToken2022Decoder; +/// Parsed Token-2022 Mint, Account, or Multisig state. +pub use self::state::Token2022State; +/// Exact Token-2022 base account state kind. +pub use self::state::Token2022StateKind; +/// One exact Token-2022 TLV entry. +pub use self::state::Token2022TlvEntry; +/// Parses one Token-2022 Mint, Account, or Multisig state. +pub use self::state::parse_token_2022_state; diff --git a/kb-lib/src/decoder/spl/token_2022/constants.rs b/kb-lib/src/decoder/spl/token_2022/constants.rs new file mode 100644 index 0000000..5340103 --- /dev/null +++ b/kb-lib/src/decoder/spl/token_2022/constants.rs @@ -0,0 +1,72 @@ +// file: kb-lib/src/decoder/spl/token_2022/constants.rs +// version: 1 + +//! Local constants for the exact Token-2022 decoder. Program identifiers live in `kb_program_ids`. + +/// Stable Token-2022 surface code. +pub(crate) const SPL_TOKEN_2022_SURFACE_CODE: &str = "spl_token_2022"; +/// Current decoded event contract version. +pub(crate) const SPL_TOKEN_2022_EVENT_VERSION: u32 = 1; +/// Maximum retained instruction payload size. +pub(crate) const SPL_TOKEN_2022_MAX_INSTRUCTION_BYTES: usize = 16_384; +/// Maximum retained UTF-8 string size for embedded Token Metadata instructions. +pub(crate) const SPL_TOKEN_2022_MAX_METADATA_STRING_BYTES: usize = 4_096; +/// Maximum prefix retained in diagnostics. +pub(crate) const SPL_TOKEN_2022_DIAGNOSTIC_PREFIX_BYTES: usize = 32; +/// Maximum decoded sub-instructions in one batch. +pub(crate) const SPL_TOKEN_2022_MAX_BATCH_INSTRUCTIONS: usize = 64; +/// Maximum total account metas consumed by one batch. +pub(crate) const SPL_TOKEN_2022_MAX_BATCH_ACCOUNTS: usize = 512; +/// Exact top-level instruction inventory published by the resolved Token-2022 interface. +pub(crate) const SPL_TOKEN_2022_INSTRUCTION_ENTRIES: &[(u8, &str, bool)] = &[ + (0, "initialize_mint", false), + (1, "initialize_account", false), + (2, "initialize_multisig", false), + (3, "transfer", false), + (4, "approve", false), + (5, "revoke", false), + (6, "set_authority", false), + (7, "mint_to", false), + (8, "burn", false), + (9, "close_account", false), + (10, "freeze_account", false), + (11, "thaw_account", false), + (12, "transfer_checked", false), + (13, "approve_checked", false), + (14, "mint_to_checked", false), + (15, "burn_checked", false), + (16, "initialize_account2", false), + (17, "sync_native", false), + (18, "initialize_account3", false), + (19, "initialize_multisig2", false), + (20, "initialize_mint2", false), + (21, "get_account_data_size", false), + (22, "initialize_immutable_owner", false), + (23, "amount_to_ui_amount", false), + (24, "ui_amount_to_amount", false), + (25, "initialize_mint_close_authority", false), + (26, "transfer_fee_extension", false), + (27, "confidential_transfer_extension", false), + (28, "default_account_state_extension", false), + (29, "reallocate", false), + (30, "memo_transfer_extension", false), + (31, "create_native_mint", false), + (32, "initialize_non_transferable_mint", false), + (33, "interest_bearing_mint_extension", false), + (34, "cpi_guard_extension", false), + (35, "initialize_permanent_delegate", false), + (36, "transfer_hook_extension", false), + (37, "confidential_transfer_fee_extension", false), + (38, "withdraw_excess_lamports", false), + (39, "metadata_pointer_extension", false), + (40, "group_pointer_extension", false), + (41, "group_member_pointer_extension", false), + (42, "confidential_mint_burn_extension", false), + (43, "scaled_ui_amount_extension", false), + (44, "pausable_extension", false), + (45, "unwrap_lamports", false), + (46, "permissioned_burn_extension", false), + (255, "batch", false), +]; +/// Canonical tracing target for this crate. +pub(crate) const SPL_TOKEN_2022_TRACING_TARGET: &str = "kb-lib.decoder.spl.token_2022"; diff --git a/kb-lib/src/decoder/spl/token_2022/decoder.rs b/kb-lib/src/decoder/spl/token_2022/decoder.rs new file mode 100644 index 0000000..482a93d --- /dev/null +++ b/kb-lib/src/decoder/spl/token_2022/decoder.rs @@ -0,0 +1,1238 @@ +// file: kb-lib/src/decoder/spl/token_2022/decoder.rs +// version: 1 + +//! Exact Token-2022 dispatch for the common decode pipeline. + +const TOKEN_SURFACES: &[crate::DecoderSurface] = &[crate::DecoderSurface { + program_id: kb_program_ids::SPL_TOKEN_2022_PROGRAM_ID, + surface_code: crate::SPL_TOKEN_2022_SURFACE_CODE, + priority: 100, +}]; + +const PROGRAM_IDS: &[&str] = &[kb_program_ids::SPL_TOKEN_2022_PROGRAM_ID]; + +/// Exact decoder for the Token-2022 program. +#[derive(Clone, Debug, Default)] +pub struct SplToken2022Decoder; + +impl crate::ProtocolDecoder for crate::SplToken2022Decoder { + fn decoder_name(&self) -> &'static str { + return "kb_decoder_spl_token_2022"; + } + + fn decoder_version(&self) -> &'static str { + return env!("CARGO_PKG_VERSION"); + } + + fn program_ids(&self) -> &'static [&'static str] { + return PROGRAM_IDS; + } + + fn supports_observation( + &self, + observation: &crate::ProgramObservation, + ) -> crate::DecoderSupport { + return if crate::ProtocolDecoder::handles_program_id(self, &observation.program_id) { + crate::DecoderSupport::Yes + } else { + crate::DecoderSupport::No + }; + } + + fn decode_observation( + &self, + _observation: &crate::ProgramObservation, + ) -> kb_core::Result> { + return std::result::Result::Ok(std::vec::Vec::new()); + } +} + +impl crate::InstructionDecoder for crate::SplToken2022Decoder { + fn identity(&self) -> crate::DecoderIdentity { + return crate::DecoderIdentity { + name: "spl_token_2022".to_string(), + version: env!("CARGO_PKG_VERSION").to_string(), + }; + } + + fn surfaces(&self) -> &'static [crate::DecoderSurface] { + return TOKEN_SURFACES; + } + + fn coverage(&self) -> std::vec::Vec { + return crate::SPL_TOKEN_2022_INSTRUCTION_ENTRIES + .iter() + .map(|(tag, code, historical)| { + return crate::DecoderCoverageDeclaration { + program_id: kb_program_ids::SPL_TOKEN_2022_PROGRAM_ID.to_string(), + surface_code: std::option::Option::Some( + crate::SPL_TOKEN_2022_SURFACE_CODE.to_string(), + ), + entry_kind: crate::DecoderCoverageEntryKind::Instruction, + entry_code: (*code).to_string(), + discriminator_hex: std::option::Option::Some(format!("{tag:02x}")), + historical: *historical, + }; + }) + .collect(); + } + + fn recognize(&self, input: &crate::CoreInstructionReplayInput) -> crate::DecoderRecognition { + if input.program_id != kb_program_ids::SPL_TOKEN_2022_PROGRAM_ID { + return crate::DecoderRecognition::incompatible(); + } + let tag = crate::spl_token_2022_payload_tag(input); + let entry = tag.and_then(crate::spl_token_2022_entry_for_tag); + return crate::DecoderRecognition::compatible( + entry.is_some(), + 100, + std::option::Option::Some(crate::SPL_TOKEN_2022_SURFACE_CODE.to_string()), + entry.map(|(_, code, _)| return code.to_string()), + tag.map(|value| return format!("{value:02x}")), + ); + } + + fn decode(&self, input: &crate::CoreInstructionReplayInput) -> crate::DecoderExecutionResult { + if input.program_id != kb_program_ids::SPL_TOKEN_2022_PROGRAM_ID { + return crate::DecoderExecutionResult::unsupported(std::option::Option::None); + } + let result = crate::spl_token_2022_decode(input); + if matches!( + result.status, + crate::DecoderOutcomeStatus::Failed | crate::DecoderOutcomeStatus::Unsupported + ) { + tracing::error!( + target: crate::SPL_TOKEN_2022_TRACING_TARGET, + action = "decode_failure", + signature = %input.signature, + slot = input.slot, + instruction_path = %input.instruction_path, + program_id = %input.program_id, + processor_name = "spl_token_2022", + processor_version = env!("CARGO_PKG_VERSION"), + input_key = %input.replay_input_key, + payload_hash = ?input.instruction_payload_hash, + result_status = ?result.status, + diagnostics = ?result.diagnostics, + "Token-2022 instruction was not decoded successfully" + ); + } + tracing::debug!( + target: crate::SPL_TOKEN_2022_TRACING_TARGET, + action = "decode", + signature = %input.signature, + instruction_path = %input.instruction_path, + transaction_failed = input.transaction_failed, + result_status = ?result.status, + observation_count = result.observations.len(), + "Token-2022 instruction decode completed" + ); + return result; + } +} + +#[cfg(test)] +mod tests { + use base64::Engine; // rust-rules: trait-import + + fn input( + program_id: &str, + payload: &[u8], + accounts: serde_json::Value, + keys: serde_json::Value, + failed: bool, + path: &str, + ) -> crate::CoreInstructionReplayInput { + let result = crate::CoreInstructionReplayInput::new( + format!("signature:{path}"), + "signature", + 42, + path, + program_id, + failed, + if failed { + std::option::Option::Some(serde_json::json!({"InstructionError":[0,"Custom"]})) + } else { + std::option::Option::None + }, + keys, + accounts, + std::option::Option::Some(serde_json::json!({ + "dataBase64": base64::engine::general_purpose::STANDARD.encode(payload) + })), + std::option::Option::None, + serde_json::json!([]), + serde_json::json!([]), + serde_json::json!([]), + serde_json::json!([]), + ); + return match result { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => panic!("Token-2022 replay input failed: {error}"), + }; + } + + fn account(position: usize, signer: bool, writable: bool) -> serde_json::Value { + return serde_json::json!({ + "position": position, + "accountIndex": position, + "accountKey": format!("account{position}"), + "signer": signer, + "writable": writable, + "source": "static", + }); + } + + #[test] + fn exact_token_2022_dispatch_decodes_checked_transfer() { + let mut wire = std::vec![12]; + wire.extend_from_slice(&u64::MAX.to_le_bytes()); + wire.push(9); + let input = input( + kb_program_ids::SPL_TOKEN_2022_PROGRAM_ID, + wire.as_slice(), + serde_json::json!([ + {"position":0,"accountIndex":0,"accountKey":"account0"}, + {"position":1,"accountIndex":1,"accountKey":"account1"}, + {"position":2,"accountIndex":2,"accountKey":"account2"}, + {"position":3,"accountIndex":3,"accountKey":"account3"} + ]), + serde_json::json!([ + account(0, false, true), + account(1, false, false), + account(2, false, true), + account(3, true, false), + ]), + false, + "0", + ); + let result = crate::InstructionDecoder::decode(&crate::SplToken2022Decoder, &input); + assert_eq!(result.status, crate::DecoderOutcomeStatus::Decoded); + assert_eq!(result.recognized_entry_code.as_deref(), Some("transfer_checked")); + assert_eq!(result.observations.len(), 1); + assert!(result.observations[0].observation_committed); + assert_eq!( + result.observations[0].event.program_id.0, + kb_program_ids::SPL_TOKEN_2022_PROGRAM_ID + ); + assert_eq!( + result.observations[0].payload_json["parameters"]["amountRaw"], + u64::MAX.to_string() + ); + assert_eq!(result.observations[0].payload_json["parameters"]["decimals"], 9); + } + + #[test] + fn failed_inner_token_2022_instruction_remains_uncommitted() { + let mut wire = std::vec![7]; + wire.extend_from_slice(&42_u64.to_le_bytes()); + let input = input( + kb_program_ids::SPL_TOKEN_2022_PROGRAM_ID, + wire.as_slice(), + serde_json::json!([ + {"position":0,"accountIndex":0,"accountKey":"account0"}, + {"position":1,"accountIndex":1,"accountKey":"account1"}, + {"position":2,"accountIndex":2,"accountKey":"account2"} + ]), + serde_json::json!([ + account(0, false, true), + account(1, false, true), + account(2, true, false), + ]), + true, + "1/0", + ); + let result = crate::InstructionDecoder::decode(&crate::SplToken2022Decoder, &input); + assert_eq!(result.status, crate::DecoderOutcomeStatus::Decoded); + assert_eq!(result.recognized_entry_code.as_deref(), Some("mint_to")); + assert_eq!(result.observations.len(), 1); + assert!(!result.observations[0].observation_committed); + assert!(result.observations[0].transaction_failed); + assert_eq!(result.observations[0].event.instruction_path.0, "1/0"); + } + + #[test] + fn interest_bearing_non_transferable_permanent_delegate_and_reallocate_decode_exactly() { + let mut initialize_interest = vec![33, 0]; + initialize_interest.extend_from_slice(&[7_u8; 32]); + initialize_interest.extend_from_slice(&(-125_i16).to_le_bytes()); + let cases = vec![ + (initialize_interest, "initialize_interest_bearing_mint", serde_json::json!(-125)), + (vec![33, 1, 250, 0], "update_interest_bearing_rate", serde_json::json!(250)), + (vec![32], "initialize_non_transferable_mint", serde_json::Value::Null), + ( + { + let mut value = vec![35]; + value.extend_from_slice(&[9_u8; 32]); + value + }, + "initialize_permanent_delegate", + serde_json::Value::Null, + ), + (vec![29, 9, 0, 10, 0, 12, 0], "reallocate", serde_json::Value::Null), + ]; + for (wire, expected, expected_rate) in cases { + let input = input( + kb_program_ids::SPL_TOKEN_2022_PROGRAM_ID, + wire.as_slice(), + serde_json::json!([{"position":0,"accountIndex":0,"accountKey":"account0"}]), + serde_json::json!([account(0, true, true)]), + false, + "0", + ); + let result = crate::InstructionDecoder::decode(&crate::SplToken2022Decoder, &input); + assert_eq!(result.status, crate::DecoderOutcomeStatus::Decoded, "{expected}"); + assert_eq!(result.recognized_entry_code.as_deref(), Some(expected)); + assert_eq!(result.observations[0].event.event_name.0, expected); + if !expected_rate.is_null() { + assert_eq!( + result.observations[0].payload_json["parameters"]["rateBasisPoints"], + expected_rate + ); + } + } + } + + #[test] + fn interest_bearing_wire_rejects_unknown_truncated_and_suffixed_forms() { + for wire in [vec![33], vec![33, 0], vec![33, 1, 0], vec![33, 1, 0, 0, 0], vec![33, 2]] { + let input = input( + kb_program_ids::SPL_TOKEN_2022_PROGRAM_ID, + wire.as_slice(), + serde_json::json!([]), + serde_json::json!([]), + false, + "0", + ); + let result = crate::InstructionDecoder::decode(&crate::SplToken2022Decoder, &input); + assert_eq!(result.status, crate::DecoderOutcomeStatus::Failed); + } + } + + #[test] + fn embedded_metadata_and_group_interfaces_decode_as_token_2022_leaves() { + fn string(value: &str) -> std::vec::Vec { + let mut output = std::vec::Vec::new(); + output.extend_from_slice(&(value.len() as u32).to_le_bytes()); + output.extend_from_slice(value.as_bytes()); + return output; + } + let mut metadata = vec![0xd2, 0xe1, 0x1e, 0xa2, 0x58, 0xb8, 0x4d, 0x8d]; + metadata.extend_from_slice(string("Token").as_slice()); + metadata.extend_from_slice(string("TKN").as_slice()); + metadata.extend_from_slice(string("https://example.invalid/token.json").as_slice()); + let mut group = vec![0x79, 0x71, 0x6c, 0x27, 0x36, 0x33, 0x00, 0x04]; + group.extend_from_slice(&[7_u8; 32]); + group.extend_from_slice(&u64::MAX.to_le_bytes()); + for (wire, expected, account_count) in [ + (metadata, "initialize_token_metadata", 4_usize), + (group, "initialize_token_group", 3_usize), + ( + vec![0x98, 0x20, 0xde, 0xb0, 0xdf, 0xed, 0x74, 0x86], + "initialize_token_group_member", + 5_usize, + ), + ] { + let instruction_accounts = (0..account_count) + .map(|position| return serde_json::json!({"position":position,"accountIndex":position,"accountKey":format!("account{position}")})) + .collect::>(); + let resolved_accounts = (0..account_count) + .map(|position| { + return account(position, position + 1 == account_count, true); + }) + .collect::>(); + let input = input( + kb_program_ids::SPL_TOKEN_2022_PROGRAM_ID, + wire.as_slice(), + serde_json::Value::Array(instruction_accounts), + serde_json::Value::Array(resolved_accounts), + false, + "0", + ); + let result = crate::InstructionDecoder::decode(&crate::SplToken2022Decoder, &input); + assert_eq!(result.status, crate::DecoderOutcomeStatus::Decoded, "{expected}"); + assert_eq!(result.recognized_entry_code.as_deref(), Some(expected)); + assert_eq!( + result.observations[0].event.program_id.0, + kb_program_ids::SPL_TOKEN_2022_PROGRAM_ID + ); + } + } + + #[test] + fn embedded_interfaces_reject_truncation_suffix_and_unbounded_metadata_strings() { + let malformed = [ + vec![0xd2, 0xe1, 0x1e, 0xa2, 0x58, 0xb8, 0x4d, 0x8d], + vec![0x98, 0x20, 0xde, 0xb0, 0xdf, 0xed, 0x74, 0x86, 1], + { + let mut value = vec![0xd2, 0xe1, 0x1e, 0xa2, 0x58, 0xb8, 0x4d, 0x8d]; + value.extend_from_slice( + &((crate::SPL_TOKEN_2022_MAX_METADATA_STRING_BYTES + 1) as u32).to_le_bytes(), + ); + value + }, + ]; + for wire in malformed { + let input = input( + kb_program_ids::SPL_TOKEN_2022_PROGRAM_ID, + wire.as_slice(), + serde_json::json!([]), + serde_json::json!([]), + false, + "0", + ); + let result = crate::InstructionDecoder::decode(&crate::SplToken2022Decoder, &input); + assert_eq!(result.status, crate::DecoderOutcomeStatus::Failed); + } + } + + #[test] + fn pointer_and_transfer_hook_extensions_decode_all_published_leaves() { + for (tag, initialize_code, update_code, address_field) in [ + ( + 36_u8, + "initialize_transfer_hook", + "update_transfer_hook_program_id", + "programId", + ), + ( + 39_u8, + "initialize_metadata_pointer", + "update_metadata_pointer", + "metadataAddress", + ), + (40_u8, "initialize_group_pointer", "update_group_pointer", "groupAddress"), + ( + 41_u8, + "initialize_group_member_pointer", + "update_group_member_pointer", + "memberAddress", + ), + ] { + let mut initialize = vec![tag, 0]; + initialize.extend_from_slice(&[7_u8; 32]); + initialize.extend_from_slice(&[9_u8; 32]); + let mut update = vec![tag, 1]; + update.extend_from_slice(&[11_u8; 32]); + for (wire, expected) in [(initialize, initialize_code), (update, update_code)] { + let input = input( + kb_program_ids::SPL_TOKEN_2022_PROGRAM_ID, + wire.as_slice(), + serde_json::json!([{"position":0,"accountIndex":0,"accountKey":"account0"}]), + serde_json::json!([account(0, true, true)]), + false, + "0", + ); + let result = crate::InstructionDecoder::decode(&crate::SplToken2022Decoder, &input); + assert_eq!(result.status, crate::DecoderOutcomeStatus::Decoded, "{expected}"); + assert_eq!(result.recognized_entry_code.as_deref(), Some(expected)); + assert!( + result.observations[0].payload_json["parameters"][address_field].is_string() + ); + } + } + } + + #[test] + fn pointer_and_transfer_hook_extensions_reject_unknown_truncated_and_suffixed_wire() { + for wire in [ + vec![36], + vec![36, 2], + vec![39, 0, 1], + { + let mut value = vec![40, 1]; + value.extend_from_slice(&[3_u8; 33]); + value + }, + { + let mut value = vec![41, 0]; + value.extend_from_slice(&[4_u8; 63]); + value + }, + ] { + let input = input( + kb_program_ids::SPL_TOKEN_2022_PROGRAM_ID, + wire.as_slice(), + serde_json::json!([]), + serde_json::json!([]), + false, + "0", + ); + let result = crate::InstructionDecoder::decode(&crate::SplToken2022Decoder, &input); + assert_eq!(result.status, crate::DecoderOutcomeStatus::Failed); + } + } + + #[test] + fn classic_token_and_unknown_programs_never_emit_token_2022_events() { + for program_id in [kb_program_ids::SPL_TOKEN_PROGRAM_ID, kb_program_ids::SYSTEM_PROGRAM_ID] + { + let input = + input(program_id, &[17], serde_json::json!([]), serde_json::json!([]), false, "0"); + let result = crate::InstructionDecoder::decode(&crate::SplToken2022Decoder, &input); + assert_eq!(result.status, crate::DecoderOutcomeStatus::Unsupported); + assert!(result.observations.is_empty()); + } + } + + fn matrix() -> serde_json::Value { + return match serde_json::from_str(include_str!( + "../../../../../docs/SPL_TOKEN_2022_MATRIX.json" + )) { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => panic!("invalid Token-2022 matrix JSON: {error}"), + }; + } + + #[test] + fn initial_matrix_has_exact_program_boundary_and_candidate_inventory() { + let matrix = matrix(); + assert_eq!(matrix["programId"], kb_program_ids::SPL_TOKEN_2022_PROGRAM_ID); + assert_eq!(matrix["surfaceEquality"]["topLevelInstructionCount"], 48); + assert_eq!(matrix["surfaceEquality"]["productionExtensionTypeCount"], 29); + assert_eq!(matrix["instructions"].as_array().map(std::vec::Vec::len), Some(48)); + assert_eq!(matrix["extensionTypes"].as_array().map(std::vec::Vec::len), Some(29)); + } + + #[test] + fn initial_matrix_tags_are_unique_and_include_recent_surface() { + let matrix = matrix(); + let rows = match matrix["instructions"].as_array() { + std::option::Option::Some(rows) => rows, + std::option::Option::None => panic!("Token-2022 instruction rows missing"), + }; + let mut tags = std::collections::BTreeSet::new(); + for row in rows { + let tag = match row["tag"].as_u64() { + std::option::Option::Some(tag) => tag, + std::option::Option::None => panic!("Token-2022 instruction tag missing"), + }; + assert!(tags.insert(tag), "duplicate Token-2022 instruction tag {tag}"); + } + assert_eq!( + rows.iter().find(|row| return row["tag"] == 46).map(|row| return &row["name"]), + Some(&serde_json::json!("permissioned_burn_extension")), + ); + assert_eq!( + rows.iter().find(|row| return row["tag"] == 255).map(|row| return &row["name"]), + Some(&serde_json::json!("batch")), + ); + } + + #[test] + fn extension_types_are_contiguous_and_external_interfaces_stay_explicit() { + let matrix = matrix(); + let rows = match matrix["extensionTypes"].as_array() { + std::option::Option::Some(rows) => rows, + std::option::Option::None => panic!("Token-2022 extension rows missing"), + }; + for (expected, row) in rows.iter().enumerate() { + assert_eq!(row["type"].as_u64(), Some(expected as u64)); + } + assert_eq!(rows[19]["name"], "token_metadata"); + assert_eq!(rows[21]["name"], "token_group"); + assert_eq!(rows[23]["name"], "token_group_member"); + assert!(matrix["strictProgramBoundary"]["externalInterfaceRule"].is_string()); + } + + #[test] + fn matrix_records_operator_resolution_and_keeps_elgamal_technically_separate() { + let matrix = matrix(); + assert_eq!( + spl_token_2022_interface::ID.to_string(), + kb_program_ids::SPL_TOKEN_2022_PROGRAM_ID, + ); + assert_eq!( + spl_elgamal_registry_interface::ID.to_string(), + kb_program_ids::SPL_TOKEN_2022_ELGAMAL_REGISTRY_PROGRAM_ID, + ); + assert_eq!(spl_elgamal_registry_interface::REGISTRY_ADDRESS_SEED, b"elgamal-registry",); + assert_eq!(matrix["interfaceAudit"]["resolvedVersion"], "3.1.1"); + assert_eq!( + matrix["interfaceAudit"]["resolvedFeatures"], + serde_json::json!(["default", "serde"]) + ); + assert_eq!(matrix["interfaceAudit"]["workspaceCargoLockPresentInProvidedArchive"], true); + assert_eq!(matrix["elgamalRegistryBoundary"]["resolvedVersion"], "0.2.1"); + assert_eq!( + matrix["elgamalRegistryBoundary"]["programId"], + kb_program_ids::SPL_TOKEN_2022_ELGAMAL_REGISTRY_PROGRAM_ID + ); + assert_eq!(matrix["elgamalRegistryBoundary"]["sharedDecoder"], false); + assert_eq!(matrix["elgamalRegistryBoundary"]["sharedExecutor"], false); + } + + #[test] + fn matrix_matches_the_pinned_processor_dispatch_shape() { + let matrix = matrix(); + assert_eq!(matrix["processorAudit"]["auditedVersion"], "11.0.0"); + assert_eq!( + matrix["processorAudit"]["sourceCommit"], + "e18f9c6f9bf6044b934f48e3090e8e59e4820f02", + ); + assert_eq!(matrix["surfaceEquality"]["extensionDispatchFamilyCount"], 15); + assert_eq!(matrix["surfaceEquality"]["extensionSubInstructionCount"], 58); + assert_eq!(matrix["surfaceEquality"]["embeddedInterfaceFallbackCount"], 9); + assert_eq!(matrix["surfaceEquality"]["processorRoutableLeafInstructionCount"], 100); + assert_eq!( + matrix["processorDispatch"]["embeddedInterfaceFallbacks"] + .as_array() + .map(std::vec::Vec::len), + Some(9), + ); + } + + #[test] + fn matrix_leaf_inventory_equals_compiled_decoder_coverage() { + let matrix = matrix(); + let base_rows = match matrix["baseInstructionWireAudit"]["rows"].as_array() { + std::option::Option::Some(rows) => rows, + std::option::Option::None => panic!("Token-2022 base wire rows missing"), + }; + let extension_families = match matrix["processorDispatch"]["extensionFamilies"].as_array() { + std::option::Option::Some(rows) => rows, + std::option::Option::None => panic!("Token-2022 extension families missing"), + }; + let embedded_fallbacks = match matrix["processorDispatch"]["embeddedInterfaceFallbacks"] + .as_array() + { + std::option::Option::Some(rows) => rows, + std::option::Option::None => panic!("Token-2022 embedded interface fallbacks missing"), + }; + let mut leaf_keys = std::collections::BTreeSet::new(); + for row in base_rows { + let tag = match row["tag"].as_u64() { + std::option::Option::Some(tag) => tag, + std::option::Option::None => panic!("Token-2022 base leaf tag missing"), + }; + assert!( + leaf_keys.insert(format!("base:{tag}")), + "duplicate Token-2022 base leaf {tag}" + ); + } + let mut extension_leaf_count = 0usize; + for family in extension_families { + let tag = match family["tag"].as_u64() { + std::option::Option::Some(tag) => tag, + std::option::Option::None => panic!("Token-2022 extension family tag missing"), + }; + let leaves = match family["subInstructions"].as_array() { + std::option::Option::Some(leaves) => leaves, + std::option::Option::None => panic!("Token-2022 extension family leaves missing"), + }; + assert_eq!(family["subInstructionCount"].as_u64(), Some(leaves.len() as u64)); + extension_leaf_count += leaves.len(); + for leaf in leaves { + let name = match leaf.as_str() { + std::option::Option::Some(name) => name, + std::option::Option::None => panic!("Token-2022 extension leaf name missing"), + }; + assert!( + leaf_keys.insert(format!("extension:{tag}:{name}")), + "duplicate Token-2022 extension leaf {tag}:{name}", + ); + } + } + for fallback in embedded_fallbacks { + let interface = match fallback["interface"].as_str() { + std::option::Option::Some(interface) => interface, + std::option::Option::None => panic!("Token-2022 embedded interface name missing"), + }; + let name = match fallback["name"].as_str() { + std::option::Option::Some(name) => name, + std::option::Option::None => panic!("Token-2022 embedded fallback name missing"), + }; + assert!( + leaf_keys.insert(format!("embedded:{interface}:{name}")), + "duplicate Token-2022 embedded leaf {interface}:{name}", + ); + } + assert_eq!(base_rows.len(), 33); + assert_eq!(extension_leaf_count, 58); + assert_eq!(embedded_fallbacks.len(), 9); + assert_eq!(leaf_keys.len(), 100); + assert_eq!( + matrix["surfaceEquality"]["compiledDecoderCoverage"], + "exact_100_of_100_routable_leaves" + ); + assert_eq!(matrix["extensionImplementationStatus"]["decodedLeafCount"], 58); + assert_eq!(matrix["extensionImplementationStatus"]["embeddedInterfaceDecodedLeafCount"], 9); + } + + #[test] + fn matrix_requires_bounded_token_metadata_string_decoding() { + let matrix = matrix(); + let audit = &matrix["tokenMetadataVersionDriftAudit"]; + assert_eq!(audit["processorSourceVersion"], "1.0.0"); + assert_eq!(audit["operatorWorkspaceVersion"], "1.0.1"); + assert_eq!(audit["wireDiscriminantsChanged"], false); + assert_eq!(audit["buildersChanged"], false); + assert_eq!(audit["affectedInstructions"].as_object().map(serde_json::Map::len), Some(3),); + assert!(audit["decoderRequirement"].is_string()); + } + + #[test] + fn matrix_records_exact_public_wire_for_all_non_extension_envelopes() { + let matrix = matrix(); + let audit = &matrix["baseInstructionWireAudit"]; + let rows = match audit["rows"].as_array() { + std::option::Option::Some(rows) => rows, + std::option::Option::None => panic!("Token-2022 base wire rows missing"), + }; + assert_eq!(audit["rowCount"], 33); + assert_eq!(rows.len(), 33); + assert_eq!(matrix["operatorValidation"]["cargoTestPackage"]["passed"], 8); + assert_eq!(rows[0]["tag"], 0); + assert_eq!(rows[0]["sizeRule"], "35_or_67"); + assert_eq!(rows[25]["tag"], 25); + assert_eq!(rows[25]["sizeRule"], "2_or_34"); + assert_eq!(rows[31]["tag"], 45); + assert_eq!(rows[31]["sizeRule"], "2_or_10"); + assert_eq!(rows[32]["tag"], 255); + assert_eq!(audit["publicUnpackContract"]["runtimeClaim"], "none_from_public_unpack_alone",); + assert!(rows.iter().all(|row| return row["officialBuilder"].is_string())); + } + + #[test] + fn matrix_partitions_every_base_tag_by_exact_processor_suffix_policy() { + let matrix = matrix(); + let audit = &matrix["baseInstructionProcessorSuffixAudit"]; + let partitions = match audit["partition"].as_array() { + std::option::Option::Some(partitions) => partitions, + std::option::Option::None => panic!("Token-2022 processor suffix partition missing"), + }; + assert_eq!(partitions.len(), 5); + assert_eq!(matrix["operatorValidation"]["cargoTestPackage"]["passed"], 8); + + let mut processor_tags = std::collections::BTreeSet::new(); + for partition in partitions { + let tags = match partition["tags"].as_array() { + std::option::Option::Some(tags) => tags, + std::option::Option::None => panic!("processor suffix policy tags missing"), + }; + assert_eq!(partition["count"].as_u64(), Some(tags.len() as u64)); + for tag in tags { + let tag = match tag.as_u64() { + std::option::Option::Some(tag) => tag, + std::option::Option::None => panic!("processor suffix policy tag invalid"), + }; + assert!(processor_tags.insert(tag), "duplicate processor suffix tag {tag}"); + } + } + + let base_tags = match matrix["baseInstructionWireAudit"]["rows"].as_array() { + std::option::Option::Some(rows) => rows + .iter() + .filter_map(|row| return row["tag"].as_u64()) + .collect::>(), + std::option::Option::None => panic!("Token-2022 base wire rows missing"), + }; + assert_eq!(processor_tags, base_tags); + assert_eq!(audit["partitionTagCount"], 33); + assert_eq!(partitions[0]["suffix"], "rejected"); + assert_eq!(partitions[1]["suffix"], "accepted_after_complete_none_or_some_prefix"); + assert_eq!(partitions[2]["suffix"], "accepted_without_inspection"); + assert_eq!(partitions[4]["record"]["nestedBatch"], "rejected"); + } + + #[test] + fn matrix_maps_every_base_tag_to_exact_official_builder_accounts() { + let matrix = matrix(); + let audit = &matrix["baseInstructionBuilderAccountAudit"]; + let rows = match audit["rows"].as_array() { + std::option::Option::Some(rows) => rows, + std::option::Option::None => panic!("Token-2022 builder account rows missing"), + }; + assert_eq!(audit["rowCount"], 33); + assert_eq!(rows.len(), 33); + + let builder_tags = rows + .iter() + .filter_map(|row| return row["tag"].as_u64()) + .collect::>(); + let wire_tags = match matrix["baseInstructionWireAudit"]["rows"].as_array() { + std::option::Option::Some(rows) => rows + .iter() + .filter_map(|row| return row["tag"].as_u64()) + .collect::>(), + std::option::Option::None => panic!("Token-2022 base wire rows missing"), + }; + assert_eq!(builder_tags, wire_tags); + assert_eq!(rows.iter().filter(|row| return row["builder"].is_string()).count(), 32,); + + let transfer = &rows[3]; + assert_eq!(transfer["tag"], 3); + assert_eq!(transfer["accounts"][0]["role"], "source"); + assert_eq!(transfer["accounts"][0]["writable"], true); + assert_eq!(transfer["accounts"][2]["signer"], "conditional_authority",); + assert_eq!(rows[17]["optionalAccounts"][0]["role"], "rent_sysvar"); + assert_eq!( + rows[26]["processorAccountProof"], + "pending_exact_reallocate_module_reconfirmation", + ); + assert!(rows[32]["builder"].is_null()); + assert_eq!( + audit["processorFactsAlreadyConfirmed"]["initializeMultisig"], + "all accounts after multisig and optional rent become configuration keys; runtime validates m and n independently in 1..11 but does not enforce builder rule m <= n", + ); + } + #[test] + fn transfer_fee_extension_decodes_all_six_published_subinstructions() { + let cases = [ + ( + std::vec![26, 0, 0, 0, 5, 0, 9, 0, 0, 0, 0, 0, 0, 0], + "initialize_transfer_fee_config", + ), + ( + { + let mut value = std::vec![26, 1]; + value.extend_from_slice(&7_u64.to_le_bytes()); + value.push(6); + value.extend_from_slice(&1_u64.to_le_bytes()); + value + }, + "transfer_checked_with_fee", + ), + (std::vec![26, 2], "withdraw_withheld_tokens_from_mint"), + (std::vec![26, 3, 1], "withdraw_withheld_tokens_from_accounts"), + (std::vec![26, 4], "harvest_withheld_tokens_to_mint"), + ( + { + let mut value = std::vec![26, 5]; + value.extend_from_slice(&25_u16.to_le_bytes()); + value.extend_from_slice(&99_u64.to_le_bytes()); + value + }, + "set_transfer_fee", + ), + ]; + for (wire, expected) in cases { + let input = input( + kb_program_ids::SPL_TOKEN_2022_PROGRAM_ID, + wire.as_slice(), + serde_json::json!([]), + serde_json::json!([]), + false, + expected, + ); + let result = crate::InstructionDecoder::decode(&crate::SplToken2022Decoder, &input); + assert_eq!(result.status, crate::DecoderOutcomeStatus::Decoded); + assert_eq!( + result.recognized_entry_code.as_deref(), + std::option::Option::Some(expected) + ); + assert_eq!(result.observations[0].payload_json["instruction"], expected); + } + } + + #[test] + fn compliance_extensions_decode_exact_toggle_and_state_contracts() { + let cases = [ + (std::vec![28, 0, 2], "initialize_default_account_state"), + (std::vec![28, 1, 1], "update_default_account_state"), + (std::vec![30, 0], "enable_required_transfer_memos"), + (std::vec![30, 1], "disable_required_transfer_memos"), + (std::vec![34, 0], "enable_cpi_guard"), + (std::vec![34, 1], "disable_cpi_guard"), + ]; + for (wire, expected) in cases { + let input = input( + kb_program_ids::SPL_TOKEN_2022_PROGRAM_ID, + wire.as_slice(), + serde_json::json!([]), + serde_json::json!([]), + false, + expected, + ); + let result = crate::InstructionDecoder::decode(&crate::SplToken2022Decoder, &input); + assert_eq!(result.status, crate::DecoderOutcomeStatus::Decoded); + assert_eq!( + result.recognized_entry_code.as_deref(), + std::option::Option::Some(expected) + ); + } + } + + #[test] + fn audited_extension_families_reject_unknown_truncated_and_suffixed_wire() { + for wire in [ + std::vec![26], + std::vec![26, 9], + std::vec![28, 0], + std::vec![28, 0, 1, 0], + std::vec![30, 0, 0], + std::vec![34, 2], + ] { + let input = input( + kb_program_ids::SPL_TOKEN_2022_PROGRAM_ID, + wire.as_slice(), + serde_json::json!([]), + serde_json::json!([]), + false, + "invalid", + ); + let result = crate::InstructionDecoder::decode(&crate::SplToken2022Decoder, &input); + assert_eq!(result.status, crate::DecoderOutcomeStatus::Failed); + assert!(result.observations.is_empty()); + } + } + + #[test] + fn scaled_ui_pausable_and_permissioned_burn_decode_exact_non_confidential_leaves() { + let mut scaled_initialize = std::vec![43, 0]; + scaled_initialize.extend_from_slice(&[7_u8; 32]); + scaled_initialize.extend_from_slice(&1.5_f64.to_bits().to_le_bytes()); + let mut scaled_update = std::vec![43, 1]; + scaled_update.extend_from_slice(&2.25_f64.to_bits().to_le_bytes()); + scaled_update.extend_from_slice(&(-5_i64).to_le_bytes()); + let mut pause_initialize = std::vec![44, 0]; + pause_initialize.extend_from_slice(&[9_u8; 32]); + let mut burn_initialize = std::vec![46, 0]; + burn_initialize.extend_from_slice(&[11_u8; 32]); + let mut burn = std::vec![46, 1]; + burn.extend_from_slice(&u64::MAX.to_le_bytes()); + let mut burn_checked = std::vec![46, 2]; + burn_checked.extend_from_slice(&17_u64.to_le_bytes()); + burn_checked.push(9); + let cases = [ + (scaled_initialize, "initialize_scaled_ui_amount"), + (scaled_update, "update_scaled_ui_amount_multiplier"), + (pause_initialize, "initialize_pausable_config"), + (std::vec![44, 1], "pause_token_mint"), + (std::vec![44, 2], "resume_token_mint"), + (burn_initialize, "initialize_permissioned_burn"), + (burn, "permissioned_burn"), + (burn_checked, "permissioned_burn_checked"), + ]; + for (wire, expected) in cases { + let input = input( + kb_program_ids::SPL_TOKEN_2022_PROGRAM_ID, + wire.as_slice(), + serde_json::json!([]), + serde_json::json!([]), + false, + expected, + ); + let result = crate::InstructionDecoder::decode(&crate::SplToken2022Decoder, &input); + assert_eq!(result.status, crate::DecoderOutcomeStatus::Decoded, "{expected}"); + assert_eq!( + result.recognized_entry_code.as_deref(), + std::option::Option::Some(expected) + ); + } + } + + #[test] + fn scaled_ui_pausable_and_permissioned_burn_reject_invalid_exact_wire() { + for wire in [ + std::vec![43], + std::vec![43, 2], + { + let mut value = std::vec![43, 0]; + value.extend_from_slice(&[1_u8; 39]); + value + }, + std::vec![44, 3], + std::vec![44, 1, 0], + std::vec![46, 4], + std::vec![46, 1, 0], + { + let mut value = std::vec![46, 2]; + value.extend_from_slice(&[0_u8; 10]); + value.push(0); + value + }, + ] { + let input = input( + kb_program_ids::SPL_TOKEN_2022_PROGRAM_ID, + wire.as_slice(), + serde_json::json!([]), + serde_json::json!([]), + false, + "invalid", + ); + let result = crate::InstructionDecoder::decode(&crate::SplToken2022Decoder, &input); + assert_eq!(result.status, crate::DecoderOutcomeStatus::Failed); + } + } + + #[test] + fn confidential_transfer_simple_public_leaves_decode_exactly() { + let mut initialize = std::vec![27, 0]; + initialize.extend_from_slice(&[7_u8; 32]); + initialize.push(1); + initialize.extend_from_slice(&[9_u8; 32]); + let mut update = std::vec![27, 1, 0]; + update.extend_from_slice(&[0_u8; 32]); + let mut deposit = std::vec![27, 5]; + deposit.extend_from_slice(&u64::MAX.to_le_bytes()); + deposit.push(9); + let mut apply = std::vec![27, 8]; + apply.extend_from_slice(&17_u64.to_le_bytes()); + apply.extend_from_slice(&[3_u8; 36]); + for (wire, expected) in [ + (initialize, "initialize_confidential_transfer_mint"), + (update, "update_confidential_transfer_mint"), + (std::vec![27, 3], "approve_confidential_transfer_account"), + (std::vec![27, 4, 255], "empty_confidential_transfer_account"), + (deposit, "deposit_confidential_tokens"), + (apply, "apply_confidential_pending_balance"), + (std::vec![27, 9], "enable_confidential_credits"), + (std::vec![27, 10], "disable_confidential_credits"), + (std::vec![27, 11], "enable_non_confidential_credits"), + (std::vec![27, 12], "disable_non_confidential_credits"), + ] { + let input = input( + kb_program_ids::SPL_TOKEN_2022_PROGRAM_ID, + wire.as_slice(), + serde_json::json!([]), + serde_json::json!([]), + false, + expected, + ); + let result = crate::InstructionDecoder::decode(&crate::SplToken2022Decoder, &input); + assert_eq!(result.status, crate::DecoderOutcomeStatus::Decoded, "{expected}"); + assert_eq!( + result.recognized_entry_code.as_deref(), + std::option::Option::Some(expected) + ); + } + } + + #[test] + fn confidential_transfer_simple_public_leaves_reject_invalid_wire() { + for wire in [ + std::vec![27], + std::vec![27, 0], + { + let mut value = std::vec![27, 0]; + value.extend_from_slice(&[0_u8; 32]); + value.push(2); + value.extend_from_slice(&[0_u8; 32]); + value + }, + std::vec![27, 3, 0], + std::vec![27, 4], + std::vec![27, 5, 0], + std::vec![27, 8, 0], + std::vec![27, 9, 0], + std::vec![27, 15], + ] { + let input = input( + kb_program_ids::SPL_TOKEN_2022_PROGRAM_ID, + wire.as_slice(), + serde_json::json!([]), + serde_json::json!([]), + false, + "invalid", + ); + let result = crate::InstructionDecoder::decode(&crate::SplToken2022Decoder, &input); + assert_eq!(result.status, crate::DecoderOutcomeStatus::Failed); + } + } + + #[test] + fn confidential_transfer_complex_public_leaves_decode_exactly() { + let mut configure = std::vec![27, 2]; + configure.extend_from_slice(&[1_u8; 36]); + configure.extend_from_slice(&19_u64.to_le_bytes()); + configure.push(255); + let mut withdraw = std::vec![27, 6]; + withdraw.extend_from_slice(&23_u64.to_le_bytes()); + withdraw.push(9); + withdraw.extend_from_slice(&[2_u8; 36]); + withdraw.extend_from_slice(&[1_u8, 0_u8]); + let mut transfer = std::vec![27, 7]; + transfer.extend_from_slice(&[3_u8; 36]); + transfer.extend_from_slice(&[4_u8; 64]); + transfer.extend_from_slice(&[5_u8; 64]); + transfer.extend_from_slice(&[1_u8, 2_u8, 0_u8]); + let mut transfer_with_fee = std::vec![27, 13]; + transfer_with_fee.extend_from_slice(&[6_u8; 36]); + transfer_with_fee.extend_from_slice(&[7_u8; 64]); + transfer_with_fee.extend_from_slice(&[8_u8; 64]); + transfer_with_fee.extend_from_slice(&[1_u8, 2_u8, 3_u8, 4_u8, 0_u8]); + for (wire, expected) in [ + (configure, "configure_confidential_transfer_account"), + (withdraw, "withdraw_confidential_tokens"), + (transfer, "transfer_confidential_tokens"), + (transfer_with_fee, "transfer_confidential_tokens_with_fee"), + (std::vec![27, 14], "configure_confidential_transfer_account_with_registry"), + ] { + let input = input( + kb_program_ids::SPL_TOKEN_2022_PROGRAM_ID, + wire.as_slice(), + serde_json::json!([]), + serde_json::json!([]), + false, + expected, + ); + let result = crate::InstructionDecoder::decode(&crate::SplToken2022Decoder, &input); + assert_eq!(result.status, crate::DecoderOutcomeStatus::Decoded, "{expected}"); + assert_eq!( + result.recognized_entry_code.as_deref(), + std::option::Option::Some(expected) + ); + } + } + + #[test] + fn confidential_transfer_complex_public_leaves_reject_invalid_wire() { + for wire in [ + std::vec![27, 2], + std::vec![27, 6, 0], + { + let mut value = std::vec![27, 7]; + value.extend_from_slice(&[0_u8; 168]); + value + }, + { + let mut value = std::vec![27, 13]; + value.extend_from_slice(&[0_u8; 168]); + value + }, + std::vec![27, 14, 0], + ] { + let input = input( + kb_program_ids::SPL_TOKEN_2022_PROGRAM_ID, + wire.as_slice(), + serde_json::json!([]), + serde_json::json!([]), + false, + "invalid", + ); + let result = crate::InstructionDecoder::decode(&crate::SplToken2022Decoder, &input); + assert_eq!(result.status, crate::DecoderOutcomeStatus::Failed); + } + } + + #[test] + fn confidential_transfer_fee_leaves_decode_exactly() { + let mut initialize = std::vec![37, 0]; + initialize.extend_from_slice(&[7_u8; 32]); + initialize.extend_from_slice(&[8_u8; 32]); + let mut withdraw_from_mint = std::vec![37, 1, 255]; + withdraw_from_mint.extend_from_slice(&[3_u8; 36]); + let mut withdraw_from_accounts = std::vec![37, 2, 11, 0]; + withdraw_from_accounts.extend_from_slice(&[4_u8; 36]); + for (wire, expected) in [ + (initialize, "initialize_confidential_transfer_fee_config"), + (withdraw_from_mint, "withdraw_confidential_withheld_tokens_from_mint"), + (withdraw_from_accounts, "withdraw_confidential_withheld_tokens_from_accounts"), + (std::vec![37, 3], "harvest_confidential_withheld_tokens_to_mint"), + (std::vec![37, 4], "enable_confidential_harvest_to_mint"), + (std::vec![37, 5], "disable_confidential_harvest_to_mint"), + ] { + let input = input( + kb_program_ids::SPL_TOKEN_2022_PROGRAM_ID, + wire.as_slice(), + serde_json::json!([]), + serde_json::json!([]), + false, + expected, + ); + let result = crate::InstructionDecoder::decode(&crate::SplToken2022Decoder, &input); + assert_eq!(result.status, crate::DecoderOutcomeStatus::Decoded, "{expected}"); + assert_eq!( + result.recognized_entry_code.as_deref(), + std::option::Option::Some(expected) + ); + assert_eq!(result.observations.len(), 1); + assert_eq!(result.observations[0].event.event_family, crate::EventFamily::Fee); + } + } + + #[test] + fn confidential_transfer_fee_leaves_reject_invalid_wire() { + for wire in [ + std::vec![37], + std::vec![37, 0], + std::vec![37, 1, 0], + std::vec![37, 2, 1, 0], + std::vec![37, 3, 0], + std::vec![37, 6], + ] { + let input = input( + kb_program_ids::SPL_TOKEN_2022_PROGRAM_ID, + wire.as_slice(), + serde_json::json!([]), + serde_json::json!([]), + false, + "invalid", + ); + let result = crate::InstructionDecoder::decode(&crate::SplToken2022Decoder, &input); + assert_eq!(result.status, crate::DecoderOutcomeStatus::Failed); + } + } + + #[test] + fn confidential_mint_burn_and_permissioned_confidential_burn_decode_exactly() { + let mut initialize = std::vec![42, 0]; + initialize.extend_from_slice(&[1_u8; 32]); + initialize.extend_from_slice(&[2_u8; 36]); + let mut rotate = std::vec![42, 1]; + rotate.extend_from_slice(&[3_u8; 32]); + rotate.push(255); + let mut update = std::vec![42, 2]; + update.extend_from_slice(&[4_u8; 36]); + let mut mint = std::vec![42, 3]; + mint.extend_from_slice(&[5_u8; 36]); + mint.extend_from_slice(&[6_u8; 64]); + mint.extend_from_slice(&[7_u8; 64]); + mint.extend_from_slice(&[1_u8, 2_u8, 3_u8]); + let mut burn = mint.clone(); + burn[1] = 4; + let mut permissioned = mint.clone(); + permissioned[0] = 46; + permissioned[1] = 3; + for (wire, expected) in [ + (initialize, "initialize_confidential_mint_burn"), + (rotate, "rotate_confidential_supply_elgamal_pubkey"), + (update, "update_confidential_decryptable_supply"), + (mint, "confidential_mint"), + (burn, "confidential_burn"), + (std::vec![42, 5], "apply_pending_confidential_burn"), + (permissioned, "permissioned_confidential_burn"), + ] { + let input = input( + kb_program_ids::SPL_TOKEN_2022_PROGRAM_ID, + wire.as_slice(), + serde_json::json!([]), + serde_json::json!([]), + false, + expected, + ); + let result = crate::InstructionDecoder::decode(&crate::SplToken2022Decoder, &input); + assert_eq!(result.status, crate::DecoderOutcomeStatus::Decoded, "{expected}"); + assert_eq!( + result.recognized_entry_code.as_deref(), + std::option::Option::Some(expected) + ); + } + } + + #[test] + fn confidential_mint_burn_and_permissioned_confidential_burn_reject_invalid_wire() { + for wire in [ + std::vec![42], + std::vec![42, 0], + std::vec![42, 1, 0], + std::vec![42, 2, 0], + std::vec![42, 3, 0], + std::vec![42, 5, 0], + std::vec![42, 6], + std::vec![46, 3, 0], + ] { + let input = input( + kb_program_ids::SPL_TOKEN_2022_PROGRAM_ID, + wire.as_slice(), + serde_json::json!([]), + serde_json::json!([]), + false, + "invalid", + ); + let result = crate::InstructionDecoder::decode(&crate::SplToken2022Decoder, &input); + assert_eq!(result.status, crate::DecoderOutcomeStatus::Failed); + } + } +} diff --git a/kb-lib/src/decoder/spl/token_2022/state.rs b/kb-lib/src/decoder/spl/token_2022/state.rs new file mode 100644 index 0000000..32ac749 --- /dev/null +++ b/kb-lib/src/decoder/spl/token_2022/state.rs @@ -0,0 +1,1647 @@ +// file: kb-lib/src/decoder/spl/token_2022/state.rs +// version: 1 + +//! Strict Token-2022 base-state and TLV parsing. + +use base64::Engine as _; // rust-rules: trait-import + +const MINT_BASE_LEN: usize = 82; +const ACCOUNT_BASE_LEN: usize = 165; +const MULTISIG_BASE_LEN: usize = 355; +const ACCOUNT_TYPE_OFFSET: usize = 165; +const TLV_START_OFFSET: usize = 166; +const MAX_ACCOUNT_DATA_LEN: usize = 1_048_576; +const MAX_TLV_ENTRIES: usize = 64; +const MAX_METADATA_STRING_LEN: usize = 1_024; +const MAX_METADATA_PAIRS: usize = 64; + +/// Token-2022 account state category selected by the caller. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum Token2022StateKind { + /// Mint base state, optionally followed by Token-2022 TLV extensions. + Mint, + /// Token account base state, optionally followed by Token-2022 TLV extensions. + Account, + /// Multisignature base state. Token-2022 does not append TLV extensions. + Multisig, +} + +/// One bounded Token-2022 TLV extension entry. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct Token2022TlvEntry { + /// Numeric `ExtensionType` discriminant retained exactly from the wire. + pub extension_type: u16, + /// Stable name for published types, or `unknown` for a future type. + pub extension_name: &'static str, + /// Exact extension bytes encoded as lowercase hexadecimal. + pub value_hex: String, + /// Structured public fields for audited fixed-size layouts, or `null` when still opaque. + pub value_fields: serde_json::Value, +} + +/// Strictly parsed Token-2022 account state. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct Token2022State { + /// Caller-selected base state category. + pub kind: Token2022StateKind, + /// Structured fields decoded from the exact base layout. + pub base_fields: serde_json::Value, + /// Base layout bytes encoded as lowercase hexadecimal. + pub base_hex: String, + /// Account-type discriminator for extended Mint or Account states. + pub account_type: Option, + /// Ordered TLV entries. + pub extensions: Vec, +} + +/// Parse one Token-2022 Mint, Account, or Multisig state without interpreting extension payloads. +pub fn parse_token_2022_state( + kind: Token2022StateKind, + data: &[u8], +) -> Result { + if data.len() > MAX_ACCOUNT_DATA_LEN { + return Err(format!( + "Token-2022 account data length {} exceeds bound {}", + data.len(), + MAX_ACCOUNT_DATA_LEN + )); + } + return match kind { + Token2022StateKind::Mint => parse_mint(data), + Token2022StateKind::Account => parse_account(data), + Token2022StateKind::Multisig => parse_multisig(data), + }; +} + +fn parse_mint(data: &[u8]) -> Result { + if data.len() == MINT_BASE_LEN { + return Ok(Token2022State { + kind: Token2022StateKind::Mint, + base_fields: match parse_mint_base(data) { + Ok(value) => value, + Err(error) => return Err(error), + }, + base_hex: lower_hex(data), + account_type: None, + extensions: Vec::new(), + }); + } + if data.len() < TLV_START_OFFSET { + return Err(format!( + "extended Token-2022 mint requires at least {TLV_START_OFFSET} bytes, got {}", + data.len() + )); + } + if data[MINT_BASE_LEN..ACCOUNT_TYPE_OFFSET].iter().any(|byte| return *byte != 0) { + return Err("extended Token-2022 mint padding must be zero".to_string()); + } + if data[ACCOUNT_TYPE_OFFSET] != 1 { + return Err(format!( + "extended Token-2022 mint account type must be 1, got {}", + data[ACCOUNT_TYPE_OFFSET] + )); + } + let extensions = match parse_tlv(&data[TLV_START_OFFSET..], 1) { + Ok(extensions) => extensions, + Err(error) => return Err(error), + }; + return Ok(Token2022State { + kind: Token2022StateKind::Mint, + base_fields: match parse_mint_base(&data[..MINT_BASE_LEN]) { + Ok(value) => value, + Err(error) => return Err(error), + }, + base_hex: lower_hex(&data[..MINT_BASE_LEN]), + account_type: Some(1), + extensions, + }); +} + +fn parse_account(data: &[u8]) -> Result { + if data.len() == ACCOUNT_BASE_LEN { + return Ok(Token2022State { + kind: Token2022StateKind::Account, + base_fields: match parse_account_base(data) { + Ok(value) => value, + Err(error) => return Err(error), + }, + base_hex: lower_hex(data), + account_type: None, + extensions: Vec::new(), + }); + } + if data.len() < TLV_START_OFFSET { + return Err(format!( + "extended Token-2022 account requires at least {TLV_START_OFFSET} bytes, got {}", + data.len() + )); + } + if data[ACCOUNT_TYPE_OFFSET] != 2 { + return Err(format!( + "extended Token-2022 token account type must be 2, got {}", + data[ACCOUNT_TYPE_OFFSET] + )); + } + let extensions = match parse_tlv(&data[TLV_START_OFFSET..], 2) { + Ok(extensions) => extensions, + Err(error) => return Err(error), + }; + return Ok(Token2022State { + kind: Token2022StateKind::Account, + base_fields: match parse_account_base(&data[..ACCOUNT_BASE_LEN]) { + Ok(value) => value, + Err(error) => return Err(error), + }, + base_hex: lower_hex(&data[..ACCOUNT_BASE_LEN]), + account_type: Some(2), + extensions, + }); +} + +fn parse_multisig(data: &[u8]) -> Result { + if data.len() != MULTISIG_BASE_LEN { + return Err(format!( + "Token-2022 multisig requires exactly {MULTISIG_BASE_LEN} bytes, got {}", + data.len() + )); + } + return Ok(Token2022State { + kind: Token2022StateKind::Multisig, + base_fields: match parse_multisig_base(data) { + Ok(value) => value, + Err(error) => return Err(error), + }, + base_hex: lower_hex(data), + account_type: None, + extensions: Vec::new(), + }); +} + +fn parse_mint_base(data: &[u8]) -> Result { + let mint_authority = match parse_coption_pubkey(&data[0..36], "mint authority") { + Ok(value) => value, + Err(error) => return Err(error), + }; + let freeze_authority = match parse_coption_pubkey(&data[46..82], "freeze authority") { + Ok(value) => value, + Err(error) => return Err(error), + }; + let initialized = match data[45] { + 0 => false, + 1 => true, + value => { + return Err(format!("Token-2022 mint initialized flag must be 0 or 1, got {value}")); + }, + }; + return Ok(serde_json::json!({ + "mintAuthority": mint_authority, + "supply": read_u64(&data[36..44]).to_string(), + "decimals": data[44], + "initialized": initialized, + "freezeAuthority": freeze_authority, + })); +} + +fn parse_account_base(data: &[u8]) -> Result { + let delegate = match parse_coption_pubkey(&data[72..108], "delegate") { + Ok(value) => value, + Err(error) => return Err(error), + }; + let native_reserve = match parse_coption_u64(&data[109..121], "native reserve") { + Ok(value) => value, + Err(error) => return Err(error), + }; + let close_authority = match parse_coption_pubkey(&data[129..165], "close authority") { + Ok(value) => value, + Err(error) => return Err(error), + }; + let state = match data[108] { + 0 => "uninitialized", + 1 => "initialized", + 2 => "frozen", + value => return Err(format!("Token-2022 account state must be 0, 1, or 2, got {value}")), + }; + return Ok(serde_json::json!({ + "mint": bs58::encode(&data[0..32]).into_string(), + "owner": bs58::encode(&data[32..64]).into_string(), + "amount": read_u64(&data[64..72]).to_string(), + "delegate": delegate, + "state": state, + "nativeReserve": native_reserve, + "delegatedAmount": read_u64(&data[121..129]).to_string(), + "closeAuthority": close_authority, + })); +} + +fn parse_multisig_base(data: &[u8]) -> Result { + let initialized = match data[2] { + 0 => false, + 1 => true, + value => { + return Err(format!( + "Token-2022 multisig initialized flag must be 0 or 1, got {value}" + )); + }, + }; + let m = data[0]; + let n = data[1]; + if initialized && (m == 0 || n == 0 || m > n || n > 11) { + return Err(format!("Token-2022 multisig threshold is invalid: m={m}, n={n}")); + } + let mut signers = Vec::with_capacity(11); + for index in 0..11usize { + let start = 3 + index * 32; + signers + .push(serde_json::Value::String(bs58::encode(&data[start..start + 32]).into_string())); + } + return Ok(serde_json::json!({ + "m": m, + "n": n, + "initialized": initialized, + "signers": signers, + })); +} + +fn parse_coption_pubkey(data: &[u8], field: &str) -> Result { + let tag = read_u32(&data[0..4]); + return match tag { + 0 => Ok(serde_json::Value::Null), + 1 => Ok(serde_json::Value::String(bs58::encode(&data[4..36]).into_string())), + value => Err(format!("Token-2022 {field} option tag must be 0 or 1, got {value}")), + }; +} + +fn parse_coption_u64(data: &[u8], field: &str) -> Result { + let tag = read_u32(&data[0..4]); + return match tag { + 0 => Ok(serde_json::Value::Null), + 1 => Ok(serde_json::Value::String(read_u64(&data[4..12]).to_string())), + value => Err(format!("Token-2022 {field} option tag must be 0 or 1, got {value}")), + }; +} + +fn read_u32(data: &[u8]) -> u32 { + return u32::from_le_bytes([data[0], data[1], data[2], data[3]]); +} + +fn read_u64(data: &[u8]) -> u64 { + return u64::from_le_bytes([ + data[0], data[1], data[2], data[3], data[4], data[5], data[6], data[7], + ]); +} + +fn read_i16(data: &[u8]) -> i16 { + return i16::from_le_bytes([data[0], data[1]]); +} + +fn read_i64(data: &[u8]) -> i64 { + return i64::from_le_bytes([ + data[0], data[1], data[2], data[3], data[4], data[5], data[6], data[7], + ]); +} + +fn parse_tlv(data: &[u8], kind_from_account_type: u8) -> Result, String> { + let mut entries = Vec::new(); + let mut offset = 0usize; + let mut seen = std::collections::BTreeSet::new(); + while offset < data.len() { + if data[offset..].iter().all(|byte| return *byte == 0) { + return Ok(entries); + } + if entries.len() >= MAX_TLV_ENTRIES { + return Err(format!("Token-2022 TLV entry count exceeds bound {MAX_TLV_ENTRIES}")); + } + if data.len() - offset < 4 { + return Err(format!("Token-2022 TLV header is truncated at offset {offset}")); + } + let extension_type = u16::from_le_bytes([data[offset], data[offset + 1]]); + let value_len = usize::from(u16::from_le_bytes([data[offset + 2], data[offset + 3]])); + offset += 4; + if extension_type == 0 { + return Err("Token-2022 TLV type 0 is only valid as zero padding".to_string()); + } + let value_end = match offset.checked_add(value_len) { + Some(value_end) => value_end, + None => return Err("Token-2022 TLV length overflow".to_string()), + }; + if value_end > data.len() { + return Err(format!("Token-2022 TLV value for type {extension_type} is truncated")); + } + if !seen.insert(extension_type) { + return Err(format!("Token-2022 TLV extension type {extension_type} is duplicated")); + } + let value = &data[offset..value_end]; + if !extension_kind_is_compatible(extension_type, kind_from_account_type) { + return Err(format!( + "Token-2022 TLV extension type {extension_type} is incompatible with account type {kind_from_account_type}" + )); + } + let value_fields = match decode_fixed_extension(extension_type, value) { + Ok(value_fields) => value_fields, + Err(error) => return Err(error), + }; + entries.push(Token2022TlvEntry { + extension_type, + extension_name: extension_name(extension_type), + value_hex: lower_hex(value), + value_fields, + }); + offset = value_end; + } + return Ok(entries); +} + +fn extension_kind_is_compatible(extension_type: u16, account_type: u8) -> bool { + return match extension_type { + 1 | 3 | 4 | 6 | 9 | 10 | 12 | 14 | 16 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 28 => { + account_type == 1 + }, + 2 | 5 | 7 | 8 | 11 | 13 | 15 | 17 | 27 => account_type == 2, + _ => true, + }; +} + +fn decode_fixed_extension(extension_type: u16, value: &[u8]) -> Result { + return match extension_type { + 1 => decode_transfer_fee_config(value), + 2 => decode_transfer_fee_amount(value), + 3 => decode_optional_pubkey_extension("mint close authority", value), + 4 => decode_confidential_transfer_mint(value), + 5 => decode_confidential_transfer_account(value), + 6 => decode_account_state_extension(value), + 7 => decode_empty_extension("immutable owner", value), + 8 => decode_bool_extension("memo transfer", value), + 9 => decode_empty_extension("non-transferable mint", value), + 10 => decode_interest_bearing_config(value), + 11 => decode_bool_extension("CPI guard", value), + 12 => decode_optional_pubkey_extension("permanent delegate", value), + 13 => decode_empty_extension("non-transferable account", value), + 14 => decode_authority_address_extension("transfer hook", value), + 15 => decode_bool_extension("transfer hook account", value), + 16 => decode_confidential_transfer_fee_config(value), + 17 => decode_confidential_transfer_fee_amount(value), + 18 => decode_authority_address_extension("metadata pointer", value), + 19 => decode_token_metadata(value), + 20 => decode_authority_address_extension("group pointer", value), + 21 => decode_token_group(value), + 22 => decode_authority_address_extension("group member pointer", value), + 23 => decode_token_group_member(value), + 24 => decode_confidential_mint_burn(value), + 25 => decode_scaled_ui_amount_config(value), + 26 => decode_pausable_config(value), + 27 => decode_empty_extension("pausable account", value), + 28 => decode_required_pubkey_extension("permissioned burn authority", value), + _ => Ok(serde_json::Value::Null), + }; +} + +fn decode_token_group(value: &[u8]) -> Result { + if value.len() != 80 { + return Err(format!( + "Token-2022 token group extension requires exactly 80 bytes, got {}", + value.len() + )); + } + let size = read_u64(&value[64..72]); + let max_size = read_u64(&value[72..80]); + if size > max_size { + return Err(format!("Token-2022 token group size {size} exceeds max size {max_size}")); + } + return Ok(serde_json::json!({ + "updateAuthority": optional_nonzero_pubkey(&value[0..32]), + "mint": bs58::encode(&value[32..64]).into_string(), + "size": size.to_string(), + "maxSize": max_size.to_string(), + })); +} + +fn decode_token_group_member(value: &[u8]) -> Result { + if value.len() != 72 { + return Err(format!( + "Token-2022 token group member extension requires exactly 72 bytes, got {}", + value.len() + )); + } + return Ok(serde_json::json!({ + "mint": bs58::encode(&value[0..32]).into_string(), + "group": bs58::encode(&value[32..64]).into_string(), + "memberNumber": read_u64(&value[64..72]).to_string(), + })); +} + +fn decode_token_metadata(value: &[u8]) -> Result { + if value.len() < 76 { + return Err(format!( + "Token-2022 token metadata extension requires at least 76 bytes, got {}", + value.len() + )); + } + let mut offset = 64usize; + let name = match decode_borsh_string("token metadata name", value, &mut offset) { + Ok(value) => value, + Err(error) => return Err(error), + }; + let symbol = match decode_borsh_string("token metadata symbol", value, &mut offset) { + Ok(value) => value, + Err(error) => return Err(error), + }; + let uri = match decode_borsh_string("token metadata URI", value, &mut offset) { + Ok(value) => value, + Err(error) => return Err(error), + }; + let pair_count = + match read_bounded_u32("token metadata additional metadata count", value, &mut offset) { + Ok(value) => value, + Err(error) => return Err(error), + }; + if pair_count > MAX_METADATA_PAIRS { + return Err(format!( + "Token-2022 token metadata pair count {pair_count} exceeds bound {MAX_METADATA_PAIRS}" + )); + } + let mut additional_metadata = Vec::with_capacity(pair_count); + let mut keys = std::collections::BTreeSet::new(); + for _ in 0..pair_count { + let key = match decode_borsh_string("token metadata additional key", value, &mut offset) { + Ok(value) => value, + Err(error) => return Err(error), + }; + if !keys.insert(key.clone()) { + return Err(format!("Token-2022 token metadata key is duplicated: {key}")); + } + let field_value = + match decode_borsh_string("token metadata additional value", value, &mut offset) { + Ok(value) => value, + Err(error) => return Err(error), + }; + additional_metadata.push(serde_json::json!({"key": key, "value": field_value})); + } + if offset != value.len() { + return Err(format!( + "Token-2022 token metadata extension has {} trailing bytes", + value.len() - offset + )); + } + return Ok(serde_json::json!({ + "updateAuthority": optional_nonzero_pubkey(&value[0..32]), + "mint": bs58::encode(&value[32..64]).into_string(), + "name": name, + "symbol": symbol, + "uri": uri, + "additionalMetadata": additional_metadata, + })); +} + +fn read_bounded_u32(name: &str, value: &[u8], offset: &mut usize) -> Result { + let end = match offset.checked_add(4) { + Some(end) => end, + None => return Err(format!("Token-2022 {name} offset overflow")), + }; + if end > value.len() { + return Err(format!("Token-2022 {name} is truncated")); + } + let decoded = usize::try_from(read_u32(&value[*offset..end])); + *offset = end; + return match decoded { + Ok(decoded) => Ok(decoded), + Err(_) => Err(format!("Token-2022 {name} does not fit usize")), + }; +} + +fn decode_borsh_string(name: &str, value: &[u8], offset: &mut usize) -> Result { + let length = match read_bounded_u32(name, value, offset) { + Ok(length) => length, + Err(error) => return Err(error), + }; + if length > MAX_METADATA_STRING_LEN { + return Err(format!( + "Token-2022 {name} length {length} exceeds bound {MAX_METADATA_STRING_LEN}" + )); + } + let end = match offset.checked_add(length) { + Some(end) => end, + None => return Err(format!("Token-2022 {name} length overflow")), + }; + if end > value.len() { + return Err(format!("Token-2022 {name} bytes are truncated")); + } + let decoded = match std::str::from_utf8(&value[*offset..end]) { + Ok(decoded) => decoded.to_string(), + Err(_) => return Err(format!("Token-2022 {name} is not valid UTF-8")), + }; + *offset = end; + return Ok(decoded); +} + +fn decode_confidential_transfer_fee_config(value: &[u8]) -> Result { + if value.len() != 129 { + return Err(format!( + "Token-2022 confidential transfer fee config extension requires exactly 129 bytes, got {}", + value.len() + )); + } + let harvest_to_mint_enabled = + match decode_pod_bool("confidential transfer fee harvest-to-mint flag", value[64]) { + Ok(value) => value, + Err(error) => return Err(error), + }; + return Ok(serde_json::json!({ + "authority": optional_nonzero_pubkey(&value[0..32]), + "withdrawWithheldAuthorityElgamalPubkey": encode_base64(&value[32..64]), + "harvestToMintEnabled": harvest_to_mint_enabled, + "withheldAmount": encode_base64(&value[65..129]), + })); +} + +fn decode_confidential_transfer_fee_amount(value: &[u8]) -> Result { + if value.len() != 64 { + return Err(format!( + "Token-2022 confidential transfer fee amount extension requires exactly 64 bytes, got {}", + value.len() + )); + } + return Ok(serde_json::json!({ + "withheldAmount": encode_base64(value), + })); +} + +fn decode_confidential_mint_burn(value: &[u8]) -> Result { + if value.len() != 196 { + return Err(format!( + "Token-2022 confidential mint-burn extension requires exactly 196 bytes, got {}", + value.len() + )); + } + return Ok(serde_json::json!({ + "confidentialSupply": encode_base64(&value[0..64]), + "decryptableSupply": encode_base64(&value[64..100]), + "supplyElgamalPubkey": encode_base64(&value[100..132]), + "pendingBurn": encode_base64(&value[132..196]), + })); +} + +fn decode_interest_bearing_config(value: &[u8]) -> Result { + if value.len() != 52 { + return Err(format!( + "Token-2022 interest-bearing config extension requires exactly 52 bytes, got {}", + value.len() + )); + } + return Ok(serde_json::json!({ + "rateAuthority": optional_nonzero_pubkey(&value[0..32]), + "initializationTimestamp": read_i64(&value[32..40]).to_string(), + "preUpdateAverageRateBasisPoints": read_i16(&value[40..42]), + "lastUpdateTimestamp": read_i64(&value[42..50]).to_string(), + "currentRateBasisPoints": read_i16(&value[50..52]), + })); +} + +fn decode_scaled_ui_amount_config(value: &[u8]) -> Result { + if value.len() != 56 { + return Err(format!( + "Token-2022 scaled UI amount extension requires exactly 56 bytes, got {}", + value.len() + )); + } + let multiplier = f64::from_le_bytes(match value[32..40].try_into() { + Ok(bytes) => bytes, + Err(_) => return Err("Token-2022 scaled UI multiplier has invalid width".to_string()), + }); + let new_multiplier = f64::from_le_bytes(match value[48..56].try_into() { + Ok(bytes) => bytes, + Err(_) => return Err("Token-2022 scaled UI new multiplier has invalid width".to_string()), + }); + if !multiplier.is_finite() || multiplier <= 0.0 { + return Err(format!( + "Token-2022 scaled UI multiplier must be finite and positive, got {multiplier}" + )); + } + if !new_multiplier.is_finite() || new_multiplier <= 0.0 { + return Err(format!( + "Token-2022 scaled UI new multiplier must be finite and positive, got {new_multiplier}" + )); + } + return Ok(serde_json::json!({ + "authority": optional_nonzero_pubkey(&value[0..32]), + "multiplier": multiplier.to_string(), + "newMultiplierEffectiveTimestamp": read_i64(&value[40..48]).to_string(), + "newMultiplier": new_multiplier.to_string(), + })); +} + +fn decode_pausable_config(value: &[u8]) -> Result { + if value.len() != 33 { + return Err(format!( + "Token-2022 pausable config extension requires exactly 33 bytes, got {}", + value.len() + )); + } + let paused = match decode_pod_bool("pausable config paused flag", value[32]) { + Ok(value) => value, + Err(error) => return Err(error), + }; + return Ok(serde_json::json!({ + "authority": optional_nonzero_pubkey(&value[0..32]), + "paused": paused, + })); +} + +fn decode_confidential_transfer_mint(value: &[u8]) -> Result { + if value.len() != 65 { + return Err(format!( + "Token-2022 confidential transfer mint extension requires exactly 65 bytes, got {}", + value.len() + )); + } + let auto_approve_new_accounts = match value[32] { + 0 => false, + 1 => true, + invalid => { + return Err(format!( + "Token-2022 confidential transfer mint auto-approve flag must be 0 or 1, got {invalid}" + )); + }, + }; + return Ok(serde_json::json!({ + "authority": optional_nonzero_pubkey(&value[0..32]), + "autoApproveNewAccounts": auto_approve_new_accounts, + "auditorElgamalPubkey": optional_nonzero_base64(&value[33..65]), + })); +} + +fn decode_confidential_transfer_account(value: &[u8]) -> Result { + if value.len() != 295 { + return Err(format!( + "Token-2022 confidential transfer account extension requires exactly 295 bytes, got {}", + value.len() + )); + } + let approved = match decode_pod_bool("confidential transfer account approved", value[0]) { + Ok(value) => value, + Err(error) => return Err(error), + }; + let allow_confidential_credits = match decode_pod_bool( + "confidential transfer account allow confidential credits", + value[261], + ) { + Ok(value) => value, + Err(error) => return Err(error), + }; + let allow_non_confidential_credits = match decode_pod_bool( + "confidential transfer account allow non-confidential credits", + value[262], + ) { + Ok(value) => value, + Err(error) => return Err(error), + }; + return Ok(serde_json::json!({ + "approved": approved, + "elgamalPubkey": encode_base64(&value[1..33]), + "pendingBalanceLo": encode_base64(&value[33..97]), + "pendingBalanceHi": encode_base64(&value[97..161]), + "availableBalance": encode_base64(&value[161..225]), + "decryptableAvailableBalance": encode_base64(&value[225..261]), + "allowConfidentialCredits": allow_confidential_credits, + "allowNonConfidentialCredits": allow_non_confidential_credits, + "pendingBalanceCreditCounter": read_u64(&value[263..271]).to_string(), + "maximumPendingBalanceCreditCounter": read_u64(&value[271..279]).to_string(), + "expectedPendingBalanceCreditCounter": read_u64(&value[279..287]).to_string(), + "actualPendingBalanceCreditCounter": read_u64(&value[287..295]).to_string(), + })); +} + +fn decode_pod_bool(name: &str, value: u8) -> Result { + return match value { + 0 => Ok(false), + 1 => Ok(true), + invalid => Err(format!("Token-2022 {name} must be 0 or 1, got {invalid}")), + }; +} + +fn encode_base64(value: &[u8]) -> String { + return base64::engine::general_purpose::STANDARD.encode(value); +} + +fn optional_nonzero_base64(value: &[u8]) -> serde_json::Value { + if value.iter().all(|byte| return *byte == 0) { + return serde_json::Value::Null; + } + return serde_json::Value::String(encode_base64(value)); +} + +fn decode_transfer_fee_config(value: &[u8]) -> Result { + if value.len() != 108 { + return Err(format!( + "Token-2022 transfer fee config extension requires exactly 108 bytes, got {}", + value.len() + )); + } + let older_transfer_fee = match decode_transfer_fee("older transfer fee", &value[72..90]) { + Ok(value) => value, + Err(error) => return Err(error), + }; + let newer_transfer_fee = match decode_transfer_fee("newer transfer fee", &value[90..108]) { + Ok(value) => value, + Err(error) => return Err(error), + }; + return Ok(serde_json::json!({ + "transferFeeConfigAuthority": optional_nonzero_pubkey(&value[0..32]), + "withdrawWithheldAuthority": optional_nonzero_pubkey(&value[32..64]), + "withheldAmount": read_u64(&value[64..72]).to_string(), + "olderTransferFee": older_transfer_fee, + "newerTransferFee": newer_transfer_fee, + })); +} + +fn decode_transfer_fee(name: &str, value: &[u8]) -> Result { + if value.len() != 18 { + return Err(format!("Token-2022 {name} requires exactly 18 bytes, got {}", value.len())); + } + let basis_points = u16::from_le_bytes([value[16], value[17]]); + if basis_points > 10_000 { + return Err(format!("Token-2022 {name} basis points exceed 10000: {basis_points}")); + } + return Ok(serde_json::json!({ + "epoch": read_u64(&value[0..8]).to_string(), + "maximumFee": read_u64(&value[8..16]).to_string(), + "transferFeeBasisPoints": basis_points, + })); +} + +fn decode_transfer_fee_amount(value: &[u8]) -> Result { + if value.len() != 8 { + return Err(format!( + "Token-2022 transfer fee amount extension requires exactly 8 bytes, got {}", + value.len() + )); + } + return Ok(serde_json::json!({ + "withheldAmount": read_u64(value).to_string(), + })); +} + +fn decode_empty_extension(name: &str, value: &[u8]) -> Result { + if !value.is_empty() { + return Err(format!( + "Token-2022 {name} extension requires zero bytes, got {}", + value.len() + )); + } + return Ok(serde_json::json!({})); +} + +fn decode_bool_extension(name: &str, value: &[u8]) -> Result { + if value.len() != 1 { + return Err(format!( + "Token-2022 {name} extension requires exactly 1 byte, got {}", + value.len() + )); + } + let enabled = match value[0] { + 0 => false, + 1 => true, + invalid => return Err(format!("Token-2022 {name} bool must be 0 or 1, got {invalid}")), + }; + return Ok(serde_json::json!({"enabled": enabled})); +} + +fn decode_account_state_extension(value: &[u8]) -> Result { + if value.len() != 1 { + return Err(format!( + "Token-2022 default account state requires exactly 1 byte, got {}", + value.len() + )); + } + let state = match value[0] { + 0 => "uninitialized", + 1 => "initialized", + 2 => "frozen", + invalid => { + return Err(format!( + "Token-2022 default account state must be 0, 1, or 2, got {invalid}" + )); + }, + }; + return Ok(serde_json::json!({"state": state})); +} + +fn decode_optional_pubkey_extension(name: &str, value: &[u8]) -> Result { + if value.len() != 32 { + return Err(format!( + "Token-2022 {name} extension requires exactly 32 bytes, got {}", + value.len() + )); + } + let address = optional_nonzero_pubkey(value); + return Ok(serde_json::json!({"address": address})); +} + +fn decode_required_pubkey_extension(name: &str, value: &[u8]) -> Result { + if value.len() != 32 { + return Err(format!( + "Token-2022 {name} extension requires exactly 32 bytes, got {}", + value.len() + )); + } + return Ok(serde_json::json!({"address": bs58::encode(value).into_string()})); +} + +fn decode_authority_address_extension( + name: &str, + value: &[u8], +) -> Result { + if value.len() != 64 { + return Err(format!( + "Token-2022 {name} extension requires exactly 64 bytes, got {}", + value.len() + )); + } + return Ok(serde_json::json!({ + "authority": optional_nonzero_pubkey(&value[0..32]), + "address": optional_nonzero_pubkey(&value[32..64]), + })); +} + +fn optional_nonzero_pubkey(value: &[u8]) -> serde_json::Value { + if value.iter().all(|byte| return *byte == 0) { + return serde_json::Value::Null; + } + return serde_json::Value::String(bs58::encode(value).into_string()); +} + +fn extension_name(extension_type: u16) -> &'static str { + return match extension_type { + 1 => "transfer_fee_config", + 2 => "transfer_fee_amount", + 3 => "mint_close_authority", + 4 => "confidential_transfer_mint", + 5 => "confidential_transfer_account", + 6 => "default_account_state", + 7 => "immutable_owner", + 8 => "memo_transfer", + 9 => "non_transferable", + 10 => "interest_bearing_config", + 11 => "cpi_guard", + 12 => "permanent_delegate", + 13 => "non_transferable_account", + 14 => "transfer_hook", + 15 => "transfer_hook_account", + 16 => "confidential_transfer_fee_config", + 17 => "confidential_transfer_fee_amount", + 18 => "metadata_pointer", + 19 => "token_metadata", + 20 => "group_pointer", + 21 => "token_group", + 22 => "group_member_pointer", + 23 => "token_group_member", + 24 => "confidential_mint_burn", + 25 => "scaled_ui_amount", + 26 => "pausable", + 27 => "pausable_account", + 28 => "permissioned_burn", + _ => "unknown", + }; +} + +fn lower_hex(bytes: &[u8]) -> String { + let mut output = String::with_capacity(bytes.len() * 2); + for byte in bytes { + output.push_str(&format!("{byte:02x}")); + } + return output; +} + +#[cfg(test)] +mod tests { + + #[test] + fn exact_base_mint_account_and_multisig_layouts_are_preserved() { + let mint = super::parse_token_2022_state(super::Token2022StateKind::Mint, &[0; 82]); + let account = super::parse_token_2022_state(super::Token2022StateKind::Account, &[0; 165]); + let multisig = + super::parse_token_2022_state(super::Token2022StateKind::Multisig, &[0; 355]); + assert_eq!(mint.as_ref().map(|state| return state.base_hex.len()), Ok(164)); + assert_eq!(account.as_ref().map(|state| return state.base_hex.len()), Ok(330)); + assert_eq!(multisig.as_ref().map(|state| return state.base_hex.len()), Ok(710)); + assert_eq!( + mint.as_ref().map(|state| return state.base_fields["supply"].as_str()), + Ok(Some("0")) + ); + assert_eq!( + account.as_ref().map(|state| return state.base_fields["amount"].as_str()), + Ok(Some("0")) + ); + assert_eq!( + multisig.as_ref().map(|state| return state.base_fields["m"].as_u64()), + Ok(Some(0)) + ); + } + + #[test] + fn extended_mint_and_account_preserve_ordered_known_and_unknown_tlv() { + let mut mint = vec![0; 166]; + mint[165] = 1; + mint.extend_from_slice(&9u16.to_le_bytes()); + mint.extend_from_slice(&0u16.to_le_bytes()); + mint.extend_from_slice(&77u16.to_le_bytes()); + mint.extend_from_slice(&1u16.to_le_bytes()); + mint.push(0xcc); + mint.extend_from_slice(&[0; 8]); + let parsed_mint = super::parse_token_2022_state(super::Token2022StateKind::Mint, &mint); + assert_eq!(parsed_mint.as_ref().map(|state| return state.extensions.len()), Ok(2)); + assert_eq!( + parsed_mint.as_ref().map(|state| return state.extensions[0].extension_name), + Ok("non_transferable") + ); + assert_eq!( + parsed_mint.as_ref().map(|state| return state.extensions[1].extension_name), + Ok("unknown") + ); + let mut account = vec![0; 166]; + account[165] = 2; + account.extend_from_slice(&8u16.to_le_bytes()); + account.extend_from_slice(&1u16.to_le_bytes()); + account.push(1); + let parsed_account = + super::parse_token_2022_state(super::Token2022StateKind::Account, &account); + assert_eq!( + parsed_account.as_ref().map(|state| return state.extensions[0].extension_name), + Ok("memo_transfer") + ); + assert_eq!( + parsed_account + .as_ref() + .map(|state| return state.extensions[0].value_fields["enabled"].as_bool()), + Ok(Some(true)) + ); + } + + #[test] + fn malformed_padding_account_type_tlv_and_duplicates_fail_closed() { + let mut mint_padding = vec![0; 166]; + mint_padding[82] = 1; + mint_padding[165] = 1; + assert!( + super::parse_token_2022_state(super::Token2022StateKind::Mint, &mint_padding).is_err() + ); + let mut wrong_type = vec![0; 166]; + wrong_type[165] = 2; + assert!( + super::parse_token_2022_state(super::Token2022StateKind::Mint, &wrong_type).is_err() + ); + let mut truncated = vec![0; 166]; + truncated[165] = 2; + truncated.extend_from_slice(&1u16.to_le_bytes()); + truncated.extend_from_slice(&3u16.to_le_bytes()); + truncated.push(9); + assert!( + super::parse_token_2022_state(super::Token2022StateKind::Account, &truncated).is_err() + ); + let mut duplicate = vec![0; 166]; + duplicate[165] = 2; + for _ in 0..2 { + duplicate.extend_from_slice(&8u16.to_le_bytes()); + duplicate.extend_from_slice(&1u16.to_le_bytes()); + duplicate.push(1); + } + assert!( + super::parse_token_2022_state(super::Token2022StateKind::Account, &duplicate).is_err() + ); + } + + #[test] + fn state_lengths_and_tlv_count_are_bounded() { + assert!( + super::parse_token_2022_state(super::Token2022StateKind::Multisig, &[0; 354]).is_err() + ); + let oversized = vec![0; 1_048_577]; + assert!( + super::parse_token_2022_state(super::Token2022StateKind::Account, &oversized).is_err() + ); + let mut account = vec![0; 166]; + account[165] = 2; + for extension_type in 1u16..=65 { + account.extend_from_slice(&extension_type.to_le_bytes()); + account.extend_from_slice(&0u16.to_le_bytes()); + } + assert!( + super::parse_token_2022_state(super::Token2022StateKind::Account, &account).is_err() + ); + } + #[test] + fn fixed_extension_payloads_are_structured_and_kind_checked() { + let mut mint = vec![0; 166]; + mint[165] = 1; + mint.extend_from_slice(&6u16.to_le_bytes()); + mint.extend_from_slice(&1u16.to_le_bytes()); + mint.push(2); + mint.extend_from_slice(&18u16.to_le_bytes()); + mint.extend_from_slice(&64u16.to_le_bytes()); + mint.extend_from_slice(&[0; 64]); + let parsed = super::parse_token_2022_state(super::Token2022StateKind::Mint, &mint); + assert_eq!( + parsed + .as_ref() + .map(|state| return state.extensions[0].value_fields["state"].as_str()), + Ok(Some("frozen")) + ); + assert_eq!( + parsed + .as_ref() + .map(|state| return state.extensions[1].value_fields["authority"].is_null()), + Ok(true) + ); + let mut incompatible = vec![0; 166]; + incompatible[165] = 2; + incompatible.extend_from_slice(&6u16.to_le_bytes()); + incompatible.extend_from_slice(&1u16.to_le_bytes()); + incompatible.push(1); + assert!( + super::parse_token_2022_state(super::Token2022StateKind::Account, &incompatible) + .is_err() + ); + } + + #[test] + fn audited_fixed_extension_lengths_and_values_fail_closed() { + let mut invalid_bool = vec![0; 166]; + invalid_bool[165] = 2; + invalid_bool.extend_from_slice(&8u16.to_le_bytes()); + invalid_bool.extend_from_slice(&1u16.to_le_bytes()); + invalid_bool.push(2); + assert!( + super::parse_token_2022_state(super::Token2022StateKind::Account, &invalid_bool) + .is_err() + ); + let mut invalid_pointer = vec![0; 166]; + invalid_pointer[165] = 1; + invalid_pointer.extend_from_slice(&18u16.to_le_bytes()); + invalid_pointer.extend_from_slice(&63u16.to_le_bytes()); + invalid_pointer.extend_from_slice(&[0; 63]); + assert!( + super::parse_token_2022_state(super::Token2022StateKind::Mint, &invalid_pointer) + .is_err() + ); + } + + #[test] + fn transfer_fee_state_extensions_decode_exact_public_fields() { + let mut mint = vec![0; 166]; + mint[165] = 1; + let mut config = vec![0; 108]; + config[0..32].copy_from_slice(&[1; 32]); + config[32..64].copy_from_slice(&[2; 32]); + config[64..72].copy_from_slice(&7u64.to_le_bytes()); + config[72..80].copy_from_slice(&3u64.to_le_bytes()); + config[80..88].copy_from_slice(&11u64.to_le_bytes()); + config[88..90].copy_from_slice(&25u16.to_le_bytes()); + config[90..98].copy_from_slice(&4u64.to_le_bytes()); + config[98..106].copy_from_slice(&13u64.to_le_bytes()); + config[106..108].copy_from_slice(&50u16.to_le_bytes()); + mint.extend_from_slice(&1u16.to_le_bytes()); + mint.extend_from_slice(&108u16.to_le_bytes()); + mint.extend_from_slice(&config); + let parsed_mint = super::parse_token_2022_state(super::Token2022StateKind::Mint, &mint); + assert_eq!( + parsed_mint + .as_ref() + .map(|state| return state.extensions[0].value_fields["withheldAmount"].as_str()), + Ok(Some("7")) + ); + assert_eq!( + parsed_mint.as_ref().map(|state| return state.extensions[0].value_fields["newerTransferFee"]["transferFeeBasisPoints"].as_u64()), + Ok(Some(50)) + ); + let mut account = vec![0; 166]; + account[165] = 2; + account.extend_from_slice(&2u16.to_le_bytes()); + account.extend_from_slice(&8u16.to_le_bytes()); + account.extend_from_slice(&19u64.to_le_bytes()); + let parsed_account = + super::parse_token_2022_state(super::Token2022StateKind::Account, &account); + assert_eq!( + parsed_account + .as_ref() + .map(|state| return state.extensions[0].value_fields["withheldAmount"].as_str()), + Ok(Some("19")) + ); + } + + #[test] + fn transfer_fee_state_extensions_reject_invalid_lengths_and_basis_points() { + let mut short_config = vec![0; 166]; + short_config[165] = 1; + short_config.extend_from_slice(&1u16.to_le_bytes()); + short_config.extend_from_slice(&107u16.to_le_bytes()); + short_config.extend_from_slice(&[0; 107]); + assert!( + super::parse_token_2022_state(super::Token2022StateKind::Mint, &short_config).is_err() + ); + let mut invalid_basis_points = vec![0; 166]; + invalid_basis_points[165] = 1; + let mut config = vec![0; 108]; + config[88..90].copy_from_slice(&10_001u16.to_le_bytes()); + invalid_basis_points.extend_from_slice(&1u16.to_le_bytes()); + invalid_basis_points.extend_from_slice(&108u16.to_le_bytes()); + invalid_basis_points.extend_from_slice(&config); + assert!( + super::parse_token_2022_state(super::Token2022StateKind::Mint, &invalid_basis_points) + .is_err() + ); + let mut short_amount = vec![0; 166]; + short_amount[165] = 2; + short_amount.extend_from_slice(&2u16.to_le_bytes()); + short_amount.extend_from_slice(&7u16.to_le_bytes()); + short_amount.extend_from_slice(&[0; 7]); + assert!( + super::parse_token_2022_state(super::Token2022StateKind::Account, &short_amount) + .is_err() + ); + } + + #[test] + fn confidential_transfer_state_extensions_decode_exact_public_fields() { + let mut mint = vec![0; 166]; + mint[165] = 1; + let mut mint_extension = vec![0; 65]; + mint_extension[0..32].copy_from_slice(&[1; 32]); + mint_extension[32] = 1; + mint_extension[33..65].copy_from_slice(&[2; 32]); + mint.extend_from_slice(&4u16.to_le_bytes()); + mint.extend_from_slice(&65u16.to_le_bytes()); + mint.extend_from_slice(&mint_extension); + let parsed_mint = super::parse_token_2022_state(super::Token2022StateKind::Mint, &mint); + assert_eq!( + parsed_mint + .as_ref() + .map(|state| return state.extensions[0].value_fields["autoApproveNewAccounts"] + .as_bool()), + Ok(Some(true)) + ); + assert_eq!( + parsed_mint + .as_ref() + .map(|state| return state.extensions[0].value_fields["auditorElgamalPubkey"] + .is_string()), + Ok(true) + ); + let mut account = vec![0; 166]; + account[165] = 2; + let mut account_extension = vec![0; 295]; + account_extension[0] = 1; + account_extension[1..33].copy_from_slice(&[3; 32]); + account_extension[33..97].copy_from_slice(&[4; 64]); + account_extension[97..161].copy_from_slice(&[5; 64]); + account_extension[161..225].copy_from_slice(&[6; 64]); + account_extension[225..261].copy_from_slice(&[7; 36]); + account_extension[261] = 1; + account_extension[262] = 0; + account_extension[263..271].copy_from_slice(&8u64.to_le_bytes()); + account_extension[271..279].copy_from_slice(&9u64.to_le_bytes()); + account_extension[279..287].copy_from_slice(&10u64.to_le_bytes()); + account_extension[287..295].copy_from_slice(&11u64.to_le_bytes()); + account.extend_from_slice(&5u16.to_le_bytes()); + account.extend_from_slice(&295u16.to_le_bytes()); + account.extend_from_slice(&account_extension); + let parsed_account = + super::parse_token_2022_state(super::Token2022StateKind::Account, &account); + assert_eq!( + parsed_account + .as_ref() + .map(|state| return state.extensions[0].value_fields["approved"].as_bool()), + Ok(Some(true)) + ); + assert_eq!( + parsed_account.as_ref().map(|state| { + return state.extensions[0].value_fields["maximumPendingBalanceCreditCounter"] + .as_str(); + }), + Ok(Some("9")) + ); + assert_eq!( + parsed_account.as_ref().map(|state| { + return state.extensions[0].value_fields["allowNonConfidentialCredits"].as_bool(); + }), + Ok(Some(false)) + ); + } + + #[test] + fn confidential_transfer_state_extensions_reject_invalid_lengths_and_flags() { + let mut short_mint = vec![0; 166]; + short_mint[165] = 1; + short_mint.extend_from_slice(&4u16.to_le_bytes()); + short_mint.extend_from_slice(&64u16.to_le_bytes()); + short_mint.extend_from_slice(&[0; 64]); + assert!( + super::parse_token_2022_state(super::Token2022StateKind::Mint, &short_mint).is_err() + ); + let mut invalid_mint_flag = vec![0; 166]; + invalid_mint_flag[165] = 1; + let mut mint_extension = vec![0; 65]; + mint_extension[32] = 2; + invalid_mint_flag.extend_from_slice(&4u16.to_le_bytes()); + invalid_mint_flag.extend_from_slice(&65u16.to_le_bytes()); + invalid_mint_flag.extend_from_slice(&mint_extension); + assert!( + super::parse_token_2022_state(super::Token2022StateKind::Mint, &invalid_mint_flag) + .is_err() + ); + let mut invalid_account_flag = vec![0; 166]; + invalid_account_flag[165] = 2; + let mut account_extension = vec![0; 295]; + account_extension[261] = 2; + invalid_account_flag.extend_from_slice(&5u16.to_le_bytes()); + invalid_account_flag.extend_from_slice(&295u16.to_le_bytes()); + invalid_account_flag.extend_from_slice(&account_extension); + assert!( + super::parse_token_2022_state( + super::Token2022StateKind::Account, + &invalid_account_flag + ) + .is_err() + ); + } + + #[test] + fn interest_scaled_ui_and_pausable_states_decode_exact_public_fields() { + let mut interest = Vec::new(); + interest.extend_from_slice(&[0; 32]); + interest.extend_from_slice(&(-10i64).to_le_bytes()); + interest.extend_from_slice(&(-250i16).to_le_bytes()); + interest.extend_from_slice(&20i64.to_le_bytes()); + interest.extend_from_slice(&500i16.to_le_bytes()); + let mut mint = vec![0; 166]; + mint[165] = 1; + mint.extend_from_slice(&10u16.to_le_bytes()); + mint.extend_from_slice(&(interest.len() as u16).to_le_bytes()); + mint.extend_from_slice(&interest); + let parsed = super::parse_token_2022_state(super::Token2022StateKind::Mint, &mint); + assert_eq!( + parsed + .as_ref() + .map(|state| return state.extensions[0].value_fields["currentRateBasisPoints"] + .as_i64()), + Ok(Some(500)) + ); + + let mut scaled = Vec::new(); + scaled.extend_from_slice(&[0; 32]); + scaled.extend_from_slice(&1.5f64.to_le_bytes()); + scaled.extend_from_slice(&123i64.to_le_bytes()); + scaled.extend_from_slice(&2.5f64.to_le_bytes()); + let mut scaled_mint = vec![0; 166]; + scaled_mint[165] = 1; + scaled_mint.extend_from_slice(&25u16.to_le_bytes()); + scaled_mint.extend_from_slice(&(scaled.len() as u16).to_le_bytes()); + scaled_mint.extend_from_slice(&scaled); + let parsed_scaled = + super::parse_token_2022_state(super::Token2022StateKind::Mint, &scaled_mint); + assert_eq!( + parsed_scaled + .as_ref() + .map(|state| return state.extensions[0].value_fields["multiplier"].as_str()), + Ok(Some("1.5")) + ); + + let mut pausable_mint = vec![0; 166]; + pausable_mint[165] = 1; + pausable_mint.extend_from_slice(&26u16.to_le_bytes()); + pausable_mint.extend_from_slice(&33u16.to_le_bytes()); + pausable_mint.extend_from_slice(&[0; 32]); + pausable_mint.push(1); + let parsed_pausable = + super::parse_token_2022_state(super::Token2022StateKind::Mint, &pausable_mint); + assert_eq!( + parsed_pausable + .as_ref() + .map(|state| return state.extensions[0].value_fields["paused"].as_bool()), + Ok(Some(true)) + ); + } + + #[test] + fn interest_scaled_ui_and_pausable_states_fail_closed() { + let mut short_interest = vec![0; 166]; + short_interest[165] = 1; + short_interest.extend_from_slice(&10u16.to_le_bytes()); + short_interest.extend_from_slice(&51u16.to_le_bytes()); + short_interest.extend_from_slice(&[0; 51]); + assert!( + super::parse_token_2022_state(super::Token2022StateKind::Mint, &short_interest) + .is_err() + ); + + let mut invalid_scaled = vec![0; 166]; + invalid_scaled[165] = 1; + invalid_scaled.extend_from_slice(&25u16.to_le_bytes()); + invalid_scaled.extend_from_slice(&56u16.to_le_bytes()); + invalid_scaled.extend_from_slice(&[0; 32]); + invalid_scaled.extend_from_slice(&f64::NAN.to_le_bytes()); + invalid_scaled.extend_from_slice(&0i64.to_le_bytes()); + invalid_scaled.extend_from_slice(&1.0f64.to_le_bytes()); + assert!( + super::parse_token_2022_state(super::Token2022StateKind::Mint, &invalid_scaled) + .is_err() + ); + + let mut invalid_pausable = vec![0; 166]; + invalid_pausable[165] = 1; + invalid_pausable.extend_from_slice(&26u16.to_le_bytes()); + invalid_pausable.extend_from_slice(&33u16.to_le_bytes()); + invalid_pausable.extend_from_slice(&[0; 32]); + invalid_pausable.push(2); + assert!( + super::parse_token_2022_state(super::Token2022StateKind::Mint, &invalid_pausable) + .is_err() + ); + + let mut account_marker = vec![0; 166]; + account_marker[165] = 2; + account_marker.extend_from_slice(&27u16.to_le_bytes()); + account_marker.extend_from_slice(&0u16.to_le_bytes()); + assert!( + super::parse_token_2022_state(super::Token2022StateKind::Account, &account_marker) + .is_ok() + ); + } + + #[test] + fn confidential_fee_and_mint_burn_states_decode_exact_public_fields() { + let mut fee_mint = vec![0; 166]; + fee_mint[165] = 1; + let mut fee_config = vec![0; 129]; + fee_config[0..32].copy_from_slice(&[1; 32]); + fee_config[32..64].copy_from_slice(&[2; 32]); + fee_config[64] = 1; + fee_config[65..129].copy_from_slice(&[3; 64]); + fee_mint.extend_from_slice(&16u16.to_le_bytes()); + fee_mint.extend_from_slice(&129u16.to_le_bytes()); + fee_mint.extend_from_slice(&fee_config); + let parsed_fee_mint = + super::parse_token_2022_state(super::Token2022StateKind::Mint, &fee_mint); + assert_eq!( + parsed_fee_mint + .as_ref() + .map(|state| return state.extensions[0].value_fields["harvestToMintEnabled"] + .as_bool()), + Ok(Some(true)) + ); + assert_eq!( + parsed_fee_mint + .as_ref() + .map(|state| return state.extensions[0].value_fields["withheldAmount"].is_string()), + Ok(true) + ); + + let mut fee_account = vec![0; 166]; + fee_account[165] = 2; + fee_account.extend_from_slice(&17u16.to_le_bytes()); + fee_account.extend_from_slice(&64u16.to_le_bytes()); + fee_account.extend_from_slice(&[4; 64]); + let parsed_fee_account = + super::parse_token_2022_state(super::Token2022StateKind::Account, &fee_account); + assert_eq!( + parsed_fee_account + .as_ref() + .map(|state| return state.extensions[0].value_fields["withheldAmount"].is_string()), + Ok(true) + ); + + let mut mint_burn = vec![0; 166]; + mint_burn[165] = 1; + let mut mint_burn_extension = vec![0; 196]; + mint_burn_extension[0..64].copy_from_slice(&[5; 64]); + mint_burn_extension[64..100].copy_from_slice(&[6; 36]); + mint_burn_extension[100..132].copy_from_slice(&[7; 32]); + mint_burn_extension[132..196].copy_from_slice(&[8; 64]); + mint_burn.extend_from_slice(&24u16.to_le_bytes()); + mint_burn.extend_from_slice(&196u16.to_le_bytes()); + mint_burn.extend_from_slice(&mint_burn_extension); + let parsed_mint_burn = + super::parse_token_2022_state(super::Token2022StateKind::Mint, &mint_burn); + assert_eq!( + parsed_mint_burn + .as_ref() + .map(|state| return state.extensions[0].value_fields["decryptableSupply"] + .is_string()), + Ok(true) + ); + assert_eq!( + parsed_mint_burn + .as_ref() + .map(|state| return state.extensions[0].value_fields["pendingBurn"].is_string()), + Ok(true) + ); + } + + #[test] + fn confidential_fee_and_mint_burn_states_fail_closed() { + let mut invalid_fee_flag = vec![0; 166]; + invalid_fee_flag[165] = 1; + let mut fee_config = vec![0; 129]; + fee_config[64] = 2; + invalid_fee_flag.extend_from_slice(&16u16.to_le_bytes()); + invalid_fee_flag.extend_from_slice(&129u16.to_le_bytes()); + invalid_fee_flag.extend_from_slice(&fee_config); + assert!( + super::parse_token_2022_state(super::Token2022StateKind::Mint, &invalid_fee_flag) + .is_err() + ); + + let mut short_fee_amount = vec![0; 166]; + short_fee_amount[165] = 2; + short_fee_amount.extend_from_slice(&17u16.to_le_bytes()); + short_fee_amount.extend_from_slice(&63u16.to_le_bytes()); + short_fee_amount.extend_from_slice(&[0; 63]); + assert!( + super::parse_token_2022_state(super::Token2022StateKind::Account, &short_fee_amount) + .is_err() + ); + + let mut short_mint_burn = vec![0; 166]; + short_mint_burn[165] = 1; + short_mint_burn.extend_from_slice(&24u16.to_le_bytes()); + short_mint_burn.extend_from_slice(&195u16.to_le_bytes()); + short_mint_burn.extend_from_slice(&[0; 195]); + assert!( + super::parse_token_2022_state(super::Token2022StateKind::Mint, &short_mint_burn) + .is_err() + ); + } + + #[test] + fn token_group_and_member_states_decode_exact_public_fields() { + let mut mint = vec![0; 166]; + mint[165] = 1; + let mut group = vec![0; 80]; + group[0..32].copy_from_slice(&[1; 32]); + group[32..64].copy_from_slice(&[2; 32]); + group[64..72].copy_from_slice(&3u64.to_le_bytes()); + group[72..80].copy_from_slice(&5u64.to_le_bytes()); + mint.extend_from_slice(&21u16.to_le_bytes()); + mint.extend_from_slice(&80u16.to_le_bytes()); + mint.extend_from_slice(&group); + let parsed_group = super::parse_token_2022_state(super::Token2022StateKind::Mint, &mint); + assert_eq!( + parsed_group + .as_ref() + .map(|state| return state.extensions[0].value_fields["size"].as_str()), + Ok(Some("3")) + ); + assert_eq!( + parsed_group + .as_ref() + .map(|state| return state.extensions[0].value_fields["maxSize"].as_str()), + Ok(Some("5")) + ); + let mut member_mint = vec![0; 166]; + member_mint[165] = 1; + let mut member = vec![0; 72]; + member[0..32].copy_from_slice(&[3; 32]); + member[32..64].copy_from_slice(&[4; 32]); + member[64..72].copy_from_slice(&7u64.to_le_bytes()); + member_mint.extend_from_slice(&23u16.to_le_bytes()); + member_mint.extend_from_slice(&72u16.to_le_bytes()); + member_mint.extend_from_slice(&member); + let parsed_member = + super::parse_token_2022_state(super::Token2022StateKind::Mint, &member_mint); + assert_eq!( + parsed_member + .as_ref() + .map(|state| return state.extensions[0].value_fields["memberNumber"].as_str()), + Ok(Some("7")) + ); + assert_eq!( + parsed_member + .as_ref() + .map(|state| return state.extensions[0].value_fields["group"].is_string()), + Ok(true) + ); + } + + #[test] + fn token_group_and_member_states_reject_invalid_lengths_and_size() { + let mut short_group = vec![0; 166]; + short_group[165] = 1; + short_group.extend_from_slice(&21u16.to_le_bytes()); + short_group.extend_from_slice(&79u16.to_le_bytes()); + short_group.extend_from_slice(&[0; 79]); + assert!( + super::parse_token_2022_state(super::Token2022StateKind::Mint, &short_group).is_err() + ); + let mut invalid_size = vec![0; 166]; + invalid_size[165] = 1; + let mut group = vec![0; 80]; + group[64..72].copy_from_slice(&6u64.to_le_bytes()); + group[72..80].copy_from_slice(&5u64.to_le_bytes()); + invalid_size.extend_from_slice(&21u16.to_le_bytes()); + invalid_size.extend_from_slice(&80u16.to_le_bytes()); + invalid_size.extend_from_slice(&group); + assert!( + super::parse_token_2022_state(super::Token2022StateKind::Mint, &invalid_size).is_err() + ); + let mut short_member = vec![0; 166]; + short_member[165] = 1; + short_member.extend_from_slice(&23u16.to_le_bytes()); + short_member.extend_from_slice(&71u16.to_le_bytes()); + short_member.extend_from_slice(&[0; 71]); + assert!( + super::parse_token_2022_state(super::Token2022StateKind::Mint, &short_member).is_err() + ); + } + + #[test] + fn token_metadata_state_decodes_name_symbol_uri_and_additional_fields() { + let mut mint = vec![0; 166]; + mint[165] = 1; + let mut metadata = Vec::new(); + metadata.extend_from_slice(&[1; 32]); + metadata.extend_from_slice(&[2; 32]); + append_borsh_string(&mut metadata, "Khadhroony Token"); + append_borsh_string(&mut metadata, "KHD"); + append_borsh_string(&mut metadata, "https://example.invalid/token.json"); + metadata.extend_from_slice(&2u32.to_le_bytes()); + append_borsh_string(&mut metadata, "category"); + append_borsh_string(&mut metadata, "utility"); + append_borsh_string(&mut metadata, "website"); + append_borsh_string(&mut metadata, "https://example.invalid"); + mint.extend_from_slice(&19u16.to_le_bytes()); + mint.extend_from_slice(&(metadata.len() as u16).to_le_bytes()); + mint.extend_from_slice(&metadata); + let parsed = super::parse_token_2022_state(super::Token2022StateKind::Mint, &mint); + assert_eq!( + parsed + .as_ref() + .map(|state| return state.extensions[0].value_fields["name"].as_str()), + Ok(Some("Khadhroony Token")) + ); + assert_eq!( + parsed + .as_ref() + .map(|state| return state.extensions[0].value_fields["symbol"].as_str()), + Ok(Some("KHD")) + ); + assert_eq!( + parsed.as_ref().map(|state| { + return state.extensions[0].value_fields["additionalMetadata"] + .as_array() + .map(|values| return values.len()); + }), + Ok(Some(2)) + ); + } + + #[test] + fn token_metadata_state_rejects_truncation_invalid_utf8_duplicates_and_trailing_bytes() { + let mut truncated = token_metadata_fixture("name", "SYM", "uri", &[]); + truncated.pop(); + assert!(parse_metadata_extension(&truncated).is_err()); + let mut invalid_utf8 = token_metadata_fixture("name", "SYM", "uri", &[]); + invalid_utf8[68] = 0xff; + assert!(parse_metadata_extension(&invalid_utf8).is_err()); + let duplicate = + token_metadata_fixture("name", "SYM", "uri", &[("key", "one"), ("key", "two")]); + assert!(parse_metadata_extension(&duplicate).is_err()); + let mut trailing = token_metadata_fixture("name", "SYM", "uri", &[]); + trailing.push(1); + assert!(parse_metadata_extension(&trailing).is_err()); + let oversized = "x".repeat(1_025); + let oversized_value = token_metadata_fixture(&oversized, "SYM", "uri", &[]); + assert!(parse_metadata_extension(&oversized_value).is_err()); + } + + fn parse_metadata_extension(value: &[u8]) -> Result { + let mut mint = vec![0; 166]; + mint[165] = 1; + mint.extend_from_slice(&19u16.to_le_bytes()); + mint.extend_from_slice(&(value.len() as u16).to_le_bytes()); + mint.extend_from_slice(value); + return super::parse_token_2022_state(super::Token2022StateKind::Mint, &mint); + } + + fn token_metadata_fixture( + name: &str, + symbol: &str, + uri: &str, + fields: &[(&str, &str)], + ) -> Vec { + let mut value = Vec::new(); + value.extend_from_slice(&[0; 32]); + value.extend_from_slice(&[2; 32]); + append_borsh_string(&mut value, name); + append_borsh_string(&mut value, symbol); + append_borsh_string(&mut value, uri); + value.extend_from_slice(&(fields.len() as u32).to_le_bytes()); + for (key, field_value) in fields { + append_borsh_string(&mut value, key); + append_borsh_string(&mut value, field_value); + } + return value; + } + + fn append_borsh_string(output: &mut Vec, value: &str) { + output.extend_from_slice(&(value.len() as u32).to_le_bytes()); + output.extend_from_slice(value.as_bytes()); + } +} diff --git a/kb-lib/src/decoder/spl/token_2022/wire.rs b/kb-lib/src/decoder/spl/token_2022/wire.rs new file mode 100644 index 0000000..2261261 --- /dev/null +++ b/kb-lib/src/decoder/spl/token_2022/wire.rs @@ -0,0 +1,2603 @@ +// file: kb-lib/src/decoder/spl/token_2022/wire.rs +// version: 1 + +//! Bounded Token-2022 wire, account and authority decoding. + +use base64::Engine; // rust-rules: trait-import +use sha2::Digest; // rust-rules: trait-import + +#[derive(Clone, Debug)] +struct ParsedInstruction { + code: &'static str, + tag: u8, + parameters: serde_json::Value, + consumed: usize, +} + +#[derive(Clone, Debug)] +struct ResolvedAccount { + position: usize, + account_index: u64, + account_key: std::string::String, + signer: bool, + writable: bool, + source: serde_json::Value, +} + +pub(crate) fn spl_token_2022_entry_for_tag( + tag: u8, +) -> std::option::Option<(u8, &'static str, bool)> { + return crate::SPL_TOKEN_2022_INSTRUCTION_ENTRIES + .iter() + .copied() + .find(|(candidate, _, _)| return *candidate == tag); +} + +pub(crate) fn spl_token_2022_payload_tag( + input: &crate::CoreInstructionReplayInput, +) -> std::option::Option { + let bytes = decode_payload(input).ok(); + return bytes.and_then(|value| return value.first().copied()); +} + +pub(crate) fn spl_token_2022_decode( + input: &crate::CoreInstructionReplayInput, +) -> crate::DecoderExecutionResult { + let bytes = match decode_payload(input) { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => { + return failed( + std::option::Option::None, + "token_payload_unavailable", + error.to_string(), + ); + }, + }; + let tag = match bytes.first().copied() { + std::option::Option::Some(value) => value, + std::option::Option::None => { + return failed( + std::option::Option::None, + "token_payload_empty", + "Token-2022 instruction payload is empty".to_string(), + ); + }, + }; + let recognized_code = crate::spl_token_2022_entry_for_tag(tag) + .map(|(_, code, _)| return code) + .or_else(|| return embedded_interface_code(bytes.as_slice())); + if recognized_code.is_none() { + return crate::DecoderExecutionResult { + status: crate::DecoderOutcomeStatus::Unsupported, + recognized_entry_code: std::option::Option::None, + observations: std::vec::Vec::new(), + diagnostics: std::vec![crate::DecoderDiagnostic { + code: "unknown_token_instruction_tag_or_interface_discriminator".to_string(), + message: format!( + "unknown Token-2022 tag or embedded interface discriminator; payloadPrefixHex={}", + hex_prefix(bytes.as_slice()) + ), + retriable: false, + }], + }; + } + let parsed = match parse_instruction(bytes.as_slice(), false) { + std::result::Result::Ok(value) => value, + std::result::Result::Err(message) => { + return failed( + recognized_code, + "malformed_token_instruction", + format!("{message}; payloadPrefixHex={}", hex_prefix(bytes.as_slice())), + ); + }, + }; + let accounts = match resolve_accounts(input) { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => { + return failed( + std::option::Option::Some(parsed.code), + "token_accounts_malformed", + error.to_string(), + ); + }, + }; + let (parameters, batch_children) = if parsed.code == "batch" { + match batch_observations(input, bytes.as_slice(), accounts.as_slice()) { + std::result::Result::Ok((children, manifest)) => (manifest, children), + std::result::Result::Err(message) => { + return failed( + std::option::Option::Some("batch"), + "malformed_token_batch", + message, + ); + }, + } + } else { + (parsed.parameters.clone(), std::vec::Vec::new()) + }; + let account_projection = project_accounts(parsed.code, accounts.as_slice()); + let suffix_length = bytes.len().saturating_sub(parsed.consumed); + let authority = authority_projection(parsed.code, accounts.as_slice()); + let semantic_diagnostics = + semantic_diagnostics(parsed.code, parameters.clone(), accounts.as_slice(), suffix_length); + let committed = !input.transaction_failed; + let parent = observation( + input, + parsed.code, + parsed.tag, + parameters, + account_projection, + authority, + semantic_diagnostics, + bytes.as_slice(), + suffix_length, + committed, + format!("token_2022:{}:0", parsed.code), + input.instruction_path.clone(), + event_family(parsed.code), + ); + let mut observations = std::vec![parent]; + observations.extend(batch_children); + return crate::DecoderExecutionResult { + status: crate::DecoderOutcomeStatus::Decoded, + recognized_entry_code: std::option::Option::Some(parsed.code.to_string()), + observations, + diagnostics: std::vec::Vec::new(), + }; +} + +fn decode_payload(input: &crate::CoreInstructionReplayInput) -> kb_core::Result> { + let payload = match input.instruction_payload_json.as_ref() { + std::option::Option::Some(value) => value, + std::option::Option::None => { + return std::result::Result::Err(kb_core::Error::invalid_state( + "Token-2022 instruction payload is not retained", + )); + }, + }; + let encoded = match payload.get("dataBase64").and_then(serde_json::Value::as_str) { + std::option::Option::Some(value) => value, + std::option::Option::None => { + return std::result::Result::Err(kb_core::Error::invalid_state( + "Token-2022 payload does not contain dataBase64", + )); + }, + }; + let maximum_encoded = crate::SPL_TOKEN_2022_MAX_INSTRUCTION_BYTES + .saturating_mul(4) + .saturating_div(3) + .saturating_add(4); + if encoded.len() > maximum_encoded { + return std::result::Result::Err(kb_core::Error::invalid_state(format!( + "Token-2022 base64 payload exceeds {maximum_encoded} bytes" + ))); + } + let decoded = match base64::engine::general_purpose::STANDARD.decode(encoded.as_bytes()) { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => { + return std::result::Result::Err(kb_core::Error::invalid_state(format!( + "Token-2022 payload is not valid base64: {error}" + ))); + }, + }; + if decoded.len() > crate::SPL_TOKEN_2022_MAX_INSTRUCTION_BYTES { + return std::result::Result::Err(kb_core::Error::invalid_state(format!( + "Token-2022 payload exceeds {} decoded bytes", + crate::SPL_TOKEN_2022_MAX_INSTRUCTION_BYTES + ))); + } + return std::result::Result::Ok(decoded); +} + +fn parse_instruction( + bytes: &[u8], + nested_in_batch: bool, +) -> std::result::Result { + if embedded_interface_code(bytes).is_some() { + return parse_embedded_interface_instruction(bytes); + } + let tag = match bytes.first().copied() { + std::option::Option::Some(value) => value, + std::option::Option::None => { + return std::result::Result::Err("empty instruction".to_string()); + }, + }; + let code = match crate::spl_token_2022_entry_for_tag(tag) { + std::option::Option::Some((_, value, _)) => value, + std::option::Option::None => { + return std::result::Result::Err(format!("unknown instruction tag {tag}")); + }, + }; + if nested_in_batch && tag == 255 { + return std::result::Result::Err("nested Batch instruction is forbidden".to_string()); + } + let result = match tag { + 0 | 20 => parse_initialize_mint(bytes, code, tag), + 1 | 5 | 9 | 10 | 11 | 17 | 21 | 22 | 38 => std::result::Result::Ok(ParsedInstruction { + code, + tag, + parameters: serde_json::json!({}), + consumed: 1, + }), + 255 => std::result::Result::Ok(ParsedInstruction { + code, + tag, + parameters: serde_json::json!({}), + consumed: bytes.len(), + }), + 2 | 19 => parse_one_u8(bytes, code, tag, "m"), + 3 | 4 | 7 | 8 | 23 => parse_one_u64(bytes, code, tag, "amountRaw"), + 6 => parse_set_authority(bytes, code, tag), + 12 | 13 | 14 | 15 => parse_amount_decimals(bytes, code, tag), + 16 | 18 => parse_pubkey_parameter(bytes, code, tag, "owner"), + 24 => parse_ui_amount(bytes, code, tag), + 29 => parse_reallocate(bytes, code, tag), + 31 | 32 => std::result::Result::Ok(ParsedInstruction { + code, + tag, + parameters: serde_json::json!({}), + consumed: 1, + }), + 35 => parse_pubkey_parameter(bytes, code, tag, "delegate"), + 45 => parse_unwrap_lamports(bytes, code, tag), + 26 | 27 | 28 | 30 | 33 | 34 | 36 | 37 | 39 | 40 | 41 | 42 | 43 | 44 | 46 => { + parse_extension_envelope(bytes, code, tag) + }, + _ => std::result::Result::Err(format!("unsupported instruction tag {tag}")), + }; + return result; +} + +const TOKEN_METADATA_INITIALIZE_DISCRIMINATOR: [u8; 8] = + [0xd2, 0xe1, 0x1e, 0xa2, 0x58, 0xb8, 0x4d, 0x8d]; +const TOKEN_METADATA_UPDATE_FIELD_DISCRIMINATOR: [u8; 8] = + [0xdd, 0xe9, 0x31, 0x2d, 0xb5, 0xca, 0xdc, 0xc8]; +const TOKEN_METADATA_REMOVE_KEY_DISCRIMINATOR: [u8; 8] = + [0xea, 0x12, 0x20, 0x38, 0x59, 0x8d, 0x25, 0xb5]; +const TOKEN_METADATA_UPDATE_AUTHORITY_DISCRIMINATOR: [u8; 8] = + [0xd7, 0xe4, 0xa6, 0xe4, 0x54, 0x64, 0x56, 0x7b]; +const TOKEN_METADATA_EMIT_DISCRIMINATOR: [u8; 8] = [0xfa, 0xa6, 0xb4, 0xfa, 0x0d, 0x0c, 0xb8, 0x46]; +const TOKEN_GROUP_INITIALIZE_DISCRIMINATOR: [u8; 8] = + [0x79, 0x71, 0x6c, 0x27, 0x36, 0x33, 0x00, 0x04]; +const TOKEN_GROUP_UPDATE_MAX_SIZE_DISCRIMINATOR: [u8; 8] = + [0x6c, 0x25, 0xab, 0x8f, 0xf8, 0x1e, 0x12, 0x6e]; +const TOKEN_GROUP_UPDATE_AUTHORITY_DISCRIMINATOR: [u8; 8] = + [0xa1, 0x69, 0x58, 0x01, 0xed, 0xdd, 0xd8, 0xcb]; +const TOKEN_GROUP_INITIALIZE_MEMBER_DISCRIMINATOR: [u8; 8] = + [0x98, 0x20, 0xde, 0xb0, 0xdf, 0xed, 0x74, 0x86]; + +fn embedded_interface_code(bytes: &[u8]) -> std::option::Option<&'static str> { + let discriminator = match bytes.get(..8) { + std::option::Option::Some(value) => value, + std::option::Option::None => return std::option::Option::None, + }; + return match discriminator { + value if value == TOKEN_METADATA_INITIALIZE_DISCRIMINATOR => { + std::option::Option::Some("initialize_token_metadata") + }, + value if value == TOKEN_METADATA_UPDATE_FIELD_DISCRIMINATOR => { + std::option::Option::Some("update_token_metadata_field") + }, + value if value == TOKEN_METADATA_REMOVE_KEY_DISCRIMINATOR => { + std::option::Option::Some("remove_token_metadata_key") + }, + value if value == TOKEN_METADATA_UPDATE_AUTHORITY_DISCRIMINATOR => { + std::option::Option::Some("update_token_metadata_authority") + }, + value if value == TOKEN_METADATA_EMIT_DISCRIMINATOR => { + std::option::Option::Some("emit_token_metadata") + }, + value if value == TOKEN_GROUP_INITIALIZE_DISCRIMINATOR => { + std::option::Option::Some("initialize_token_group") + }, + value if value == TOKEN_GROUP_UPDATE_MAX_SIZE_DISCRIMINATOR => { + std::option::Option::Some("update_token_group_max_size") + }, + value if value == TOKEN_GROUP_UPDATE_AUTHORITY_DISCRIMINATOR => { + std::option::Option::Some("update_token_group_authority") + }, + value if value == TOKEN_GROUP_INITIALIZE_MEMBER_DISCRIMINATOR => { + std::option::Option::Some("initialize_token_group_member") + }, + _ => std::option::Option::None, + }; +} + +macro_rules! token_value_or_return { + ($expression:expr) => { + match $expression { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error), + } + }; +} + +fn parse_embedded_interface_instruction( + bytes: &[u8], +) -> std::result::Result { + let code = match embedded_interface_code(bytes) { + std::option::Option::Some(value) => value, + std::option::Option::None => { + return std::result::Result::Err( + "unknown embedded interface discriminator".to_string(), + ); + }, + }; + let mut offset = 8_usize; + let parameters = match code { + "initialize_token_metadata" => { + let name = token_value_or_return!(read_borsh_string(bytes, &mut offset, "name")); + let symbol = token_value_or_return!(read_borsh_string(bytes, &mut offset, "symbol")); + let uri = token_value_or_return!(read_borsh_string(bytes, &mut offset, "uri")); + serde_json::json!({"name":name,"symbol":symbol,"uri":uri}) + }, + "update_token_metadata_field" => { + let field_tag = token_value_or_return!(read_u8(bytes, &mut offset, "metadata field")); + let field = match field_tag { + 0 => serde_json::json!({"kind":"name"}), + 1 => serde_json::json!({"kind":"symbol"}), + 2 => serde_json::json!({"kind":"uri"}), + 3 => { + serde_json::json!({"kind":"key","key":token_value_or_return!(read_borsh_string(bytes, &mut offset, "field key"))}) + }, + _ => { + return std::result::Result::Err(format!( + "unknown Token Metadata field discriminant {field_tag}" + )); + }, + }; + let value = token_value_or_return!(read_borsh_string(bytes, &mut offset, "value")); + serde_json::json!({"field":field,"value":value}) + }, + "remove_token_metadata_key" => { + let idempotent = match token_value_or_return!(read_u8(bytes, &mut offset, "idempotent")) + { + 0 => false, + 1 => true, + value => return std::result::Result::Err(format!("invalid Borsh bool {value}")), + }; + let key = token_value_or_return!(read_borsh_string(bytes, &mut offset, "key")); + serde_json::json!({"idempotent":idempotent,"key":key}) + }, + "update_token_metadata_authority" => { + let authority = + token_value_or_return!(read_nullable_pubkey(bytes, &mut offset, "new authority")); + serde_json::json!({"newAuthority":authority}) + }, + "emit_token_metadata" => { + let start = token_value_or_return!(read_borsh_option_u64(bytes, &mut offset, "start")); + let end = token_value_or_return!(read_borsh_option_u64(bytes, &mut offset, "end")); + serde_json::json!({"start":start,"end":end}) + }, + "initialize_token_group" => { + let update_authority = token_value_or_return!(read_nullable_pubkey( + bytes, + &mut offset, + "update authority" + )); + let max_size = token_value_or_return!(read_u64(bytes, &mut offset, "max size")); + serde_json::json!({"updateAuthority":update_authority,"maxSize":max_size.to_string()}) + }, + "update_token_group_max_size" => { + let max_size = token_value_or_return!(read_u64(bytes, &mut offset, "max size")); + serde_json::json!({"maxSize":max_size.to_string()}) + }, + "update_token_group_authority" => { + let authority = + token_value_or_return!(read_nullable_pubkey(bytes, &mut offset, "new authority")); + serde_json::json!({"newAuthority":authority}) + }, + "initialize_token_group_member" => serde_json::json!({}), + _ => { + return std::result::Result::Err(format!( + "unsupported embedded interface instruction {code}" + )); + }, + }; + if offset != bytes.len() { + return std::result::Result::Err(format!( + "{code} requires exact wire length; trailingBytes={}", + bytes.len().saturating_sub(offset) + )); + } + return std::result::Result::Ok(ParsedInstruction { + code, + tag: bytes[0], + parameters, + consumed: offset, + }); +} + +fn read_u8( + bytes: &[u8], + offset: &mut usize, + label: &str, +) -> std::result::Result { + let value = match bytes.get(*offset).copied() { + std::option::Option::Some(value) => value, + std::option::Option::None => return std::result::Result::Err(format!("truncated {label}")), + }; + *offset = offset.saturating_add(1); + return std::result::Result::Ok(value); +} + +fn read_u64( + bytes: &[u8], + offset: &mut usize, + label: &str, +) -> std::result::Result { + let end = offset.saturating_add(8); + let slice = match bytes.get(*offset..end) { + std::option::Option::Some(value) => value, + std::option::Option::None => return std::result::Result::Err(format!("truncated {label}")), + }; + *offset = end; + return std::result::Result::Ok(u64::from_le_bytes([ + slice[0], slice[1], slice[2], slice[3], slice[4], slice[5], slice[6], slice[7], + ])); +} + +fn read_borsh_string( + bytes: &[u8], + offset: &mut usize, + label: &str, +) -> std::result::Result { + let length_end = offset.saturating_add(4); + let length_bytes = match bytes.get(*offset..length_end) { + std::option::Option::Some(value) => value, + std::option::Option::None => { + return std::result::Result::Err(format!("truncated {label} length")); + }, + }; + let length = + u32::from_le_bytes([length_bytes[0], length_bytes[1], length_bytes[2], length_bytes[3]]) + as usize; + if length > crate::SPL_TOKEN_2022_MAX_METADATA_STRING_BYTES { + return std::result::Result::Err(format!( + "{label} exceeds {} bytes", + crate::SPL_TOKEN_2022_MAX_METADATA_STRING_BYTES + )); + } + let start = length_end; + let end = start.saturating_add(length); + let value = match bytes.get(start..end) { + std::option::Option::Some(value) => value, + std::option::Option::None => return std::result::Result::Err(format!("truncated {label}")), + }; + let text = match std::str::from_utf8(value) { + std::result::Result::Ok(value) => value.to_string(), + std::result::Result::Err(_) => { + return std::result::Result::Err(format!("{label} is not valid UTF-8")); + }, + }; + *offset = end; + return std::result::Result::Ok(text); +} + +fn read_nullable_pubkey( + bytes: &[u8], + offset: &mut usize, + label: &str, +) -> std::result::Result { + let end = offset.saturating_add(32); + let value = match bytes.get(*offset..end) { + std::option::Option::Some(value) => value, + std::option::Option::None => return std::result::Result::Err(format!("truncated {label}")), + }; + *offset = end; + if value.iter().all(|byte| return *byte == 0) { + return std::result::Result::Ok(serde_json::Value::Null); + } + return std::result::Result::Ok(serde_json::Value::String(bs58::encode(value).into_string())); +} + +fn read_borsh_option_u64( + bytes: &[u8], + offset: &mut usize, + label: &str, +) -> std::result::Result { + let tag = match read_u8(bytes, offset, label) { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + return match tag { + 0 => std::result::Result::Ok(serde_json::Value::Null), + 1 => { + let value = match read_u64(bytes, offset, label) { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + std::result::Result::Ok(serde_json::Value::String(value.to_string())) + }, + value => std::result::Result::Err(format!("invalid Borsh option tag {value} for {label}")), + }; +} + +fn parse_reallocate( + bytes: &[u8], + code: &'static str, + tag: u8, +) -> std::result::Result { + if bytes.is_empty() || bytes.len().saturating_sub(1) % 2 != 0 { + return std::result::Result::Err(format!( + "{code} requires a complete u16 extension type sequence" + )); + } + let mut extension_types = std::vec::Vec::new(); + for chunk in bytes[1..].chunks_exact(2) { + extension_types.push(u16::from_le_bytes([chunk[0], chunk[1]])); + } + return std::result::Result::Ok(ParsedInstruction { + code, + tag, + parameters: serde_json::json!({"extensionTypes": extension_types}), + consumed: bytes.len(), + }); +} + +fn parse_extension_envelope( + bytes: &[u8], + code: &'static str, + tag: u8, +) -> std::result::Result { + return match tag { + 26 => parse_transfer_fee_instruction(bytes, tag), + 27 => parse_confidential_transfer_instruction(bytes, tag), + 28 => parse_default_account_state_instruction(bytes, tag), + 30 => parse_toggle_extension_instruction( + bytes, + tag, + "memo_transfer", + "enable_required_transfer_memos", + "disable_required_transfer_memos", + ), + 33 => parse_interest_bearing_instruction(bytes, tag), + 34 => parse_toggle_extension_instruction( + bytes, + tag, + "cpi_guard", + "enable_cpi_guard", + "disable_cpi_guard", + ), + 36 => parse_pointer_extension_instruction( + bytes, + tag, + "transfer_hook", + "initialize_transfer_hook", + "update_transfer_hook_program_id", + "authority", + "programId", + ), + 37 => parse_confidential_transfer_fee_instruction(bytes, tag), + 39 => parse_pointer_extension_instruction( + bytes, + tag, + "metadata_pointer", + "initialize_metadata_pointer", + "update_metadata_pointer", + "authority", + "metadataAddress", + ), + 40 => parse_pointer_extension_instruction( + bytes, + tag, + "group_pointer", + "initialize_group_pointer", + "update_group_pointer", + "authority", + "groupAddress", + ), + 41 => parse_pointer_extension_instruction( + bytes, + tag, + "group_member_pointer", + "initialize_group_member_pointer", + "update_group_member_pointer", + "authority", + "memberAddress", + ), + 42 => parse_confidential_mint_burn_instruction(bytes, tag), + 43 => parse_scaled_ui_amount_instruction(bytes, tag), + 44 => parse_pausable_instruction(bytes, tag), + 46 => parse_permissioned_burn_instruction(bytes, tag), + _ => parse_opaque_extension_envelope(bytes, code, tag), + }; +} + +fn parse_confidential_transfer_instruction( + bytes: &[u8], + tag: u8, +) -> std::result::Result { + let sub = match bytes.get(1).copied() { + std::option::Option::Some(value) => value, + std::option::Option::None => { + return std::result::Result::Err( + "confidential transfer instruction requires a sub-discriminant".to_string(), + ); + }, + }; + let parsed = match sub { + 0 => { + if bytes.len() != 67 { + return std::result::Result::Err(format!( + "initialize_confidential_transfer_mint requires exactly 67 bytes; received {}", + bytes.len() + )); + } + let auto_approve = match bytes[34] { + 0 => false, + 1 => true, + value => { + return std::result::Result::Err(format!( + "initialize_confidential_transfer_mint has invalid Bool value {value}" + )); + }, + }; + ParsedInstruction { + code: "initialize_confidential_transfer_mint", + tag, + parameters: serde_json::json!({ + "extension": "confidential_transfer", + "subDiscriminant": sub, + "authority": optional_nonzero_pubkey(&bytes[2..34]), + "autoApproveNewAccounts": auto_approve, + "auditorElgamalPubkeyBase64": base64::engine::general_purpose::STANDARD.encode(&bytes[35..67]), + "auditorElgamalPubkeyPresent": bytes[35..67].iter().any(|value| return *value != 0), + }), + consumed: 67, + } + }, + 1 => { + if bytes.len() != 35 { + return std::result::Result::Err(format!( + "update_confidential_transfer_mint requires exactly 35 bytes; received {}", + bytes.len() + )); + } + let auto_approve = match bytes[2] { + 0 => false, + 1 => true, + value => { + return std::result::Result::Err(format!( + "update_confidential_transfer_mint has invalid Bool value {value}" + )); + }, + }; + ParsedInstruction { + code: "update_confidential_transfer_mint", + tag, + parameters: serde_json::json!({ + "extension": "confidential_transfer", + "subDiscriminant": sub, + "autoApproveNewAccounts": auto_approve, + "auditorElgamalPubkeyBase64": base64::engine::general_purpose::STANDARD.encode(&bytes[3..35]), + "auditorElgamalPubkeyPresent": bytes[3..35].iter().any(|value| return *value != 0), + }), + consumed: 35, + } + }, + 3 => ParsedInstruction { + code: "approve_confidential_transfer_account", + tag, + parameters: serde_json::json!({"extension":"confidential_transfer","subDiscriminant":sub}), + consumed: 2, + }, + 4 => { + if bytes.len() != 3 { + return std::result::Result::Err(format!( + "empty_confidential_transfer_account requires exactly 3 bytes; received {}", + bytes.len() + )); + } + ParsedInstruction { + code: "empty_confidential_transfer_account", + tag, + parameters: serde_json::json!({ + "extension":"confidential_transfer", + "subDiscriminant":sub, + "proofInstructionOffset": i8::from_le_bytes([bytes[2]]), + "proofLocation": if bytes[2] == 0 { "context_state_account" } else { "instruction_offset" }, + }), + consumed: 3, + } + }, + 5 => { + if bytes.len() != 11 { + return std::result::Result::Err(format!( + "deposit_confidential_tokens requires exactly 11 bytes; received {}", + bytes.len() + )); + } + let amount = u64::from_le_bytes([ + bytes[2], bytes[3], bytes[4], bytes[5], bytes[6], bytes[7], bytes[8], bytes[9], + ]); + ParsedInstruction { + code: "deposit_confidential_tokens", + tag, + parameters: serde_json::json!({ + "extension":"confidential_transfer", + "subDiscriminant":sub, + "amountRaw": amount.to_string(), + "decimals": bytes[10], + }), + consumed: 11, + } + }, + 8 => { + if bytes.len() != 46 { + return std::result::Result::Err(format!( + "apply_confidential_pending_balance requires exactly 46 bytes; received {}", + bytes.len() + )); + } + let counter = u64::from_le_bytes([ + bytes[2], bytes[3], bytes[4], bytes[5], bytes[6], bytes[7], bytes[8], bytes[9], + ]); + ParsedInstruction { + code: "apply_confidential_pending_balance", + tag, + parameters: serde_json::json!({ + "extension":"confidential_transfer", + "subDiscriminant":sub, + "expectedPendingBalanceCreditCounter": counter.to_string(), + "newDecryptableAvailableBalanceBase64": base64::engine::general_purpose::STANDARD.encode(&bytes[10..46]), + }), + consumed: 46, + } + }, + 9 | 10 | 11 | 12 => { + if bytes.len() != 2 { + return std::result::Result::Err(format!( + "confidential credit toggle requires exactly 2 bytes; received {}", + bytes.len() + )); + } + let (code, credit_kind, enabled) = match sub { + 9 => ("enable_confidential_credits", "confidential", true), + 10 => ("disable_confidential_credits", "confidential", false), + 11 => ("enable_non_confidential_credits", "non_confidential", true), + 12 => ("disable_non_confidential_credits", "non_confidential", false), + _ => { + return std::result::Result::Err("unreachable confidential toggle".to_string()); + }, + }; + ParsedInstruction { + code, + tag, + parameters: serde_json::json!({ + "extension":"confidential_transfer", + "subDiscriminant":sub, + "creditKind": credit_kind, + "enabled": enabled, + }), + consumed: 2, + } + }, + 2 => { + if bytes.len() != 47 { + return std::result::Result::Err(format!( + "configure_confidential_transfer_account requires exactly 47 bytes; received {}", + bytes.len() + )); + } + let maximum_pending_balance_credit_counter = u64::from_le_bytes([ + bytes[38], bytes[39], bytes[40], bytes[41], bytes[42], bytes[43], bytes[44], + bytes[45], + ]); + ParsedInstruction { + code: "configure_confidential_transfer_account", + tag, + parameters: serde_json::json!({ + "extension":"confidential_transfer", + "subDiscriminant":sub, + "decryptableZeroBalanceBase64":base64::engine::general_purpose::STANDARD.encode(&bytes[2..38]), + "maximumPendingBalanceCreditCounter":maximum_pending_balance_credit_counter.to_string(), + "proofInstructionOffset":i8::from_le_bytes([bytes[46]]), + "proofLocation":if bytes[46] == 0 { "context_state_account" } else { "instruction_offset" }, + }), + consumed: 47, + } + }, + 6 => { + if bytes.len() != 49 { + return std::result::Result::Err(format!( + "withdraw_confidential_tokens requires exactly 49 bytes; received {}", + bytes.len() + )); + } + let amount = u64::from_le_bytes([ + bytes[2], bytes[3], bytes[4], bytes[5], bytes[6], bytes[7], bytes[8], bytes[9], + ]); + ParsedInstruction { + code: "withdraw_confidential_tokens", + tag, + parameters: serde_json::json!({ + "extension":"confidential_transfer", + "subDiscriminant":sub, + "amountRaw":amount.to_string(), + "decimals":bytes[10], + "newDecryptableAvailableBalanceBase64":base64::engine::general_purpose::STANDARD.encode(&bytes[11..47]), + "equalityProofInstructionOffset":i8::from_le_bytes([bytes[47]]), + "rangeProofInstructionOffset":i8::from_le_bytes([bytes[48]]), + }), + consumed: 49, + } + }, + 7 => { + if bytes.len() != 169 { + return std::result::Result::Err(format!( + "transfer_confidential_tokens requires exactly 169 bytes; received {}", + bytes.len() + )); + } + ParsedInstruction { + code: "transfer_confidential_tokens", + tag, + parameters: serde_json::json!({ + "extension":"confidential_transfer", + "subDiscriminant":sub, + "newSourceDecryptableAvailableBalanceBase64":base64::engine::general_purpose::STANDARD.encode(&bytes[2..38]), + "transferAmountAuditorCiphertextLoBase64":base64::engine::general_purpose::STANDARD.encode(&bytes[38..102]), + "transferAmountAuditorCiphertextHiBase64":base64::engine::general_purpose::STANDARD.encode(&bytes[102..166]), + "equalityProofInstructionOffset":i8::from_le_bytes([bytes[166]]), + "ciphertextValidityProofInstructionOffset":i8::from_le_bytes([bytes[167]]), + "rangeProofInstructionOffset":i8::from_le_bytes([bytes[168]]), + }), + consumed: 169, + } + }, + 13 => { + if bytes.len() != 171 { + return std::result::Result::Err(format!( + "transfer_confidential_tokens_with_fee requires exactly 171 bytes; received {}", + bytes.len() + )); + } + ParsedInstruction { + code: "transfer_confidential_tokens_with_fee", + tag, + parameters: serde_json::json!({ + "extension":"confidential_transfer", + "subDiscriminant":sub, + "newSourceDecryptableAvailableBalanceBase64":base64::engine::general_purpose::STANDARD.encode(&bytes[2..38]), + "transferAmountAuditorCiphertextLoBase64":base64::engine::general_purpose::STANDARD.encode(&bytes[38..102]), + "transferAmountAuditorCiphertextHiBase64":base64::engine::general_purpose::STANDARD.encode(&bytes[102..166]), + "equalityProofInstructionOffset":i8::from_le_bytes([bytes[166]]), + "transferAmountCiphertextValidityProofInstructionOffset":i8::from_le_bytes([bytes[167]]), + "feeSigmaProofInstructionOffset":i8::from_le_bytes([bytes[168]]), + "feeCiphertextValidityProofInstructionOffset":i8::from_le_bytes([bytes[169]]), + "rangeProofInstructionOffset":i8::from_le_bytes([bytes[170]]), + }), + consumed: 171, + } + }, + 14 => { + if bytes.len() != 2 { + return std::result::Result::Err(format!( + "configure_confidential_transfer_account_with_registry requires exactly 2 bytes; received {}", + bytes.len() + )); + } + ParsedInstruction { + code: "configure_confidential_transfer_account_with_registry", + tag, + parameters: serde_json::json!({ + "extension":"confidential_transfer", + "subDiscriminant":sub, + "proofLocation":"elgamal_registry_account", + "ownerSignatureRequired":false, + }), + consumed: 2, + } + }, + value => { + return std::result::Result::Err(format!( + "unknown confidential transfer sub-discriminant {value}" + )); + }, + }; + if bytes.len() != parsed.consumed { + return std::result::Result::Err(format!( + "{} requires exactly {} bytes; received {}", + parsed.code, + parsed.consumed, + bytes.len() + )); + } + return std::result::Result::Ok(parsed); +} + +fn parse_confidential_transfer_fee_instruction( + bytes: &[u8], + tag: u8, +) -> std::result::Result { + let sub = match bytes.get(1).copied() { + std::option::Option::Some(value) => value, + std::option::Option::None => { + return std::result::Result::Err( + "confidential transfer fee instruction requires a sub-discriminant".to_string(), + ); + }, + }; + let parsed = match sub { + 0 => { + if bytes.len() != 66 { + return std::result::Result::Err(format!( + "initialize_confidential_transfer_fee_config requires exactly 66 bytes; received {}", + bytes.len() + )); + } + ParsedInstruction { + code: "initialize_confidential_transfer_fee_config", + tag, + parameters: serde_json::json!({ + "extension":"confidential_transfer_fee", + "subDiscriminant":sub, + "authority":optional_nonzero_pubkey(&bytes[2..34]), + "withdrawWithheldAuthorityElgamalPubkeyBase64":base64::engine::general_purpose::STANDARD.encode(&bytes[34..66]), + }), + consumed: 66, + } + }, + 1 => { + if bytes.len() != 39 { + return std::result::Result::Err(format!( + "withdraw_confidential_withheld_tokens_from_mint requires exactly 39 bytes; received {}", + bytes.len() + )); + } + ParsedInstruction { + code: "withdraw_confidential_withheld_tokens_from_mint", + tag, + parameters: serde_json::json!({ + "extension":"confidential_transfer_fee", + "subDiscriminant":sub, + "proofInstructionOffset":i8::from_le_bytes([bytes[2]]), + "proofLocation":if bytes[2] == 0 { "context_state_account" } else { "instruction_offset" }, + "newDecryptableAvailableBalanceBase64":base64::engine::general_purpose::STANDARD.encode(&bytes[3..39]), + }), + consumed: 39, + } + }, + 2 => { + if bytes.len() != 40 { + return std::result::Result::Err(format!( + "withdraw_confidential_withheld_tokens_from_accounts requires exactly 40 bytes; received {}", + bytes.len() + )); + } + ParsedInstruction { + code: "withdraw_confidential_withheld_tokens_from_accounts", + tag, + parameters: serde_json::json!({ + "extension":"confidential_transfer_fee", + "subDiscriminant":sub, + "numTokenAccounts":bytes[2], + "proofInstructionOffset":i8::from_le_bytes([bytes[3]]), + "proofLocation":if bytes[3] == 0 { "context_state_account" } else { "instruction_offset" }, + "newDecryptableAvailableBalanceBase64":base64::engine::general_purpose::STANDARD.encode(&bytes[4..40]), + }), + consumed: 40, + } + }, + 3 | 4 | 5 => { + if bytes.len() != 2 { + return std::result::Result::Err(format!( + "confidential transfer fee toggle or harvest instruction requires exactly 2 bytes; received {}", + bytes.len() + )); + } + let code = match sub { + 3 => "harvest_confidential_withheld_tokens_to_mint", + 4 => "enable_confidential_harvest_to_mint", + 5 => "disable_confidential_harvest_to_mint", + _ => { + return std::result::Result::Err( + "unreachable confidential transfer fee leaf".to_string(), + ); + }, + }; + ParsedInstruction { + code, + tag, + parameters: serde_json::json!({ + "extension":"confidential_transfer_fee", + "subDiscriminant":sub, + }), + consumed: 2, + } + }, + value => { + return std::result::Result::Err(format!( + "unknown confidential transfer fee sub-discriminant {value}" + )); + }, + }; + if bytes.len() != parsed.consumed { + return std::result::Result::Err(format!( + "{} requires exactly {} bytes; received {}", + parsed.code, + parsed.consumed, + bytes.len() + )); + } + return std::result::Result::Ok(parsed); +} + +fn parse_confidential_mint_burn_instruction( + bytes: &[u8], + tag: u8, +) -> std::result::Result { + let sub = match bytes.get(1).copied() { + std::option::Option::Some(value) => value, + std::option::Option::None => { + return std::result::Result::Err( + "confidential mint/burn instruction requires a sub-discriminant".to_string(), + ); + }, + }; + let expected = match sub { + 0 => 70, + 1 => 35, + 2 => 38, + 3 | 4 => 169, + 5 => 2, + value => { + return std::result::Result::Err(format!( + "unknown confidential mint/burn sub-discriminant {value}" + )); + }, + }; + if bytes.len() != expected { + return std::result::Result::Err(format!( + "confidential mint/burn sub-discriminant {sub} requires exactly {expected} bytes; received {}", + bytes.len() + )); + } + return match sub { + 0 => std::result::Result::Ok(ParsedInstruction { + code: "initialize_confidential_mint_burn", + tag, + parameters: serde_json::json!({ + "extension":"confidential_mint_burn", + "subDiscriminant":sub, + "supplyElgamalPubkeyBase64":base64::engine::general_purpose::STANDARD.encode(&bytes[2..34]), + "decryptableSupplyBase64":base64::engine::general_purpose::STANDARD.encode(&bytes[34..70]), + }), + consumed: 70, + }), + 1 => std::result::Result::Ok(ParsedInstruction { + code: "rotate_confidential_supply_elgamal_pubkey", + tag, + parameters: serde_json::json!({ + "extension":"confidential_mint_burn", + "subDiscriminant":sub, + "newSupplyElgamalPubkeyBase64":base64::engine::general_purpose::STANDARD.encode(&bytes[2..34]), + "proofInstructionOffset":i8::from_le_bytes([bytes[34]]), + "proofLocation":if bytes[34] == 0 { "context_state_account" } else { "instruction_offset" }, + }), + consumed: 35, + }), + 2 => std::result::Result::Ok(ParsedInstruction { + code: "update_confidential_decryptable_supply", + tag, + parameters: serde_json::json!({ + "extension":"confidential_mint_burn", + "subDiscriminant":sub, + "newDecryptableSupplyBase64":base64::engine::general_purpose::STANDARD.encode(&bytes[2..38]), + }), + consumed: 38, + }), + 3 | 4 => std::result::Result::Ok(ParsedInstruction { + code: if sub == 3 { "confidential_mint" } else { "confidential_burn" }, + tag, + parameters: serde_json::json!({ + "extension":"confidential_mint_burn", + "subDiscriminant":sub, + "newDecryptableBalanceBase64":base64::engine::general_purpose::STANDARD.encode(&bytes[2..38]), + "amountAuditorCiphertextLoBase64":base64::engine::general_purpose::STANDARD.encode(&bytes[38..102]), + "amountAuditorCiphertextHiBase64":base64::engine::general_purpose::STANDARD.encode(&bytes[102..166]), + "equalityProofInstructionOffset":i8::from_le_bytes([bytes[166]]), + "ciphertextValidityProofInstructionOffset":i8::from_le_bytes([bytes[167]]), + "rangeProofInstructionOffset":i8::from_le_bytes([bytes[168]]), + }), + consumed: 169, + }), + 5 => std::result::Result::Ok(ParsedInstruction { + code: "apply_pending_confidential_burn", + tag, + parameters: serde_json::json!({ + "extension":"confidential_mint_burn", + "subDiscriminant":sub, + }), + consumed: 2, + }), + _ => std::result::Result::Err( + "unreachable confidential mint/burn sub-discriminant".to_string(), + ), + }; +} + +fn parse_scaled_ui_amount_instruction( + bytes: &[u8], + tag: u8, +) -> std::result::Result { + let sub = match bytes.get(1).copied() { + std::option::Option::Some(value) => value, + std::option::Option::None => { + return std::result::Result::Err( + "scaled UI amount instruction requires a sub-discriminant".to_string(), + ); + }, + }; + return match sub { + 0 => { + if bytes.len() != 42 { + return std::result::Result::Err(format!( + "initialize_scaled_ui_amount requires exactly 42 bytes; received {}", + bytes.len() + )); + } + let multiplier_bits = u64::from_le_bytes([ + bytes[34], bytes[35], bytes[36], bytes[37], bytes[38], bytes[39], bytes[40], + bytes[41], + ]); + std::result::Result::Ok(ParsedInstruction { + code: "initialize_scaled_ui_amount", + tag, + parameters: serde_json::json!({ + "extension": "scaled_ui_amount", + "subDiscriminant": sub, + "authority": optional_nonzero_pubkey(&bytes[2..34]), + "multiplierBitsHex": format!("{multiplier_bits:016x}"), + "multiplierFiniteDecimal": finite_f64_decimal(multiplier_bits), + }), + consumed: 42, + }) + }, + 1 => { + if bytes.len() != 18 { + return std::result::Result::Err(format!( + "update_scaled_ui_amount_multiplier requires exactly 18 bytes; received {}", + bytes.len() + )); + } + let multiplier_bits = u64::from_le_bytes([ + bytes[2], bytes[3], bytes[4], bytes[5], bytes[6], bytes[7], bytes[8], bytes[9], + ]); + let effective_timestamp = i64::from_le_bytes([ + bytes[10], bytes[11], bytes[12], bytes[13], bytes[14], bytes[15], bytes[16], + bytes[17], + ]); + std::result::Result::Ok(ParsedInstruction { + code: "update_scaled_ui_amount_multiplier", + tag, + parameters: serde_json::json!({ + "extension": "scaled_ui_amount", + "subDiscriminant": sub, + "multiplierBitsHex": format!("{multiplier_bits:016x}"), + "multiplierFiniteDecimal": finite_f64_decimal(multiplier_bits), + "effectiveTimestamp": effective_timestamp.to_string(), + }), + consumed: 18, + }) + }, + value => { + std::result::Result::Err(format!("unknown scaled UI amount sub-discriminant {value}")) + }, + }; +} + +fn parse_pausable_instruction( + bytes: &[u8], + tag: u8, +) -> std::result::Result { + let sub = match bytes.get(1).copied() { + std::option::Option::Some(value) => value, + std::option::Option::None => { + return std::result::Result::Err( + "pausable instruction requires a sub-discriminant".to_string(), + ); + }, + }; + return match sub { + 0 => { + if bytes.len() != 34 { + return std::result::Result::Err(format!( + "initialize_pausable_config requires exactly 34 bytes; received {}", + bytes.len() + )); + } + std::result::Result::Ok(ParsedInstruction { + code: "initialize_pausable_config", + tag, + parameters: serde_json::json!({ + "extension": "pausable", + "subDiscriminant": sub, + "pauseAuthority": bs58::encode(&bytes[2..34]).into_string(), + }), + consumed: 34, + }) + }, + 1 | 2 => { + if bytes.len() != 2 { + return std::result::Result::Err(format!( + "{} requires exactly 2 bytes; received {}", + if sub == 1 { "pause_token_mint" } else { "resume_token_mint" }, + bytes.len() + )); + } + std::result::Result::Ok(ParsedInstruction { + code: if sub == 1 { "pause_token_mint" } else { "resume_token_mint" }, + tag, + parameters: serde_json::json!({ + "extension": "pausable", + "subDiscriminant": sub, + "paused": sub == 1, + }), + consumed: 2, + }) + }, + value => std::result::Result::Err(format!("unknown pausable sub-discriminant {value}")), + }; +} + +fn parse_permissioned_burn_instruction( + bytes: &[u8], + tag: u8, +) -> std::result::Result { + let sub = match bytes.get(1).copied() { + std::option::Option::Some(value) => value, + std::option::Option::None => { + return std::result::Result::Err( + "permissioned burn instruction requires a sub-discriminant".to_string(), + ); + }, + }; + return match sub { + 0 => { + if bytes.len() != 34 { + return std::result::Result::Err(format!( + "initialize_permissioned_burn requires exactly 34 bytes; received {}", + bytes.len() + )); + } + std::result::Result::Ok(ParsedInstruction { + code: "initialize_permissioned_burn", + tag, + parameters: serde_json::json!({ + "extension": "permissioned_burn", + "subDiscriminant": sub, + "permissionedBurnAuthority": bs58::encode(&bytes[2..34]).into_string(), + }), + consumed: 34, + }) + }, + 1 => { + if bytes.len() != 10 { + return std::result::Result::Err(format!( + "permissioned_burn requires exactly 10 bytes; received {}", + bytes.len() + )); + } + let amount = match read_u64_at(bytes, 2) { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + std::result::Result::Ok(ParsedInstruction { + code: "permissioned_burn", + tag, + parameters: serde_json::json!({ + "extension": "permissioned_burn", + "subDiscriminant": sub, + "amountRaw": amount.to_string(), + }), + consumed: 10, + }) + }, + 2 => { + if bytes.len() != 11 { + return std::result::Result::Err(format!( + "permissioned_burn_checked requires exactly 11 bytes; received {}", + bytes.len() + )); + } + let amount = match read_u64_at(bytes, 2) { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + std::result::Result::Ok(ParsedInstruction { + code: "permissioned_burn_checked", + tag, + parameters: serde_json::json!({ + "extension": "permissioned_burn", + "subDiscriminant": sub, + "amountRaw": amount.to_string(), + "decimals": bytes[10], + }), + consumed: 11, + }) + }, + 3 => { + if bytes.len() != 169 { + return std::result::Result::Err(format!( + "permissioned_confidential_burn requires exactly 169 bytes; received {}", + bytes.len() + )); + } + std::result::Result::Ok(ParsedInstruction { + code: "permissioned_confidential_burn", + tag, + parameters: serde_json::json!({ + "extension":"permissioned_burn", + "subDiscriminant":sub, + "newDecryptableAvailableBalanceBase64":base64::engine::general_purpose::STANDARD.encode(&bytes[2..38]), + "burnAmountAuditorCiphertextLoBase64":base64::engine::general_purpose::STANDARD.encode(&bytes[38..102]), + "burnAmountAuditorCiphertextHiBase64":base64::engine::general_purpose::STANDARD.encode(&bytes[102..166]), + "equalityProofInstructionOffset":i8::from_le_bytes([bytes[166]]), + "ciphertextValidityProofInstructionOffset":i8::from_le_bytes([bytes[167]]), + "rangeProofInstructionOffset":i8::from_le_bytes([bytes[168]]), + }), + consumed: 169, + }) + }, + value => { + std::result::Result::Err(format!("unknown permissioned burn sub-discriminant {value}")) + }, + }; +} + +fn finite_f64_decimal(bits: u64) -> serde_json::Value { + let value = f64::from_bits(bits); + if value.is_finite() { + return serde_json::Value::String(value.to_string()); + } + return serde_json::Value::Null; +} + +fn parse_interest_bearing_instruction( + bytes: &[u8], + tag: u8, +) -> std::result::Result { + let sub = match bytes.get(1).copied() { + std::option::Option::Some(value) => value, + std::option::Option::None => { + return std::result::Result::Err( + "interest-bearing instruction requires a sub-discriminant".to_string(), + ); + }, + }; + return match sub { + 0 => { + if bytes.len() != 36 { + return std::result::Result::Err(format!( + "initialize_interest_bearing_mint requires exactly 36 bytes; received {}", + bytes.len() + )); + } + let authority_bytes = &bytes[2..34]; + let authority = if authority_bytes.iter().all(|value| return *value == 0) { + serde_json::Value::Null + } else { + serde_json::Value::String(bs58::encode(authority_bytes).into_string()) + }; + let rate = i16::from_le_bytes([bytes[34], bytes[35]]); + std::result::Result::Ok(ParsedInstruction { + code: "initialize_interest_bearing_mint", + tag, + parameters: serde_json::json!({ + "subDiscriminant": sub, + "rateAuthority": authority, + "rateBasisPoints": rate, + }), + consumed: 36, + }) + }, + 1 => { + if bytes.len() != 4 { + return std::result::Result::Err(format!( + "update_interest_bearing_rate requires exactly 4 bytes; received {}", + bytes.len() + )); + } + let rate = i16::from_le_bytes([bytes[2], bytes[3]]); + std::result::Result::Ok(ParsedInstruction { + code: "update_interest_bearing_rate", + tag, + parameters: serde_json::json!({ + "subDiscriminant": sub, + "rateBasisPoints": rate, + }), + consumed: 4, + }) + }, + _ => std::result::Result::Err(format!("unknown interest-bearing sub-discriminant {sub}")), + }; +} + +fn parse_transfer_fee_instruction( + bytes: &[u8], + tag: u8, +) -> std::result::Result { + let sub = match bytes.get(1).copied() { + std::option::Option::Some(value) => value, + std::option::Option::None => { + return std::result::Result::Err( + "transfer fee instruction requires a sub-discriminant".to_string(), + ); + }, + }; + let parsed = match sub { + 0 => { + let (config_authority, first_length) = match pubkey_option(&bytes[2..]) { + std::result::Result::Ok(value) => value, + std::result::Result::Err(message) => return std::result::Result::Err(message), + }; + let second_offset = 2_usize.saturating_add(first_length); + let (withdraw_authority, second_length) = match pubkey_option(&bytes[second_offset..]) { + std::result::Result::Ok(value) => value, + std::result::Result::Err(message) => return std::result::Result::Err(message), + }; + let basis_offset = second_offset.saturating_add(second_length); + let basis_points = match read_u16(bytes, basis_offset) { + std::result::Result::Ok(value) => value, + std::result::Result::Err(message) => return std::result::Result::Err(message), + }; + let maximum_fee = match read_u64_at(bytes, basis_offset.saturating_add(2)) { + std::result::Result::Ok(value) => value, + std::result::Result::Err(message) => return std::result::Result::Err(message), + }; + ParsedInstruction { + code: "initialize_transfer_fee_config", + tag, + parameters: serde_json::json!({ + "extension": "transfer_fee", + "subDiscriminant": sub, + "transferFeeConfigAuthority": config_authority, + "withdrawWithheldAuthority": withdraw_authority, + "transferFeeBasisPoints": basis_points, + "maximumFeeRaw": maximum_fee.to_string(), + }), + consumed: basis_offset.saturating_add(10), + } + }, + 1 => { + let amount = match read_u64_at(bytes, 2) { + std::result::Result::Ok(value) => value, + std::result::Result::Err(message) => return std::result::Result::Err(message), + }; + let decimals = match bytes.get(10).copied() { + std::option::Option::Some(value) => value, + std::option::Option::None => { + return std::result::Result::Err( + "transfer_checked_with_fee requires decimals".to_string(), + ); + }, + }; + let fee = match read_u64_at(bytes, 11) { + std::result::Result::Ok(value) => value, + std::result::Result::Err(message) => return std::result::Result::Err(message), + }; + ParsedInstruction { + code: "transfer_checked_with_fee", + tag, + parameters: serde_json::json!({ + "extension": "transfer_fee", + "subDiscriminant": sub, + "amountRaw": amount.to_string(), + "decimals": decimals, + "feeRaw": fee.to_string(), + }), + consumed: 19, + } + }, + 2 => ParsedInstruction { + code: "withdraw_withheld_tokens_from_mint", + tag, + parameters: serde_json::json!({"extension":"transfer_fee","subDiscriminant":sub}), + consumed: 2, + }, + 3 => { + let count = match bytes.get(2).copied() { + std::option::Option::Some(value) => value, + std::option::Option::None => { + return std::result::Result::Err( + "withdraw_withheld_tokens_from_accounts requires a count".to_string(), + ); + }, + }; + ParsedInstruction { + code: "withdraw_withheld_tokens_from_accounts", + tag, + parameters: serde_json::json!({ + "extension": "transfer_fee", + "subDiscriminant": sub, + "tokenAccountCount": count, + }), + consumed: 3, + } + }, + 4 => ParsedInstruction { + code: "harvest_withheld_tokens_to_mint", + tag, + parameters: serde_json::json!({"extension":"transfer_fee","subDiscriminant":sub}), + consumed: 2, + }, + 5 => { + let basis_points = match read_u16(bytes, 2) { + std::result::Result::Ok(value) => value, + std::result::Result::Err(message) => return std::result::Result::Err(message), + }; + let maximum_fee = match read_u64_at(bytes, 4) { + std::result::Result::Ok(value) => value, + std::result::Result::Err(message) => return std::result::Result::Err(message), + }; + ParsedInstruction { + code: "set_transfer_fee", + tag, + parameters: serde_json::json!({ + "extension": "transfer_fee", + "subDiscriminant": sub, + "transferFeeBasisPoints": basis_points, + "maximumFeeRaw": maximum_fee.to_string(), + }), + consumed: 12, + } + }, + value => { + return std::result::Result::Err(format!( + "unknown transfer fee sub-discriminant {value}" + )); + }, + }; + if bytes.len() < parsed.consumed { + return std::result::Result::Err(format!("{} payload is truncated", parsed.code)); + } + return std::result::Result::Ok(parsed); +} + +fn parse_default_account_state_instruction( + bytes: &[u8], + tag: u8, +) -> std::result::Result { + if bytes.len() < 3 { + return std::result::Result::Err( + "default account state instruction requires 3 bytes".to_string(), + ); + } + let code = match bytes[1] { + 0 => "initialize_default_account_state", + 1 => "update_default_account_state", + value => { + return std::result::Result::Err(format!( + "unknown default account state sub-discriminant {value}" + )); + }, + }; + let state = match bytes[2] { + 0 => "uninitialized", + 1 => "initialized", + 2 => "frozen", + value => return std::result::Result::Err(format!("unknown token account state {value}")), + }; + if bytes.len() != 3 { + return std::result::Result::Err(format!("{code} requires exactly 3 bytes")); + } + return std::result::Result::Ok(ParsedInstruction { + code, + tag, + parameters: serde_json::json!({ + "extension": "default_account_state", + "subDiscriminant": bytes[1], + "accountState": state, + "accountStateCode": bytes[2], + }), + consumed: 3, + }); +} + +fn parse_toggle_extension_instruction( + bytes: &[u8], + tag: u8, + extension: &str, + enable_code: &'static str, + disable_code: &'static str, +) -> std::result::Result { + if bytes.len() != 2 { + return std::result::Result::Err(format!( + "{extension} instruction requires exactly 2 bytes" + )); + } + let code = match bytes[1] { + 0 => enable_code, + 1 => disable_code, + value => { + return std::result::Result::Err(format!( + "unknown {extension} sub-discriminant {value}" + )); + }, + }; + return std::result::Result::Ok(ParsedInstruction { + code, + tag, + parameters: serde_json::json!({ + "extension": extension, + "subDiscriminant": bytes[1], + "enabled": bytes[1] == 0, + }), + consumed: 2, + }); +} + +fn parse_pointer_extension_instruction( + bytes: &[u8], + tag: u8, + extension: &str, + initialize_code: &'static str, + update_code: &'static str, + authority_field: &str, + address_field: &str, +) -> std::result::Result { + let sub = match bytes.get(1).copied() { + std::option::Option::Some(value) => value, + std::option::Option::None => { + return std::result::Result::Err(format!( + "{extension} instruction requires a sub-discriminant" + )); + }, + }; + let (code, authority, address, consumed) = match sub { + 0 => { + if bytes.len() != 66 { + return std::result::Result::Err(format!( + "{initialize_code} requires exactly 66 bytes; received {}", + bytes.len() + )); + } + ( + initialize_code, + optional_nonzero_pubkey(&bytes[2..34]), + optional_nonzero_pubkey(&bytes[34..66]), + 66_usize, + ) + }, + 1 => { + if bytes.len() != 34 { + return std::result::Result::Err(format!( + "{update_code} requires exactly 34 bytes; received {}", + bytes.len() + )); + } + ( + update_code, + serde_json::Value::Null, + optional_nonzero_pubkey(&bytes[2..34]), + 34_usize, + ) + }, + value => { + return std::result::Result::Err(format!( + "unknown {extension} sub-discriminant {value}" + )); + }, + }; + let mut parameters = serde_json::Map::new(); + parameters.insert("extension".to_string(), serde_json::Value::String(extension.to_string())); + parameters.insert("subDiscriminant".to_string(), serde_json::json!(sub)); + parameters.insert(address_field.to_string(), address); + if sub == 0 { + parameters.insert(authority_field.to_string(), authority); + } + return std::result::Result::Ok(ParsedInstruction { + code, + tag, + parameters: serde_json::Value::Object(parameters), + consumed, + }); +} + +fn optional_nonzero_pubkey(bytes: &[u8]) -> serde_json::Value { + if bytes.len() != 32 || bytes.iter().all(|value| return *value == 0) { + return serde_json::Value::Null; + } + return serde_json::Value::String(bs58::encode(bytes).into_string()); +} + +fn parse_opaque_extension_envelope( + bytes: &[u8], + code: &'static str, + tag: u8, +) -> std::result::Result { + let sub_discriminant = match bytes.get(1).copied() { + std::option::Option::Some(value) => value, + std::option::Option::None => { + return std::result::Result::Err(format!( + "{code} requires an extension sub-discriminant" + )); + }, + }; + let opaque = &bytes[2..]; + return std::result::Result::Ok(ParsedInstruction { + code, + tag, + parameters: serde_json::json!({ + "subDiscriminant": sub_discriminant, + "opaquePayloadLength": opaque.len(), + "opaquePayloadPrefixHex": hex_prefix(opaque), + "semanticStatus": "opaque_until_extension_layout_audit" + }), + consumed: bytes.len(), + }); +} + +fn parse_initialize_mint( + bytes: &[u8], + code: &'static str, + tag: u8, +) -> std::result::Result { + if bytes.len() < 35 { + return std::result::Result::Err(format!("{code} requires at least 35 bytes")); + } + let mint_authority = pubkey_text(&bytes[2..34]); + let (freeze_authority, option_length) = match pubkey_option(&bytes[34..]) { + std::result::Result::Ok(value) => value, + std::result::Result::Err(message) => return std::result::Result::Err(message), + }; + return std::result::Result::Ok(ParsedInstruction { + code, + tag, + parameters: serde_json::json!({ + "decimals": bytes[1], + "mintAuthority": mint_authority, + "freezeAuthority": freeze_authority, + }), + consumed: 34 + option_length, + }); +} + +fn parse_one_u8( + bytes: &[u8], + code: &'static str, + tag: u8, + field: &str, +) -> std::result::Result { + if bytes.len() < 2 { + return std::result::Result::Err(format!("{code} requires 2 bytes")); + } + return std::result::Result::Ok(ParsedInstruction { + code, + tag, + parameters: one_parameter(field, serde_json::Value::from(bytes[1])), + consumed: 2, + }); +} + +fn parse_one_u64( + bytes: &[u8], + code: &'static str, + tag: u8, + field: &str, +) -> std::result::Result { + let amount = match read_u64_at(bytes, 1) { + std::result::Result::Ok(value) => value, + std::result::Result::Err(message) => return std::result::Result::Err(message), + }; + return std::result::Result::Ok(ParsedInstruction { + code, + tag, + parameters: one_parameter(field, serde_json::Value::String(amount.to_string())), + consumed: 9, + }); +} + +fn parse_set_authority( + bytes: &[u8], + code: &'static str, + tag: u8, +) -> std::result::Result { + if bytes.len() < 3 { + return std::result::Result::Err("set_authority requires at least 3 bytes".to_string()); + } + let authority_type = match bytes[1] { + 0 => "mint_tokens", + 1 => "freeze_account", + 2 => "account_owner", + 3 => "close_account", + value => return std::result::Result::Err(format!("invalid authority type {value}")), + }; + let (new_authority, option_length) = match pubkey_option(&bytes[2..]) { + std::result::Result::Ok(value) => value, + std::result::Result::Err(message) => return std::result::Result::Err(message), + }; + return std::result::Result::Ok(ParsedInstruction { + code, + tag, + parameters: serde_json::json!({ + "authorityType": authority_type, + "newAuthority": new_authority, + }), + consumed: 2 + option_length, + }); +} + +fn parse_amount_decimals( + bytes: &[u8], + code: &'static str, + tag: u8, +) -> std::result::Result { + if bytes.len() < 10 { + return std::result::Result::Err(format!("{code} requires 10 bytes")); + } + let amount = match read_u64_at(bytes, 1) { + std::result::Result::Ok(value) => value, + std::result::Result::Err(message) => return std::result::Result::Err(message), + }; + return std::result::Result::Ok(ParsedInstruction { + code, + tag, + parameters: serde_json::json!({ + "amountRaw": amount.to_string(), + "decimals": bytes[9], + }), + consumed: 10, + }); +} + +fn parse_pubkey_parameter( + bytes: &[u8], + code: &'static str, + tag: u8, + field: &str, +) -> std::result::Result { + if bytes.len() < 33 { + return std::result::Result::Err(format!("{code} requires 33 bytes")); + } + return std::result::Result::Ok(ParsedInstruction { + code, + tag, + parameters: one_parameter(field, serde_json::Value::String(pubkey_text(&bytes[1..33]))), + consumed: 33, + }); +} + +fn parse_ui_amount( + bytes: &[u8], + code: &'static str, + tag: u8, +) -> std::result::Result { + let text = match std::str::from_utf8(&bytes[1..]) { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => { + return std::result::Result::Err(format!( + "ui amount is not UTF-8 at byte {}", + error.valid_up_to() + )); + }, + }; + return std::result::Result::Ok(ParsedInstruction { + code, + tag, + parameters: serde_json::json!({"uiAmount": text}), + consumed: bytes.len(), + }); +} + +fn parse_unwrap_lamports( + bytes: &[u8], + code: &'static str, + tag: u8, +) -> std::result::Result { + if bytes.len() < 2 { + return std::result::Result::Err("unwrap_lamports requires an option tag".to_string()); + } + let (amount, consumed) = match bytes[1] { + 0 => (serde_json::Value::Null, 2), + 1 => { + let value = match read_u64_at(bytes, 2) { + std::result::Result::Ok(value) => value, + std::result::Result::Err(message) => return std::result::Result::Err(message), + }; + (serde_json::Value::String(value.to_string()), 10) + }, + value => return std::result::Result::Err(format!("invalid unwrap amount option {value}")), + }; + return std::result::Result::Ok(ParsedInstruction { + code, + tag, + parameters: serde_json::json!({"amountLamports": amount}), + consumed, + }); +} + +fn read_u16(bytes: &[u8], offset: usize) -> std::result::Result { + let slice = match bytes.get(offset..offset.saturating_add(2)) { + std::option::Option::Some(value) if value.len() == 2 => value, + _ => return std::result::Result::Err("truncated little-endian u16".to_string()), + }; + return std::result::Result::Ok(u16::from_le_bytes([slice[0], slice[1]])); +} + +fn read_u64_at(bytes: &[u8], offset: usize) -> std::result::Result { + let slice = match bytes.get(offset..offset.saturating_add(8)) { + std::option::Option::Some(value) if value.len() == 8 => value, + _ => return std::result::Result::Err("truncated little-endian u64".to_string()), + }; + let array = match <[u8; 8]>::try_from(slice) { + std::result::Result::Ok(value) => value, + std::result::Result::Err(_error) => { + return std::result::Result::Err("invalid little-endian u64".to_string()); + }, + }; + return std::result::Result::Ok(u64::from_le_bytes(array)); +} + +fn pubkey_option( + bytes: &[u8], +) -> std::result::Result<(serde_json::Value, usize), std::string::String> { + return match bytes.first().copied() { + std::option::Option::Some(0) => std::result::Result::Ok((serde_json::Value::Null, 1)), + std::option::Option::Some(1) if bytes.len() >= 33 => { + std::result::Result::Ok((serde_json::Value::String(pubkey_text(&bytes[1..33])), 33)) + }, + std::option::Option::Some(1) => { + std::result::Result::Err("truncated optional pubkey".to_string()) + }, + std::option::Option::Some(value) => { + std::result::Result::Err(format!("invalid optional pubkey tag {value}")) + }, + std::option::Option::None => { + std::result::Result::Err("missing optional pubkey tag".to_string()) + }, + }; +} + +fn pubkey_text(bytes: &[u8]) -> std::string::String { + return bs58::encode(bytes).into_string(); +} + +fn one_parameter(field: &str, value: serde_json::Value) -> serde_json::Value { + let mut parameters = serde_json::Map::new(); + parameters.insert(field.to_string(), value); + return serde_json::Value::Object(parameters); +} + +fn resolve_accounts( + input: &crate::CoreInstructionReplayInput, +) -> kb_core::Result> { + let instruction_accounts = match input.instruction_accounts_json.as_array() { + std::option::Option::Some(value) => value, + std::option::Option::None => { + return std::result::Result::Err(kb_core::Error::invalid_state( + "Token-2022 instruction accounts must be a JSON array", + )); + }, + }; + let account_keys = match input.account_keys_json.as_array() { + std::option::Option::Some(value) => value, + std::option::Option::None => { + return std::result::Result::Err(kb_core::Error::invalid_state( + "Token-2022 transaction account keys must be a JSON array", + )); + }, + }; + let mut output = std::vec::Vec::with_capacity(instruction_accounts.len()); + for (position, account) in instruction_accounts.iter().enumerate() { + let account_index = match account.get("accountIndex").and_then(serde_json::Value::as_u64) { + std::option::Option::Some(value) => value, + std::option::Option::None => { + return std::result::Result::Err(kb_core::Error::invalid_state(format!( + "Token-2022 instruction account {position} has no accountIndex" + ))); + }, + }; + let account_key = match account.get("accountKey").and_then(serde_json::Value::as_str) { + std::option::Option::Some(value) if !value.trim().is_empty() => value, + _ => { + return std::result::Result::Err(kb_core::Error::invalid_state(format!( + "Token-2022 instruction account {position} has no accountKey" + ))); + }, + }; + let resolved = account_keys.iter().find(|candidate| { + return candidate.get("accountIndex").and_then(serde_json::Value::as_u64) + == std::option::Option::Some(account_index); + }); + let resolved = match resolved { + std::option::Option::Some(value) => value, + std::option::Option::None => { + return std::result::Result::Err(kb_core::Error::invalid_state(format!( + "Token-2022 account index {account_index} is not resolved" + ))); + }, + }; + if resolved.get("accountKey").and_then(serde_json::Value::as_str) + != std::option::Option::Some(account_key) + { + return std::result::Result::Err(kb_core::Error::invalid_state(format!( + "Token-2022 account index {account_index} resolves to a different key" + ))); + } + let signer = match resolved.get("signer").and_then(serde_json::Value::as_bool) { + std::option::Option::Some(value) => value, + std::option::Option::None => { + return std::result::Result::Err(kb_core::Error::invalid_state(format!( + "Token-2022 account index {account_index} has no signer flag" + ))); + }, + }; + let writable = match resolved.get("writable").and_then(serde_json::Value::as_bool) { + std::option::Option::Some(value) => value, + std::option::Option::None => { + return std::result::Result::Err(kb_core::Error::invalid_state(format!( + "Token-2022 account index {account_index} has no writable flag" + ))); + }, + }; + output.push(ResolvedAccount { + position, + account_index, + account_key: account_key.to_string(), + signer, + writable, + source: resolved.get("source").cloned().unwrap_or(serde_json::Value::Null), + }); + } + return std::result::Result::Ok(output); +} + +fn project_accounts(code: &str, accounts: &[ResolvedAccount]) -> serde_json::Value { + let authority_index = authority_index(code); + let values = accounts + .iter() + .enumerate() + .map(|(role_position, account)| { + return serde_json::json!({ + "position": account.position, + "rolePosition": role_position, + "accountIndex": account.account_index, + "accountKey": account.account_key, + "signer": account.signer, + "writable": account.writable, + "source": account.source, + "role": account_role(code, role_position, authority_index), + }); + }) + .collect::>(); + return serde_json::Value::Array(values); +} + +fn authority_projection(code: &str, accounts: &[ResolvedAccount]) -> serde_json::Value { + let authority_index = authority_index(code); + let index = match authority_index { + std::option::Option::Some(value) => value, + std::option::Option::None => { + return serde_json::json!({ + "form": "not_applicable", + "declaredAuthority": serde_json::Value::Null, + "providedSigners": [], + "statefulMultisigValidation": "not_applicable", + }); + }, + }; + let authority = accounts.get(index); + let provided_signers = accounts + .iter() + .skip(index.saturating_add(1)) + .filter(|account| return account.signer) + .map(|account| return account.account_key.clone()) + .collect::>(); + let form = if accounts.len() > index.saturating_add(1) { "multisig" } else { "single" }; + return serde_json::json!({ + "form": form, + "declaredAuthority": authority.map(|value| return value.account_key.clone()), + "declaredAuthoritySigner": authority.map(|value| return value.signer), + "providedSigners": provided_signers, + "providedSignerCount": provided_signers.len(), + "statefulMultisigValidation": if form == "multisig" { + "requires_multisig_account_snapshot" + } else { + "not_applicable" + }, + "membersClaimed": false, + }); +} + +fn authority_index(code: &str) -> std::option::Option { + return match code { + "transfer" + | "approve" + | "mint_to" + | "burn" + | "close_account" + | "freeze_account" + | "thaw_account" + | "withdraw_excess_lamports" + | "unwrap_lamports" => std::option::Option::Some(2), + "revoke" | "set_authority" => std::option::Option::Some(1), + "transfer_checked" | "approve_checked" => std::option::Option::Some(3), + "mint_to_checked" | "burn_checked" => std::option::Option::Some(2), + "transfer_checked_with_fee" => std::option::Option::Some(3), + "withdraw_withheld_tokens_from_mint" | "withdraw_withheld_tokens_from_accounts" => { + std::option::Option::Some(2) + }, + "set_transfer_fee" | "update_default_account_state" => std::option::Option::Some(1), + "enable_required_transfer_memos" + | "disable_required_transfer_memos" + | "enable_cpi_guard" + | "disable_cpi_guard" => std::option::Option::Some(1), + "update_token_metadata_field" + | "remove_token_metadata_key" + | "update_token_metadata_authority" + | "update_token_group_max_size" + | "update_token_group_authority" => std::option::Option::Some(1), + _ => std::option::Option::None, + }; +} + +fn account_role( + code: &str, + position: usize, + authority_index: std::option::Option, +) -> std::string::String { + if authority_index.is_some_and(|index| return position > index) { + return "multisig_signer".to_string(); + } + let role = match (code, position) { + ("initialize_mint", 0) | ("initialize_mint2", 0) => "mint", + ("initialize_mint", 1) => "rent_sysvar", + ("initialize_account", 0) | ("initialize_account2", 0) | ("initialize_account3", 0) => { + "token_account" + }, + ("initialize_account", 1) | ("initialize_account2", 1) | ("initialize_account3", 1) => { + "mint" + }, + ("initialize_account", 2) => "owner", + ("initialize_account", 3) | ("initialize_account2", 2) => "rent_sysvar", + ("initialize_multisig", 0) | ("initialize_multisig2", 0) => "multisig_account", + ("initialize_multisig", 1) => "rent_sysvar", + ("initialize_multisig", _) | ("initialize_multisig2", _) => "multisig_member", + ("transfer", 0) | ("transfer_checked", 0) => "source_token_account", + ("transfer", 1) => "destination_token_account", + ("transfer_checked", 1) => "mint", + ("transfer_checked", 2) => "destination_token_account", + ("approve", 0) | ("approve_checked", 0) | ("revoke", 0) => "source_token_account", + ("approve", 1) | ("approve_checked", 2) => "delegate", + ("approve_checked", 1) => "mint", + ("mint_to", 0) | ("mint_to_checked", 0) => "mint", + ("mint_to", 1) | ("mint_to_checked", 1) => "destination_token_account", + ("burn", 0) | ("burn_checked", 0) => "source_token_account", + ("burn", 1) | ("burn_checked", 1) => "mint", + ("close_account", 0) | ("withdraw_excess_lamports", 0) | ("unwrap_lamports", 0) => { + "source_token_account" + }, + ("close_account", 1) | ("withdraw_excess_lamports", 1) | ("unwrap_lamports", 1) => { + "destination_account" + }, + ("freeze_account", 0) | ("thaw_account", 0) => "token_account", + ("freeze_account", 1) | ("thaw_account", 1) => "mint", + ("set_authority", 0) => "authority_target", + ("sync_native", 0) | ("initialize_immutable_owner", 0) => "token_account", + ("sync_native", 1) => "rent_sysvar", + ("get_account_data_size", 0) | ("amount_to_ui_amount", 0) | ("ui_amount_to_amount", 0) => { + "mint" + }, + ("initialize_transfer_fee_config", 0) + | ("initialize_default_account_state", 0) + | ("update_default_account_state", 0) + | ("set_transfer_fee", 0) + | ("harvest_withheld_tokens_to_mint", 0) => "mint", + ("transfer_checked_with_fee", 0) => "source_token_account", + ("transfer_checked_with_fee", 1) => "mint", + ("transfer_checked_with_fee", 2) => "destination_token_account", + ("withdraw_withheld_tokens_from_mint", 0) => "mint", + ("withdraw_withheld_tokens_from_mint", 1) => "destination_token_account", + ("withdraw_withheld_tokens_from_accounts", 0) => "mint", + ("withdraw_withheld_tokens_from_accounts", 1) => "destination_token_account", + ("withdraw_withheld_tokens_from_accounts", _) => "source_or_multisig_account", + ("harvest_withheld_tokens_to_mint", _) => "source_token_account", + ("enable_required_transfer_memos", 0) + | ("disable_required_transfer_memos", 0) + | ("enable_cpi_guard", 0) + | ("disable_cpi_guard", 0) => "token_account", + ("initialize_token_metadata", 0) + | ("update_token_metadata_field", 0) + | ("remove_token_metadata_key", 0) + | ("update_token_metadata_authority", 0) + | ("emit_token_metadata", 0) => "metadata", + ("initialize_token_metadata", 1) => "update_authority", + ("initialize_token_metadata", 2) => "mint", + ("initialize_token_metadata", 3) => "mint_authority", + ("initialize_token_group", 0) + | ("update_token_group_max_size", 0) + | ("update_token_group_authority", 0) + | ("initialize_token_group_member", 3) => "group", + ("initialize_token_group", 1) => "mint", + ("initialize_token_group", 2) => "mint_authority", + ("initialize_token_group_member", 0) => "member", + ("initialize_token_group_member", 1) => "member_mint", + ("initialize_token_group_member", 2) => "member_mint_authority", + ("initialize_token_group_member", 4) => "group_update_authority", + (_, value) if authority_index == std::option::Option::Some(value) => "authority", + _ => "unresolved", + }; + return role.to_string(); +} + +fn semantic_diagnostics( + code: &str, + parameters: serde_json::Value, + accounts: &[ResolvedAccount], + suffix_length: usize, +) -> serde_json::Value { + let mut values = std::vec::Vec::new(); + if suffix_length > 0 { + values.push(serde_json::json!({ + "code": "ignored_wire_suffix", + "lengthBytes": suffix_length, + "runtimeSemantics": "official_interface_unpack_ignores_suffix_for_this_variant", + })); + } + let valid_count = valid_account_count(code, accounts.len()); + if !valid_count { + values.push(serde_json::json!({ + "code": "unexpected_account_count", + "provided": accounts.len(), + })); + } + if matches!(code, "initialize_multisig" | "initialize_multisig2") { + let member_start = if code == "initialize_multisig" { 2 } else { 1 }; + let n = accounts.len().saturating_sub(member_start); + let m = parameters.get("m").and_then(serde_json::Value::as_u64).unwrap_or(0); + if !(1..=11).contains(&n) || m == 0 || m > n as u64 { + values.push(serde_json::json!({ + "code": "invalid_multisig_threshold", + "m": m, + "n": n, + "minSigners": 1, + "maxSigners": 11, + })); + } + } + let authority_index = authority_index(code); + if let std::option::Option::Some(index) = authority_index { + if let std::option::Option::Some(authority) = accounts.get(index) { + let multisig_form = accounts.len() > index.saturating_add(1); + if !multisig_form && !authority.signer { + values.push(serde_json::json!({"code":"missing_single_authority_signature"})); + } + if multisig_form { + let invalid_signers = accounts + .iter() + .skip(index.saturating_add(1)) + .filter(|account| return !account.signer) + .map(|account| return account.position) + .collect::>(); + if !invalid_signers.is_empty() { + values.push(serde_json::json!({ + "code": "multisig_meta_missing_signature", + "positions": invalid_signers, + })); + } + } + } + } + return serde_json::Value::Array(values); +} + +fn valid_account_count(code: &str, count: usize) -> bool { + return match code { + "initialize_mint" => count == 2, + "initialize_mint2" => count == 1, + "initialize_account" => count == 4, + "initialize_account2" => count == 3, + "initialize_account3" => count == 2, + "initialize_multisig" => (3..=13).contains(&count), + "initialize_multisig2" => (2..=12).contains(&count), + "transfer" + | "approve" + | "mint_to" + | "burn" + | "close_account" + | "freeze_account" + | "thaw_account" + | "withdraw_excess_lamports" + | "unwrap_lamports" => count >= 3, + "revoke" | "set_authority" => count >= 2, + "transfer_checked" | "approve_checked" => count >= 4, + "mint_to_checked" | "burn_checked" => count >= 3, + "sync_native" => count == 1 || count == 2, + "get_account_data_size" + | "initialize_immutable_owner" + | "amount_to_ui_amount" + | "ui_amount_to_amount" => count == 1, + "initialize_transfer_fee_config" | "initialize_default_account_state" => count == 1, + "transfer_checked_with_fee" => count >= 4, + "withdraw_withheld_tokens_from_mint" => count >= 3, + "withdraw_withheld_tokens_from_accounts" => count >= 4, + "harvest_withheld_tokens_to_mint" => count >= 2, + "set_transfer_fee" + | "update_default_account_state" + | "enable_required_transfer_memos" + | "disable_required_transfer_memos" + | "enable_cpi_guard" + | "disable_cpi_guard" => count >= 2, + "batch" => true, + _ => false, + }; +} + +#[allow(clippy::too_many_arguments)] +fn observation( + input: &crate::CoreInstructionReplayInput, + code: &str, + tag: u8, + parameters: serde_json::Value, + accounts: serde_json::Value, + authority: serde_json::Value, + semantic_diagnostics: serde_json::Value, + wire: &[u8], + suffix_length: usize, + committed: bool, + event_key: std::string::String, + instruction_path: std::string::String, + family: crate::EventFamily, +) -> crate::DecodedObservation { + let wire_hash = hash(wire); + let event = crate::DecodedProtocolEvent { + signature: crate::Signature(input.signature.clone()), + slot: crate::Slot(input.slot), + instruction_path: crate::InstructionPath(instruction_path.clone()), + program_id: crate::ProgramId(input.program_id.clone()), + protocol_code: crate::ProtocolCode(crate::SPL_TOKEN_2022_SURFACE_CODE.to_string()), + surface_code: crate::SurfaceCode(crate::SPL_TOKEN_2022_SURFACE_CODE.to_string()), + event_code: crate::EventCode(format!("{}.{}", crate::SPL_TOKEN_2022_SURFACE_CODE, code)), + event_name: crate::EventName(code.to_string()), + event_family: family, + source_kind: if input.instruction_path.contains('/') { + crate::EventSourceKind::InnerInstruction + } else { + crate::EventSourceKind::Instruction + }, + confidence: crate::DecoderConfidence::ManualExact, + }; + return crate::DecodedObservation { + event_key, + event, + payload_json: serde_json::json!({ + "eventVersion": crate::SPL_TOKEN_2022_EVENT_VERSION, + "programId": input.program_id, + "instruction": code, + "wireTag": tag, + "wireTagHex": format!("{tag:02x}"), + "instructionPath": instruction_path, + "instructionLocation": if input.instruction_path.contains('/') { "inner" } else { "outer" }, + "transactionSucceeded": !input.transaction_failed, + "committed": committed, + "parameters": parameters, + "accounts": accounts, + "authority": authority, + "semanticDiagnostics": semantic_diagnostics, + "wire": { + "lengthBytes": wire.len(), + "sha256": wire_hash, + "prefixHex": hex_prefix(wire), + "suffixLengthBytes": suffix_length, + "complete": true, + }, + "inference": { + "mintFromCoreBalanceChanges": serde_json::Value::Null, + "mintInvented": false, + "rpcAccountReadUsed": false, + }, + "returnData": { + "availableInCoreReplayInput": false, + "validated": false, + }, + }), + transaction_failed: input.transaction_failed, + transaction_error: input.transaction_err_json.clone(), + observation_committed: committed, + proof: crate::DecoderProof { + kind: crate::DecoderProofKind::Manual, + confidence: crate::DecoderConfidence::ManualExact, + evidence: std::vec![ + "spl_token_interface_3_0_0_wire_audit".to_string(), + format!("wire_tag:{tag}"), + format!("wire_sha256:{wire_hash}"), + ], + }, + }; +} + +fn batch_observations( + input: &crate::CoreInstructionReplayInput, + bytes: &[u8], + accounts: &[ResolvedAccount], +) -> std::result::Result< + (std::vec::Vec, serde_json::Value), + std::string::String, +> { + if bytes.len() == 1 { + return std::result::Result::Err("Batch requires at least one sub-instruction".to_string()); + } + let mut cursor = 1_usize; + let mut account_cursor = 0_usize; + let mut index = 0_usize; + let mut observations = std::vec::Vec::new(); + let mut manifest = std::vec::Vec::new(); + while cursor < bytes.len() { + if index >= crate::SPL_TOKEN_2022_MAX_BATCH_INSTRUCTIONS { + return std::result::Result::Err(format!( + "Batch exceeds {} sub-instructions", + crate::SPL_TOKEN_2022_MAX_BATCH_INSTRUCTIONS + )); + } + let account_count = match bytes.get(cursor).copied() { + std::option::Option::Some(value) => value as usize, + std::option::Option::None => { + return std::result::Result::Err(format!( + "Batch entry {index} has no account count" + )); + }, + }; + let data_length = match bytes.get(cursor.saturating_add(1)).copied() { + std::option::Option::Some(value) => value as usize, + std::option::Option::None => { + return std::result::Result::Err(format!("Batch entry {index} has no data length")); + }, + }; + if data_length == 0 { + return std::result::Result::Err(format!("Batch entry {index} has empty data")); + } + let data_start = cursor.saturating_add(2); + let data_end = data_start.saturating_add(data_length); + let child_wire = match bytes.get(data_start..data_end) { + std::option::Option::Some(value) => value, + std::option::Option::None => { + return std::result::Result::Err(format!( + "Batch entry {index} data length exceeds payload" + )); + }, + }; + let account_end = account_cursor.saturating_add(account_count); + if account_end > crate::SPL_TOKEN_2022_MAX_BATCH_ACCOUNTS || account_end > accounts.len() { + return std::result::Result::Err(format!( + "Batch entry {index} account slice exceeds provided accounts or bound" + )); + } + let parsed = match parse_instruction(child_wire, true) { + std::result::Result::Ok(value) => value, + std::result::Result::Err(message) => { + return std::result::Result::Err(format!("Batch entry {index}: {message}")); + }, + }; + let child_accounts = &accounts[account_cursor..account_end]; + let suffix_length = child_wire.len().saturating_sub(parsed.consumed); + observations.push(observation( + input, + parsed.code, + parsed.tag, + parsed.parameters.clone(), + project_accounts(parsed.code, child_accounts), + authority_projection(parsed.code, child_accounts), + semantic_diagnostics(parsed.code, parsed.parameters, child_accounts, suffix_length), + child_wire, + suffix_length, + !input.transaction_failed, + format!("token_2022:batch:{index}"), + format!("{}/batch/{index}", input.instruction_path), + event_family(parsed.code), + )); + manifest.push(serde_json::json!({ + "position": index, + "instruction": parsed.code, + "wireTag": parsed.tag, + "accountStart": account_cursor, + "accountCount": account_count, + "dataOffset": data_start, + "dataLength": data_length, + "derivedInstructionPath": format!("{}/batch/{index}", input.instruction_path), + })); + cursor = data_end; + account_cursor = account_end; + index = index.saturating_add(1); + } + if account_cursor != accounts.len() { + return std::result::Result::Err(format!( + "Batch consumed {account_cursor} of {} provided accounts", + accounts.len() + )); + } + return std::result::Result::Ok(( + observations, + serde_json::json!({ + "subInstructionCount": manifest.len(), + "subInstructions": manifest, + "nestedBatchAllowed": false, + }), + )); +} + +fn event_family(code: &str) -> crate::EventFamily { + return match code { + "set_authority" + | "initialize_multisig" + | "initialize_multisig2" + | "initialize_permanent_delegate" + | "initialize_interest_bearing_mint" + | "update_interest_bearing_rate" + | "update_token_metadata_authority" + | "update_token_group_authority" + | "update_token_group_max_size" => crate::EventFamily::Admin, + "initialize_mint" + | "initialize_mint2" + | "mint_to" + | "mint_to_checked" + | "initialize_non_transferable_mint" => crate::EventFamily::TokenMint, + "burn" | "burn_checked" => crate::EventFamily::TokenBurn, + "initialize_transfer_fee_config" + | "set_transfer_fee" + | "withdraw_withheld_tokens_from_mint" + | "withdraw_withheld_tokens_from_accounts" + | "harvest_withheld_tokens_to_mint" + | "transfer_checked_with_fee" + | "initialize_confidential_transfer_fee_config" + | "withdraw_confidential_withheld_tokens_from_mint" + | "withdraw_confidential_withheld_tokens_from_accounts" + | "harvest_confidential_withheld_tokens_to_mint" + | "enable_confidential_harvest_to_mint" + | "disable_confidential_harvest_to_mint" + | "transfer_confidential_tokens_with_fee" => crate::EventFamily::Fee, + "initialize_default_account_state" + | "update_default_account_state" + | "enable_required_transfer_memos" + | "disable_required_transfer_memos" + | "enable_cpi_guard" + | "disable_cpi_guard" => crate::EventFamily::Audit, + "initialize_token_metadata" + | "update_token_metadata_field" + | "remove_token_metadata_key" + | "emit_token_metadata" + | "initialize_token_group" + | "initialize_token_group_member" => crate::EventFamily::Metadata, + "batch" | "get_account_data_size" | "amount_to_ui_amount" | "ui_amount_to_amount" => { + crate::EventFamily::Audit + }, + _ => crate::EventFamily::TokenAccount, + }; +} + +fn hash(bytes: &[u8]) -> std::string::String { + let digest = sha2::Sha256::digest(bytes); + let mut output = std::string::String::with_capacity(64); + for byte in digest { + output.push_str(format!("{byte:02x}").as_str()); + } + return output; +} + +fn hex_prefix(bytes: &[u8]) -> std::string::String { + let length = std::cmp::min(bytes.len(), crate::SPL_TOKEN_2022_DIAGNOSTIC_PREFIX_BYTES); + let mut output = std::string::String::with_capacity(length.saturating_mul(2)); + for byte in &bytes[..length] { + output.push_str(format!("{byte:02x}").as_str()); + } + return output; +} + +fn failed( + entry_code: std::option::Option<&str>, + code: &str, + message: std::string::String, +) -> crate::DecoderExecutionResult { + return crate::DecoderExecutionResult { + status: crate::DecoderOutcomeStatus::Failed, + recognized_entry_code: entry_code.map(str::to_string), + observations: std::vec::Vec::new(), + diagnostics: std::vec![crate::DecoderDiagnostic { + code: code.to_string(), + message, + retriable: false, + }], + }; +} diff --git a/kb-lib/src/lib.rs b/kb-lib/src/lib.rs index 587d5aa..00aba06 100644 --- a/kb-lib/src/lib.rs +++ b/kb-lib/src/lib.rs @@ -1,5 +1,5 @@ // file: kb-lib/src/lib.rs -// version: 9 +// version: 10 //! Consolidated decoder, executor, materializer and shared model library. #![warn(missing_docs)] @@ -11,6 +11,8 @@ pub mod executor; pub mod materializer; pub mod model; +/// One parsed SPL ElGamal registry instruction. +pub(crate) use crate::decoder::ParsedRegistryInstruction; /// Current stable Associated Token Account decoded event contract version. pub(crate) use crate::decoder::SPL_ASSOCIATED_TOKEN_ACCOUNT_EVENT_VERSION; /// Exact Associated Token Account instructions published by the official interface. @@ -29,6 +31,14 @@ pub(crate) use crate::decoder::SPL_ASSOCIATED_TOKEN_ACCOUNT_MAX_TRANSACTION_KEYS pub(crate) use crate::decoder::SPL_ASSOCIATED_TOKEN_ACCOUNT_SURFACE_CODE; /// Canonical tracing target for the Associated Token Account decoder. pub(crate) use crate::decoder::SPL_ASSOCIATED_TOKEN_ACCOUNT_TRACING_TARGET; +/// Current stable SPL ElGamal registry event contract version. +pub(crate) use crate::decoder::SPL_ELGAMAL_REGISTRY_EVENT_VERSION; +/// Maximum retained SPL ElGamal registry instruction payload size. +pub(crate) use crate::decoder::SPL_ELGAMAL_REGISTRY_MAX_INSTRUCTION_BYTES; +/// Stable SPL ElGamal registry surface code. +pub(crate) use crate::decoder::SPL_ELGAMAL_REGISTRY_SURFACE_CODE; +/// Canonical tracing target for the SPL ElGamal registry decoder. +pub(crate) use crate::decoder::SPL_ELGAMAL_REGISTRY_TRACING_TARGET; /// Maximum payload prefix retained for bounded Memo diagnostics. pub(crate) use crate::decoder::SPL_MEMO_DIAGNOSTIC_PREFIX_BYTES; /// Current stable Memo decoded event contract version. @@ -45,6 +55,24 @@ pub(crate) use crate::decoder::SPL_MEMO_V1_SURFACE_CODE; pub(crate) use crate::decoder::SPL_MEMO_V3_SURFACE_CODE; /// Stable Memo v4 surface code. pub(crate) use crate::decoder::SPL_MEMO_V4_SURFACE_CODE; +/// Maximum prefix retained in bounded Token-2022 diagnostics. +pub(crate) use crate::decoder::SPL_TOKEN_2022_DIAGNOSTIC_PREFIX_BYTES; +/// Current stable Token-2022 decoded event contract version. +pub(crate) use crate::decoder::SPL_TOKEN_2022_EVENT_VERSION; +/// Exact top-level instruction inventory published by the resolved Token-2022 interface. +pub(crate) use crate::decoder::SPL_TOKEN_2022_INSTRUCTION_ENTRIES; +/// Maximum total account metas consumed by one Token-2022 batch. +pub(crate) use crate::decoder::SPL_TOKEN_2022_MAX_BATCH_ACCOUNTS; +/// Maximum decoded sub-instructions in one Token-2022 batch. +pub(crate) use crate::decoder::SPL_TOKEN_2022_MAX_BATCH_INSTRUCTIONS; +/// Maximum retained Token-2022 instruction payload size. +pub(crate) use crate::decoder::SPL_TOKEN_2022_MAX_INSTRUCTION_BYTES; +/// Maximum retained UTF-8 string size for embedded Token Metadata instructions. +pub(crate) use crate::decoder::SPL_TOKEN_2022_MAX_METADATA_STRING_BYTES; +/// Stable Token-2022 surface code. +pub(crate) use crate::decoder::SPL_TOKEN_2022_SURFACE_CODE; +/// Canonical tracing target for the Token-2022 decoder. +pub(crate) use crate::decoder::SPL_TOKEN_2022_TRACING_TARGET; /// Maximum prefix retained in bounded classic SPL Token diagnostics. pub(crate) use crate::decoder::SPL_TOKEN_DIAGNOSTIC_PREFIX_BYTES; /// Current stable classic SPL Token decoded event contract version. @@ -89,43 +117,6 @@ pub(crate) use crate::decoder::solana::SOLANA_CORE_NATIVE_EVENT_VERSION; pub(crate) use crate::decoder::solana::SOLANA_CORE_NATIVE_LOADER_SURFACE_CODE; /// Maximum number of component bytes retained as a hexadecimal event prefix. pub(crate) use crate::decoder::solana::SOLANA_CORE_PRECOMPILE_COMPONENT_PREFIX_BYTES; -/// Expected role and privileges for one positional instruction account. -pub(crate) use crate::decoder::solana::SolanaCoreAccountRole; -/// Returns declared Address Lookup Table instruction coverage. -pub(crate) use crate::decoder::solana::solana_core_address_lookup_table_coverage; -/// Decodes one Address Lookup Table instruction. -pub(crate) use crate::decoder::solana::solana_core_address_lookup_table_decode; -/// Recognizes one Address Lookup Table instruction without producing an event. -pub(crate) use crate::decoder::solana::solana_core_address_lookup_table_recognize; -/// Returns declared Compute Budget instruction coverage. -pub(crate) use crate::decoder::solana::solana_core_compute_budget_coverage; -/// Decodes one Compute Budget instruction. -pub(crate) use crate::decoder::solana::solana_core_compute_budget_decode; -/// Recognizes one Compute Budget instruction without producing an event. -pub(crate) use crate::decoder::solana::solana_core_compute_budget_recognize; -/// Returns declared Config Program instruction coverage. -pub(crate) use crate::decoder::solana::solana_core_config_coverage; -/// Decodes one generic Config Program store instruction. -pub(crate) use crate::decoder::solana::solana_core_config_decode; -/// Recognizes one generic Config Program store instruction. -pub(crate) use crate::decoder::solana::solana_core_config_recognize; -/// Resolves positional instruction accounts and validates their core indexes. -pub(crate) use crate::decoder::solana::solana_core_resolve_accounts; -/// Decodes one bounded Associated Token Account instruction. -pub(crate) use crate::decoder::spl_associated_token_account_decode; -/// Returns the exact entry matching an Associated Token Account wire. -pub(crate) use crate::decoder::spl_associated_token_account_entry; -/// Reads one bounded Associated Token Account payload. -pub(crate) use crate::decoder::spl_associated_token_account_payload; -/// Decodes one bounded SPL Memo instruction. -pub(crate) use crate::decoder::spl_memo_decode; -/// Decodes one bounded classic SPL Token instruction. -pub(crate) use crate::decoder::spl_token_decode; -/// Returns the published entry matching one classic SPL Token tag. -pub(crate) use crate::decoder::spl_token_entry_for_tag; -/// Returns the first byte of one retained classic SPL Token payload. -pub(crate) use crate::decoder::spl_token_payload_tag; - /// Stable protocol code shared by native Solana events. pub(crate) use crate::decoder::solana::SOLANA_CORE_PROTOCOL_CODE; /// Byte length of a secp256k1 Ethereum address. @@ -158,12 +149,32 @@ pub(crate) use crate::decoder::solana::SOLANA_CORE_VOTE_SURFACE_CODE; pub(crate) use crate::decoder::solana::SOLANA_CORE_ZK_ELGAMAL_PROOF_SURFACE_CODE; /// Stable historical ZK Token Proof surface code. pub(crate) use crate::decoder::solana::SOLANA_CORE_ZK_TOKEN_PROOF_SURFACE_CODE; +/// Expected role and privileges for one positional instruction account. +pub(crate) use crate::decoder::solana::SolanaCoreAccountRole; /// One resolved outer instruction payload and its provenance relative to the target instruction. pub(crate) use crate::decoder::solana::SolanaCoreResolvedInstructionPayload; +/// Returns declared Address Lookup Table instruction coverage. +pub(crate) use crate::decoder::solana::solana_core_address_lookup_table_coverage; +/// Decodes one Address Lookup Table instruction. +pub(crate) use crate::decoder::solana::solana_core_address_lookup_table_decode; +/// Recognizes one Address Lookup Table instruction without producing an event. +pub(crate) use crate::decoder::solana::solana_core_address_lookup_table_recognize; /// Returns a hexadecimal prefix no longer than the available byte slice. pub(crate) use crate::decoder::solana::solana_core_bounded_hexadecimal_prefix; /// Extracts one exact bounded byte slice with checked arithmetic. pub(crate) use crate::decoder::solana::solana_core_bounded_slice; +/// Returns declared Compute Budget instruction coverage. +pub(crate) use crate::decoder::solana::solana_core_compute_budget_coverage; +/// Decodes one Compute Budget instruction. +pub(crate) use crate::decoder::solana::solana_core_compute_budget_decode; +/// Recognizes one Compute Budget instruction without producing an event. +pub(crate) use crate::decoder::solana::solana_core_compute_budget_recognize; +/// Returns declared Config Program instruction coverage. +pub(crate) use crate::decoder::solana::solana_core_config_coverage; +/// Decodes one generic Config Program store instruction. +pub(crate) use crate::decoder::solana::solana_core_config_decode; +/// Recognizes one generic Config Program store instruction. +pub(crate) use crate::decoder::solana::solana_core_config_recognize; /// Decodes one retained base64 instruction payload with an explicit byte limit. pub(crate) use crate::decoder::solana::solana_core_decode_instruction_data; /// Returns the decoded target payload length when retained and valid. @@ -202,6 +213,8 @@ pub(crate) use crate::decoder::solana::solana_core_precompiles_recognize; pub(crate) use crate::decoder::solana::solana_core_read_u32_le; /// Reads one little-endian `u64` from an exact byte range. pub(crate) use crate::decoder::solana::solana_core_read_u64_le; +/// Resolves positional instruction accounts and validates their core indexes. +pub(crate) use crate::decoder::solana::solana_core_resolve_accounts; /// Resolves a secp256k1 instruction reference. The runtime format has no current-instruction /// sentinel: every `u8` value is an explicit outer instruction index. pub(crate) use crate::decoder::solana::solana_core_resolve_u8_instruction_payload; @@ -247,13 +260,53 @@ pub(crate) use crate::decoder::solana::solana_core_zk_token_proof_coverage; pub(crate) use crate::decoder::solana::solana_core_zk_token_proof_decode; /// Recognizes one historical ZK Token Proof layout or the current no-op runtime fallback. pub(crate) use crate::decoder::solana::solana_core_zk_token_proof_recognize; +/// Decodes one bounded Associated Token Account instruction. +pub(crate) use crate::decoder::spl_associated_token_account_decode; +/// Returns the exact entry matching an Associated Token Account wire. +pub(crate) use crate::decoder::spl_associated_token_account_entry; +/// Reads one bounded Associated Token Account payload. +pub(crate) use crate::decoder::spl_associated_token_account_payload; +/// Decodes one bounded SPL ElGamal registry instruction. +pub(crate) use crate::decoder::spl_elgamal_registry_decode; +/// Decodes one bounded SPL ElGamal registry instruction payload. +pub(crate) use crate::decoder::spl_elgamal_registry_decode_payload; +/// Parses one exact SPL ElGamal registry instruction. +pub(crate) use crate::decoder::spl_elgamal_registry_parse; +/// Decodes one bounded SPL Memo instruction. +pub(crate) use crate::decoder::spl_memo_decode; +/// Decodes one bounded Token-2022 instruction. +pub(crate) use crate::decoder::spl_token_2022_decode; +/// Returns the exact entry matching one Token-2022 tag. +pub(crate) use crate::decoder::spl_token_2022_entry_for_tag; +/// Returns the first byte of one retained Token-2022 payload. +pub(crate) use crate::decoder::spl_token_2022_payload_tag; +/// Decodes one bounded classic SPL Token instruction. +pub(crate) use crate::decoder::spl_token_decode; +/// Returns the published entry matching one classic SPL Token tag. +pub(crate) use crate::decoder::spl_token_entry_for_tag; +/// Returns the first byte of one retained classic SPL Token payload. +pub(crate) use crate::decoder::spl_token_payload_tag; +/// Exact byte length of one SPL ElGamal registry account. +pub use crate::decoder::ELGAMAL_REGISTRY_ACCOUNT_LEN; +/// Parsed SPL ElGamal public-key registry account. +pub use crate::decoder::ElGamalRegistryState; /// Exact decoder for the SPL Associated Token Account program. pub use crate::decoder::SplAssociatedTokenAccountDecoder; +/// Exact decoder for the SPL ElGamal registry program. +pub use crate::decoder::SplElgamalRegistryDecoder; /// Exact decoder for the three registered SPL Memo generations. pub use crate::decoder::SplMemoDecoder; +/// Exact decoder for the Token-2022 program. +pub use crate::decoder::SplToken2022Decoder; /// Exact decoder for the classic SPL Token program. pub use crate::decoder::SplTokenDecoder; +/// Parsed Token-2022 Mint, Account, or Multisig state. +pub use crate::decoder::Token2022State; +/// Exact Token-2022 base account state kind. +pub use crate::decoder::Token2022StateKind; +/// One exact Token-2022 TLV entry. +pub use crate::decoder::Token2022TlvEntry; /// Current contextual core instruction input contract version. pub use crate::decoder::api::contracts::CORE_INSTRUCTION_INPUT_CONTRACT_VERSION; /// Stable contextual decoded observation. @@ -294,6 +347,10 @@ pub use crate::decoder::api::decoder::DecoderSupport; pub use crate::decoder::api::decoder::InitialDecoder; /// Exposes the common protocol decoder trait. pub use crate::decoder::api::decoder::ProtocolDecoder; +/// Parses one exact SPL ElGamal registry account. +pub use crate::decoder::parse_elgamal_registry_state; +/// Parses one Token-2022 Mint, Account, or Multisig state. +pub use crate::decoder::parse_token_2022_state; /// Runtime-native Solana decoder with maximal native instruction coverage. pub use crate::decoder::solana::SolanaCoreDecoder; /// Exposes the blockhash policy kind. diff --git a/kb-program-ids/README.md b/kb-program-ids/README.md index c130ec8..70dd655 100644 --- a/kb-program-ids/README.md +++ b/kb-program-ids/README.md @@ -1,11 +1,15 @@ - + # kb-program-ids `kb-program-ids` est la source unique des identifiants de programmes Solana et des comptes natifs connus utilisés par le workspace. -La tranche `0.1.0-pre.004` expose les 18 surfaces exécutables nécessaires au décodeur Solana Core, les comptes natifs associés utilisés par leurs contrats et l’identifiant SPL Token classique employé par les tests de frontière. La tranche `0.1.0-pre.005` ajoute les trois identifiants SPL Memo v1, v3 et v4 sans les classer parmi les surfaces natives. `0.1.0-pre.006` active l’identifiant SPL Token classique dans un décodeur concret tout en le maintenant hors des surfaces natives. `0.1.0-pre.007` ajoute les identifiants SPL Associated Token Account et Token‑2022 nécessaires à la frontière ATA, toujours sans les classer parmi les surfaces natives. +La tranche `0.1.0-pre.004` expose les 18 surfaces exécutables nécessaires au décodeur Solana Core, les comptes natifs associés utilisés par leurs contrats et l’identifiant SPL Token classique employé par les tests de frontière. La tranche `0.1.0-pre.005` ajoute les trois identifiants SPL Memo v1, v3 et v4 sans les classer parmi les surfaces natives. `0.1.0-pre.006` active l’identifiant SPL Token classique dans un décodeur concret tout en le maintenant hors des surfaces natives. `0.1.0-pre.007` ajoute les identifiants SPL Associated Token Account et Token‑2022 nécessaires à la frontière ATA. `0.1.0-pre.008` ajoute le Program ID indépendant du registre ElGamal de Token‑2022, toujours hors des surfaces natives. + +`constants.rs` reste l’unique source des adresses et des tableaux statiques. `programs.rs` porte +`ProgramIdEntry`, ses méthodes et toutes les fonctions de consultation. `lib.rs` se limite aux +déclarations de modules et aux réexports documentés de la façade. ## API publique diff --git a/kb-program-ids/src/constants.rs b/kb-program-ids/src/constants.rs index 45342f4..c97753c 100644 --- a/kb-program-ids/src/constants.rs +++ b/kb-program-ids/src/constants.rs @@ -1,5 +1,5 @@ // file: kb-program-ids/src/constants.rs -// version: 3 +// version: 4 //! Canonical Solana program and well-known account identifiers. @@ -43,6 +43,9 @@ pub const SPL_MEMO_V4_PROGRAM_ID: &str = "Memo4c2pN8afCj432Lb7RMVKi9PbQnnW7ewFFa pub const SPL_TOKEN_PROGRAM_ID: &str = "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA"; /// SPL Token-2022 program identifier. pub const SPL_TOKEN_2022_PROGRAM_ID: &str = "TokenzQdBNbLqP5VEhdkAS6EPFLC1PHnBqCXEpPxuEb"; +/// SPL Token-2022 ElGamal public-key registry program identifier. +pub const SPL_TOKEN_2022_ELGAMAL_REGISTRY_PROGRAM_ID: &str = + "regVYJW7tcT8zipN5YiBvHsvR5jXW1uLFxaHSbugABg"; /// Historical Stake configuration account identifier. pub const STAKE_CONFIG_ACCOUNT_ID: &str = "StakeConfig11111111111111111111111111111111"; /// Compatibility alias for the historical Stake configuration account identifier. @@ -235,6 +238,10 @@ pub(crate) const PROGRAM_ID_ENTRIES: &[crate::ProgramIdEntry] = &[ name: "spl_token_2022", address: crate::SPL_TOKEN_2022_PROGRAM_ID, }, + crate::ProgramIdEntry { + name: "spl_token_2022_elgamal_registry", + address: crate::SPL_TOKEN_2022_ELGAMAL_REGISTRY_PROGRAM_ID, + }, crate::ProgramIdEntry { name: "stake_config", address: crate::STAKE_CONFIG_ACCOUNT_ID, diff --git a/kb-program-ids/src/lib.rs b/kb-program-ids/src/lib.rs index dc52b6e..4a998a7 100644 --- a/kb-program-ids/src/lib.rs +++ b/kb-program-ids/src/lib.rs @@ -1,5 +1,5 @@ // file: kb-program-ids/src/lib.rs -// version: 5 +// version: 7 #![forbid(unsafe_code)] #![deny(unreachable_pub)] @@ -8,6 +8,14 @@ //! Shared canonical Solana program identifiers. mod constants; +mod programs; + +/// Internal list of executable native programs. +pub(crate) use constants::NATIVE_PROGRAM_ID_ENTRIES; +/// Internal list of native well-known accounts. +pub(crate) use constants::NATIVE_WELL_KNOWN_ACCOUNT_ID_ENTRIES; +/// Internal list of currently registered identifiers. +pub(crate) use constants::PROGRAM_ID_ENTRIES; /// Address Lookup Table program identifier. pub use constants::ADDRESS_LOOKUP_TABLE_PROGRAM_ID; @@ -33,12 +41,6 @@ pub use constants::INCINERATOR_PROGRAM_ID; pub use constants::LOADER_V4_PROGRAM_ID; /// Native Loader program identifier. pub use constants::NATIVE_LOADER_PROGRAM_ID; -/// Internal list of executable native programs. -pub(crate) use constants::NATIVE_PROGRAM_ID_ENTRIES; -/// Internal list of native well-known accounts. -pub(crate) use constants::NATIVE_WELL_KNOWN_ACCOUNT_ID_ENTRIES; -/// Internal list of currently registered identifiers. -pub(crate) use constants::PROGRAM_ID_ENTRIES; /// Secp256k1 precompile program identifier. pub use constants::SECP256K1_PROGRAM_ID; /// Secp256r1 precompile program identifier. @@ -51,6 +53,8 @@ pub use constants::SPL_MEMO_V1_PROGRAM_ID; pub use constants::SPL_MEMO_V3_PROGRAM_ID; /// Current SPL Memo v4 program identifier. pub use constants::SPL_MEMO_V4_PROGRAM_ID; +/// SPL Token-2022 ElGamal public-key registry program identifier. +pub use constants::SPL_TOKEN_2022_ELGAMAL_REGISTRY_PROGRAM_ID; /// SPL Token-2022 program identifier. pub use constants::SPL_TOKEN_2022_PROGRAM_ID; /// Classic SPL Token program identifier. @@ -79,142 +83,15 @@ pub use constants::VOTE_PROGRAM_ID; pub use constants::ZK_ELGAMAL_PROOF_PROGRAM_ID; /// ZK Token Proof program identifier. pub use constants::ZK_TOKEN_PROOF_PROGRAM_ID; - /// One enumerable program identifier entry. -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -pub struct ProgramIdEntry { - /// Stable lower snake case registry code. - pub name: &'static str, - /// Base58 Solana program identifier. - pub address: &'static str, -} - -impl ProgramIdEntry { - /// Returns the stable lower snake case registry code. - pub const fn code(&self) -> &'static str { - return self.name; - } - - /// Returns the Base58 Solana program identifier. - pub const fn program_id(&self) -> &'static str { - return self.address; - } -} - +pub use programs::ProgramIdEntry; /// Returns every program identifier currently registered by this crate. -pub fn entries() -> &'static [crate::ProgramIdEntry] { - return crate::PROGRAM_ID_ENTRIES; -} - -/// Returns every program identifier currently registered by this crate. -pub fn registered_program_ids() -> &'static [crate::ProgramIdEntry] { - return crate::PROGRAM_ID_ENTRIES; -} - -/// Returns every executable runtime-native, loader, precompile and historical native program identifier. -pub fn native_program_ids() -> &'static [crate::ProgramIdEntry] { - return crate::NATIVE_PROGRAM_ID_ENTRIES; -} - -/// Returns native well-known accounts that are not executable programs. -pub fn native_well_known_account_ids() -> &'static [crate::ProgramIdEntry] { - return crate::NATIVE_WELL_KNOWN_ACCOUNT_ID_ENTRIES; -} - +pub use programs::entries; /// Finds one registered program or well-known account by exact Base58 identifier. -pub fn find_registered_program_id( - program_id: &str, -) -> std::option::Option<&'static crate::ProgramIdEntry> { - return crate::PROGRAM_ID_ENTRIES - .iter() - .find(|entry| return entry.address == program_id); -} - -#[cfg(test)] -mod tests { - #[test] - fn registry_entries_are_unique_and_non_empty() { - let mut codes = std::collections::BTreeSet::new(); - let mut program_ids = std::collections::BTreeSet::new(); - for entry in crate::registered_program_ids() { - assert!(!entry.code().is_empty()); - assert!(!entry.program_id().is_empty()); - assert!(codes.insert(entry.code())); - assert!(program_ids.insert(entry.program_id())); - } - assert_eq!(crate::registered_program_ids().len(), 31); - } - - #[test] - fn native_registry_contains_exactly_every_solana_core_surface() { - let codes = crate::native_program_ids() - .iter() - .map(crate::ProgramIdEntry::code) - .collect::>(); - assert!(codes.contains("vote")); - assert!(codes.contains("slashing")); - assert!(codes.contains("bpf_loader_deprecated")); - assert!(codes.contains("bpf_loader")); - assert!(codes.contains("bpf_loader_upgradeable")); - assert!(codes.contains("loader_v4")); - assert_eq!(codes.len(), 18); - assert_eq!(codes.len(), crate::native_program_ids().len()); - } - - #[test] - fn stake_config_is_a_well_known_account_not_an_executable_program() { - assert!(crate::native_well_known_account_ids().iter().any(|entry| { - return entry.program_id() == crate::STAKE_CONFIG_ACCOUNT_ID; - })); - assert!(!crate::native_program_ids().iter().any(|entry| { - return entry.program_id() == crate::STAKE_CONFIG_ACCOUNT_ID; - })); - } - - #[test] - fn registry_lookup_finds_system_program() { - let entry = crate::find_registered_program_id(crate::SYSTEM_PROGRAM_ID); - assert_eq!(entry.map(crate::ProgramIdEntry::code), std::option::Option::Some("system")); - } - - #[test] - fn compatibility_api_and_core_constants_are_complete() { - assert_eq!(crate::entries(), crate::registered_program_ids()); - assert_eq!(crate::STAKE_CONFIG_PROGRAM_ID, crate::STAKE_CONFIG_ACCOUNT_ID); - let expected = [ - crate::ADDRESS_LOOKUP_TABLE_PROGRAM_ID, - crate::COMPUTE_BUDGET_PROGRAM_ID, - crate::CONFIG_PROGRAM_ID, - crate::FEATURE_PROGRAM_ID, - crate::SYSTEM_PROGRAM_ID, - crate::VOTE_PROGRAM_ID, - crate::STAKE_PROGRAM_ID, - crate::SLASHING_PROGRAM_ID, - crate::ZK_ELGAMAL_PROOF_PROGRAM_ID, - crate::ZK_TOKEN_PROOF_PROGRAM_ID, - ]; - for program_id in expected { - assert!(crate::find_registered_program_id(program_id).is_some()); - } - } - - #[test] - fn spl_primitives_are_registered_without_joining_native_surfaces() { - let spl_program_ids = [ - crate::SPL_MEMO_V1_PROGRAM_ID, - crate::SPL_MEMO_V3_PROGRAM_ID, - crate::SPL_MEMO_V4_PROGRAM_ID, - crate::SPL_TOKEN_PROGRAM_ID, - crate::SPL_TOKEN_2022_PROGRAM_ID, - crate::ASSOCIATED_TOKEN_PROGRAM_ID, - ]; - for program_id in spl_program_ids { - assert!(crate::find_registered_program_id(program_id).is_some()); - assert!( - !crate::native_program_ids() - .iter() - .any(|entry| return entry.program_id() == program_id) - ); - } - } -} +pub use programs::find_registered_program_id; +/// Returns every executable runtime-native, loader, precompile and historical native program identifier. +pub use programs::native_program_ids; +/// Returns native well-known accounts that are not executable programs. +pub use programs::native_well_known_account_ids; +/// Returns every program identifier currently registered by this crate. +pub use programs::registered_program_ids; diff --git a/kb-program-ids/src/programs.rs b/kb-program-ids/src/programs.rs new file mode 100644 index 0000000..883b272 --- /dev/null +++ b/kb-program-ids/src/programs.rs @@ -0,0 +1,144 @@ +// file: kb-program-ids/src/programs.rs +// version: 1 + +//! Enumerable canonical program registry. + +/// One enumerable program identifier entry. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct ProgramIdEntry { + /// Stable lower snake case registry code. + pub name: &'static str, + /// Base58 Solana program identifier. + pub address: &'static str, +} + +impl ProgramIdEntry { + /// Returns the stable lower snake case registry code. + pub const fn code(&self) -> &'static str { + return self.name; + } + + /// Returns the Base58 Solana program identifier. + pub const fn program_id(&self) -> &'static str { + return self.address; + } +} + +/// Returns every program identifier currently registered by this crate. +pub fn entries() -> &'static [crate::ProgramIdEntry] { + return crate::PROGRAM_ID_ENTRIES; +} + +/// Returns every program identifier currently registered by this crate. +pub fn registered_program_ids() -> &'static [crate::ProgramIdEntry] { + return crate::PROGRAM_ID_ENTRIES; +} + +/// Returns every executable runtime-native, loader, precompile and historical native program identifier. +pub fn native_program_ids() -> &'static [crate::ProgramIdEntry] { + return crate::NATIVE_PROGRAM_ID_ENTRIES; +} + +/// Returns native well-known accounts that are not executable programs. +pub fn native_well_known_account_ids() -> &'static [crate::ProgramIdEntry] { + return crate::NATIVE_WELL_KNOWN_ACCOUNT_ID_ENTRIES; +} + +/// Finds one registered program or well-known account by exact Base58 identifier. +pub fn find_registered_program_id( + program_id: &str, +) -> std::option::Option<&'static crate::ProgramIdEntry> { + return crate::PROGRAM_ID_ENTRIES + .iter() + .find(|entry| return entry.address == program_id); +} + +#[cfg(test)] +mod tests { + #[test] + fn registry_entries_are_unique_and_non_empty() { + let mut codes = std::collections::BTreeSet::new(); + let mut program_ids = std::collections::BTreeSet::new(); + for entry in crate::registered_program_ids() { + assert!(!entry.code().is_empty()); + assert!(!entry.program_id().is_empty()); + assert!(codes.insert(entry.code())); + assert!(program_ids.insert(entry.program_id())); + } + assert_eq!(crate::registered_program_ids().len(), 32); + } + + #[test] + fn native_registry_contains_exactly_every_solana_core_surface() { + let codes = crate::native_program_ids() + .iter() + .map(crate::ProgramIdEntry::code) + .collect::>(); + assert!(codes.contains("vote")); + assert!(codes.contains("slashing")); + assert!(codes.contains("bpf_loader_deprecated")); + assert!(codes.contains("bpf_loader")); + assert!(codes.contains("bpf_loader_upgradeable")); + assert!(codes.contains("loader_v4")); + assert_eq!(codes.len(), 18); + assert_eq!(codes.len(), crate::native_program_ids().len()); + } + + #[test] + fn stake_config_is_a_well_known_account_not_an_executable_program() { + assert!(crate::native_well_known_account_ids().iter().any(|entry| { + return entry.program_id() == crate::STAKE_CONFIG_ACCOUNT_ID; + })); + assert!(!crate::native_program_ids().iter().any(|entry| { + return entry.program_id() == crate::STAKE_CONFIG_ACCOUNT_ID; + })); + } + + #[test] + fn registry_lookup_finds_system_program() { + let entry = crate::find_registered_program_id(crate::SYSTEM_PROGRAM_ID); + assert_eq!(entry.map(crate::ProgramIdEntry::code), std::option::Option::Some("system")); + } + + #[test] + fn compatibility_api_and_core_constants_are_complete() { + assert_eq!(crate::entries(), crate::registered_program_ids()); + assert_eq!(crate::STAKE_CONFIG_PROGRAM_ID, crate::STAKE_CONFIG_ACCOUNT_ID); + let expected = [ + crate::ADDRESS_LOOKUP_TABLE_PROGRAM_ID, + crate::COMPUTE_BUDGET_PROGRAM_ID, + crate::CONFIG_PROGRAM_ID, + crate::FEATURE_PROGRAM_ID, + crate::SYSTEM_PROGRAM_ID, + crate::VOTE_PROGRAM_ID, + crate::STAKE_PROGRAM_ID, + crate::SLASHING_PROGRAM_ID, + crate::ZK_ELGAMAL_PROOF_PROGRAM_ID, + crate::ZK_TOKEN_PROOF_PROGRAM_ID, + ]; + for program_id in expected { + assert!(crate::find_registered_program_id(program_id).is_some()); + } + } + + #[test] + fn spl_primitives_are_registered_without_joining_native_surfaces() { + let spl_program_ids = [ + crate::SPL_MEMO_V1_PROGRAM_ID, + crate::SPL_MEMO_V3_PROGRAM_ID, + crate::SPL_MEMO_V4_PROGRAM_ID, + crate::SPL_TOKEN_PROGRAM_ID, + crate::SPL_TOKEN_2022_PROGRAM_ID, + crate::SPL_TOKEN_2022_ELGAMAL_REGISTRY_PROGRAM_ID, + crate::ASSOCIATED_TOKEN_PROGRAM_ID, + ]; + for program_id in spl_program_ids { + assert!(crate::find_registered_program_id(program_id).is_some()); + assert!( + !crate::native_program_ids() + .iter() + .any(|entry| return entry.program_id() == program_id) + ); + } + } +}