From 1ee1d0297d561e05e22a5d67f599f182a17e3d56 Mon Sep 17 00:00:00 2001 From: SinuS Von SifriduS Date: Tue, 11 Aug 2026 00:30:03 +0200 Subject: [PATCH] v0.5.2-pre.006-fix-004 --- kb-app-demo-desktop/CHANGELOG.md | 11 +- kb-app-demo-desktop/README.md | 4 +- kb-app-demo-desktop/TODO.md | 3 +- kb-app-demo-desktop/USAGE.md | 19 +- kb-app-demo-desktop/frontend/demo_wallet.html | 62 ++++- .../frontend/ts/demo_wallet.ts | 174 +++++++++++- kb-app-demo-desktop/src/demo_wallet.rs | 248 +++++++++++++++++- kb-app-demo-desktop/src/lib.rs | 18 +- kb-app-demo-desktop/src/tauri.rs | 28 +- ks-pipeline-demo-scenarios/TODO.md | 3 +- ks-wallet/CHANGELOG.md | 9 +- ks-wallet/README.md | 4 +- ks-wallet/TODO.md | 5 +- ks-wallet/USAGE.md | 23 +- ks-wallet/src/lib.rs | 6 +- ks-wallet/src/transfer.rs | 81 +++++- ks-wallet/tests/transfer.rs | 41 ++- 17 files changed, 698 insertions(+), 41 deletions(-) diff --git a/kb-app-demo-desktop/CHANGELOG.md b/kb-app-demo-desktop/CHANGELOG.md index 27ae291..bda502d 100644 --- a/kb-app-demo-desktop/CHANGELOG.md +++ b/kb-app-demo-desktop/CHANGELOG.md @@ -1,8 +1,17 @@ - + # CHANGELOG — kb-app-demo-desktop +## `0.5.2-pre.006-delta-fix-004` + +- complète la fenêtre `Wallets` avec inspection publique, import et export des deux formats `ks-wallet` réellement supportés : Solana CLI JSON 64 octets et keypair Base58 64 octets ; +- garde le password hors Tauri IPC : création, import et export utilisent uniquement `KB_SECRET_DEMO_WALLET_PASSWORD` côté backend ; +- permet d'inspecter un ancien fichier keypair sans le convertir, d'en extraire uniquement la pubkey puis de l'envoyer directement vers l'explorateur Devnet/Mainnet ; +- peuple les sélecteurs de formats depuis l'inventaire exposé par `ks-wallet` afin d'éviter une liste frontend indépendante du backend ; +- aligne visuellement `Profil RPC`, `Adresse publique` et `Transactions`, puis affiche le profil/cluster sélectionné sous la ligne de contrôles ; +- prépare la validation suivante où les démos d'exécution Devnet devront consommer une capacité `.kswallet` explicite au lieu de recréer directement leur wallet JSON temporaire. + ## `0.5.2-pre.006-delta-fix-003` - ajoute dans `Wallets` un sélecteur explicite de profil RPC pour l’explorateur on-chain ; le profil actif reste la valeur initiale mais Devnet/Mainnet peuvent être interrogés sans redémarrer l’application ni modifier `active_profile` ; diff --git a/kb-app-demo-desktop/README.md b/kb-app-demo-desktop/README.md index 3fc5f7e..1906faa 100644 --- a/kb-app-demo-desktop/README.md +++ b/kb-app-demo-desktop/README.md @@ -1,5 +1,5 @@ - + # kb-app-demo-desktop @@ -8,7 +8,7 @@ ## Fonctionnalités - configuration et diagnostics de logging ; -- fenêtre Wallets dédiée : inventaire sûr des identités `.kswallet`, création d’un conteneur persistant via un secret backend-only, inspection explicite d’un fichier externe et exploration publique du solde SOL, des comptes SPL Token / Token-2022 et des transactions récentes sur un profil RPC explicitement sélectionnable ; +- fenêtre Wallets dédiée : inventaire sûr des identités `.kswallet`, création d’un conteneur persistant via un secret backend-only, inspection/import/export des formats keypair supportés, extraction sûre de pubkey depuis un keypair legacy, inspection explicite d’un `.kswallet` externe et exploration publique du solde SOL, des comptes SPL Token / Token-2022 et des transactions récentes sur un profil RPC explicitement sélectionnable ; - transport HTTP JSON-RPC et WebSocket ; - diagnostics PostgreSQL ; - backfill, extraction Core et replay, avec enregistrement générique de Solana Program Metadata ; diff --git a/kb-app-demo-desktop/TODO.md b/kb-app-demo-desktop/TODO.md index 5abd277..2e31bfe 100644 --- a/kb-app-demo-desktop/TODO.md +++ b/kb-app-demo-desktop/TODO.md @@ -1,10 +1,11 @@ - + # TODO — kb-app-demo-desktop ## Évolutions générales +- [ ] `0.5.2` — valider les démos Devnet Solana Core/SPL/Metadata avec un `.kswallet` sélectionné et déverrouillé côté backend, sans transmettre le password au frontend. - [ ] `0.5.4` — réduire les panneaux d'exécution à l'adaptation UI/Tauri en déplaçant toute orchestration de campagne encore réutilisable vers `ks-pipeline-demo-scenarios`. - [ ] UX — empêcher les doubles déclenchements pendant une préparation, simulation ou soumission active. - [ ] Observabilité — afficher explicitement les comptes relus et les projections matérialisées. diff --git a/kb-app-demo-desktop/USAGE.md b/kb-app-demo-desktop/USAGE.md index 3bd55ae..98f0baa 100644 --- a/kb-app-demo-desktop/USAGE.md +++ b/kb-app-demo-desktop/USAGE.md @@ -1,5 +1,5 @@ - + # Utilisation de kb-app-demo-desktop @@ -11,10 +11,12 @@ L’application expose une interface opérateur pour diagnostiquer, simuler et v Le backend desktop peut lister les `.kswallet` du répertoire configuré, créer un wallet natif protégé, inspecter un fichier sélectionné explicitement ailleurs et explorer l’état on-chain d’une adresse publique. Les DTO frontend ne contiennent jamais de matériau secret. Aucun password, chemin interne du store, sel, nonce, ciphertext ou byte privé n’est renvoyé par Tauri. -La fenêtre `Wallets`, accessible depuis le menu `Démos` de `main`, fournit quatre surfaces : +La fenêtre `Wallets`, accessible depuis le menu `Démos` de `main`, fournit maintenant six surfaces : - inventaire des wallets natifs du profil actif ; - création d’un nouveau `.kswallet` dans le store du profil actif ; +- inspection et import d'un keypair externe dans un format supporté ; +- export authentifié d'un `.kswallet` vers un format supporté ; - explorateur public on-chain ; - inspection structurale d’un `.kswallet` explicite situé hors du store. @@ -31,6 +33,19 @@ Le secret peut aussi être placé dans le fichier `.env` local chargé par `ks-c La création demande uniquement l’alias. Elle crée `.kswallet` dans le répertoire wallet résolu du profil actif et ne modifie pas automatiquement `wallet_alias` dans la configuration. Un changement de valeur de `KB_SECRET_DEMO_WALLET_PASSWORD` avant une autre session permet d’utiliser un autre mot de passe pour de nouveaux wallets ; ce secret de démonstration n’est pas un mot de passe global du format `.kswallet`. +### Inspecter, importer et exporter les formats keypair + +Les sélecteurs de format sont alimentés par `ks-wallet` et exposent actuellement : + +- `Solana CLI keypair JSON (64 octets)` ; +- `Solana private key Base58 (64 octets)`. + +L'action **Inspecter la keypair** ne crée aucun `.kswallet` : le backend valide le fichier avec le codec choisi et ne renvoie que la pubkey. Cette pubkey peut être envoyée directement à l'explorateur on-chain, ce qui permet par exemple de contrôler un ancien `wallets/temporary/local_devnet/*.json` avant de décider de le migrer. + +L'action **Importer en .kswallet** demande en plus un alias destination et utilise `KB_SECRET_DEMO_WALLET_PASSWORD` uniquement côté backend. La source reste inchangée. L'export fonctionne dans le sens inverse : choix d'un alias natif, du format et d'un chemin destination explicite ; le backend authentifie le `.kswallet` avec le même secret de démonstration et refuse d'écraser un fichier existant. + +Un keypair JSON de 64 octets ne contient pas son rôle métier. L'inspection peut dire « keypair Solana valide » et donner sa pubkey, mais elle ne peut pas décider localement si cette clé a servi de wallet opérateur, mint, authority ou autre fixture. Cette classification nécessite le contexte du scénario ou une lecture on-chain séparée. + ### Explorer une adresse sur un profil RPC sélectionné L’explorateur on-chain ne déverrouille aucun wallet : seule la pubkey publique est nécessaire. Le bouton `Explorer` d’un wallet inventorié remplit l’adresse automatiquement ; toute autre pubkey Solana peut également être saisie. diff --git a/kb-app-demo-desktop/frontend/demo_wallet.html b/kb-app-demo-desktop/frontend/demo_wallet.html index 4f6d6eb..3080782 100644 --- a/kb-app-demo-desktop/frontend/demo_wallet.html +++ b/kb-app-demo-desktop/frontend/demo_wallet.html @@ -1,5 +1,5 @@ - + @@ -90,6 +90,62 @@ +
+
+
+

Importer ou inspecter une keypair externe

+

Le fichier source reste inchangé. L'inspection valide la keypair et ne retourne que sa pubkey ; l'import crée ensuite un nouveau .kswallet dans le store du profil actif.

+ + +
+
+ + +
+
+ + +
+
+
+ + + +
+
+ Pubkey détectée : + +
+
+ Statut : + En attente +
+
+
+
+ +
+
+
+

Exporter un wallet natif

+

L'export exige le même secret backend que la création et refuse d'écraser un fichier existant.

+ + + + + + +
+ +
+
+ Statut : + En attente +
+
+
+
+
@@ -104,7 +160,6 @@
-
Cluster :
@@ -115,6 +170,7 @@
+
Profil sélectionné : · Cluster :
@@ -242,4 +298,4 @@ - \ No newline at end of file + diff --git a/kb-app-demo-desktop/frontend/ts/demo_wallet.ts b/kb-app-demo-desktop/frontend/ts/demo_wallet.ts index 34002f4..5d38635 100644 --- a/kb-app-demo-desktop/frontend/ts/demo_wallet.ts +++ b/kb-app-demo-desktop/frontend/ts/demo_wallet.ts @@ -1,5 +1,5 @@ // file: kb-app-demo-desktop/frontend/ts/demo_wallet.ts -// version: 3 +// version: 4 import * as bootstrap from "bootstrap"; import "simplebar"; @@ -9,11 +9,16 @@ import { frontendDebug, frontendError, installFrontendConsoleBridge } from "./fr import { renderJsonViewer } from "./json_viewer.ts"; import type { DemoWalletBalancePayload } from "./bindings/kb_app_demo_desktop/demo_wallet/DemoWalletBalancePayload.ts"; import type { DemoWalletCreateRequest } from "./bindings/kb_app_demo_desktop/demo_wallet/DemoWalletCreateRequest.ts"; +import type { DemoWalletExportRequest } from "./bindings/kb_app_demo_desktop/demo_wallet/DemoWalletExportRequest.ts"; import type { DemoWalletIdentityPayload } from "./bindings/kb_app_demo_desktop/demo_wallet/DemoWalletIdentityPayload.ts"; +import type { DemoWalletImportRequest } from "./bindings/kb_app_demo_desktop/demo_wallet/DemoWalletImportRequest.ts"; import type { DemoWalletInventoryPayload } from "./bindings/kb_app_demo_desktop/demo_wallet/DemoWalletInventoryPayload.ts"; import type { DemoWalletOnchainProfilePayload } from "./bindings/kb_app_demo_desktop/demo_wallet/DemoWalletOnchainProfilePayload.ts"; import type { DemoWalletRpcExecutionPayload } from "./bindings/kb_app_demo_desktop/demo_wallet/DemoWalletRpcExecutionPayload.ts"; import type { DemoWalletRpcRequest } from "./bindings/kb_app_demo_desktop/demo_wallet/DemoWalletRpcRequest.ts"; +import type { DemoWalletTransferFormatPayload } from "./bindings/kb_app_demo_desktop/demo_wallet/DemoWalletTransferFormatPayload.ts"; +import type { DemoWalletTransferInspectionPayload } from "./bindings/kb_app_demo_desktop/demo_wallet/DemoWalletTransferInspectionPayload.ts"; +import type { DemoWalletTransferInspectionRequest } from "./bindings/kb_app_demo_desktop/demo_wallet/DemoWalletTransferInspectionRequest.ts"; (window as Window & typeof globalThis & { bootstrap?: typeof bootstrap }).bootstrap = bootstrap; (window as Window & typeof globalThis & { ResizeObserver?: typeof ResizeObserver }).ResizeObserver = ResizeObserver; @@ -37,6 +42,7 @@ interface ParsedSignatureInfo { } let walletInventory: DemoWalletInventoryPayload | null = null; +let inspectedTransferPublicKey = ""; function setText(selector: string, value: string): void { const element = document.querySelector(selector); @@ -63,6 +69,7 @@ function selectedOnchainProfilePayload(): DemoWalletOnchainProfilePayload | null function updateOnchainClusterLabel(): void { const selected = selectedOnchainProfilePayload(); + setText("#walletOnchainSelectedProfile", selected?.name ?? "—"); setText("#walletOnchainCluster", selected?.cluster ?? "—"); } @@ -102,6 +109,65 @@ function parseJsonResponse(response: DemoWalletRpcExecutionPayload): unknown { return JSON.parse(response.response_json) as unknown; } +function renderTransferFormats(formats: ReadonlyArray): void { + for (const selector of ["#walletImportFormatSelect", "#walletExportFormatSelect"]) { + const select = document.querySelector(selector); + if (!select) { + continue; + } + const previous = select.value; + select.replaceChildren(); + for (const format of formats) { + const option = document.createElement("option"); + option.value = format.code; + option.textContent = `${format.label} (.${format.default_extension})`; + option.dataset.defaultExtension = format.default_extension; + select.appendChild(option); + } + if (formats.some(format => format.code === previous)) { + select.value = previous; + } + } +} + +function renderExportWalletAliases(wallets: ReadonlyArray): void { + const select = document.querySelector("#walletExportAliasSelect"); + if (!select) { + return; + } + const previous = select.value; + select.replaceChildren(); + if (wallets.length === 0) { + const option = document.createElement("option"); + option.value = ""; + option.textContent = "Aucun wallet natif"; + select.appendChild(option); + return; + } + for (const wallet of wallets) { + const option = document.createElement("option"); + option.value = wallet.alias; + option.textContent = `${wallet.alias} — ${wallet.public_key}`; + select.appendChild(option); + } + if (wallets.some(wallet => wallet.alias === previous)) { + select.value = previous; + } +} + +function selectedTransferFormat(selector: string): string { + return document.querySelector(selector)?.value.trim() ?? ""; +} + +function setTransferPublicKey(publicKey: string): void { + inspectedTransferPublicKey = publicKey; + setText("#walletTransferPublicKey", publicKey.length > 0 ? publicKey : "—"); + const exploreButton = document.querySelector("#exploreWalletTransferButton"); + if (exploreButton) { + exploreButton.disabled = publicKey.length === 0; + } +} + function renderWalletRows(wallets: ReadonlyArray): void { const body = document.querySelector("#walletInventoryTableBody"); if (!body) { @@ -158,6 +224,8 @@ async function refreshWalletInventory(): Promise { setText("#walletProfile", payload.profile); setText("#walletCluster", payload.cluster); renderOnchainProfiles(payload.onchain_profiles, payload.profile); + renderTransferFormats(payload.transfer_formats); + renderExportWalletAliases(payload.wallets); setText("#walletCreateSecretStatus", payload.create_password_configured ? "configuré" : "absent"); setText("#walletSelectedAlias", payload.selected_alias ?? "—"); setText("#walletInventoryCount", String(payload.wallets.length)); @@ -169,6 +237,7 @@ async function refreshWalletInventory(): Promise { setText("#walletInventoryStatus", `Erreur : ${message}`); setText("#walletInventoryCount", "0"); renderWalletRows([]); + renderExportWalletAliases([]); frontendError("kb-app-demo-desktop.frontend.demo_wallet", `Wallet inventory loading failed: ${message}`); } } @@ -201,6 +270,89 @@ async function createWallet(): Promise { } } +async function inspectTransferFile(): Promise { + const sourcePath = inputValue("#walletImportSourcePathInput"); + const format = selectedTransferFormat("#walletImportFormatSelect"); + if (sourcePath.length === 0 || format.length === 0) { + setText("#walletTransferStatus", "Chemin source et format requis"); + setTransferPublicKey(""); + return; + } + const request: DemoWalletTransferInspectionRequest = { source_path: sourcePath, format }; + setText("#walletTransferStatus", "Inspection..."); + setTransferPublicKey(""); + try { + const payload = await invoke("demo_wallet_inspect_transfer_file", { request }); + setTransferPublicKey(payload.public_key); + setText("#walletTransferStatus", `Keypair valide — ${payload.format}`); + frontendDebug("kb-app-demo-desktop.frontend.demo_wallet", "external transfer keypair inspected"); + } catch (caughtError) { + const message = caughtError instanceof Error ? caughtError.message : String(caughtError); + setText("#walletTransferStatus", `Erreur : ${message}`); + setTransferPublicKey(""); + frontendError("kb-app-demo-desktop.frontend.demo_wallet", `External transfer inspection failed: ${message}`); + } +} + +async function importTransferFile(): Promise { + const sourcePath = inputValue("#walletImportSourcePathInput"); + const format = selectedTransferFormat("#walletImportFormatSelect"); + const alias = inputValue("#walletImportAliasInput"); + if (sourcePath.length === 0 || format.length === 0 || alias.length === 0) { + setText("#walletTransferStatus", "Chemin source, format et alias requis"); + return; + } + const request: DemoWalletImportRequest = { alias, source_path: sourcePath, format }; + setText("#walletTransferStatus", "Import..."); + try { + const payload = await invoke("demo_wallet_import_file", { request }); + setTransferPublicKey(payload.public_key); + setText("#walletTransferStatus", `Importé : ${payload.alias}.kswallet`); + const addressInput = document.querySelector("#walletAddressInput"); + if (addressInput) { + addressInput.value = payload.public_key; + } + await refreshWalletInventory(); + frontendDebug("kb-app-demo-desktop.frontend.demo_wallet", `external keypair imported: ${payload.alias}`); + } catch (caughtError) { + const message = caughtError instanceof Error ? caughtError.message : String(caughtError); + setText("#walletTransferStatus", `Erreur : ${message}`); + frontendError("kb-app-demo-desktop.frontend.demo_wallet", `External keypair import failed: ${message}`); + } +} + +async function exportWallet(): Promise { + const alias = document.querySelector("#walletExportAliasSelect")?.value.trim() ?? ""; + const format = selectedTransferFormat("#walletExportFormatSelect"); + const destinationPath = inputValue("#walletExportDestinationPathInput"); + if (alias.length === 0 || format.length === 0 || destinationPath.length === 0) { + setText("#walletExportStatus", "Wallet, format et destination requis"); + return; + } + const request: DemoWalletExportRequest = { alias, destination_path: destinationPath, format }; + setText("#walletExportStatus", "Export..."); + try { + await invoke("demo_wallet_export_file", { request }); + setText("#walletExportStatus", `Exporté : ${alias}`); + frontendDebug("kb-app-demo-desktop.frontend.demo_wallet", `native wallet exported: ${alias}`); + } catch (caughtError) { + const message = caughtError instanceof Error ? caughtError.message : String(caughtError); + setText("#walletExportStatus", `Erreur : ${message}`); + frontendError("kb-app-demo-desktop.frontend.demo_wallet", `Native wallet export failed: ${message}`); + } +} + +function exploreInspectedTransfer(): void { + if (inspectedTransferPublicKey.length === 0) { + return; + } + const addressInput = document.querySelector("#walletAddressInput"); + if (addressInput) { + addressInput.value = inspectedTransferPublicKey; + } + document.querySelector("#walletAddressInput")?.scrollIntoView({ behavior: "smooth", block: "center" }); +} + function parseTokenAccounts(value: unknown, program: string): ParsedTokenAccount[] { if (typeof value !== "object" || value === null || !("value" in value)) { return []; @@ -504,6 +656,26 @@ document.addEventListener("DOMContentLoaded", () => { void createWallet(); } }); + document.querySelector("#inspectWalletTransferButton")?.addEventListener("click", () => { + void inspectTransferFile(); + }); + document.querySelector("#importWalletTransferButton")?.addEventListener("click", () => { + void importTransferFile(); + }); + document.querySelector("#exploreWalletTransferButton")?.addEventListener("click", () => { + exploreInspectedTransfer(); + }); + document.querySelector("#exportWalletTransferButton")?.addEventListener("click", () => { + void exportWallet(); + }); + document.querySelector("#walletImportSourcePathInput")?.addEventListener("input", () => { + setTransferPublicKey(""); + setText("#walletTransferStatus", "En attente"); + }); + document.querySelector("#walletImportFormatSelect")?.addEventListener("change", () => { + setTransferPublicKey(""); + setText("#walletTransferStatus", "En attente"); + }); document.querySelector("#walletOnchainProfileSelect")?.addEventListener("change", () => { updateOnchainClusterLabel(); }); diff --git a/kb-app-demo-desktop/src/demo_wallet.rs b/kb-app-demo-desktop/src/demo_wallet.rs index f4b08f0..533f775 100644 --- a/kb-app-demo-desktop/src/demo_wallet.rs +++ b/kb-app-demo-desktop/src/demo_wallet.rs @@ -1,5 +1,5 @@ // file: kb-app-demo-desktop/src/demo_wallet.rs -// version: 4 +// version: 5 //! UI-safe wallet management and public on-chain exploration. @@ -21,6 +21,8 @@ pub(crate) struct DemoWalletInventoryPayload { pub(crate) cluster: std::string::String, /// Profiles available for explicit read-only on-chain exploration. pub(crate) onchain_profiles: std::vec::Vec, + /// Secret transfer formats supported by the reusable wallet boundary. + pub(crate) transfer_formats: std::vec::Vec, /// Canonical classic SPL Token program identifier used by the explorer. pub(crate) token_program_id: std::string::String, /// Canonical Token-2022 program identifier used by the explorer. @@ -61,6 +63,77 @@ pub(crate) struct DemoWalletCreateRequest { pub(crate) alias: std::string::String, } +/// One transfer format selectable by the wallet demo. +#[derive(Clone, Debug, serde::Serialize, TS)] +#[ts( + export, + export_to = "../frontend/ts/bindings/kb_app_demo_desktop/demo_wallet/DemoWalletTransferFormatPayload.ts" +)] +pub(crate) struct DemoWalletTransferFormatPayload { + /// Stable machine-readable transfer format code. + pub(crate) code: std::string::String, + /// Human-readable transfer format label. + pub(crate) label: std::string::String, + /// Conventional extension suggested for explicit export paths. + pub(crate) default_extension: std::string::String, +} + +/// Request to inspect one external keypair transfer file without importing it. +#[derive(Clone, Debug, serde::Deserialize, TS)] +#[ts( + export, + export_to = "../frontend/ts/bindings/kb_app_demo_desktop/demo_wallet/DemoWalletTransferInspectionRequest.ts" +)] +pub(crate) struct DemoWalletTransferInspectionRequest { + /// Explicit path of the external transfer file. + pub(crate) source_path: std::string::String, + /// Stable transfer format code selected by the operator. + pub(crate) format: std::string::String, +} + +/// Safe public identity extracted from one external keypair transfer file. +#[derive(Clone, Debug, serde::Serialize, TS)] +#[ts( + export, + export_to = "../frontend/ts/bindings/kb_app_demo_desktop/demo_wallet/DemoWalletTransferInspectionPayload.ts" +)] +pub(crate) struct DemoWalletTransferInspectionPayload { + /// Public key derived from the validated secret keypair. + pub(crate) public_key: std::string::String, + /// Stable transfer format code used for validation. + pub(crate) format: std::string::String, +} + +/// Request to import one external keypair into the active native wallet store. +#[derive(Clone, Debug, serde::Deserialize, TS)] +#[ts( + export, + export_to = "../frontend/ts/bindings/kb_app_demo_desktop/demo_wallet/DemoWalletImportRequest.ts" +)] +pub(crate) struct DemoWalletImportRequest { + /// Alias assigned to the newly created native wallet. + pub(crate) alias: std::string::String, + /// Explicit path of the external source file. + pub(crate) source_path: std::string::String, + /// Stable transfer format code selected by the operator. + pub(crate) format: std::string::String, +} + +/// Request to export one native wallet to an explicit external file. +#[derive(Clone, Debug, serde::Deserialize, TS)] +#[ts( + export, + export_to = "../frontend/ts/bindings/kb_app_demo_desktop/demo_wallet/DemoWalletExportRequest.ts" +)] +pub(crate) struct DemoWalletExportRequest { + /// Alias of the native wallet to export. + pub(crate) alias: std::string::String, + /// Explicit destination path created without overwrite. + pub(crate) destination_path: std::string::String, + /// Stable transfer format code selected by the operator. + pub(crate) format: std::string::String, +} + /// Exact SOL balance returned for one public wallet address. #[derive(Clone, Debug, serde::Serialize, TS)] #[ts( @@ -157,6 +230,7 @@ pub(crate) async fn demo_wallet_inventory( profile: profile.name.clone(), cluster: profile.wallet.cluster.clone(), onchain_profiles: onchain_profile_payloads(state), + transfer_formats: wallet_transfer_format_payloads(), token_program_id: ks_program_ids::SPL_TOKEN_PROGRAM_ID.to_string(), token_2022_program_id: ks_program_ids::SPL_TOKEN_2022_PROGRAM_ID.to_string(), create_password_configured: std::env::var_os(DEMO_WALLET_PASSWORD_ENV).is_some(), @@ -174,17 +248,9 @@ pub(crate) async fn demo_wallet_create_native( std::result::Result::Ok(value) => value, std::result::Result::Err(error) => return std::result::Result::Err(error.to_string()), }; - let password_text = match std::env::var(DEMO_WALLET_PASSWORD_ENV) { + let password = match demo_wallet_password() { std::result::Result::Ok(value) => value, - std::result::Result::Err(_) => { - return std::result::Result::Err(format!( - "backend secret {DEMO_WALLET_PASSWORD_ENV} is not configured as UTF-8" - )); - }, - }; - let password = match ks_wallet::WalletPassword::new(password_text) { - std::result::Result::Ok(value) => value, - std::result::Result::Err(error) => return std::result::Result::Err(error.to_string()), + std::result::Result::Err(error) => return std::result::Result::Err(error), }; let profile = state.active_profile(); let manager = match wallet_manager_for_profile(profile) { @@ -207,6 +273,100 @@ pub(crate) async fn demo_wallet_create_native( return std::result::Result::Ok(payload); } +/// Inspects one external transfer file and returns only the derived public identity. +pub(crate) async fn demo_wallet_inspect_transfer_file( + request: crate::DemoWalletTransferInspectionRequest, +) -> std::result::Result { + let format = match wallet_transfer_format_from_code(request.format.as_str()) { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + let inspection = match ks_wallet::inspect_transfer_file( + std::path::PathBuf::from(request.source_path), + format, + ) + .await + { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error.to_string()), + }; + return std::result::Result::Ok(DemoWalletTransferInspectionPayload { + public_key: inspection.public_key().to_string(), + format: inspection.format().code().to_string(), + }); +} + +/// Imports one external keypair into a new password-protected native wallet. +pub(crate) async fn demo_wallet_import_file( + state: &crate::AppState, + request: crate::DemoWalletImportRequest, +) -> std::result::Result { + let alias = match ks_wallet::WalletAlias::parse(request.alias.trim().to_string()) { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error.to_string()), + }; + let format = match wallet_transfer_format_from_code(request.format.as_str()) { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + let password = match demo_wallet_password() { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + let profile = state.active_profile(); + let manager = match wallet_manager_for_profile(profile) { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + let wallet = match manager + .import_file(alias, password, std::path::PathBuf::from(request.source_path), format) + .await + { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error.to_string()), + }; + let identity = wallet.identity(); + let payload = DemoWalletIdentityPayload { + selected: profile.wallet.wallet_alias.as_deref() + == std::option::Option::Some(identity.alias.as_str()), + alias: identity.alias.as_str().to_string(), + public_key: identity.public_key, + format_version: 1, + }; + wallet.lock(); + return std::result::Result::Ok(payload); +} + +/// Exports one native wallet after backend-only password authentication. +pub(crate) async fn demo_wallet_export_file( + state: &crate::AppState, + request: crate::DemoWalletExportRequest, +) -> std::result::Result<(), std::string::String> { + let alias = match ks_wallet::WalletAlias::parse(request.alias.trim().to_string()) { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error.to_string()), + }; + let format = match wallet_transfer_format_from_code(request.format.as_str()) { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + let password = match demo_wallet_password() { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + let manager = match wallet_manager_for_profile(state.active_profile()) { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + return match manager + .export_file(&alias, password, std::path::PathBuf::from(request.destination_path), format) + .await + { + std::result::Result::Ok(()) => std::result::Result::Ok(()), + std::result::Result::Err(error) => std::result::Result::Err(error.to_string()), + }; +} + /// Inspects one explicitly selected native wallet file and returns only its safe identity. pub(crate) async fn demo_wallet_inspect_file( state: &crate::AppState, @@ -329,6 +489,45 @@ pub(crate) async fn demo_wallet_rpc_execute( }); } +fn demo_wallet_password() -> std::result::Result { + let password_text = match std::env::var(DEMO_WALLET_PASSWORD_ENV) { + std::result::Result::Ok(value) => value, + std::result::Result::Err(_) => { + return std::result::Result::Err(format!( + "backend secret {DEMO_WALLET_PASSWORD_ENV} is not configured as UTF-8" + )); + }, + }; + return match ks_wallet::WalletPassword::new(password_text) { + std::result::Result::Ok(value) => std::result::Result::Ok(value), + std::result::Result::Err(error) => std::result::Result::Err(error.to_string()), + }; +} + +fn wallet_transfer_format_payloads() -> std::vec::Vec { + let mut formats = std::vec::Vec::new(); + for format in ks_wallet::WalletTransferFormat::supported() { + formats.push(crate::DemoWalletTransferFormatPayload { + code: format.code().to_string(), + label: format.label().to_string(), + default_extension: format.default_extension().to_string(), + }); + } + return formats; +} + +fn wallet_transfer_format_from_code( + code: &str, +) -> std::result::Result { + let code = code.trim(); + for format in ks_wallet::WalletTransferFormat::supported() { + if format.code() == code { + return std::result::Result::Ok(format); + } + } + return std::result::Result::Err(format!("wallet transfer format '{code}' is not supported")); +} + fn onchain_profile<'a>( state: &'a crate::AppState, profile_name: &str, @@ -470,6 +669,15 @@ mod tests { "walletTokensTableBody", "walletTransactionsTableBody", "walletTransactionJson", + "walletImportSourcePathInput", + "walletImportFormatSelect", + "walletImportAliasInput", + "inspectWalletTransferButton", + "importWalletTransferButton", + "walletExportAliasSelect", + "walletExportFormatSelect", + "walletExportDestinationPathInput", + "exportWalletTransferButton", "walletExternalPathInput", "inspectWalletFileButton", "walletInspectionJson", @@ -477,6 +685,9 @@ mod tests { assert!(html.contains(required)); } assert!(script.contains("demo_wallet_create_native")); + assert!(script.contains("demo_wallet_inspect_transfer_file")); + assert!(script.contains("demo_wallet_import_file")); + assert!(script.contains("demo_wallet_export_file")); assert!(script.contains("demo_wallet_balance")); assert!(script.contains("demo_wallet_inventory")); assert!(script.contains("demo_wallet_rpc_execute")); @@ -490,6 +701,9 @@ mod tests { assert!(main_script.contains("open_demo_wallet_window")); assert!(tauri_runtime.contains("open_demo_wallet_window")); assert!(tauri_runtime.contains("demo_wallet_create_native")); + assert!(tauri_runtime.contains("demo_wallet_inspect_transfer_file")); + assert!(tauri_runtime.contains("demo_wallet_import_file")); + assert!(tauri_runtime.contains("demo_wallet_export_file")); assert!(tauri_runtime.contains("demo_wallet_balance")); assert!(tauri_runtime.contains("demo_wallet_rpc_execute")); assert!(tauri_runtime.contains("demo_wallet.html")); @@ -507,6 +721,7 @@ mod tests { cluster: "devnet".to_string(), active: false, }], + transfer_formats: super::wallet_transfer_format_payloads(), token_program_id: ks_program_ids::SPL_TOKEN_PROGRAM_ID.to_string(), token_2022_program_id: ks_program_ids::SPL_TOKEN_2022_PROGRAM_ID.to_string(), create_password_configured: true, @@ -549,6 +764,17 @@ mod tests { } } + #[test] + fn wallet_transfer_format_inventory_matches_reusable_wallet_boundary() { + let formats = super::wallet_transfer_format_payloads(); + assert_eq!(formats.len(), 2); + assert_eq!(formats[0].code, "solana_cli_json"); + assert_eq!(formats[1].code, "solana_private_key_base58"); + assert!(super::wallet_transfer_format_from_code("solana_cli_json").is_ok()); + assert!(super::wallet_transfer_format_from_code("solana_private_key_base58").is_ok()); + assert!(super::wallet_transfer_format_from_code("unknown").is_err()); + } + #[test] fn lamport_formatting_is_exact_without_float_conversion() { assert_eq!(super::format_lamports_as_sol(0), "0"); diff --git a/kb-app-demo-desktop/src/lib.rs b/kb-app-demo-desktop/src/lib.rs index f514d06..0a899d2 100644 --- a/kb-app-demo-desktop/src/lib.rs +++ b/kb-app-demo-desktop/src/lib.rs @@ -1,5 +1,5 @@ // file: kb-app-demo-desktop/src/lib.rs -// version: 40 +// version: 41 //! Tauri desktop demo application for `khadhroony-bot3`. @@ -367,8 +367,12 @@ pub(crate) use self::demo_transport::DemoEndpointRolePayload; pub(crate) use self::demo_wallet::DemoWalletBalancePayload; /// Request to create one native wallet from the backend-only demo secret. pub(crate) use self::demo_wallet::DemoWalletCreateRequest; +/// Request to export one native wallet secret through the backend-only demo secret. +pub(crate) use self::demo_wallet::DemoWalletExportRequest; /// UI-safe native wallet identity exposed by the desktop adapter. pub(crate) use self::demo_wallet::DemoWalletIdentityPayload; +/// Request to import one external keypair into the native wallet store. +pub(crate) use self::demo_wallet::DemoWalletImportRequest; /// UI-safe native wallet inventory exposed by the desktop adapter. pub(crate) use self::demo_wallet::DemoWalletInventoryPayload; /// One selectable profile for read-only wallet on-chain exploration. @@ -377,12 +381,24 @@ pub(crate) use self::demo_wallet::DemoWalletOnchainProfilePayload; pub(crate) use self::demo_wallet::DemoWalletRpcExecutionPayload; /// Request for one wallet-scoped public JSON-RPC read. pub(crate) use self::demo_wallet::DemoWalletRpcRequest; +/// One secret transfer format selectable by the wallet demo. +pub(crate) use self::demo_wallet::DemoWalletTransferFormatPayload; +/// Safe public identity extracted from one external keypair transfer file. +pub(crate) use self::demo_wallet::DemoWalletTransferInspectionPayload; +/// Request to inspect one external keypair transfer file without importing it. +pub(crate) use self::demo_wallet::DemoWalletTransferInspectionRequest; /// Loads the exact SOL balance of one public wallet address. pub(crate) use self::demo_wallet::demo_wallet_balance; /// Creates one password-protected native wallet without exposing the password to Tauri IPC. pub(crate) use self::demo_wallet::demo_wallet_create_native; +/// Exports one native wallet through an explicitly selected transfer format. +pub(crate) use self::demo_wallet::demo_wallet_export_file; +/// Imports one external keypair into a new native wallet. +pub(crate) use self::demo_wallet::demo_wallet_import_file; /// Inspects an explicitly selected native wallet file without exposing its path in the result. pub(crate) use self::demo_wallet::demo_wallet_inspect_file; +/// Inspects one external transfer file and returns only its derived public identity. +pub(crate) use self::demo_wallet::demo_wallet_inspect_transfer_file; /// Returns native wallet identities without paths, passwords or protected payloads. pub(crate) use self::demo_wallet::demo_wallet_inventory; /// Executes one bounded read-only wallet explorer RPC through an explicitly selected profile. diff --git a/kb-app-demo-desktop/src/tauri.rs b/kb-app-demo-desktop/src/tauri.rs index 964d134..507ad14 100644 --- a/kb-app-demo-desktop/src/tauri.rs +++ b/kb-app-demo-desktop/src/tauri.rs @@ -1,5 +1,5 @@ // file: kb-app-demo-desktop/src/tauri.rs -// version: 42 +// version: 43 //! Tauri runtime assembly and private command wrappers. @@ -39,6 +39,9 @@ pub fn run() -> ks_core::Result<()> { open_demo_wallet_window, demo_wallet_inventory, demo_wallet_create_native, + demo_wallet_inspect_transfer_file, + demo_wallet_import_file, + demo_wallet_export_file, demo_wallet_balance, demo_wallet_inspect_file, demo_wallet_rpc_execute, @@ -263,6 +266,29 @@ async fn demo_wallet_create_native( return crate::demo_wallet_create_native(state.inner(), request).await; } +#[tauri::command] +async fn demo_wallet_inspect_transfer_file( + request: crate::DemoWalletTransferInspectionRequest, +) -> std::result::Result { + return crate::demo_wallet_inspect_transfer_file(request).await; +} + +#[tauri::command] +async fn demo_wallet_import_file( + state: tauri::State<'_, crate::AppState>, + request: crate::DemoWalletImportRequest, +) -> std::result::Result { + return crate::demo_wallet_import_file(state.inner(), request).await; +} + +#[tauri::command] +async fn demo_wallet_export_file( + state: tauri::State<'_, crate::AppState>, + request: crate::DemoWalletExportRequest, +) -> std::result::Result<(), std::string::String> { + return crate::demo_wallet_export_file(state.inner(), request).await; +} + #[tauri::command] async fn demo_wallet_balance( state: tauri::State<'_, crate::AppState>, diff --git a/ks-pipeline-demo-scenarios/TODO.md b/ks-pipeline-demo-scenarios/TODO.md index 5faf022..358eb6e 100644 --- a/ks-pipeline-demo-scenarios/TODO.md +++ b/ks-pipeline-demo-scenarios/TODO.md @@ -1,10 +1,11 @@ - + # TODO — ks-pipeline-demo-scenarios ## Évolutions générales +- [ ] `0.5.2` — refactorer les runners Devnet qui appellent encore directement `load_profile_temporary_wallet()` afin qu'ils puissent recevoir une capacité signer issue d'un `.kswallet` explicitement déverrouillé, sans fallback silencieux. - [ ] `0.5.4` — centraliser les dispatchs, compositions decoder/materializer et critères de complétion encore réutilisables depuis le desktop. - [ ] `0.5.4` — produire la matrice active decoder/materializer/executor/scénario/preuve sans transformer les surfaces réservées en dettes implicites. - [ ] Ajouter au CLI uniquement les préparations de fixtures explicitement justifiées. diff --git a/ks-wallet/CHANGELOG.md b/ks-wallet/CHANGELOG.md index 7739164..c5a8b23 100644 --- a/ks-wallet/CHANGELOG.md +++ b/ks-wallet/CHANGELOG.md @@ -1,8 +1,15 @@ - + # CHANGELOG — ks-wallet +## `0.5.2-pre.006-delta-fix-004` + +- ajoute `WalletTransferFormat::supported()`, `code()`, `label()` et `default_extension()` afin que les consommateurs puissent présenter exactement les formats réellement compilés ; +- ajoute `inspect_transfer_file()` et `WalletTransferInspection` pour valider un fichier secret externe et n'en exposer que la pubkey dérivée et le format sélectionné, sans import ni conversion ; +- conserve les codecs secrets dans `ks-wallet` et permet au desktop de diagnostiquer les anciens keypairs de `wallets/temporary/**` un par un avant toute migration ; +- ajoute un test externe couvrant l'inspection publique JSON/Base58 et l'inventaire des formats supportés. + ## `0.5.2-pre.006` - clôt les TODO de sélection non sensible par alias et d'adaptation des consommateurs : `ks-config` ne porte que `wallet_alias`, les scénarios exigent un unlock explicite pour une sélection persistante et le desktop ne projette que des DTO sûrs ; diff --git a/ks-wallet/README.md b/ks-wallet/README.md index 6a73980..ac128b8 100644 --- a/ks-wallet/README.md +++ b/ks-wallet/README.md @@ -1,5 +1,5 @@ - + # ks-wallet @@ -21,6 +21,8 @@ La crate fournit actuellement : - ouverture d'un fichier explicitement sélectionné avec `WalletManager::unlock_file()` sans l'enregistrer dans le store ; - changement du mot de passe avec `WalletManager::change_password()` en conservant exactement la même keypair/pubkey ; - migration non destructive de `.json` vers `.kswallet` avec `WalletManager::migrate_legacy()` ; +- inspection sûre d'un fichier de transfert via `inspect_transfer_file()`, qui valide le keypair sans l'importer et ne retourne que sa pubkey publique ; +- inventaire stable des formats via `WalletTransferFormat::supported()` avec code, libellé et extension conventionnelle ; - import de fichiers secrets avec `WalletManager::import_file()` en `SolanaCliJson` ou `SolanaPrivateKeyBase58` ; - export de fichiers secrets avec `WalletManager::export_file()`, uniquement après authentification du mot de passe du `.kswallet` ; - refus des collisions d’alias et de pubkey lors d’un import ; diff --git a/ks-wallet/TODO.md b/ks-wallet/TODO.md index d11874a..cd5e989 100644 --- a/ks-wallet/TODO.md +++ b/ks-wallet/TODO.md @@ -1,5 +1,5 @@ - + # TODO — ks-wallet @@ -35,7 +35,8 @@ - [ ] inventorier et classifier non destructivement les fichiers keypair JSON sous `wallets/temporary/**` avant toute conversion de masse : distinguer wallets/signers, autorités, mints, recipients et autres keypairs de fixtures ; - [ ] proposer une migration sélective des seuls signers devant devenir persistants vers `.kswallet`, en préservant les fichiers JSON sources et les pubkeys ; -- [ ] permettre aux outils de diagnostic d’extraire la pubkey publique des keypairs legacy compatibles pour explorer leur historique on-chain sans exiger leur conversion préalable en `.kswallet`. +- [x] permettre l'inspection explicite d'un fichier keypair legacy compatible et l'extraction de sa pubkey publique sans conversion préalable en `.kswallet`. +- [ ] ajouter un scanner borné de répertoire pour inventorier plusieurs candidats legacy avant migration, sans conversion automatique ni classification métier inventée à partir du secret seul. ## Version ultérieure non déterminée — adaptateurs de transfert diff --git a/ks-wallet/USAGE.md b/ks-wallet/USAGE.md index dff752b..1124486 100644 --- a/ks-wallet/USAGE.md +++ b/ks-wallet/USAGE.md @@ -1,5 +1,5 @@ - + # Utilisation de ks-wallet @@ -317,6 +317,27 @@ println!("pubkey={}", handle.public_key()); La matrice des formats vérifiés et reportés est maintenue dans [`../docs/WALLET_FORMAT_COMPATIBILITY.md`](../docs/WALLET_FORMAT_COMPATIBILITY.md). +### Inspecter un keypair externe sans l'importer + +```rust +let inspection = match ks_wallet::inspect_transfer_file( + source_path, + ks_wallet::WalletTransferFormat::SolanaCliJson, +) +.await +{ + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => { + return std::result::Result::Err(error); + }, +}; +println!("pubkey={}", inspection.public_key()); +``` + +`inspect_transfer_file()` applique les mêmes validations bornées que l'import mais ne crée aucun `.kswallet`. Le résultat ne contient que la pubkey dérivée et le format explicitement sélectionné. `WalletTransferFormat::supported()` fournit la liste compilée des formats afin qu'un consommateur n'ait pas à maintenir une seconde matrice. + +Cette inspection confirme uniquement qu'un fichier contient un keypair Solana valide dans le format choisi. Le wire secret ne permet pas de déduire de manière fiable le rôle historique de la clé (`wallet`, `mint`, `authority`, `recipient`, etc.). + ### Migrer un legacy `.json` ```rust diff --git a/ks-wallet/src/lib.rs b/ks-wallet/src/lib.rs index 6319bff..1088cd6 100644 --- a/ks-wallet/src/lib.rs +++ b/ks-wallet/src/lib.rs @@ -1,5 +1,5 @@ // file: ks-wallet/src/lib.rs -// version: 8 +// version: 9 //! Wallet boundary for local key storage and transaction signing. #![warn(missing_docs)] @@ -24,6 +24,10 @@ pub use self::manager::WalletManager; pub use self::password::WalletPassword; /// Explicit secret import/export format supported by the wallet boundary. pub use self::transfer::WalletTransferFormat; +/// Safe public identity extracted from one validated transfer file. +pub use self::transfer::WalletTransferInspection; +/// Validates one external transfer file and returns only its public identity. +pub use self::transfer::inspect_transfer_file; /// Authenticated signing capability for one unlocked persistent wallet. pub use self::unlocked::UnlockedWallet; /// Solana keypair kept private inside the wallet boundary. diff --git a/ks-wallet/src/transfer.rs b/ks-wallet/src/transfer.rs index 7c74c9e..b76f8c8 100644 --- a/ks-wallet/src/transfer.rs +++ b/ks-wallet/src/transfer.rs @@ -1,5 +1,5 @@ // file: ks-wallet/src/transfer.rs -// version: 1 +// version: 2 //! Explicit migration and secret import/export adapters. @@ -23,6 +23,74 @@ pub enum WalletTransferFormat { SolanaPrivateKeyBase58, } +impl WalletTransferFormat { + /// Returns every transfer format currently supported for both import and export. + pub const fn supported() -> [Self; 2] { + return [Self::SolanaCliJson, Self::SolanaPrivateKeyBase58]; + } + + /// Returns the stable machine-readable code of this transfer format. + pub const fn code(self) -> &'static str { + return match self { + Self::SolanaCliJson => "solana_cli_json", + Self::SolanaPrivateKeyBase58 => "solana_private_key_base58", + }; + } + + /// Returns the human-readable label of this transfer format. + pub const fn label(self) -> &'static str { + return match self { + Self::SolanaCliJson => "Solana CLI keypair JSON (64 bytes)", + Self::SolanaPrivateKeyBase58 => "Solana private key Base58 (64 bytes)", + }; + } + + /// Returns the conventional file extension used by this transfer format. + pub const fn default_extension(self) -> &'static str { + return match self { + Self::SolanaCliJson => "json", + Self::SolanaPrivateKeyBase58 => "txt", + }; + } +} + +/// Safe result of validating one external secret-transfer file. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct WalletTransferInspection { + public_key: std::string::String, + format: WalletTransferFormat, +} + +impl WalletTransferInspection { + /// Returns the public key derived from the validated external keypair. + pub fn public_key(&self) -> &str { + return self.public_key.as_str(); + } + + /// Returns the transfer format used to validate the external keypair. + pub fn format(&self) -> WalletTransferFormat { + return self.format; + } +} + +/// Validates one external transfer file and returns only its public identity. +/// +/// The secret bytes never leave `ks-wallet`; callers receive only the derived +/// Solana public key and the format that was explicitly selected for validation. +pub async fn inspect_transfer_file( + source_path: impl std::convert::AsRef, + format: WalletTransferFormat, +) -> ks_core::Result { + let keypair = match read_transfer_keypair(source_path.as_ref(), format).await { + std::result::Result::Ok(keypair) => keypair, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + return std::result::Result::Ok(WalletTransferInspection { + public_key: keypair.pubkey().to_string(), + format, + }); +} + impl crate::WalletManager { /// Imports one secret file into a new password-protected native wallet. /// @@ -136,7 +204,7 @@ impl crate::WalletManager { action = "export_wallet_secret", wallet_alias = alias.as_str(), public_key = public_key.as_str(), - transfer_format = transfer_format_code(format), + transfer_format = format.code(), "exported wallet secret after password authentication" ); return std::result::Result::Ok(()); @@ -189,7 +257,7 @@ async fn persist_imported_keypair( action = "import_wallet_secret", wallet_alias = alias.as_str(), public_key = %public_key, - transfer_format = transfer_format_code(format), + transfer_format = format.code(), "imported wallet secret into native protected storage" ); return std::result::Result::Ok(crate::UnlockedWallet::new(alias, keypair)); @@ -656,10 +724,3 @@ fn native_path(manager: &crate::WalletManager, alias: &crate::WalletAlias) -> st crate::KSWALLET_FILE_EXTENSION )); } - -fn transfer_format_code(format: crate::WalletTransferFormat) -> &'static str { - return match format { - crate::WalletTransferFormat::SolanaCliJson => "solana_cli_json", - crate::WalletTransferFormat::SolanaPrivateKeyBase58 => "solana_private_key_base58", - }; -} diff --git a/ks-wallet/tests/transfer.rs b/ks-wallet/tests/transfer.rs index 5dff058..6b22ff2 100644 --- a/ks-wallet/tests/transfer.rs +++ b/ks-wallet/tests/transfer.rs @@ -1,5 +1,5 @@ // file: ks-wallet/tests/transfer.rs -// version: 1 +// version: 2 //! External migration and secret import/export contract tests. @@ -277,3 +277,42 @@ async fn import_rejects_invalid_sources_without_creating_native_destination() { assert_eq!(base58_error.code(), "wallet_import_base58_invalid"); assert!(!directory.path().join("invalid-base58.kswallet").exists()); } +#[tokio::test] +async fn transfer_inspection_exposes_only_public_identity_for_supported_formats() { + let directory = tempfile::tempdir() + .unwrap_or_else(|error| panic!("temporary directory must exist: {error}")); + make_directory_private(directory.path()); + let keypair = solana_keypair::Keypair::new(); + let expected_public_key = keypair.pubkey().to_string(); + let mut keypair_bytes = keypair.to_bytes(); + let mut json = serde_json::to_vec(keypair_bytes.as_slice()) + .unwrap_or_else(|error| panic!("Solana JSON fixture must encode: {error}")); + let json_path = directory.path().join("inspect.json"); + write_private_file(&json_path, json.as_slice()); + let json_inspection = ks_wallet::inspect_transfer_file( + &json_path, + ks_wallet::WalletTransferFormat::SolanaCliJson, + ) + .await + .unwrap_or_else(|error| panic!("Solana JSON inspection must succeed: {error}")); + assert_eq!(json_inspection.public_key(), expected_public_key); + assert_eq!(json_inspection.format().code(), "solana_cli_json"); + json.zeroize(); + let mut base58 = bs58::encode(keypair_bytes.as_slice()).into_string(); + let base58_path = directory.path().join("inspect.txt"); + write_private_file(&base58_path, base58.as_bytes()); + let base58_inspection = ks_wallet::inspect_transfer_file( + &base58_path, + ks_wallet::WalletTransferFormat::SolanaPrivateKeyBase58, + ) + .await + .unwrap_or_else(|error| panic!("Base58 inspection must succeed: {error}")); + assert_eq!(base58_inspection.public_key(), expected_public_key); + assert_eq!(base58_inspection.format().code(), "solana_private_key_base58"); + let formats = ks_wallet::WalletTransferFormat::supported(); + assert_eq!(formats.len(), 2); + assert_eq!(formats[0].default_extension(), "json"); + assert_eq!(formats[1].default_extension(), "txt"); + base58.zeroize(); + keypair_bytes.zeroize(); +}