v0.5.2-pre.006-fix-010
This commit is contained in:
@@ -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"] {
|
||||
|
||||
Reference in New Issue
Block a user