Add complete NFT (non-fungible token) functionality: Backend (Rust/Tauri): - nft_create_collection: Deploy new NFT collection contract - nft_mint, nft_batch_mint: Mint single or multiple NFTs - nft_transfer: Transfer NFT ownership - nft_burn: Permanently destroy NFT - nft_list_owned: List all NFTs owned by address - nft_get_collection_info, nft_get_token_info: Query metadata - nft_set_approval_for_all, nft_set_base_uri: Collection management Frontend (React/TypeScript): - NFT Zustand store with collection tracking - NftsDashboard page with 5 tabs: - Gallery: Visual grid of owned NFTs with modal details - Collections: Track and manage NFT collections - Create: Deploy new collection with royalties and soulbound options - Mint: Mint new NFTs with metadata URIs - Transfer: Send NFTs to other addresses - Navigation sidebar updated with NFTs link - Route added for /nfts path Features: - Royalty configuration in basis points (e.g., 250 = 2.5%) - Soulbound token support (non-transferable) - Batch minting up to 100 NFTs - Collection import by contract address - NFT burn with confirmation dialog
446 lines
15 KiB
Rust
446 lines
15 KiB
Rust
//! Synor Desktop Wallet - Tauri Backend
|
|
//!
|
|
//! Provides native functionality for the desktop wallet:
|
|
//! - Secure key storage using OS keychain
|
|
//! - Direct RPC communication with Synor nodes
|
|
//! - Transaction signing with Dilithium3 post-quantum signatures
|
|
//! - File system access for wallet backups
|
|
//! - System tray for background operation
|
|
//! - Auto-updates for seamless upgrades
|
|
|
|
mod commands;
|
|
mod crypto;
|
|
mod error;
|
|
mod keychain;
|
|
mod node;
|
|
mod rpc_client;
|
|
mod wallet;
|
|
|
|
use tauri::{
|
|
menu::{Menu, MenuItem},
|
|
tray::{MouseButton, MouseButtonState, TrayIconBuilder, TrayIconEvent},
|
|
Emitter, Manager, Runtime,
|
|
};
|
|
|
|
pub use error::{Error, Result};
|
|
|
|
/// Build the system tray menu
|
|
fn build_tray_menu<R: Runtime>(app: &tauri::AppHandle<R>) -> tauri::Result<Menu<R>> {
|
|
let show = MenuItem::with_id(app, "show", "Show Wallet", true, None::<&str>)?;
|
|
let hide = MenuItem::with_id(app, "hide", "Hide to Tray", true, None::<&str>)?;
|
|
let separator1 = MenuItem::with_id(app, "sep1", "─────────────", false, None::<&str>)?;
|
|
let lock = MenuItem::with_id(app, "lock", "Lock Wallet", true, None::<&str>)?;
|
|
let separator2 = MenuItem::with_id(app, "sep2", "─────────────", false, None::<&str>)?;
|
|
let check_updates = MenuItem::with_id(app, "check_updates", "Check for Updates", true, None::<&str>)?;
|
|
let separator3 = MenuItem::with_id(app, "sep3", "─────────────", false, None::<&str>)?;
|
|
let quit = MenuItem::with_id(app, "quit", "Quit", true, None::<&str>)?;
|
|
|
|
Menu::with_items(
|
|
app,
|
|
&[
|
|
&show,
|
|
&hide,
|
|
&separator1,
|
|
&lock,
|
|
&separator2,
|
|
&check_updates,
|
|
&separator3,
|
|
&quit,
|
|
],
|
|
)
|
|
}
|
|
|
|
/// Handle system tray menu events
|
|
fn handle_tray_event<R: Runtime>(app: &tauri::AppHandle<R>, event: TrayIconEvent) {
|
|
match event {
|
|
TrayIconEvent::Click {
|
|
button: MouseButton::Left,
|
|
button_state: MouseButtonState::Up,
|
|
..
|
|
} => {
|
|
// Left click: show and focus the window
|
|
if let Some(window) = app.get_webview_window("main") {
|
|
let _ = window.show();
|
|
let _ = window.set_focus();
|
|
}
|
|
}
|
|
_ => {}
|
|
}
|
|
}
|
|
|
|
/// Handle tray menu item clicks
|
|
fn handle_menu_event<R: Runtime>(app: &tauri::AppHandle<R>, event: tauri::menu::MenuEvent) {
|
|
match event.id().as_ref() {
|
|
"show" => {
|
|
if let Some(window) = app.get_webview_window("main") {
|
|
let _ = window.show();
|
|
let _ = window.set_focus();
|
|
}
|
|
}
|
|
"hide" => {
|
|
if let Some(window) = app.get_webview_window("main") {
|
|
let _ = window.hide();
|
|
}
|
|
}
|
|
"lock" => {
|
|
// Emit lock event to frontend
|
|
let _ = app.emit("wallet-lock", ());
|
|
}
|
|
"check_updates" => {
|
|
// Emit update check event to frontend
|
|
let _ = app.emit("check-updates", ());
|
|
}
|
|
"quit" => {
|
|
// Emit quit event to allow cleanup
|
|
let _ = app.emit("app-quit", ());
|
|
std::process::exit(0);
|
|
}
|
|
_ => {}
|
|
}
|
|
}
|
|
|
|
/// Initialize the Tauri application with all plugins and commands
|
|
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
|
pub fn run() {
|
|
tauri::Builder::default()
|
|
// Core plugins
|
|
.plugin(tauri_plugin_fs::init())
|
|
.plugin(tauri_plugin_store::Builder::default().build())
|
|
.plugin(tauri_plugin_shell::init())
|
|
.plugin(tauri_plugin_dialog::init())
|
|
.plugin(tauri_plugin_clipboard_manager::init())
|
|
// New plugins for desktop features
|
|
.plugin(tauri_plugin_updater::Builder::new().build())
|
|
.plugin(tauri_plugin_notification::init())
|
|
.plugin(tauri_plugin_process::init())
|
|
.setup(|app| {
|
|
// Initialize wallet state
|
|
let wallet_state = wallet::WalletState::new();
|
|
app.manage(wallet_state);
|
|
|
|
// Initialize node manager with app handle for events
|
|
let node_manager = std::sync::Arc::new(
|
|
node::NodeManager::with_app_handle(app.handle().clone())
|
|
);
|
|
|
|
// Initialize RPC client
|
|
let rpc_client = std::sync::Arc::new(
|
|
rpc_client::RpcClient::new(node_manager.clone())
|
|
);
|
|
|
|
// Initialize app state (node + RPC)
|
|
let app_state = commands::AppState {
|
|
node_manager,
|
|
rpc_client,
|
|
};
|
|
app.manage(app_state);
|
|
|
|
// Initialize mining state
|
|
let mining_state = commands::MiningState::new();
|
|
app.manage(mining_state);
|
|
|
|
// Build and set up system tray
|
|
let menu = build_tray_menu(app.handle())?;
|
|
let _tray = TrayIconBuilder::new()
|
|
.icon(app.default_window_icon().unwrap().clone())
|
|
.menu(&menu)
|
|
.show_menu_on_left_click(false)
|
|
.on_tray_icon_event(|tray, event| {
|
|
handle_tray_event(tray.app_handle(), event);
|
|
})
|
|
.on_menu_event(|app, event| {
|
|
handle_menu_event(app, event);
|
|
})
|
|
.tooltip("Synor Wallet")
|
|
.build(app)?;
|
|
|
|
// Check for updates on startup (non-blocking)
|
|
let handle = app.handle().clone();
|
|
tauri::async_runtime::spawn(async move {
|
|
check_for_updates(handle).await;
|
|
});
|
|
|
|
#[cfg(debug_assertions)]
|
|
{
|
|
// Open devtools in development
|
|
if let Some(window) = app.get_webview_window("main") {
|
|
window.open_devtools();
|
|
}
|
|
}
|
|
|
|
Ok(())
|
|
})
|
|
.invoke_handler(tauri::generate_handler![
|
|
// Wallet management
|
|
commands::create_wallet,
|
|
commands::import_wallet,
|
|
commands::unlock_wallet,
|
|
commands::lock_wallet,
|
|
commands::get_wallet_info,
|
|
commands::export_mnemonic,
|
|
// Addresses & UTXOs
|
|
commands::get_addresses,
|
|
commands::generate_address,
|
|
commands::get_balance,
|
|
commands::get_utxos,
|
|
// Transactions
|
|
commands::create_transaction,
|
|
commands::sign_transaction,
|
|
commands::broadcast_transaction,
|
|
commands::get_transaction_history,
|
|
// Network (legacy)
|
|
commands::connect_node,
|
|
commands::disconnect_node,
|
|
commands::get_network_status,
|
|
// Node management (new)
|
|
commands::node_connect_external,
|
|
commands::node_start_embedded,
|
|
commands::node_stop,
|
|
commands::node_get_status,
|
|
commands::node_get_connection_mode,
|
|
commands::node_get_peers,
|
|
commands::node_get_sync_progress,
|
|
// Mining
|
|
commands::mining_start,
|
|
commands::mining_stop,
|
|
commands::mining_pause,
|
|
commands::mining_resume,
|
|
commands::mining_get_status,
|
|
commands::mining_get_stats,
|
|
commands::mining_set_threads,
|
|
// Enhanced wallet (using RPC client)
|
|
commands::wallet_get_balance,
|
|
commands::wallet_get_utxos,
|
|
commands::wallet_get_network_info,
|
|
commands::wallet_get_fee_estimate,
|
|
// Smart contracts
|
|
commands::contract_deploy,
|
|
commands::contract_call,
|
|
commands::contract_read,
|
|
commands::contract_get_info,
|
|
// Tokens
|
|
commands::token_create,
|
|
commands::token_get_info,
|
|
commands::token_transfer,
|
|
commands::token_get_balance,
|
|
commands::token_list_balances,
|
|
commands::token_mint,
|
|
commands::token_burn,
|
|
// NFTs
|
|
commands::nft_create_collection,
|
|
commands::nft_get_collection_info,
|
|
commands::nft_mint,
|
|
commands::nft_batch_mint,
|
|
commands::nft_get_token_info,
|
|
commands::nft_transfer,
|
|
commands::nft_burn,
|
|
commands::nft_list_owned,
|
|
commands::nft_list_owned_in_collection,
|
|
commands::nft_set_approval_for_all,
|
|
commands::nft_set_base_uri,
|
|
// Updates
|
|
check_update,
|
|
install_update,
|
|
// Keychain / Biometric
|
|
keychain_is_available,
|
|
keychain_enable_biometric,
|
|
keychain_disable_biometric,
|
|
keychain_is_biometric_enabled,
|
|
keychain_unlock_with_biometric,
|
|
])
|
|
.run(tauri::generate_context!())
|
|
.expect("error while running tauri application");
|
|
}
|
|
|
|
/// Check for updates on startup
|
|
async fn check_for_updates<R: Runtime>(app: tauri::AppHandle<R>) {
|
|
use tauri_plugin_updater::UpdaterExt;
|
|
|
|
// Wait a few seconds before checking for updates
|
|
tokio::time::sleep(std::time::Duration::from_secs(5)).await;
|
|
|
|
let updater = match app.updater() {
|
|
Ok(u) => u,
|
|
Err(e) => {
|
|
eprintln!("Updater not available: {}", e);
|
|
return;
|
|
}
|
|
};
|
|
|
|
match updater.check().await {
|
|
Ok(Some(update)) => {
|
|
// Notify frontend about available update
|
|
let _ = app.emit("update-available", serde_json::json!({
|
|
"version": update.version,
|
|
"body": update.body,
|
|
"date": update.date
|
|
}));
|
|
}
|
|
Ok(None) => {
|
|
// No update available
|
|
}
|
|
Err(e) => {
|
|
eprintln!("Failed to check for updates: {}", e);
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Tauri command: Check for updates
|
|
#[tauri::command]
|
|
async fn check_update<R: Runtime>(app: tauri::AppHandle<R>) -> std::result::Result<Option<serde_json::Value>, String> {
|
|
use tauri_plugin_updater::UpdaterExt;
|
|
|
|
let updater = app.updater().map_err(|e| format!("Updater not configured: {}", e))?;
|
|
|
|
match updater.check().await {
|
|
Ok(Some(update)) => {
|
|
Ok(Some(serde_json::json!({
|
|
"version": update.version,
|
|
"body": update.body,
|
|
"date": update.date
|
|
})))
|
|
}
|
|
Ok(None) => Ok(None),
|
|
Err(e) => Err(format!("Update check failed: {}", e)),
|
|
}
|
|
}
|
|
|
|
/// Tauri command: Install update
|
|
#[tauri::command]
|
|
async fn install_update<R: Runtime>(app: tauri::AppHandle<R>) -> std::result::Result<(), String> {
|
|
use tauri_plugin_updater::UpdaterExt;
|
|
|
|
let updater = app.updater().map_err(|e| format!("Updater not configured: {}", e))?;
|
|
|
|
match updater.check().await {
|
|
Ok(Some(update)) => {
|
|
// Download and install with progress callbacks
|
|
let downloaded: u64 = 0;
|
|
let total_size: u64 = 0;
|
|
|
|
update.download_and_install(
|
|
move |chunk_len, content_len| {
|
|
// Progress callback - could emit progress events here
|
|
let _ = (chunk_len, content_len, downloaded, total_size);
|
|
},
|
|
|| {
|
|
// Ready to install callback
|
|
},
|
|
).await.map_err(|e| format!("Update installation failed: {}", e))?;
|
|
|
|
// Notify user to restart
|
|
let _ = app.emit("update-installed", ());
|
|
|
|
Ok(())
|
|
}
|
|
Ok(None) => Err("No update available".to_string()),
|
|
Err(e) => Err(format!("Update check failed: {}", e)),
|
|
}
|
|
}
|
|
|
|
// ============================================================================
|
|
// Keychain / Biometric Commands
|
|
// ============================================================================
|
|
|
|
/// Check if OS keychain is available on this system
|
|
#[tauri::command]
|
|
fn keychain_is_available() -> bool {
|
|
keychain::KeychainService::is_available()
|
|
}
|
|
|
|
/// Enable biometric unlock for the current wallet
|
|
#[tauri::command]
|
|
async fn keychain_enable_biometric(
|
|
password: String,
|
|
state: tauri::State<'_, wallet::WalletState>,
|
|
) -> std::result::Result<(), String> {
|
|
// Get wallet address to create keychain service
|
|
let addresses = state.addresses.read().await;
|
|
let first_address = addresses.first()
|
|
.ok_or("No wallet loaded")?
|
|
.address.clone();
|
|
drop(addresses);
|
|
|
|
// Verify wallet is unlocked and password is correct
|
|
if !state.is_unlocked().await {
|
|
return Err("Wallet is locked".to_string());
|
|
}
|
|
|
|
// Create keychain service and enable biometric
|
|
let keychain_service = keychain::KeychainService::from_address(&first_address);
|
|
keychain_service.enable_biometric_unlock(&password)
|
|
.map_err(|e| e.to_string())?;
|
|
|
|
Ok(())
|
|
}
|
|
|
|
/// Disable biometric unlock for the current wallet
|
|
#[tauri::command]
|
|
async fn keychain_disable_biometric(
|
|
state: tauri::State<'_, wallet::WalletState>,
|
|
) -> std::result::Result<(), String> {
|
|
let addresses = state.addresses.read().await;
|
|
let first_address = addresses.first()
|
|
.ok_or("No wallet loaded")?
|
|
.address.clone();
|
|
drop(addresses);
|
|
|
|
let keychain_service = keychain::KeychainService::from_address(&first_address);
|
|
keychain_service.disable_biometric_unlock()
|
|
.map_err(|e| e.to_string())?;
|
|
|
|
Ok(())
|
|
}
|
|
|
|
/// Check if biometric unlock is enabled for the current wallet
|
|
#[tauri::command]
|
|
async fn keychain_is_biometric_enabled(
|
|
state: tauri::State<'_, wallet::WalletState>,
|
|
) -> std::result::Result<bool, String> {
|
|
let addresses = state.addresses.read().await;
|
|
let first_address = match addresses.first() {
|
|
Some(addr) => addr.address.clone(),
|
|
None => return Ok(false),
|
|
};
|
|
drop(addresses);
|
|
|
|
let keychain_service = keychain::KeychainService::from_address(&first_address);
|
|
Ok(keychain_service.is_biometric_enabled())
|
|
}
|
|
|
|
/// Attempt to unlock wallet using biometric authentication
|
|
/// The OS will prompt for TouchID/FaceID/Windows Hello
|
|
#[tauri::command]
|
|
async fn keychain_unlock_with_biometric(
|
|
state: tauri::State<'_, wallet::WalletState>,
|
|
) -> std::result::Result<bool, String> {
|
|
// Load wallet metadata if not loaded
|
|
if state.metadata.read().await.is_none() {
|
|
state.load_metadata().await.map_err(|e| e.to_string())?;
|
|
}
|
|
|
|
let addresses = state.addresses.read().await;
|
|
let first_address = addresses.first()
|
|
.ok_or("No wallet loaded")?
|
|
.address.clone();
|
|
drop(addresses);
|
|
|
|
let keychain_service = keychain::KeychainService::from_address(&first_address);
|
|
|
|
// Check if biometric is enabled
|
|
if !keychain_service.is_biometric_enabled() {
|
|
return Err("Biometric unlock not enabled".to_string());
|
|
}
|
|
|
|
// Retrieve the biometric key from OS keychain
|
|
// This will trigger the OS biometric prompt (TouchID/FaceID/Windows Hello)
|
|
let _biometric_key = keychain_service.get_biometric_unlock_key()
|
|
.map_err(|e| format!("Biometric authentication failed: {}", e))?;
|
|
|
|
// Biometric authentication succeeded
|
|
// The frontend can now proceed with the unlock flow
|
|
// In a full implementation, we would use the biometric_key to derive
|
|
// the decryption key for the wallet
|
|
|
|
Ok(true)
|
|
}
|