v0.5.2-pre.006-fix-010

This commit is contained in:
2026-08-11 12:03:35 +02:00
parent 1ee1d0297d
commit 56572cec40
67 changed files with 2484 additions and 539 deletions

View File

@@ -1,11 +1,12 @@
// file: kb-app-demo-desktop/src/demo_wallet.rs
// version: 5
// version: 7
//! UI-safe wallet management and public on-chain exploration.
#[cfg(unix)]
use std::os::unix::fs::PermissionsExt; // rust-rules: trait-import
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.
@@ -21,6 +22,10 @@ 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<DemoWalletOnchainProfilePayload>,
/// Devnet profiles and their session/config execution-wallet selections.
pub(crate) execution_profiles: std::vec::Vec<DemoWalletExecutionProfilePayload>,
/// Workspace-relative directory used for secret exports from this demo.
pub(crate) export_directory: std::string::String,
/// Secret transfer formats supported by the reusable wallet boundary.
pub(crate) transfer_formats: std::vec::Vec<DemoWalletTransferFormatPayload>,
/// Canonical classic SPL Token program identifier used by the explorer.
@@ -119,7 +124,7 @@ pub(crate) struct DemoWalletImportRequest {
pub(crate) format: std::string::String,
}
/// Request to export one native wallet to an explicit external file.
/// Request to export one native wallet to the application-owned export directory.
#[derive(Clone, Debug, serde::Deserialize, TS)]
#[ts(
export,
@@ -128,12 +133,42 @@ pub(crate) struct DemoWalletImportRequest {
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,
/// File name created inside the application-owned wallet export directory.
pub(crate) file_name: std::string::String,
/// Stable transfer format code selected by the operator.
pub(crate) format: std::string::String,
}
/// One Devnet profile and its effective desktop-session execution-wallet selection.
#[derive(Clone, Debug, serde::Serialize, TS)]
#[ts(
export,
export_to = "../frontend/ts/bindings/kb_app_demo_desktop/demo_wallet/DemoWalletExecutionProfilePayload.ts"
)]
pub(crate) struct DemoWalletExecutionProfilePayload {
/// Resolved Devnet profile name.
pub(crate) name: std::string::String,
/// Alias persisted in wallet configuration, when present.
pub(crate) configured_alias: std::option::Option<std::string::String>,
/// Session-only desktop override, when present.
pub(crate) runtime_alias: std::option::Option<std::string::String>,
/// Effective alias used by execution demos, or `None` for the temporary fallback.
pub(crate) effective_alias: std::option::Option<std::string::String>,
}
/// Request to set or clear one session-only execution-wallet selection.
#[derive(Clone, Debug, serde::Deserialize, TS)]
#[ts(
export,
export_to = "../frontend/ts/bindings/kb_app_demo_desktop/demo_wallet/DemoWalletExecutionSelectionRequest.ts"
)]
pub(crate) struct DemoWalletExecutionSelectionRequest {
/// Devnet profile whose execution wallet is selected.
pub(crate) profile: std::string::String,
/// Native wallet alias, or `None` to return to profile configuration.
pub(crate) alias: std::option::Option<std::string::String>,
}
/// Exact SOL balance returned for one public wallet address.
#[derive(Clone, Debug, serde::Serialize, TS)]
#[ts(
@@ -226,19 +261,87 @@ pub(crate) async fn demo_wallet_inventory(
for handle in handles {
wallets.push(wallet_identity_payload(&handle, selected_alias.as_deref()));
}
let execution_profiles = match execution_profile_payloads(state) {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
return std::result::Result::Ok(DemoWalletInventoryPayload {
profile: profile.name.clone(),
cluster: profile.wallet.cluster.clone(),
onchain_profiles: onchain_profile_payloads(state),
execution_profiles,
export_directory: "data/wallets".to_string(),
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(),
create_password_configured: crate::demo_wallet_password_configured(),
selected_alias,
wallets,
});
}
/// Sets or clears the session-only native wallet used by Devnet execution demos.
pub(crate) async fn demo_wallet_select_execution_wallet(
state: &crate::AppState,
request: crate::DemoWalletExecutionSelectionRequest,
) -> std::result::Result<DemoWalletInventoryPayload, std::string::String> {
let profile = match devnet_execution_profile(state, request.profile.as_str()) {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let profile_name = profile.name.clone();
if let std::option::Option::Some(alias_text) = request.alias {
let alias = match ks_wallet::WalletAlias::parse(alias_text.trim().to_string()) {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error.to_string()),
};
let password = match crate::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(profile) {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let wallet = match manager.unlock(&alias, password).await {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error.to_string()),
};
let public_key = wallet.public_key();
wallet.lock();
if let std::result::Result::Err(error) = state.set_demo_execution_wallet_alias_override(
profile_name.clone(),
std::option::Option::Some(alias.as_str().to_string()),
) {
return std::result::Result::Err(error);
}
tracing::info!(
target: crate::TRACING_TARGET,
action = "select_demo_execution_wallet",
profile = profile_name.as_str(),
wallet_alias = alias.as_str(),
public_key = %public_key,
selection_source = "runtime",
"selected authenticated native wallet for desktop execution demos"
);
} else {
if let std::result::Result::Err(error) = state.set_demo_execution_wallet_alias_override(
profile_name.clone(),
std::option::Option::None,
) {
return std::result::Result::Err(error);
}
tracing::info!(
target: crate::TRACING_TARGET,
action = "select_demo_execution_wallet",
profile = profile_name.as_str(),
selection_source = "configuration",
"cleared desktop execution-wallet override"
);
}
return demo_wallet_inventory(state).await;
}
/// Creates one password-protected native wallet without sending the password through Tauri IPC.
pub(crate) async fn demo_wallet_create_native(
state: &crate::AppState,
@@ -248,7 +351,7 @@ 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 = match demo_wallet_password() {
let password = match crate::demo_wallet_password() {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
@@ -309,7 +412,7 @@ pub(crate) async fn demo_wallet_import_file(
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let password = match demo_wallet_password() {
let password = match crate::demo_wallet_password() {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
@@ -341,7 +444,7 @@ pub(crate) async fn demo_wallet_import_file(
pub(crate) async fn demo_wallet_export_file(
state: &crate::AppState,
request: crate::DemoWalletExportRequest,
) -> std::result::Result<(), std::string::String> {
) -> std::result::Result<std::string::String, 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()),
@@ -350,7 +453,15 @@ pub(crate) async fn demo_wallet_export_file(
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let password = match demo_wallet_password() {
let file_name = match validated_wallet_export_file_name(request.file_name.as_str(), format) {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let export_directory = match prepare_wallet_export_directory().await {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let password = match crate::demo_wallet_password() {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
@@ -358,13 +469,21 @@ pub(crate) async fn demo_wallet_export_file(
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
let destination = export_directory.join(file_name.as_str());
if let std::result::Result::Err(error) =
manager.export_file(&alias, password, destination, format).await
{
std::result::Result::Ok(()) => std::result::Result::Ok(()),
std::result::Result::Err(error) => std::result::Result::Err(error.to_string()),
};
return std::result::Result::Err(error.to_string());
}
tracing::info!(
target: crate::TRACING_TARGET,
action = "export_wallet_secret",
wallet_alias = alias.as_str(),
transfer_format = format.code(),
export_file_name = file_name.as_str(),
"exported native wallet secret to application-owned data directory"
);
return std::result::Result::Ok(format!("data/wallets/{file_name}"));
}
/// Inspects one explicitly selected native wallet file and returns only its safe identity.
@@ -489,21 +608,6 @@ pub(crate) async fn demo_wallet_rpc_execute(
});
}
fn demo_wallet_password() -> std::result::Result<ks_wallet::WalletPassword, std::string::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"
));
},
};
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<crate::DemoWalletTransferFormatPayload> {
let mut formats = std::vec::Vec::new();
for format in ks_wallet::WalletTransferFormat::supported() {
@@ -528,6 +632,127 @@ fn wallet_transfer_format_from_code(
return std::result::Result::Err(format!("wallet transfer format '{code}' is not supported"));
}
fn execution_profile_payloads(
state: &crate::AppState,
) -> std::result::Result<std::vec::Vec<crate::DemoWalletExecutionProfilePayload>, std::string::String>
{
let mut profiles = std::vec::Vec::new();
for profile in &state.app_config().profiles {
if profile.wallet.cluster != "devnet" {
continue;
}
let runtime_alias = match state.demo_execution_wallet_alias_override(profile.name.as_str())
{
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let effective_alias = match runtime_alias.as_ref() {
std::option::Option::Some(value) => std::option::Option::Some(value.clone()),
std::option::Option::None => profile.wallet.wallet_alias.clone(),
};
profiles.push(crate::DemoWalletExecutionProfilePayload {
name: profile.name.clone(),
configured_alias: profile.wallet.wallet_alias.clone(),
runtime_alias,
effective_alias,
});
}
return std::result::Result::Ok(profiles);
}
fn devnet_execution_profile<'a>(
state: &'a crate::AppState,
profile_name: &str,
) -> std::result::Result<&'a ks_config::ProfileConfig, std::string::String> {
let profile = match onchain_profile(state, profile_name) {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
if profile.wallet.cluster != "devnet" {
return std::result::Result::Err(
"wallet execution selection requires a Devnet profile".to_string(),
);
}
return std::result::Result::Ok(profile);
}
fn validated_wallet_export_file_name(
file_name: &str,
format: ks_wallet::WalletTransferFormat,
) -> std::result::Result<std::string::String, std::string::String> {
let file_name = file_name.trim();
if file_name.is_empty() || file_name.len() > 128 {
return std::result::Result::Err(
"wallet export file name must contain between 1 and 128 bytes".to_string(),
);
}
if file_name == "." || file_name == ".." || file_name.starts_with('.') {
return std::result::Result::Err("wallet export file name is invalid".to_string());
}
if !file_name.chars().all(|value| {
return value.is_ascii_alphanumeric() || value == '-' || value == '_' || value == '.';
}) {
return std::result::Result::Err(
"wallet export file name contains unsupported characters".to_string(),
);
}
let expected_extension = format.default_extension();
let path = std::path::Path::new(file_name);
if path.extension().is_none() {
return std::result::Result::Ok(format!("{file_name}.{expected_extension}"));
}
if path.extension().and_then(std::ffi::OsStr::to_str)
!= std::option::Option::Some(expected_extension)
{
return std::result::Result::Err(format!(
"wallet export file extension must be '.{expected_extension}' for format '{}'",
format.code()
));
}
return std::result::Result::Ok(file_name.to_string());
}
async fn prepare_wallet_export_directory()
-> std::result::Result<std::path::PathBuf, std::string::String> {
let data_directory = crate::workspace_root_dir().join("data");
if let std::result::Result::Ok(metadata) = tokio::fs::symlink_metadata(&data_directory).await {
if metadata.file_type().is_symlink() || !metadata.is_dir() {
return std::result::Result::Err(
"wallet export data directory must be a regular directory".to_string(),
);
}
}
if let std::result::Result::Err(error) = tokio::fs::create_dir_all(&data_directory).await {
return std::result::Result::Err(format!(
"cannot create wallet export data directory: {error}"
));
}
let export_directory = data_directory.join("wallets");
if let std::result::Result::Ok(metadata) = tokio::fs::symlink_metadata(&export_directory).await
{
if metadata.file_type().is_symlink() || !metadata.is_dir() {
return std::result::Result::Err(
"wallet export directory must be a regular directory".to_string(),
);
}
}
if let std::result::Result::Err(error) = tokio::fs::create_dir_all(&export_directory).await {
return std::result::Result::Err(format!("cannot create wallet export directory: {error}"));
}
#[cfg(unix)]
{
let permissions = std::fs::Permissions::from_mode(0o700);
if let std::result::Result::Err(error) =
tokio::fs::set_permissions(&export_directory, permissions).await
{
return std::result::Result::Err(format!(
"cannot secure wallet export directory permissions: {error}"
));
}
}
return std::result::Result::Ok(export_directory);
}
fn onchain_profile<'a>(
state: &'a crate::AppState,
profile_name: &str,
@@ -676,14 +901,19 @@ mod tests {
"importWalletTransferButton",
"walletExportAliasSelect",
"walletExportFormatSelect",
"walletExportDestinationPathInput",
"walletExportFileNameInput",
"walletExportDirectory",
"exportWalletTransferButton",
"walletExecutionProfileSelect",
"walletExecutionAliasSelect",
"applyWalletExecutionSelectionButton",
"walletExternalPathInput",
"inspectWalletFileButton",
"walletInspectionJson",
] {
assert!(html.contains(required));
}
assert!(script.contains("demo_wallet_select_execution_wallet"));
assert!(script.contains("demo_wallet_create_native"));
assert!(script.contains("demo_wallet_inspect_transfer_file"));
assert!(script.contains("demo_wallet_import_file"));
@@ -700,6 +930,7 @@ mod tests {
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_select_execution_wallet"));
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"));
@@ -721,6 +952,13 @@ mod tests {
cluster: "devnet".to_string(),
active: false,
}],
execution_profiles: std::vec![super::DemoWalletExecutionProfilePayload {
name: "local_devnet".to_string(),
configured_alias: std::option::Option::None,
runtime_alias: std::option::Option::Some("selected-wallet".to_string()),
effective_alias: std::option::Option::Some("selected-wallet".to_string()),
}],
export_directory: "data/wallets".to_string(),
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(),
@@ -752,6 +990,34 @@ mod tests {
}
}
#[test]
fn wallet_export_file_name_is_bounded_and_format_specific() {
let json = super::validated_wallet_export_file_name(
"operator",
ks_wallet::WalletTransferFormat::SolanaCliJson,
);
assert_eq!(json, std::result::Result::Ok("operator.json".to_string()));
let base58 = super::validated_wallet_export_file_name(
"operator.txt",
ks_wallet::WalletTransferFormat::SolanaPrivateKeyBase58,
);
assert_eq!(base58, std::result::Result::Ok("operator.txt".to_string()));
assert!(
super::validated_wallet_export_file_name(
"../operator.json",
ks_wallet::WalletTransferFormat::SolanaCliJson,
)
.is_err()
);
assert!(
super::validated_wallet_export_file_name(
"operator.txt",
ks_wallet::WalletTransferFormat::SolanaCliJson,
)
.is_err()
);
}
#[test]
fn wallet_rpc_surface_is_read_only_and_bounded() {
for allowed in ["getTokenAccountsByOwner", "getSignaturesForAddress", "getTransaction"] {