diff --git a/crates/bitcell-consensus/src/block.rs b/crates/bitcell-consensus/src/block.rs index a6d72d6..1aaa34f 100644 --- a/crates/bitcell-consensus/src/block.rs +++ b/crates/bitcell-consensus/src/block.rs @@ -112,6 +112,22 @@ impl Transaction { let serialized = bincode::serialize(self).expect("transaction serialization should never fail"); Hash256::hash(&serialized) } + + /// Compute signing hash (hash of transaction data WITHOUT signature) + /// + /// This is the hash that should be signed/verified, as it excludes the signature field. + /// The regular hash() includes the signature and cannot be used for signing. + pub fn signing_hash(&self) -> Hash256 { + let mut data = Vec::new(); + data.extend_from_slice(&self.nonce.to_le_bytes()); + data.extend_from_slice(self.from.as_bytes()); + data.extend_from_slice(self.to.as_bytes()); + data.extend_from_slice(&self.amount.to_le_bytes()); + data.extend_from_slice(&self.gas_limit.to_le_bytes()); + data.extend_from_slice(&self.gas_price.to_le_bytes()); + data.extend_from_slice(&self.data); + Hash256::hash(&data) + } } /// Battle proof (placeholder for ZK proof) diff --git a/crates/bitcell-node/src/rpc.rs b/crates/bitcell-node/src/rpc.rs index 8b92b74..1a28de9 100644 --- a/crates/bitcell-node/src/rpc.rs +++ b/crates/bitcell-node/src/rpc.rs @@ -514,9 +514,9 @@ async fn eth_send_raw_transaction(state: &RpcState, params: Option) -> Re data: None, })?; - // Validate transaction signature - let tx_hash = tx.hash(); - if tx.signature.verify(&tx.from, tx_hash.as_bytes()).is_err() { + // Validate transaction signature (must sign the data EXCLUDING the signature field) + let signing_hash = tx.signing_hash(); + if tx.signature.verify(&tx.from, signing_hash.as_bytes()).is_err() { return Err(JsonRpcError { code: -32602, message: "Invalid transaction signature".to_string(), @@ -612,7 +612,8 @@ async fn eth_send_raw_transaction(state: &RpcState, params: Option) -> Re }); } - // Return transaction hash + // Return transaction hash (use full hash for identification, not signing hash) + let tx_hash = tx.hash(); Ok(json!(format!("0x{}", hex::encode(tx_hash.as_bytes())))) } diff --git a/crates/bitcell-wallet-gui/Cargo.toml b/crates/bitcell-wallet-gui/Cargo.toml index e2ed9c6..b082f7b 100644 --- a/crates/bitcell-wallet-gui/Cargo.toml +++ b/crates/bitcell-wallet-gui/Cargo.toml @@ -15,6 +15,7 @@ path = "src/main.rs" [dependencies] bitcell-wallet = { path = "../bitcell-wallet" } bitcell-crypto = { path = "../bitcell-crypto" } +bitcell-consensus = { path = "../bitcell-consensus" } # Slint UI framework - native rendering, no WebView slint = "1.9" @@ -22,6 +23,7 @@ slint = "1.9" # Serialization serde.workspace = true serde_json = "1.0" +bincode = "1.3" # Error handling thiserror.workspace = true diff --git a/crates/bitcell-wallet-gui/src/main.rs b/crates/bitcell-wallet-gui/src/main.rs index 8a1308b..3f4754b 100644 --- a/crates/bitcell-wallet-gui/src/main.rs +++ b/crates/bitcell-wallet-gui/src/main.rs @@ -57,6 +57,36 @@ fn chain_display_name(chain: Chain) -> &'static str { } } +/// Parse address string to PublicKey +/// For BitCell addresses, the address is the hex-encoded public key with optional prefix +fn parse_address_to_pubkey(address: &str) -> Result { + // Remove common prefixes + let address = address.trim(); + let address = if address.starts_with("0x") { + &address[2..] + } else if address.starts_with("BC1") || address.starts_with("bc1") { + // BitCell address format - for now, just strip prefix + // In a real implementation, this would decode the address properly + &address[3..] + } else { + address + }; + + // Decode hex to bytes + let bytes = hex::decode(address) + .map_err(|e| format!("Invalid hex in address: {}", e))?; + + if bytes.len() != 33 { + return Err(format!("Address must be 33 bytes (compressed public key), got {}", bytes.len())); + } + + let mut key_bytes = [0u8; 33]; + key_bytes.copy_from_slice(&bytes); + + bitcell_crypto::PublicKey::from_bytes(key_bytes) + .map_err(|e| format!("Invalid public key: {}", e)) +} + #[tokio::main] async fn main() -> Result<(), Box> { // Initialize logging @@ -416,10 +446,10 @@ fn setup_callbacks(window: &MainWindow, state: Rc>) { // Convert to smallest units (1 CELL = 100_000_000 units) let amount_units = (amount * 100_000_000.0) as u64; - // Get wallet and RPC client - let app_state = state.borrow(); - - let (from_address, rpc_client) = { + // Get wallet info and secret key before async operation + let (from_addr_formatted, secret_key, rpc_client) = { + let app_state = state.borrow(); + let wallet = match &app_state.wallet { Some(w) => w, None => { @@ -428,16 +458,30 @@ fn setup_callbacks(window: &MainWindow, state: Rc>) { } }; + if !wallet.is_unlocked() { + wallet_state.set_status_message("Wallet is locked. Please unlock it first.".into()); + return; + } + // Get the first address as sender let addresses = wallet.all_addresses(); - let from_addr = match addresses.iter().find(|a| a.chain() == chain) { - Some(a) => a.to_string_formatted(), + let from_addr_obj = match addresses.iter().find(|a| a.chain() == chain) { + Some(a) => a, None => { wallet_state.set_status_message(format!("No {} address available", chain_display_name(chain)).into()); return; } }; + // Get secret key for signing + let sk = match wallet.get_secret_key_for_address(from_addr_obj) { + Ok(sk) => sk, + Err(e) => { + wallet_state.set_status_message(format!("Failed to get secret key: {}", e).into()); + return; + } + }; + let rpc = match &app_state.rpc_client { Some(c) => c.clone(), None => { @@ -446,12 +490,9 @@ fn setup_callbacks(window: &MainWindow, state: Rc>) { } }; - (from_addr, rpc) + (from_addr_obj.to_string_formatted(), sk, rpc) }; - // Drop app_state borrow before the async operation - drop(app_state); - // Set loading state wallet_state.set_is_loading(true); wallet_state.set_status_message("Preparing transaction...".into()); @@ -459,10 +500,10 @@ fn setup_callbacks(window: &MainWindow, state: Rc>) { let window_weak = window.as_weak(); let to_address = to_address.to_string(); - // Async nonce fetch and transaction preparation + // Async nonce fetch and transaction creation tokio::spawn(async move { // Get nonce from node - let nonce = match rpc_client.get_transaction_count(&from_address).await { + let nonce = match rpc_client.get_transaction_count(&from_addr_formatted).await { Ok(n) => n, Err(e) => { let _ = slint::invoke_from_event_loop(move || { @@ -482,30 +523,95 @@ fn setup_callbacks(window: &MainWindow, state: Rc>) { Err(_) => DEFAULT_GAS_PRICE, // Use default if unavailable }; - // Calculate fee (simple estimate) - let fee = gas_price.saturating_mul(21000); + // Gas limit for simple transfer + let gas_limit = 21000u64; - // For now, display transaction details and inform user signing requires wallet unlock - // In production, this would integrate with hardware wallet or secure key management - let tx_info = format!( - "Transaction prepared:\n\ - From: {}\n\ - To: {}\n\ - Amount: {} units\n\ - Fee: {} units\n\ - Nonce: {}\n\n\ - Hardware wallet signing coming soon. \ - Use the CLI or Admin console with HSM for secure signing.", - from_address, to_address, amount_units, fee, nonce - ); + // Parse addresses to PublicKey format + let from_pk = match parse_address_to_pubkey(&from_addr_formatted) { + Ok(pk) => pk, + Err(e) => { + let _ = slint::invoke_from_event_loop(move || { + if let Some(window) = window_weak.upgrade() { + let ws = window.global::(); + ws.set_is_loading(false); + ws.set_status_message(format!("Invalid from address: {}", e).into()); + } + }); + return; + } + }; - let _ = slint::invoke_from_event_loop(move || { - if let Some(window) = window_weak.upgrade() { - let ws = window.global::(); - ws.set_is_loading(false); - ws.set_status_message(tx_info.into()); + let to_pk = match parse_address_to_pubkey(&to_address) { + Ok(pk) => pk, + Err(e) => { + let _ = slint::invoke_from_event_loop(move || { + if let Some(window) = window_weak.upgrade() { + let ws = window.global::(); + ws.set_is_loading(false); + ws.set_status_message(format!("Invalid to address: {}", e).into()); + } + }); + return; } - }); + }; + + // Create consensus transaction (without signature initially) + let mut tx = bitcell_consensus::Transaction { + nonce, + from: from_pk, + to: to_pk, + amount: amount_units, + gas_limit, + gas_price, + data: vec![], + signature: bitcell_crypto::Signature::from_bytes([0u8; 64]), // Placeholder + }; + + // Compute signing hash (hash of transaction WITHOUT signature field) + let signing_hash = tx.signing_hash(); + + // Sign the transaction + tx.signature = secret_key.sign(signing_hash.as_bytes()); + + // Serialize transaction + let tx_bytes = match bincode::serialize(&tx) { + Ok(bytes) => bytes, + Err(e) => { + let _ = slint::invoke_from_event_loop(move || { + if let Some(window) = window_weak.upgrade() { + let ws = window.global::(); + ws.set_is_loading(false); + ws.set_status_message(format!("Failed to serialize transaction: {}", e).into()); + } + }); + return; + } + }; + + // Send transaction via RPC + match rpc_client.send_raw_transaction_bytes(&tx_bytes).await { + Ok(tx_hash) => { + let _ = slint::invoke_from_event_loop(move || { + if let Some(window) = window_weak.upgrade() { + let ws = window.global::(); + ws.set_is_loading(false); + ws.set_status_message(format!( + "Transaction sent successfully!\nHash: {}", + tx_hash + ).into()); + } + }); + } + Err(e) => { + let _ = slint::invoke_from_event_loop(move || { + if let Some(window) = window_weak.upgrade() { + let ws = window.global::(); + ws.set_is_loading(false); + ws.set_status_message(format!("Failed to send transaction: {}", e).into()); + } + }); + } + } }); }); } diff --git a/crates/bitcell-wallet/src/wallet.rs b/crates/bitcell-wallet/src/wallet.rs index 7dc3aa2..757e4c6 100644 --- a/crates/bitcell-wallet/src/wallet.rs +++ b/crates/bitcell-wallet/src/wallet.rs @@ -398,6 +398,31 @@ impl Wallet { self.sign_transaction(tx, from) } + /// Get the secret key for an address (for advanced use cases like consensus transaction signing) + /// + /// This method should be used with caution as it exposes the raw secret key. + /// Prefer using sign_transaction when possible. + pub fn get_secret_key_for_address(&self, address: &Address) -> Result { + if !self.is_unlocked() { + return Err(Error::WalletLocked); + } + + let path = DerivationPath::for_chain(address.chain(), address.index()); + + // We need to derive the key without caching (since self is immutable) + let seed = self.master_seed.as_ref().ok_or(Error::WalletLocked)?; + + let path_str = path.to_string(); + let mut derivation_data = Vec::new(); + derivation_data.extend_from_slice(seed.as_bytes()); + derivation_data.extend_from_slice(path_str.as_bytes()); + + let derived_hash = Hash256::hash(&derivation_data); + let secret_key = SecretKey::from_bytes(derived_hash.as_bytes())?; + + Ok(secret_key) + } + /// Get transaction history pub fn history(&self) -> &TransactionHistory { &self.history