v0.5.2-pre.006

This commit is contained in:
2026-08-10 23:14:17 +02:00
parent e3acb8ceb1
commit 066d969f5a
51 changed files with 1937 additions and 88 deletions

View File

@@ -1,8 +1,35 @@
<!-- file: kb-app-demo-desktop/CHANGELOG.md -->
<!-- version: 52 -->
<!-- version: 56 -->
# CHANGELOG — kb-app-demo-desktop
## `0.5.2-pre.006-delta-fix-003`
- ajoute dans `Wallets` un sélecteur explicite de profil RPC pour lexplorateur on-chain ; le profil actif reste la valeur initiale mais Devnet/Mainnet peuvent être interrogés sans redémarrer lapplication ni modifier `active_profile` ;
- route `getBalance`, `getTokenAccountsByOwner`, `getSignaturesForAddress` et `getTransaction` à travers le profil sélectionné et non plus obligatoirement à travers le pool HTTP actif ;
- borne la commande RPC dédiée à la démo wallet aux trois lectures génériques nécessaires aux tokens et à lhistorique, tandis que le solde garde son adaptateur typé ;
- conserve la gestion locale des `.kswallet` sur le profil applicatif actif : la sélection RPC de lexplorateur ne déplace ni ne recrée les fichiers wallet.
## `0.5.2-pre.006-delta-fix-002`
- complète la fenêtre `Wallets` avec la création de `.kswallet` dans le store du profil actif ; le password est lu uniquement côté backend depuis `KB_SECRET_DEMO_WALLET_PASSWORD` et ne traverse jamais HTML, TypeScript ou les arguments Tauri ;
- ajoute un explorateur public on-chain réutilisant le transport HTTP configuré : solde SOL exact, comptes SPL Token et Token-2022, signatures récentes impliquant ladresse et détail `getTransaction` via le JsonViewer commun ;
- étend le rôle `http_queries` Devnet avec `get_signatures_for_address` afin que lexplorateur fonctionne sur `local_devnet` comme sur les profils Mainnet déjà dotés des rôles dhistorique ;
- ajoute des DTO TS-RS applicatifs sûrs, des canaris frontend/backend de non-divulgation et des tests statiques de câblage pour les nouvelles commandes.
## `0.5.2-pre.006-delta-fix-001`
- ajoute la fenêtre frontend `demo_wallet.html` et son contrôleur `demo_wallet.ts` sur le même gabarit Bootstrap/Simplebar que les autres démonstrations ;
- ajoute `open_demo_wallet_window`, l'entrée `Wallets` dans le menu `Démos` de `main.html`, l'entrée Vite multipage et la capability Tauri `demo_wallet` ;
- permet de rafraîchir l'inventaire sûr des `.kswallet` et d'inspecter explicitement un fichier externe par chemin sans afficher ce chemin dans le résultat ;
- ajoute un test statique de câblage frontend afin que la présence du backend `demo_wallet` ne puisse plus être validée sans fenêtre réellement ouvrable.
## `0.5.2-pre.006`
- ajoute des DTO TS-RS possédés par le desktop pour l'inventaire des wallets natifs : alias, pubkey déclarée, version et état de sélection uniquement ;
- ajoute des commandes Tauri lecture seule pour lister le store configuré et inspecter un `.kswallet` sélectionné explicitement hors store, sans retourner son chemin ;
- expose l'alias persistant sélectionné uniquement dans le diagnostic wallet borné et ajoute des canaris interdisant password, ciphertext, nonce, sel et chemins de stockage dans les DTO.
## `0.5.1-pre.008`
- Fix: remplace les snapshots `ks-onchain-transport` retournés directement par les commandes Tauri HTTP/WS par des DTO desktop sanitisés, fait supprimer lURL des snapshots backend et supprime `endpointUrl` des payloads dexécution/statut et ajoute des canaris empêchant toute URL résolue de traverser le frontend. Supprime également le chemin local du fixture Token-2022 des payloads fonctionnels et des erreurs affichées.

View File

@@ -1,5 +1,5 @@
<!-- file: kb-app-demo-desktop/README.md -->
<!-- version: 15 -->
<!-- version: 19 -->
# kb-app-demo-desktop
@@ -8,6 +8,7 @@
## Fonctionnalités
- configuration et diagnostics de logging ;
- fenêtre Wallets dédiée : inventaire sûr des identités `.kswallet`, création dun conteneur persistant via un secret backend-only, inspection explicite dun 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 ;
- transport HTTP JSON-RPC et WebSocket ;
- diagnostics PostgreSQL ;
- backfill, extraction Core et replay, avec enregistrement générique de Solana Program Metadata ;

View File

@@ -1,5 +1,5 @@
<!-- file: kb-app-demo-desktop/USAGE.md -->
<!-- version: 15 -->
<!-- version: 19 -->
# Utilisation de kb-app-demo-desktop
@@ -7,6 +7,43 @@
Lapplication expose une interface opérateur pour diagnostiquer, simuler et valider les couches de bot3.
## Surface wallet sûre
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 dune adresse publique. Les DTO frontend ne contiennent jamais de matériau secret. Aucun password, chemin interne du store, sel, nonce, ciphertext ou byte privé nest renvoyé par Tauri.
La fenêtre `Wallets`, accessible depuis le menu `Démos` de `main`, fournit quatre surfaces :
- inventaire des wallets natifs du profil actif ;
- création dun nouveau `.kswallet` dans le store du profil actif ;
- explorateur public on-chain ;
- inspection structurale dun `.kswallet` explicite situé hors du store.
### Créer un wallet natif depuis la démo
Le mot de passe de création ne doit pas traverser le frontend ou Tauri IPC. La démo lit uniquement côté backend le secret applicatif :
```bash
export KB_SECRET_DEMO_WALLET_PASSWORD='mot-de-passe-de-demo'
cargo tauri dev -c kb-app-demo-desktop/tauri.conf.json
```
Le secret peut aussi être placé dans le fichier `.env` local chargé par `ks-config`. Le vrai `.env` reste non versionné et ne doit jamais être archivé. LUI ne reçoit quun booléen indiquant si ce secret backend est configuré.
La création demande uniquement lalias. Elle crée `<alias>.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 dutiliser un autre mot de passe pour de nouveaux wallets ; ce secret de démonstration nest pas un mot de passe global du format `.kswallet`.
### Explorer une adresse sur un profil RPC sélectionné
Lexplorateur on-chain ne déverrouille aucun wallet : seule la pubkey publique est nécessaire. Le bouton `Explorer` dun wallet inventorié remplit ladresse automatiquement ; toute autre pubkey Solana peut également être saisie.
Les lectures utilisent le transport HTTP du profil choisi dans le sélecteur `Profil RPC` de la fenêtre Wallets. Le profil actif est sélectionné par défaut, mais `local_devnet`, `mainnet_research` ou tout autre profil HTTP résolu peut être choisi sans redémarrer lapplication :
- `getBalance` pour le solde SOL exact en lamports ;
- `getTokenAccountsByOwner` pour les comptes SPL Token classique et Token-2022 ;
- `getSignaturesForAddress` pour les signatures récentes des transactions qui référencent ladresse ;
- `getTransaction` à la demande pour afficher le détail JSON dune signature.
La liste est donc intitulée **transactions impliquant ladresse** : elle ne prétend pas que le wallet a initié ou signé chaque transaction retournée. Le profil `local_devnet` interroge Devnet ; `mainnet_research` ou `mainnet` interrogent Mainnet Beta. La sélection utilisée par lexplorateur est locale à cette fenêtre : elle ne modifie pas `active_profile`, la configuration du workspace ni le profil utilisé par les autres démos.
## Lancer le desktop
Depuis la racine du workspace :
@@ -54,6 +91,7 @@ Les autres fenêtres sont ouvertes par les commandes Tauri dédiées :
- diagnostics SQL ;
- candidats replay ;
- configuration ;
- wallets ;
- extraction Core ;
- decode replay ;
- exécution Solana Core ;
@@ -68,6 +106,7 @@ Les commandes Tauri constituent linterface applicative frontend/backend. Elle
```typescript
import { invoke } from "@tauri-apps/api/core";
await invoke("open_demo_wallet_window");
await invoke("open_demo_http_window");
await invoke("open_demo_ws_window");
await invoke("open_demo_decode_replay_window");

View File

@@ -6,6 +6,7 @@
"splash",
"main",
"demo_config",
"demo_wallet",
"demo_http",
"demo_ws",
"demo_backfill",

View File

@@ -0,0 +1,245 @@
<!-- file: kb-app-demo-desktop/frontend/demo_wallet.html -->
<!-- version: 3 -->
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="utf-8">
<meta content="width=device-width, initial-scale=1.0" name="viewport">
<link rel="icon" href="imgs/logo.png">
<link rel="stylesheet" href="sass/main.scss">
<title>Khadhroony Bot3 — Wallets</title>
</head>
<body>
<header class="app-header">
<nav class="navbar navbar-expand-lg h-100 py-0 bg-light text-dark">
<div class="container-fluid px-4 my-0">
<div class="navbar-brand d-flex align-items-center">
<img alt="Logo" src="imgs/logo.png" class="app-logo">
<span class="ps-2 fs-4 fw-bold text-primary font-logo">Bot3 — <span class="badge text-bg-primary">Wallets</span></span>
</div>
</div>
</nav>
</header>
<main class="app-main">
<div class="osb-scrollable pt-1 pb-4" data-simplebar>
<div class="container-fluid px-4 py-4">
<div class="row g-4 justify-content-center">
<div class="col-12 col-xl-7">
<div class="card shadow-sm border-0 h-100">
<div class="card-body">
<div class="d-flex flex-wrap gap-2 justify-content-between align-items-center mb-3">
<div>
<h1 class="h3 card-title mb-1">Wallets natifs configurés</h1>
<p class="text-body-secondary mb-0">Inventaire des fichiers <code>.kswallet</code> du profil actif.</p>
</div>
<button id="refreshWalletInventoryButton" class="btn btn-primary" type="button">Rafraîchir</button>
</div>
<dl class="row mb-3">
<dt class="col-sm-3">Profil</dt>
<dd class="col-sm-9"><code id="walletProfile"></code></dd>
<dt class="col-sm-3">Cluster</dt>
<dd class="col-sm-9"><code id="walletCluster"></code></dd>
<dt class="col-sm-3">Statut</dt>
<dd class="col-sm-9"><code id="walletInventoryStatus">Chargement...</code></dd>
<dt class="col-sm-3">Alias sélectionné</dt>
<dd class="col-sm-9"><code id="walletSelectedAlias"></code></dd>
<dt class="col-sm-3">Wallets trouvés</dt>
<dd class="col-sm-9"><code id="walletInventoryCount">0</code></dd>
</dl>
<div class="table-responsive app-table-full-width">
<table class="table table-sm align-middle mb-0" id="walletInventoryTable">
<thead>
<tr>
<th>Alias</th>
<th>Pubkey déclarée</th>
<th>Format</th>
<th>Sélectionné</th>
<th>Action</th>
</tr>
</thead>
<tbody id="walletInventoryTableBody"></tbody>
</table>
</div>
</div>
</div>
</div>
<div class="col-12 col-xl-5">
<div class="card shadow-sm border-0 h-100">
<div class="card-body">
<h2 class="h5 card-title mb-3">Créer un wallet natif</h2>
<p class="text-body-secondary mb-2">Le mot de passe ne traverse jamais Tauri IPC. Cette démo utilise le secret backend <code>KB_SECRET_DEMO_WALLET_PASSWORD</code>.</p>
<div class="mb-3">
<span class="fw-semibold">Secret backend :</span>
<code id="walletCreateSecretStatus" class="ms-1"></code>
</div>
<label for="createWalletAliasInput" class="form-label">Alias</label>
<input id="createWalletAliasInput" class="form-control font-monospace" type="text" autocomplete="off" maxlength="64" placeholder="operator-devnet">
<div class="d-flex flex-wrap gap-2 mt-3">
<button id="createWalletButton" class="btn btn-primary" type="button">Créer</button>
</div>
<div class="mt-3">
<span class="fw-semibold">Statut :</span>
<code id="walletCreateStatus" class="ms-1">En attente</code>
</div>
<p class="small text-body-secondary mb-0 mt-3">La création n'écrit pas automatiquement <code>wallet_alias</code> dans la configuration. Elle ajoute seulement le nouveau conteneur au store du profil actif.</p>
</div>
</div>
</div>
<div class="col-12">
<div class="card shadow-sm border-0">
<div class="card-body">
<div class="d-flex flex-wrap gap-2 justify-content-between align-items-center mb-3">
<div>
<h2 class="h4 card-title mb-1">Explorateur on-chain</h2>
<p class="text-body-secondary mb-0">Lecture publique du solde SOL, des comptes SPL Token / Token-2022 et de l'historique récent impliquant l'adresse sur le profil RPC sélectionné.</p>
</div>
<button id="loadWalletOnchainButton" class="btn btn-primary" type="button">Charger l'état on-chain</button>
</div>
<div class="row g-3 align-items-end">
<div class="col-12 col-lg-3">
<label for="walletOnchainProfileSelect" class="form-label">Profil RPC</label>
<select id="walletOnchainProfileSelect" class="form-select"></select>
<div class="small text-body-secondary mt-1">Cluster : <code id="walletOnchainCluster"></code></div>
</div>
<div class="col-12 col-lg-7">
<label for="walletAddressInput" class="form-label">Adresse publique</label>
<input id="walletAddressInput" class="form-control font-monospace" type="text" autocomplete="off" placeholder="Pubkey Solana">
</div>
<div class="col-12 col-lg-2">
<label for="walletSignatureLimitInput" class="form-label">Transactions</label>
<input id="walletSignatureLimitInput" class="form-control" type="number" min="1" max="100" value="20">
</div>
</div>
<div class="row g-3 mt-2">
<div class="col-12 col-md-4">
<div class="border rounded p-3 h-100">
<div class="small text-body-secondary">SOL</div>
<div class="fs-4 fw-semibold"><span id="walletSolBalance"></span> SOL</div>
<div class="small"><code id="walletLamportBalance"></code> lamports</div>
</div>
</div>
<div class="col-12 col-md-4">
<div class="border rounded p-3 h-100">
<div class="small text-body-secondary">Comptes token</div>
<div class="fs-4 fw-semibold" id="walletTokenCount"></div>
<div class="small" id="walletTokenStatus">En attente</div>
</div>
</div>
<div class="col-12 col-md-4">
<div class="border rounded p-3 h-100">
<div class="small text-body-secondary">Transactions récentes</div>
<div class="fs-4 fw-semibold" id="walletTransactionCount"></div>
<div class="small" id="walletTransactionStatus">En attente</div>
</div>
</div>
</div>
<div class="mt-3">
<span class="fw-semibold">Statut global :</span>
<code id="walletOnchainStatus" class="ms-1">En attente</code>
</div>
</div>
</div>
</div>
<div class="col-12">
<div class="card shadow-sm border-0">
<div class="card-body">
<h2 class="h5 card-title mb-3">Tokens détenus</h2>
<div class="table-responsive app-table-full-width">
<table class="table table-sm align-middle mb-0">
<thead>
<tr>
<th>Programme</th>
<th>Mint</th>
<th>Compte token</th>
<th>Montant</th>
<th>Raw</th>
</tr>
</thead>
<tbody id="walletTokensTableBody"></tbody>
</table>
</div>
</div>
</div>
</div>
<div class="col-12">
<div class="card shadow-sm border-0">
<div class="card-body">
<h2 class="h5 card-title mb-3">Transactions impliquant l'adresse</h2>
<div class="table-responsive app-table-full-width">
<table class="table table-sm align-middle mb-0">
<thead>
<tr>
<th>Signature</th>
<th>Slot</th>
<th>Date</th>
<th>Statut</th>
<th>Confirmation</th>
<th>Action</th>
</tr>
</thead>
<tbody id="walletTransactionsTableBody"></tbody>
</table>
</div>
</div>
</div>
</div>
<div class="col-12">
<div class="card shadow-sm border-0">
<div class="card-body">
<h2 class="h5 card-title mb-3">Détail de transaction</h2>
<div id="walletTransactionJson" class="app-config-box app-json-viewer mb-0">{"status":"idle","message":"Sélectionner une transaction."}</div>
</div>
</div>
</div>
<div class="col-12 col-xl-5">
<div class="card shadow-sm border-0 h-100">
<div class="card-body">
<h2 class="h5 card-title mb-3">Inspecter un fichier externe</h2>
<p class="text-body-secondary">Le chemin saisi est transmis au backend uniquement pour cette inspection et n'est pas retourné dans le résultat.</p>
<label for="walletExternalPathInput" class="form-label">Chemin du fichier <code>.kswallet</code></label>
<input id="walletExternalPathInput" class="form-control font-monospace" type="text" autocomplete="off" placeholder="/chemin/vers/operator.kswallet">
<div class="d-flex flex-wrap gap-2 mt-3">
<button id="inspectWalletFileButton" class="btn btn-primary" type="button">Inspecter</button>
<button id="clearWalletInspectionButton" class="btn btn-outline-secondary" type="button">Effacer</button>
</div>
<div class="mt-3">
<span class="fw-semibold">Statut :</span>
<code id="walletInspectionStatus" class="ms-1">En attente</code>
</div>
</div>
</div>
</div>
<div class="col-12 col-xl-7">
<div class="card shadow-sm border-0 h-100">
<div class="card-body">
<h2 class="h5 card-title mb-3">Identité inspectée</h2>
<div id="walletInspectionJson" class="app-config-box app-json-viewer mb-0">{"status":"idle","message":"Aucun fichier inspecté."}</div>
</div>
</div>
</div>
</div>
</div>
</div>
</main>
<footer class="app-footer bg-dark text-light">
<div class="container h-100 d-flex align-items-center">
<div class="row flex-grow-1 align-items-center">
<div class="col-12 text-center text-small my-1 my-md-0">&copy; 2026 SASEDEV</div>
</div>
</div>
</footer>
<script type="module" src="ts/demo_wallet.ts" defer></script>
</body>
</html>

View File

@@ -1,5 +1,5 @@
<!-- file: kb-app-demo-desktop/frontend/main.html -->
<!-- version: 11 -->
<!-- version: 12 -->
<!DOCTYPE html>
<html lang="fr">
@@ -26,6 +26,7 @@
</button>
<ul class="dropdown-menu dropdown-menu-end">
<li><a id="openDemoConfigLink" class="dropdown-item" href="#">Configuration</a></li>
<li><a id="openDemoWalletLink" class="dropdown-item" href="#">Wallets</a></li>
<li>
<hr class="dropdown-divider">
</li>
@@ -99,4 +100,4 @@
<script type="module" src="ts/main.ts" defer></script>
</body>
</html>
</html>

View File

@@ -0,0 +1,532 @@
// file: kb-app-demo-desktop/frontend/ts/demo_wallet.ts
// version: 3
import * as bootstrap from "bootstrap";
import "simplebar";
import ResizeObserver from "resize-observer-polyfill";
import { invoke } from "@tauri-apps/api/core";
import { frontendDebug, frontendError, installFrontendConsoleBridge } from "./frontend_log.ts";
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 { DemoWalletIdentityPayload } from "./bindings/kb_app_demo_desktop/demo_wallet/DemoWalletIdentityPayload.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";
(window as Window & typeof globalThis & { bootstrap?: typeof bootstrap }).bootstrap = bootstrap;
(window as Window & typeof globalThis & { ResizeObserver?: typeof ResizeObserver }).ResizeObserver = ResizeObserver;
interface ParsedTokenAccount {
program: string;
mint: string;
tokenAccount: string;
uiAmount: string;
rawAmount: string;
decimals: number;
}
interface ParsedSignatureInfo {
signature: string;
slot: number;
err: unknown | null;
memo: string | null;
blockTime: number | null;
confirmationStatus: string | null;
}
let walletInventory: DemoWalletInventoryPayload | null = null;
function setText(selector: string, value: string): void {
const element = document.querySelector<HTMLElement>(selector);
if (element) {
element.textContent = value;
}
}
function inputValue(selector: string): string {
return document.querySelector<HTMLInputElement>(selector)?.value.trim() ?? "";
}
function selectedOnchainProfile(): string {
return document.querySelector<HTMLSelectElement>("#walletOnchainProfileSelect")?.value.trim() ?? "";
}
function selectedOnchainProfilePayload(): DemoWalletOnchainProfilePayload | null {
const profile = selectedOnchainProfile();
if (!walletInventory || profile.length === 0) {
return null;
}
return walletInventory.onchain_profiles.find(candidate => candidate.name === profile) ?? null;
}
function updateOnchainClusterLabel(): void {
const selected = selectedOnchainProfilePayload();
setText("#walletOnchainCluster", selected?.cluster ?? "—");
}
function renderOnchainProfiles(profiles: ReadonlyArray<DemoWalletOnchainProfilePayload>, activeProfile: string): void {
const select = document.querySelector<HTMLSelectElement>("#walletOnchainProfileSelect");
if (!select) {
return;
}
const previous = select.value;
select.replaceChildren();
for (const profile of profiles) {
const option = document.createElement("option");
option.value = profile.name;
option.textContent = `${profile.name}${profile.cluster}${profile.active ? " (actif)" : ""}`;
select.appendChild(option);
}
if (profiles.some(profile => profile.name === previous)) {
select.value = previous;
} else if (profiles.some(profile => profile.name === activeProfile)) {
select.value = activeProfile;
} else if (profiles.length > 0) {
select.value = profiles[0].name;
}
updateOnchainClusterLabel();
}
async function executeWalletRpc(profile: string, method: string, params: unknown[]): Promise<DemoWalletRpcExecutionPayload> {
const request: DemoWalletRpcRequest = {
profile,
method,
params_json: JSON.stringify(params),
};
return await invoke<DemoWalletRpcExecutionPayload>("demo_wallet_rpc_execute", { request });
}
function parseJsonResponse(response: DemoWalletRpcExecutionPayload): unknown {
return JSON.parse(response.response_json) as unknown;
}
function renderWalletRows(wallets: ReadonlyArray<DemoWalletIdentityPayload>): void {
const body = document.querySelector<HTMLTableSectionElement>("#walletInventoryTableBody");
if (!body) {
return;
}
body.replaceChildren();
if (wallets.length === 0) {
const row = document.createElement("tr");
const cell = document.createElement("td");
cell.colSpan = 5;
cell.className = "text-body-secondary text-center py-3";
cell.textContent = "Aucun wallet natif trouvé.";
row.appendChild(cell);
body.appendChild(row);
return;
}
for (const wallet of wallets) {
const row = document.createElement("tr");
const aliasCell = document.createElement("td");
const publicKeyCell = document.createElement("td");
const formatCell = document.createElement("td");
const selectedCell = document.createElement("td");
const actionCell = document.createElement("td");
const exploreButton = document.createElement("button");
aliasCell.textContent = wallet.alias;
publicKeyCell.textContent = wallet.public_key;
publicKeyCell.className = "font-monospace text-break";
formatCell.textContent = `v${wallet.format_version}`;
selectedCell.textContent = wallet.selected ? "Oui" : "Non";
exploreButton.type = "button";
exploreButton.className = "btn btn-sm btn-outline-primary";
exploreButton.textContent = "Explorer";
exploreButton.addEventListener("click", () => {
const input = document.querySelector<HTMLInputElement>("#walletAddressInput");
if (input) {
input.value = wallet.public_key;
}
document.querySelector<HTMLElement>("#walletAddressInput")?.scrollIntoView({ behavior: "smooth", block: "center" });
});
actionCell.appendChild(exploreButton);
if (wallet.selected) {
row.classList.add("table-primary");
}
row.append(aliasCell, publicKeyCell, formatCell, selectedCell, actionCell);
body.appendChild(row);
}
}
async function refreshWalletInventory(): Promise<void> {
setText("#walletInventoryStatus", "Chargement...");
try {
const payload = await invoke<DemoWalletInventoryPayload>("demo_wallet_inventory");
walletInventory = payload;
setText("#walletProfile", payload.profile);
setText("#walletCluster", payload.cluster);
renderOnchainProfiles(payload.onchain_profiles, payload.profile);
setText("#walletCreateSecretStatus", payload.create_password_configured ? "configuré" : "absent");
setText("#walletSelectedAlias", payload.selected_alias ?? "—");
setText("#walletInventoryCount", String(payload.wallets.length));
setText("#walletInventoryStatus", "OK");
renderWalletRows(payload.wallets);
frontendDebug("kb-app-demo-desktop.frontend.demo_wallet", "wallet inventory refreshed");
} catch (caughtError) {
const message = caughtError instanceof Error ? caughtError.message : String(caughtError);
setText("#walletInventoryStatus", `Erreur : ${message}`);
setText("#walletInventoryCount", "0");
renderWalletRows([]);
frontendError("kb-app-demo-desktop.frontend.demo_wallet", `Wallet inventory loading failed: ${message}`);
}
}
async function createWallet(): Promise<void> {
const alias = inputValue("#createWalletAliasInput");
if (alias.length === 0) {
setText("#walletCreateStatus", "Alias requis");
return;
}
const request: DemoWalletCreateRequest = { alias };
setText("#walletCreateStatus", "Création...");
try {
const created = await invoke<DemoWalletIdentityPayload>("demo_wallet_create_native", { request });
setText("#walletCreateStatus", `Créé : ${created.alias}`);
const addressInput = document.querySelector<HTMLInputElement>("#walletAddressInput");
if (addressInput) {
addressInput.value = created.public_key;
}
const aliasInput = document.querySelector<HTMLInputElement>("#createWalletAliasInput");
if (aliasInput) {
aliasInput.value = "";
}
await refreshWalletInventory();
frontendDebug("kb-app-demo-desktop.frontend.demo_wallet", `native wallet created: ${created.alias}`);
} catch (caughtError) {
const message = caughtError instanceof Error ? caughtError.message : String(caughtError);
setText("#walletCreateStatus", `Erreur : ${message}`);
frontendError("kb-app-demo-desktop.frontend.demo_wallet", `Native wallet creation failed: ${message}`);
}
}
function parseTokenAccounts(value: unknown, program: string): ParsedTokenAccount[] {
if (typeof value !== "object" || value === null || !("value" in value)) {
return [];
}
const rows = (value as { value?: unknown }).value;
if (!Array.isArray(rows)) {
return [];
}
const parsed: ParsedTokenAccount[] = [];
for (const row of rows) {
if (typeof row !== "object" || row === null) {
continue;
}
const keyed = row as { pubkey?: unknown; account?: { data?: unknown } };
if (typeof keyed.pubkey !== "string" || typeof keyed.account?.data !== "object" || keyed.account.data === null) {
continue;
}
const data = keyed.account.data as { parsed?: { info?: unknown } };
const info = data.parsed?.info;
if (typeof info !== "object" || info === null) {
continue;
}
const typedInfo = info as { mint?: unknown; tokenAmount?: unknown };
const amount = typedInfo.tokenAmount;
if (typeof typedInfo.mint !== "string" || typeof amount !== "object" || amount === null) {
continue;
}
const typedAmount = amount as { amount?: unknown; decimals?: unknown; uiAmountString?: unknown };
if (typeof typedAmount.amount !== "string" || typeof typedAmount.decimals !== "number" || typeof typedAmount.uiAmountString !== "string") {
continue;
}
parsed.push({
program,
mint: typedInfo.mint,
tokenAccount: keyed.pubkey,
uiAmount: typedAmount.uiAmountString,
rawAmount: typedAmount.amount,
decimals: typedAmount.decimals,
});
}
return parsed;
}
function renderTokenRows(tokens: ReadonlyArray<ParsedTokenAccount>): void {
const body = document.querySelector<HTMLTableSectionElement>("#walletTokensTableBody");
if (!body) {
return;
}
body.replaceChildren();
if (tokens.length === 0) {
const row = document.createElement("tr");
const cell = document.createElement("td");
cell.colSpan = 5;
cell.className = "text-body-secondary text-center py-3";
cell.textContent = "Aucun compte token trouvé.";
row.appendChild(cell);
body.appendChild(row);
return;
}
for (const token of tokens) {
const row = document.createElement("tr");
for (const value of [token.program, token.mint, token.tokenAccount, token.uiAmount, token.rawAmount]) {
const cell = document.createElement("td");
cell.textContent = value;
if (value === token.mint || value === token.tokenAccount) {
cell.className = "font-monospace text-break";
}
row.appendChild(cell);
}
body.appendChild(row);
}
}
function parseSignatureRows(value: unknown): ParsedSignatureInfo[] {
if (!Array.isArray(value)) {
return [];
}
const rows: ParsedSignatureInfo[] = [];
for (const item of value) {
if (typeof item !== "object" || item === null) {
continue;
}
const typed = item as Record<string, unknown>;
if (typeof typed.signature !== "string" || typeof typed.slot !== "number") {
continue;
}
rows.push({
signature: typed.signature,
slot: typed.slot,
err: typed.err ?? null,
memo: typeof typed.memo === "string" ? typed.memo : null,
blockTime: typeof typed.blockTime === "number" ? typed.blockTime : null,
confirmationStatus: typeof typed.confirmationStatus === "string" ? typed.confirmationStatus : null,
});
}
return rows;
}
function formatBlockTime(blockTime: number | null): string {
if (blockTime === null) {
return "—";
}
return new Date(blockTime * 1000).toLocaleString();
}
async function loadTransactionDetails(profile: string, signature: string): Promise<void> {
renderJsonViewer("#walletTransactionJson", { status: "loading", signature });
try {
const response = await executeWalletRpc(profile, "getTransaction", [
signature,
{ commitment: "confirmed", encoding: "jsonParsed", maxSupportedTransactionVersion: 0 },
]);
renderJsonViewer("#walletTransactionJson", parseJsonResponse(response));
} catch (caughtError) {
const message = caughtError instanceof Error ? caughtError.message : String(caughtError);
renderJsonViewer("#walletTransactionJson", { status: "error", message, signature });
}
}
function renderSignatureRows(rows: ReadonlyArray<ParsedSignatureInfo>, profile: string): void {
const body = document.querySelector<HTMLTableSectionElement>("#walletTransactionsTableBody");
if (!body) {
return;
}
body.replaceChildren();
if (rows.length === 0) {
const row = document.createElement("tr");
const cell = document.createElement("td");
cell.colSpan = 6;
cell.className = "text-body-secondary text-center py-3";
cell.textContent = "Aucune transaction trouvée.";
row.appendChild(cell);
body.appendChild(row);
return;
}
for (const item of rows) {
const row = document.createElement("tr");
const signatureCell = document.createElement("td");
const slotCell = document.createElement("td");
const timeCell = document.createElement("td");
const statusCell = document.createElement("td");
const confirmationCell = document.createElement("td");
const actionCell = document.createElement("td");
const detailsButton = document.createElement("button");
signatureCell.textContent = item.signature;
signatureCell.className = "font-monospace text-break";
slotCell.textContent = String(item.slot);
timeCell.textContent = formatBlockTime(item.blockTime);
statusCell.textContent = item.err === null ? "OK" : "Erreur";
confirmationCell.textContent = item.confirmationStatus ?? "—";
detailsButton.type = "button";
detailsButton.className = "btn btn-sm btn-outline-primary";
detailsButton.textContent = "Détails";
detailsButton.addEventListener("click", () => {
void loadTransactionDetails(profile, item.signature);
});
actionCell.appendChild(detailsButton);
row.append(signatureCell, slotCell, timeCell, statusCell, confirmationCell, actionCell);
body.appendChild(row);
}
}
async function loadWalletOnchain(): Promise<void> {
const profile = selectedOnchainProfile();
const address = inputValue("#walletAddressInput");
if (profile.length === 0) {
setText("#walletOnchainStatus", "Profil RPC requis");
return;
}
if (address.length === 0) {
setText("#walletOnchainStatus", "Adresse requise");
return;
}
const requestedLimit = Number.parseInt(inputValue("#walletSignatureLimitInput"), 10);
const signatureLimit = Number.isFinite(requestedLimit) ? Math.max(1, Math.min(100, requestedLimit)) : 20;
setText("#walletOnchainStatus", "Chargement...");
setText("#walletTokenStatus", "Chargement...");
setText("#walletTransactionStatus", "Chargement...");
setText("#walletSolBalance", "—");
setText("#walletLamportBalance", "—");
setText("#walletTokenCount", "—");
setText("#walletTransactionCount", "—");
renderTokenRows([]);
renderSignatureRows([], profile);
renderJsonViewer("#walletTransactionJson", { status: "idle", message: "Sélectionner une transaction." });
const operations: Promise<void>[] = [];
operations.push((async () => {
try {
const balance = await invoke<DemoWalletBalancePayload>("demo_wallet_balance", { profile, address });
setText("#walletSolBalance", balance.sol);
setText("#walletLamportBalance", balance.lamports);
} catch (caughtError) {
const message = caughtError instanceof Error ? caughtError.message : String(caughtError);
setText("#walletSolBalance", "Erreur");
setText("#walletLamportBalance", message);
}
})());
operations.push((async () => {
try {
if (!walletInventory) {
throw new Error("Inventaire wallet non chargé.");
}
const [classic, token2022] = await Promise.all([
executeWalletRpc(profile, "getTokenAccountsByOwner", [
address,
{ programId: walletInventory.token_program_id },
{ encoding: "jsonParsed", commitment: "confirmed" },
]),
executeWalletRpc(profile, "getTokenAccountsByOwner", [
address,
{ programId: walletInventory.token_2022_program_id },
{ encoding: "jsonParsed", commitment: "confirmed" },
]),
]);
const tokens = [
...parseTokenAccounts(parseJsonResponse(classic), "SPL Token"),
...parseTokenAccounts(parseJsonResponse(token2022), "Token-2022"),
];
tokens.sort((left, right) => left.mint.localeCompare(right.mint) || left.tokenAccount.localeCompare(right.tokenAccount));
setText("#walletTokenCount", String(tokens.length));
setText("#walletTokenStatus", "OK");
renderTokenRows(tokens);
} catch (caughtError) {
const message = caughtError instanceof Error ? caughtError.message : String(caughtError);
setText("#walletTokenCount", "0");
setText("#walletTokenStatus", `Erreur : ${message}`);
renderTokenRows([]);
}
})());
operations.push((async () => {
try {
const history = await executeWalletRpc(profile, "getSignaturesForAddress", [
address,
{ commitment: "confirmed", limit: signatureLimit },
]);
const rows = parseSignatureRows(parseJsonResponse(history));
setText("#walletTransactionCount", String(rows.length));
setText("#walletTransactionStatus", "OK");
renderSignatureRows(rows, profile);
} catch (caughtError) {
const message = caughtError instanceof Error ? caughtError.message : String(caughtError);
setText("#walletTransactionCount", "0");
setText("#walletTransactionStatus", `Erreur : ${message}`);
renderSignatureRows([], profile);
}
})());
await Promise.all(operations);
const selected = selectedOnchainProfilePayload();
setText("#walletOnchainStatus", `Terminé — ${selected?.name ?? profile} / ${selected?.cluster ?? "cluster inconnu"}`);
}
async function inspectWalletFile(): Promise<void> {
const path = inputValue("#walletExternalPathInput");
if (path.length === 0) {
setText("#walletInspectionStatus", "Chemin requis");
renderJsonViewer("#walletInspectionJson", { status: "error", message: "Saisir un chemin vers un fichier .kswallet." });
return;
}
setText("#walletInspectionStatus", "Inspection...");
renderJsonViewer("#walletInspectionJson", { status: "loading", message: "Inspection du wallet natif." });
try {
const payload = await invoke<DemoWalletIdentityPayload>("demo_wallet_inspect_file", { path });
setText("#walletInspectionStatus", "OK");
renderJsonViewer("#walletInspectionJson", payload);
const addressInput = document.querySelector<HTMLInputElement>("#walletAddressInput");
if (addressInput) {
addressInput.value = payload.public_key;
}
frontendDebug("kb-app-demo-desktop.frontend.demo_wallet", "external wallet inspected");
} catch (caughtError) {
const message = caughtError instanceof Error ? caughtError.message : String(caughtError);
setText("#walletInspectionStatus", `Erreur : ${message}`);
renderJsonViewer("#walletInspectionJson", { status: "error", message });
frontendError("kb-app-demo-desktop.frontend.demo_wallet", `External wallet inspection failed: ${message}`);
}
}
function clearWalletInspection(): void {
const pathInput = document.querySelector<HTMLInputElement>("#walletExternalPathInput");
if (pathInput) {
pathInput.value = "";
}
setText("#walletInspectionStatus", "En attente");
renderJsonViewer("#walletInspectionJson", { status: "idle", message: "Aucun fichier inspecté." });
}
document.addEventListener("DOMContentLoaded", () => {
installFrontendConsoleBridge("kb-app-demo-desktop.frontend.demo_wallet");
frontendDebug("kb-app-demo-desktop.frontend.demo_wallet", "wallet demo window loaded");
const tooltipTriggerList = document.querySelectorAll('[data-bs-toggle="tooltip"]');
Array.from(tooltipTriggerList).map(tooltipTriggerEl => new bootstrap.Tooltip(tooltipTriggerEl));
document.querySelector<HTMLButtonElement>("#refreshWalletInventoryButton")?.addEventListener("click", () => {
void refreshWalletInventory();
});
document.querySelector<HTMLButtonElement>("#createWalletButton")?.addEventListener("click", () => {
void createWallet();
});
document.querySelector<HTMLInputElement>("#createWalletAliasInput")?.addEventListener("keydown", event => {
if (event.key === "Enter") {
event.preventDefault();
void createWallet();
}
});
document.querySelector<HTMLSelectElement>("#walletOnchainProfileSelect")?.addEventListener("change", () => {
updateOnchainClusterLabel();
});
document.querySelector<HTMLButtonElement>("#loadWalletOnchainButton")?.addEventListener("click", () => {
void loadWalletOnchain();
});
document.querySelector<HTMLInputElement>("#walletAddressInput")?.addEventListener("keydown", event => {
if (event.key === "Enter") {
event.preventDefault();
void loadWalletOnchain();
}
});
document.querySelector<HTMLButtonElement>("#inspectWalletFileButton")?.addEventListener("click", () => {
void inspectWalletFile();
});
document.querySelector<HTMLButtonElement>("#clearWalletInspectionButton")?.addEventListener("click", () => {
clearWalletInspection();
});
document.querySelector<HTMLInputElement>("#walletExternalPathInput")?.addEventListener("keydown", event => {
if (event.key === "Enter") {
event.preventDefault();
void inspectWalletFile();
}
});
void refreshWalletInventory();
});

View File

@@ -1,5 +1,5 @@
// file: kb-app-demo-desktop/frontend/ts/main.ts
// version: 6
// version: 7
import * as bootstrap from "bootstrap";
import MarkdownIt from "markdown-it";
@@ -44,6 +44,10 @@ document.addEventListener("DOMContentLoaded", async () => {
event.preventDefault();
void openDemoConfigWindow();
});
document.querySelector<HTMLAnchorElement>("#openDemoWalletLink")?.addEventListener("click", event => {
event.preventDefault();
void invoke("open_demo_wallet_window");
});
frontendDebug("kb-app-demo-desktop.frontend.main", "main window loaded");
const readmeContent = document.querySelector<HTMLElement>("#readmeContent");
if (readmeContent) {

View File

@@ -1,5 +1,5 @@
// file: kb-app-demo-desktop/src/demo_config.rs
// version: 5
// version: 6
//! Configuration demo payloads with explicit public and bounded diagnostic projections.
@@ -203,6 +203,8 @@ pub(crate) struct DemoConfigWalletDiagnosticPayload {
pub(crate) wallet_directory_state: std::string::String,
/// Wallet cluster.
pub(crate) cluster: std::string::String,
/// Persistent native wallet alias selected by configuration, when present.
pub(crate) wallet_alias: std::option::Option<std::string::String>,
/// Whether the managed temporary wallet is enabled.
pub(crate) temporary_wallet_enabled: bool,
/// Temporary wallet alias, which is internal but not secret.
@@ -387,6 +389,7 @@ fn diagnostic_config_payload(state: &crate::AppState) -> DemoConfigDiagnosticPay
wallet: DemoConfigWalletDiagnosticPayload {
wallet_directory_state: configured_state(profile.wallet.wallet_dir.as_str()),
cluster: profile.wallet.cluster.clone(),
wallet_alias: profile.wallet.wallet_alias.clone(),
temporary_wallet_enabled: profile.wallet.temporary_wallet_enabled,
temporary_wallet_alias: profile.wallet.temporary_wallet_alias.clone(),
temporary_wallet_persist: profile.wallet.temporary_wallet_persist,
@@ -547,6 +550,7 @@ mod tests {
wallet: super::DemoConfigWalletDiagnosticPayload {
wallet_directory_state: super::configured_state(profile.wallet.wallet_dir.as_str()),
cluster: profile.wallet.cluster.clone(),
wallet_alias: profile.wallet.wallet_alias.clone(),
temporary_wallet_enabled: profile.wallet.temporary_wallet_enabled,
temporary_wallet_alias: profile.wallet.temporary_wallet_alias.clone(),
temporary_wallet_persist: profile.wallet.temporary_wallet_persist,

View File

@@ -0,0 +1,559 @@
// file: kb-app-demo-desktop/src/demo_wallet.rs
// version: 4
//! UI-safe wallet management and public on-chain exploration.
use ts_rs::TS; // rust-rules: derive-import
const DEMO_WALLET_PASSWORD_ENV: &str = "KB_SECRET_DEMO_WALLET_PASSWORD";
const LAMPORTS_PER_SOL: u64 = 1_000_000_000;
/// UI-safe inventory of native wallets visible to the active profile.
#[derive(Clone, Debug, serde::Serialize, TS)]
#[ts(
export,
export_to = "../frontend/ts/bindings/kb_app_demo_desktop/demo_wallet/DemoWalletInventoryPayload.ts"
)]
pub(crate) struct DemoWalletInventoryPayload {
/// Active resolved application profile.
pub(crate) profile: std::string::String,
/// Active wallet cluster.
pub(crate) cluster: std::string::String,
/// Profiles available for explicit read-only on-chain exploration.
pub(crate) onchain_profiles: std::vec::Vec<DemoWalletOnchainProfilePayload>,
/// 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.
pub(crate) token_2022_program_id: std::string::String,
/// Whether the backend-only demo password environment variable is present.
pub(crate) create_password_configured: bool,
/// Persistent alias selected by configuration, when present.
pub(crate) selected_alias: std::option::Option<std::string::String>,
/// Structurally valid native wallet identities discovered in the configured directory.
pub(crate) wallets: std::vec::Vec<DemoWalletIdentityPayload>,
}
/// One UI-safe native wallet identity.
#[derive(Clone, Debug, serde::Serialize, TS)]
#[ts(
export,
export_to = "../frontend/ts/bindings/kb_app_demo_desktop/demo_wallet/DemoWalletIdentityPayload.ts"
)]
pub(crate) struct DemoWalletIdentityPayload {
/// Non-sensitive wallet alias.
pub(crate) alias: std::string::String,
/// Public key declared by the native wallet header.
pub(crate) public_key: std::string::String,
/// Native wallet format version.
pub(crate) format_version: u16,
/// Whether this alias is selected by the active profile.
pub(crate) selected: bool,
}
/// Request to create one native wallet in the active profile store.
#[derive(Clone, Debug, serde::Deserialize, TS)]
#[ts(
export,
export_to = "../frontend/ts/bindings/kb_app_demo_desktop/demo_wallet/DemoWalletCreateRequest.ts"
)]
pub(crate) struct DemoWalletCreateRequest {
/// Alias of the new persistent wallet.
pub(crate) alias: std::string::String,
}
/// Exact SOL balance returned for one public wallet address.
#[derive(Clone, Debug, serde::Serialize, TS)]
#[ts(
export,
export_to = "../frontend/ts/bindings/kb_app_demo_desktop/demo_wallet/DemoWalletBalancePayload.ts"
)]
pub(crate) struct DemoWalletBalancePayload {
/// Explicitly selected resolved application profile.
pub(crate) profile: std::string::String,
/// Cluster selected by that profile.
pub(crate) cluster: std::string::String,
/// Public address queried on-chain.
pub(crate) address: std::string::String,
/// Exact lamport balance represented as a decimal string.
pub(crate) lamports: std::string::String,
/// Exact SOL balance represented without floating-point conversion.
pub(crate) sol: std::string::String,
/// Endpoint selected for the balance query.
pub(crate) endpoint_name: std::string::String,
/// Provider selected for the balance query.
pub(crate) provider: std::string::String,
}
/// One selectable profile for read-only wallet on-chain exploration.
#[derive(Clone, Debug, serde::Serialize, TS)]
#[ts(
export,
export_to = "../frontend/ts/bindings/kb_app_demo_desktop/demo_wallet/DemoWalletOnchainProfilePayload.ts"
)]
pub(crate) struct DemoWalletOnchainProfilePayload {
/// Resolved profile name.
pub(crate) name: std::string::String,
/// Cluster selected by that profile.
pub(crate) cluster: std::string::String,
/// Whether this is the application-wide active profile.
pub(crate) active: bool,
}
/// Request for one wallet-scoped public JSON-RPC read on an explicitly selected profile.
#[derive(Clone, Debug, serde::Deserialize, TS)]
#[ts(
export,
export_to = "../frontend/ts/bindings/kb_app_demo_desktop/demo_wallet/DemoWalletRpcRequest.ts"
)]
pub(crate) struct DemoWalletRpcRequest {
/// Resolved profile name used for transport selection.
pub(crate) profile: std::string::String,
/// Allowed read-only Solana JSON-RPC method.
pub(crate) method: std::string::String,
/// Complete positional JSON-RPC parameters encoded as one JSON array.
pub(crate) params_json: std::string::String,
}
/// Result of one wallet-scoped public JSON-RPC read.
#[derive(Clone, Debug, serde::Serialize, TS)]
#[ts(
export,
export_to = "../frontend/ts/bindings/kb_app_demo_desktop/demo_wallet/DemoWalletRpcExecutionPayload.ts"
)]
pub(crate) struct DemoWalletRpcExecutionPayload {
/// Resolved profile name used by the request.
pub(crate) profile: std::string::String,
/// Cluster selected by that profile.
pub(crate) cluster: std::string::String,
/// Selected endpoint name.
pub(crate) endpoint_name: std::string::String,
/// Selected provider code.
pub(crate) provider: std::string::String,
/// JSON-RPC method name.
pub(crate) method: std::string::String,
/// Pretty JSON response text.
pub(crate) response_json: std::string::String,
}
/// Returns the native wallet inventory visible to the active profile without secret material.
pub(crate) async fn demo_wallet_inventory(
state: &crate::AppState,
) -> std::result::Result<DemoWalletInventoryPayload, std::string::String> {
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 handles = match manager.scan().await {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error.to_string()),
};
let selected_alias = profile.wallet.wallet_alias.clone();
let mut wallets = std::vec::Vec::with_capacity(handles.len());
for handle in handles {
wallets.push(wallet_identity_payload(&handle, selected_alias.as_deref()));
}
return std::result::Result::Ok(DemoWalletInventoryPayload {
profile: profile.name.clone(),
cluster: profile.wallet.cluster.clone(),
onchain_profiles: onchain_profile_payloads(state),
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(),
selected_alias,
wallets,
});
}
/// Creates one password-protected native wallet without sending the password through Tauri IPC.
pub(crate) async fn demo_wallet_create_native(
state: &crate::AppState,
request: crate::DemoWalletCreateRequest,
) -> std::result::Result<DemoWalletIdentityPayload, 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 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"
));
},
};
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()),
};
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.create(alias, password).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);
}
/// Inspects one explicitly selected native wallet file and returns only its safe identity.
pub(crate) async fn demo_wallet_inspect_file(
state: &crate::AppState,
path: std::string::String,
) -> std::result::Result<DemoWalletIdentityPayload, std::string::String> {
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 handle = match manager.inspect_file(std::path::PathBuf::from(path)).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(wallet_identity_payload(
&handle,
profile.wallet.wallet_alias.as_deref(),
));
}
/// Loads the exact SOL balance of one public address through an explicitly selected profile.
pub(crate) async fn demo_wallet_balance(
state: &crate::AppState,
profile: std::string::String,
address: std::string::String,
) -> std::result::Result<crate::DemoWalletBalancePayload, std::string::String> {
let selected_profile = match onchain_profile(state, &profile) {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let pool = match ks_onchain_transport::HttpEndpointPool::from_profile(selected_profile) {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error.to_string()),
};
let address = address.trim().to_string();
if address.is_empty() {
return std::result::Result::Err("wallet address must not be empty".to_string());
}
let role = match best_http_role_for_method(&pool, "getBalance") {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let client = match pool.select_client_for_role_and_method(&role, "getBalance") {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error.to_string()),
};
let result = match client
.get_balance(
&ks_lib::MdPubkey(address.clone()),
&ks_onchain_transport::GetBalanceConfig::confirmed(),
)
.await
{
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error.to_string()),
};
return std::result::Result::Ok(crate::DemoWalletBalancePayload {
profile: selected_profile.name.clone(),
cluster: selected_profile.wallet.cluster.clone(),
address,
lamports: result.lamports.to_string(),
sol: format_lamports_as_sol(result.lamports),
endpoint_name: client.endpoint_name().to_string(),
provider: client.provider().to_string(),
});
}
/// Executes one bounded public wallet explorer RPC through an explicitly selected profile.
pub(crate) async fn demo_wallet_rpc_execute(
state: &crate::AppState,
request: crate::DemoWalletRpcRequest,
) -> std::result::Result<crate::DemoWalletRpcExecutionPayload, std::string::String> {
let selected_profile = match onchain_profile(state, &request.profile) {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let method = request.method.trim().to_string();
if !wallet_rpc_method_allowed(&method) {
return std::result::Result::Err(format!(
"wallet explorer RPC method '{method}' is not allowed"
));
}
let params = match serde_json::from_str::<std::vec::Vec<serde_json::Value>>(
request.params_json.as_str(),
) {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => {
return std::result::Result::Err(format!(
"wallet explorer params must be a JSON array: {error}"
));
},
};
let pool = match ks_onchain_transport::HttpEndpointPool::from_profile(selected_profile) {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error.to_string()),
};
let role = match best_http_role_for_method(&pool, &method) {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let client = match pool.select_client_for_role_and_method(&role, &method) {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error.to_string()),
};
let response = match client.execute_json_rpc_result_raw(method.clone(), params).await {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error.to_string()),
};
let response_json = match serde_json::to_string_pretty(&response) {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error.to_string()),
};
return std::result::Result::Ok(crate::DemoWalletRpcExecutionPayload {
profile: selected_profile.name.clone(),
cluster: selected_profile.wallet.cluster.clone(),
endpoint_name: client.endpoint_name().to_string(),
provider: client.provider().to_string(),
method,
response_json,
});
}
fn onchain_profile<'a>(
state: &'a crate::AppState,
profile_name: &str,
) -> std::result::Result<&'a ks_config::ProfileConfig, std::string::String> {
let profile_name = profile_name.trim();
if profile_name.is_empty() {
return std::result::Result::Err("wallet on-chain profile must not be empty".to_string());
}
for profile in &state.app_config().profiles {
if profile.name == profile_name {
return std::result::Result::Ok(profile);
}
}
return std::result::Result::Err(format!(
"wallet on-chain profile '{profile_name}' is not configured"
));
}
fn onchain_profile_payloads(
state: &crate::AppState,
) -> std::vec::Vec<crate::DemoWalletOnchainProfilePayload> {
let mut profiles = std::vec::Vec::new();
for profile in &state.app_config().profiles {
let has_enabled_http =
profile.solana.http_endpoints.iter().any(|endpoint| return endpoint.enabled);
if !has_enabled_http {
continue;
}
profiles.push(crate::DemoWalletOnchainProfilePayload {
name: profile.name.clone(),
cluster: profile.wallet.cluster.clone(),
active: profile.name == state.active_profile().name,
});
}
return profiles;
}
fn wallet_rpc_method_allowed(method: &str) -> bool {
return method == "getTokenAccountsByOwner"
|| method == "getSignaturesForAddress"
|| method == "getTransaction";
}
fn wallet_manager_for_profile(
profile: &ks_config::ProfileConfig,
) -> std::result::Result<ks_wallet::WalletManager, std::string::String> {
let configured = std::path::PathBuf::from(profile.wallet.wallet_dir.as_str());
let directory = if configured.is_absolute() {
configured
} else {
crate::workspace_root_dir().join(configured)
};
return match ks_wallet::WalletManager::new(directory) {
std::result::Result::Ok(value) => std::result::Result::Ok(value),
std::result::Result::Err(error) => std::result::Result::Err(error.to_string()),
};
}
fn wallet_identity_payload(
handle: &ks_wallet::WalletFileHandle,
selected_alias: std::option::Option<&str>,
) -> crate::DemoWalletIdentityPayload {
return crate::DemoWalletIdentityPayload {
selected: selected_alias == std::option::Option::Some(handle.alias().as_str()),
alias: handle.alias().as_str().to_string(),
public_key: handle.public_key().to_string(),
format_version: handle.format_version(),
};
}
fn best_http_role_for_method(
pool: &ks_onchain_transport::HttpEndpointPool,
method: &str,
) -> std::result::Result<std::string::String, std::string::String> {
let request_kind = ks_onchain_transport::request_kind_from_method(method);
let mut selected = std::option::Option::<(u32, std::string::String)>::None;
for endpoint in pool.snapshot() {
for role in endpoint.roles {
if !role.enabled {
continue;
}
let supports_kind = role
.request_kinds
.iter()
.any(|kind| return kind == &request_kind || kind == "*");
if !supports_kind {
continue;
}
let replace = match &selected {
std::option::Option::Some((priority, selected_role)) => {
role.priority < *priority
|| (role.priority == *priority && role.role < *selected_role)
},
std::option::Option::None => true,
};
if replace {
selected = std::option::Option::Some((role.priority, role.role));
}
}
}
return match selected {
std::option::Option::Some((_, role)) => std::result::Result::Ok(role),
std::option::Option::None => std::result::Result::Err(format!(
"active HTTP profile has no role for request kind '{request_kind}'"
)),
};
}
fn format_lamports_as_sol(lamports: u64) -> std::string::String {
let whole = lamports / LAMPORTS_PER_SOL;
let fraction = lamports % LAMPORTS_PER_SOL;
if fraction == 0 {
return whole.to_string();
}
let fraction_text = format!("{fraction:09}");
let trimmed = fraction_text.trim_end_matches('0');
return format!("{whole}.{trimmed}");
}
#[cfg(test)]
mod tests {
#[test]
fn wallet_frontend_is_wired_to_management_and_onchain_read_commands() {
let html = include_str!("../frontend/demo_wallet.html");
let script = include_str!("../frontend/ts/demo_wallet.ts");
let main_html = include_str!("../frontend/main.html");
let main_script = include_str!("../frontend/ts/main.ts");
let tauri_runtime = include_str!("tauri.rs");
let capability = include_str!("../capabilities/default.json");
let vite_config = include_str!("../vite.config.ts");
for required in [
"createWalletAliasInput",
"createWalletButton",
"refreshWalletInventoryButton",
"walletInventoryTableBody",
"walletOnchainProfileSelect",
"walletAddressInput",
"loadWalletOnchainButton",
"walletTokensTableBody",
"walletTransactionsTableBody",
"walletTransactionJson",
"walletExternalPathInput",
"inspectWalletFileButton",
"walletInspectionJson",
] {
assert!(html.contains(required));
}
assert!(script.contains("demo_wallet_create_native"));
assert!(script.contains("demo_wallet_balance"));
assert!(script.contains("demo_wallet_inventory"));
assert!(script.contains("demo_wallet_rpc_execute"));
assert!(script.contains("demo_wallet_inspect_file"));
assert!(!script.contains("demo_http_execute_request"));
assert!(script.contains("getTokenAccountsByOwner"));
assert!(script.contains("getSignaturesForAddress"));
assert!(script.contains("getTransaction"));
assert!(!html.to_ascii_lowercase().contains("type=\"password\""));
assert!(main_html.contains("openDemoWalletLink"));
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_balance"));
assert!(tauri_runtime.contains("demo_wallet_rpc_execute"));
assert!(tauri_runtime.contains("demo_wallet.html"));
assert!(capability.contains("\"demo_wallet\""));
assert!(vite_config.contains("frontend/demo_wallet.html"));
}
#[test]
fn wallet_inventory_payload_contains_only_explicit_safe_fields() {
let payload = super::DemoWalletInventoryPayload {
profile: "mainnet_research".to_string(),
cluster: "mainnet-beta".to_string(),
onchain_profiles: std::vec![super::DemoWalletOnchainProfilePayload {
name: "local_devnet".to_string(),
cluster: "devnet".to_string(),
active: false,
}],
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,
selected_alias: std::option::Option::Some("selected-wallet".to_string()),
wallets: std::vec![super::DemoWalletIdentityPayload {
alias: "selected-wallet".to_string(),
public_key: "11111111111111111111111111111111".to_string(),
format_version: 1,
selected: true,
}],
};
let serialized = match serde_json::to_string(&payload) {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => panic!("wallet inventory must serialize: {error}"),
};
for forbidden in [
"canary-wallet-password",
"ciphertext",
"nonce",
"salt",
"wallet_path",
"storage_path",
".kswallet",
"http://",
"https://",
] {
assert!(!serialized.contains(forbidden));
}
}
#[test]
fn wallet_rpc_surface_is_read_only_and_bounded() {
for allowed in ["getTokenAccountsByOwner", "getSignaturesForAddress", "getTransaction"] {
assert!(super::wallet_rpc_method_allowed(allowed));
}
for forbidden in
["requestAirdrop", "sendTransaction", "simulateTransaction", "getProgramAccounts"]
{
assert!(!super::wallet_rpc_method_allowed(forbidden));
}
}
#[test]
fn lamport_formatting_is_exact_without_float_conversion() {
assert_eq!(super::format_lamports_as_sol(0), "0");
assert_eq!(super::format_lamports_as_sol(1), "0.000000001");
assert_eq!(super::format_lamports_as_sol(1_500_000_000), "1.5");
assert_eq!(super::format_lamports_as_sol(u64::MAX), "18446744073.709551615");
}
}

View File

@@ -1,5 +1,5 @@
// file: kb-app-demo-desktop/src/lib.rs
// version: 37
// version: 40
//! Tauri desktop demo application for `khadhroony-bot3`.
@@ -30,6 +30,7 @@ mod demo_sql_pg_core;
mod demo_sql_pg_raw;
mod demo_sql_replay_candidates;
mod demo_transport;
mod demo_wallet;
mod demo_ws;
mod desktop_config;
mod frontend_log;
@@ -362,6 +363,30 @@ pub(crate) use self::demo_sql_replay_candidates::load_demo_sql_replay_programs;
pub(crate) use self::demo_sql_replay_candidates::load_demo_sql_replay_transactions;
/// UI-safe endpoint role shared by HTTP and WebSocket diagnostics.
pub(crate) use self::demo_transport::DemoEndpointRolePayload;
/// Exact public SOL balance projected by the wallet demo.
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;
/// UI-safe native wallet identity exposed by the desktop adapter.
pub(crate) use self::demo_wallet::DemoWalletIdentityPayload;
/// 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.
pub(crate) use self::demo_wallet::DemoWalletOnchainProfilePayload;
/// Result of one wallet-scoped public JSON-RPC read.
pub(crate) use self::demo_wallet::DemoWalletRpcExecutionPayload;
/// Request for one wallet-scoped public JSON-RPC read.
pub(crate) use self::demo_wallet::DemoWalletRpcRequest;
/// 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;
/// Inspects an explicitly selected native wallet file without exposing its path in the result.
pub(crate) use self::demo_wallet::demo_wallet_inspect_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.
pub(crate) use self::demo_wallet::demo_wallet_rpc_execute;
/// UI-safe WebSocket endpoint snapshot.
pub(crate) use self::demo_ws::DemoWsEndpointPayload;
/// WebSocket execution response payload.

View File

@@ -1,5 +1,5 @@
// file: kb-app-demo-desktop/src/tauri.rs
// version: 38
// version: 42
//! Tauri runtime assembly and private command wrappers.
@@ -36,6 +36,12 @@ pub fn run() -> ks_core::Result<()> {
load_project_readme,
open_demo_config_window,
load_demo_config,
open_demo_wallet_window,
demo_wallet_inventory,
demo_wallet_create_native,
demo_wallet_balance,
demo_wallet_inspect_file,
demo_wallet_rpc_execute,
open_demo_http_window,
demo_http_list_pool_clients,
demo_http_options,
@@ -235,6 +241,53 @@ fn load_demo_config(state: tauri::State<'_, crate::AppState>) -> crate::DemoConf
return crate::demo_config_payload(state.inner());
}
#[tauri::command]
fn open_demo_wallet_window(
app_handle: tauri::AppHandle,
) -> std::result::Result<(), std::string::String> {
return open_or_focus_demo_window(app_handle, "demo_wallet", "demo_wallet.html", "Wallets");
}
#[tauri::command]
async fn demo_wallet_inventory(
state: tauri::State<'_, crate::AppState>,
) -> std::result::Result<crate::DemoWalletInventoryPayload, std::string::String> {
return crate::demo_wallet_inventory(state.inner()).await;
}
#[tauri::command]
async fn demo_wallet_create_native(
state: tauri::State<'_, crate::AppState>,
request: crate::DemoWalletCreateRequest,
) -> std::result::Result<crate::DemoWalletIdentityPayload, std::string::String> {
return crate::demo_wallet_create_native(state.inner(), request).await;
}
#[tauri::command]
async fn demo_wallet_balance(
state: tauri::State<'_, crate::AppState>,
profile: std::string::String,
address: std::string::String,
) -> std::result::Result<crate::DemoWalletBalancePayload, std::string::String> {
return crate::demo_wallet_balance(state.inner(), profile, address).await;
}
#[tauri::command]
async fn demo_wallet_inspect_file(
state: tauri::State<'_, crate::AppState>,
path: std::string::String,
) -> std::result::Result<crate::DemoWalletIdentityPayload, std::string::String> {
return crate::demo_wallet_inspect_file(state.inner(), path).await;
}
#[tauri::command]
async fn demo_wallet_rpc_execute(
state: tauri::State<'_, crate::AppState>,
request: crate::DemoWalletRpcRequest,
) -> std::result::Result<crate::DemoWalletRpcExecutionPayload, std::string::String> {
return crate::demo_wallet_rpc_execute(state.inner(), request).await;
}
#[tauri::command]
fn open_demo_backfill_window(
app_handle: tauri::AppHandle,

View File

@@ -1,5 +1,5 @@
// file: kb-app-demo-desktop/vite.config.ts
// version: 9
// version: 10
import { defineConfig, normalizePath } from "vite";
import { NodePackageImporter } from "sass-embedded";
@@ -24,6 +24,7 @@ export default defineConfig(() => ({
"splash": normalizePath(resolve(__dirname, 'frontend/splash.html')),
"main": normalizePath(resolve(__dirname, 'frontend/main.html')),
"demo_config": normalizePath(resolve(__dirname, 'frontend/demo_config.html')),
"demo_wallet": normalizePath(resolve(__dirname, 'frontend/demo_wallet.html')),
"demo_http": normalizePath(resolve(__dirname, 'frontend/demo_http.html')),
"demo_ws": normalizePath(resolve(__dirname, 'frontend/demo_ws.html')),
"demo_backfill": normalizePath(resolve(__dirname, 'frontend/demo_backfill.html')),