diff --git a/dash-spv/Cargo.toml b/dash-spv/Cargo.toml index b15c60e15..103af05ba 100644 --- a/dash-spv/Cargo.toml +++ b/dash-spv/Cargo.toml @@ -12,6 +12,7 @@ rust-version = "1.89" # Core Dash libraries dashcore = { path = "../dash", features = ["serde", "core-block-hash-use-x11", "message_verification", "bls", "quorum_validation"] } dashcore_hashes = { path = "../hashes" } +dash-network = { path = "../dash-network" } dash-network-seeds = { path = "../dash-network-seeds" } key-wallet = { path = "../key-wallet" } key-wallet-manager = { path = "../key-wallet-manager", features = ["parallel-filters"] } diff --git a/dash-spv/src/error.rs b/dash-spv/src/error.rs index 4fbdb5b75..715011ddc 100644 --- a/dash-spv/src/error.rs +++ b/dash-spv/src/error.rs @@ -236,6 +236,10 @@ pub enum SyncError { /// Operation requires the client to be fully synced #[error("Client is not synced")] NotSynced, + + /// Wallet error during block processing (e.g. persistence failure) + #[error("Wallet error: {0}")] + Wallet(String), } impl SyncError { @@ -252,6 +256,7 @@ impl SyncError { SyncError::Storage(_) => "storage", SyncError::Headers2DecompressionFailed(_) => "headers2", SyncError::MasternodeSyncFailed(_) => "masternode", + SyncError::Wallet(_) => "wallet", // Deprecated variant - should not be used #[allow(deprecated)] SyncError::SyncFailed(_) => "unknown", @@ -329,6 +334,12 @@ impl From for SyncError { } } +impl From for SyncError { + fn from(err: key_wallet_manager::WalletError) -> Self { + SyncError::Wallet(err.to_string()) + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/dash-spv/src/network/constants.rs b/dash-spv/src/network/constants.rs index e1278f0f6..9ebcf895a 100644 --- a/dash-spv/src/network/constants.rs +++ b/dash-spv/src/network/constants.rs @@ -1,5 +1,6 @@ //! Network constants for peer support +use std::net::SocketAddr; use std::time::Duration; // Connection limits @@ -15,6 +16,53 @@ pub const PING_INTERVAL: Duration = Duration::from_secs(120); pub const RECONNECT_DELAY: Duration = Duration::from_secs(5); pub const MAX_RECONNECT_ATTEMPTS: u32 = 3; +// DNS seeds for Dash mainnet +pub const MAINNET_DNS_SEEDS: &[&str] = &[ + "dnsseed.dash.org", + // Note: dnsseed.dashdot.io and dnsseed.masternode.io are currently not resolving +]; + +// DNS seeds for Dash testnet +pub const TESTNET_DNS_SEEDS: &[&str] = &["testnet-seed.dashdot.io"]; + +// Fixed peer IPs for Dash testnet (hp-masternodes) +pub const TESTNET_FIXED_PEERS: &[&str] = &[ + "68.67.122.1", + "68.67.122.2", + "68.67.122.3", + "68.67.122.4", + "68.67.122.5", + "68.67.122.6", + "68.67.122.7", + "68.67.122.8", + "68.67.122.9", + "68.67.122.10", + "68.67.122.11", + "68.67.122.12", + "68.67.122.13", + "68.67.122.14", + "68.67.122.15", + "68.67.122.16", + "68.67.122.17", + "68.67.122.18", + "68.67.122.19", + "68.67.122.20", + "68.67.122.21", + "68.67.122.22", + "68.67.122.23", + "68.67.122.24", + "68.67.122.25", + "68.67.122.26", + "68.67.122.27", + "68.67.122.28", + "68.67.122.29", +]; + +/// Default Dash P2P port for mainnet. +pub const MAINNET_P2P_PORT: u16 = 9999; +/// Default Dash P2P port for testnet. +pub const TESTNET_P2P_PORT: u16 = 19999; + // Peer exchange pub const MAX_ADDR_TO_SEND: usize = 1000; pub const MAX_ADDR_TO_STORE: usize = 2000; @@ -27,3 +75,42 @@ pub const PEER_DISCOVERY_INTERVAL: Duration = Duration::from_secs(60); // Discov pub const DNS_DISCOVERY_DELAY: Duration = Duration::from_secs(10); pub const MESSAGE_POLL_INTERVAL: Duration = Duration::from_millis(10); pub const MESSAGE_RECEIVE_TIMEOUT: Duration = Duration::from_millis(100); + +/// Hardcoded masternode peer addresses for mainnet. +/// +/// Sourced from the [`dash_network_seeds`] crate, which is refreshed weekly +/// by `.github/workflows/update-masternode-seeds.yml` via the +/// `masternode-seeds-fetcher` tool. The returned list is the union of +/// regular and Evo masternodes — for a typed view (e.g. evo-only), depend +/// on `dash-network-seeds` directly. +pub fn mainnet_seed_peers() -> Vec { + dash_network_seeds::addresses(dash_network::Network::Mainnet) +} + +/// Hardcoded masternode peer addresses for testnet. See [`mainnet_seed_peers`] +/// for context. +pub fn testnet_seed_peers() -> Vec { + dash_network_seeds::addresses(dash_network::Network::Testnet) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn testnet_seed_peers_contains_hp_mn_ips() { + let peers = testnet_seed_peers(); + // The HP-MN range at 68.67.122.1-29 should be present; later runs may + // extend it but never shrink below 29. + let hp_mn = peers.iter().filter(|a| a.ip().to_string().starts_with("68.67.122.")).count(); + assert!(hp_mn >= 29, "expected >=29 HP-MN testnet seeds, got {}", hp_mn); + assert!(peers.iter().all(|a| a.port() == TESTNET_P2P_PORT)); + } + + #[test] + fn mainnet_seed_peers_non_empty() { + let peers = mainnet_seed_peers(); + assert!(!peers.is_empty(), "mainnet seeds must not be empty"); + assert!(peers.iter().all(|a| a.port() == MAINNET_P2P_PORT)); + } +} diff --git a/dash-spv/src/network/discovery.rs b/dash-spv/src/network/discovery.rs index 4522ab7c4..a7ac51b6e 100644 --- a/dash-spv/src/network/discovery.rs +++ b/dash-spv/src/network/discovery.rs @@ -18,6 +18,10 @@ use hickory_resolver::TokioResolver; use std::net::{IpAddr, SocketAddr}; use crate::error::SpvError as Error; +use crate::network::constants::{ + mainnet_seed_peers, testnet_seed_peers, MAINNET_DNS_SEEDS, MAINNET_P2P_PORT, TESTNET_DNS_SEEDS, + TESTNET_P2P_PORT, +}; /// DNS discovery for finding initial peers. /// @@ -50,9 +54,14 @@ impl DnsDiscovery { /// level but do not cause this function to fail — the embedded list acts /// as the primary source and DNS is a best-effort backup. pub async fn discover_peers(&self, network: Network) -> Vec { - let seeds = network.dns_seeds(); - let port = network.default_p2p_port(); - let mut addresses = dash_network_seeds::addresses(network); + let (seeds, port, mut addresses) = match network { + Network::Mainnet => (MAINNET_DNS_SEEDS, MAINNET_P2P_PORT, mainnet_seed_peers()), + Network::Testnet => (TESTNET_DNS_SEEDS, TESTNET_P2P_PORT, testnet_seed_peers()), + _ => { + tracing::debug!("No peer discovery sources for {:?} network", network); + return vec![]; + } + }; let embedded_count = addresses.len(); tracing::info!("Loaded {} hardcoded masternode seed(s) for {:?}", embedded_count, network); @@ -76,6 +85,18 @@ impl DnsDiscovery { } } + // For testnet, add fixed peers as fallback if DNS returned few/no results. + // This is on top of the embedded hardcoded seeds; fixed peers are a + // stable testnet backup we keep in code (see PR #658). + if matches!(network, Network::Testnet) { + use super::constants::TESTNET_FIXED_PEERS; + for ip_str in TESTNET_FIXED_PEERS { + if let Ok(ip) = ip_str.parse::() { + addresses.push(SocketAddr::new(ip, port)); + } + } + } + addresses.sort(); addresses.dedup(); @@ -114,7 +135,7 @@ mod tests { // All peers should use the correct port for peer in &peers { - assert_eq!(peer.port(), Network::Mainnet.default_p2p_port()); + assert_eq!(peer.port(), MAINNET_P2P_PORT); } } @@ -131,7 +152,7 @@ mod tests { peers.len() ); for peer in &peers { - assert_eq!(peer.port(), Network::Testnet.default_p2p_port()); + assert_eq!(peer.port(), TESTNET_P2P_PORT); } } diff --git a/dash-spv/src/sync/blocks/manager.rs b/dash-spv/src/sync/blocks/manager.rs index 8ea4e2f59..640b5a558 100644 --- a/dash-spv/src/sync/blocks/manager.rs +++ b/dash-spv/src/sync/blocks/manager.rs @@ -84,7 +84,7 @@ impl BlocksManager Self { - let (sync_tx, _) = broadcast::channel(256); - let (network_tx, _) = broadcast::channel(256); + let (sync_tx, _) = broadcast::channel(10000); + let (network_tx, _) = broadcast::channel(10000); let (progress_tx, _) = watch::channel(SyncProgress::default()); - let (wallet_tx, _) = broadcast::channel(256); + let (wallet_tx, _) = broadcast::channel(10000); Self { sync_tx, network_tx, diff --git a/dash-spv/src/test_utils/masternode_network.rs b/dash-spv/src/test_utils/masternode_network.rs new file mode 100644 index 000000000..c74ff5897 --- /dev/null +++ b/dash-spv/src/test_utils/masternode_network.rs @@ -0,0 +1,968 @@ +//! Masternode network test infrastructure. +//! +//! Manages a pre-generated masternode network (1 controller + N masternodes) +//! for integration testing of masternode list sync against real dashd peers. + +use std::net::SocketAddr; +use std::path::{Path, PathBuf}; +use std::time::{Duration, Instant}; + +use dashcore::BlockHash; +use serde::Deserialize; +use tempfile::TempDir; +use tracing::{debug, info, warn}; + +use super::fs_helpers::copy_dir; +use super::node::find_available_port; +use super::{retain_test_dir, DashCoreConfig, DashCoreNode, WalletFile}; + +/// Metadata for a pre-generated masternode network, deserialized from network.json. +#[derive(Debug, Deserialize)] +pub struct NetworkMetadata { + pub version: String, + pub chain_height: u32, + pub dkg_cycles_completed: u32, + pub dkg_interval: u32, + pub controller: ControllerInfo, + pub masternodes: Vec, + pub spork_private_key: String, + pub dashd_extra_args: Vec, +} + +/// Controller node info from network.json. +#[derive(Debug, Deserialize)] +pub struct ControllerInfo { + pub datadir: String, + pub wallet: String, +} + +/// Individual masternode info from network.json. +#[derive(Debug, Deserialize)] +pub struct MasternodeInfo { + pub index: u32, + pub datadir: String, + pub pro_tx_hash: String, + pub bls_private_key: String, + pub bls_public_key: String, + pub owner_address: String, + pub voting_address: String, + pub payout_address: String, +} + +impl NetworkMetadata { + /// Load from a network.json file. + fn from_json(path: &Path) -> Self { + let contents = std::fs::read_to_string(path) + .unwrap_or_else(|e| panic!("Failed to read {}: {}", path.display(), e)); + serde_json::from_str(&contents) + .unwrap_or_else(|e| panic!("Failed to parse {}: {}", path.display(), e)) + } +} + +/// Test context managing a full masternode network (controller + masternodes). +/// +/// Starts dashd instances from pre-generated blockchain data, connects them, +/// and provides the controller's P2P address for SPV client testing. +pub struct MasternodeTestContext { + pub controller: DashCoreNode, + pub masternodes: Vec, + pub metadata: NetworkMetadata, + pub controller_addr: SocketAddr, + pub wallet: WalletFile, + pub expected_height: u32, + /// Current mock time used for DKG phase orchestration. + mocktime: u64, +} + +impl MasternodeTestContext { + /// Create a new masternode test context. + /// + /// When `controller_only` is true, only the controller node is started + /// (sufficient for static masternode list sync tests). + /// + /// Returns `None` if `SKIP_DASHD_TESTS` is set or required env vars are missing. + pub async fn new(controller_only: bool) -> Option { + if std::env::var("SKIP_DASHD_TESTS").is_ok() { + eprintln!("Skipping dashd integration test (SKIP_DASHD_TESTS is set)"); + return None; + } + + let dashd_path = std::env::var("DASHD_PATH") + .ok() + .map(PathBuf::from) + .expect("DASHD_PATH must be set for masternode tests"); + assert!(dashd_path.exists(), "DASHD_PATH does not exist: {}", dashd_path.display()); + + let mn_datadir = std::env::var("DASHD_MN_DATADIR") + .ok() + .map(PathBuf::from) + .expect("DASHD_MN_DATADIR must be set for masternode tests"); + assert!(mn_datadir.exists(), "DASHD_MN_DATADIR does not exist: {}", mn_datadir.display()); + + let metadata = NetworkMetadata::from_json(&mn_datadir.join("network.json")); + info!( + "Loaded masternode network: height={}, dkg_cycles={}, masternodes={}", + metadata.chain_height, + metadata.dkg_cycles_completed, + metadata.masternodes.len() + ); + + let wallet = WalletFile::from_json(&mn_datadir, &metadata.controller.wallet); + info!( + "Loaded wallet: {} transactions, balance: {:.8}", + wallet.transaction_count, wallet.balance + ); + + // Build extra args shared by all nodes + let mut shared_args: Vec = metadata.dashd_extra_args.clone(); + shared_args.push(format!("-sporkkey={}", metadata.spork_private_key)); + // Enable full debug logging so debug.log captures all internal state + shared_args.push("-debug=all".to_string()); + shared_args.push("-debuglogfile=debug.log".to_string()); + + // Start controller — keep the temp dir so debug.log is accessible after the test + let controller_temp = TempDir::new().expect("failed to create controller temp dir"); + copy_dir(&mn_datadir.join(&metadata.controller.datadir), controller_temp.path()) + .expect("failed to copy controller datadir"); + let controller_config = DashCoreConfig { + dashd_path: dashd_path.clone(), + datadir: controller_temp.path().to_path_buf(), + wallet: metadata.controller.wallet.clone(), + p2p_port: find_available_port(), + rpc_port: find_available_port(), + extra_args: shared_args.clone(), + }; + let controller_path = controller_temp.keep(); + let mut controller = DashCoreNode::with_config(controller_config); + let controller_addr = controller.start().await; + info!( + "Controller started at {} | debug.log: {}/regtest/debug.log", + controller_addr, + controller_path.display() + ); + + // Start masternode nodes (if not controller-only) + let mut masternodes = Vec::new(); + if !controller_only { + for mn_info in &metadata.masternodes { + let mn_temp = TempDir::new().expect("failed to create mn temp dir"); + copy_dir(&mn_datadir.join(&mn_info.datadir), mn_temp.path()) + .expect("failed to copy masternode datadir"); + + let mut mn_args = shared_args.clone(); + mn_args.push("-txindex=1".to_string()); + mn_args.push(format!("-masternodeblsprivkey={}", mn_info.bls_private_key)); + + let mn_config = DashCoreConfig { + dashd_path: dashd_path.clone(), + datadir: mn_temp.keep(), + wallet: "".to_string(), + p2p_port: find_available_port(), + rpc_port: find_available_port(), + extra_args: mn_args, + }; + let mut node = DashCoreNode::with_config(mn_config); + let addr = node.start().await; + info!( + "Masternode {} started at {} | debug.log: {}/regtest/debug.log", + mn_info.datadir, + addr, + node.datadir().display() + ); + + masternodes.push(node); + } + + // Connect all masternodes to controller and to each other. + // DKG requires masternodes to exchange messages - direct connections + // between them ensure reliable message propagation. + connect_all_nodes(&controller, &masternodes).await; + + // Update each masternode's service address to match its actual P2P port. + // The proTx entries from generation reference the original ports, but the + // nodes now run on different ports. Without this update, quorum connections + // between masternodes would fail (dashd connects to registered addresses). + update_mn_service_addresses(&controller, &masternodes, &metadata); + } + + let expected_height = controller + .try_rpc_call("getblockcount", &[]) + .and_then(|v| v.as_u64()) + .expect("getblockcount on controller") as u32; + + // Get the current block time for mocktime initialization. + // DKG orchestration requires advancing time via setmocktime. + let mocktime = { + let hash = controller.get_best_block_hash().to_string(); + let block_info = controller + .try_rpc_call("getblock", &[hash.into()]) + .expect("getblock on controller"); + block_info["time"].as_u64().expect("block time") + 1 + }; + + // Set mocktime on all nodes so DKG timing is consistent with the + // pre-generated data. Without this, nodes use real system time which + // is far ahead of the block timestamps from generation. + controller.set_mocktime(mocktime); + for mn in &masternodes { + mn.set_mocktime(mocktime); + } + + info!("Network ready: controller at height {}, mocktime={}", expected_height, mocktime); + + Some(MasternodeTestContext { + controller, + masternodes, + metadata, + controller_addr, + wallet, + expected_height, + mocktime, + }) + } + /// Advance mocktime on all nodes and trigger the scheduler. + /// + /// Calls both `setmocktime` and `mockscheduler` on every node. The scheduler + /// trigger is needed for DKG phase transitions - without it, nodes don't + /// process scheduled DKG tasks. Errors are tolerated since nodes may be + /// temporarily busy during DKG processing. + fn bump_mocktime(&mut self, seconds: u64) { + self.mocktime += seconds; + let time = self.mocktime; + for node in std::iter::once(&self.controller).chain(self.masternodes.iter()) { + node.try_rpc_call("setmocktime", &[time.into()]); + node.try_rpc_call("mockscheduler", &[seconds.into()]); + } + } + + /// Generate blocks on the controller and wait for all nodes to sync. + pub fn move_blocks(&mut self, count: u64) { + if count == 0 { + return; + } + self.bump_mocktime(1); + let addr = self.controller.get_new_address(); + self.controller.generate_blocks(count, &addr); + self.wait_for_sync(); + } + + /// Wait for all masternode nodes to reach the same height as the controller. + fn wait_for_sync(&self) { + let target_height = self + .controller + .try_rpc_call("getblockcount", &[]) + .and_then(|v| v.as_u64()) + .expect("getblockcount on controller"); + + for mn in &self.masternodes { + let start = Instant::now(); + loop { + let h = mn.try_rpc_call("getblockcount", &[]).and_then(|v| v.as_u64()).unwrap_or(0); + if h >= target_height { + break; + } + if start.elapsed() > Duration::from_secs(30) { + panic!("Masternode sync timeout: at {}, expected {}", h, target_height); + } + std::thread::sleep(Duration::from_millis(200)); + } + } + } + + /// Wait for masternodes to reach a specific DKG phase for the given quorum type and hash. + /// + /// Returns true if enough members reached the phase within the timeout. + /// Uses error-tolerant RPC calls since nodes may be busy during DKG. + #[allow(clippy::too_many_arguments)] + fn wait_for_quorum_phase( + &mut self, + llmq_type: &str, + quorum_hash: &str, + phase: u64, + expected_members: usize, + check_received_messages: Option<&str>, + check_received_messages_count: u64, + timeout_secs: u64, + ) -> bool { + let start = Instant::now(); + let timeout = Duration::from_secs(timeout_secs); + + while start.elapsed() < timeout { + let mut member_count = 0; + for mn in &self.masternodes { + if let Some(status) = mn.try_rpc_call("quorum", &["dkgstatus".into()]) { + if let Some(sessions) = status.get("session").and_then(|s| s.as_array()) { + for session in sessions { + let session_type = session.get("llmqType").and_then(|t| t.as_str()); + if session_type != Some(llmq_type) { + continue; + } + let qs = session.get("status").unwrap_or(session); + let hash_matches = + qs.get("quorumHash").and_then(|h| h.as_str()) == Some(quorum_hash); + let phase_matches = + qs.get("phase").and_then(|p| p.as_u64()) == Some(phase); + let messages_match = check_received_messages.is_none_or(|field| { + qs.get(field).and_then(|v| v.as_u64()).unwrap_or(0) + >= check_received_messages_count + }); + if hash_matches && phase_matches && messages_match { + member_count += 1; + break; + } + } + } + } + } + if member_count >= expected_members { + return true; + } + self.bump_mocktime(1); + std::thread::sleep(Duration::from_millis(300)); + } + false + } + + /// Wait for quorum connections between masternodes to be actually established. + /// + /// Checks the `quorumConnections` field in dkgstatus for masternodes that have + /// outbound connections marked as `connected: true`. The TCP connections use + /// real wall-clock time, so mockscheduler alone is not enough. + fn wait_for_quorum_connections( + &mut self, + llmq_type: &str, + quorum_hash: &str, + expected_connections: usize, + timeout_secs: u64, + ) -> bool { + let start = Instant::now(); + let timeout = Duration::from_secs(timeout_secs); + + while start.elapsed() < timeout { + let mut all_connected = true; + + for mn in &self.masternodes { + let Some(status) = mn.try_rpc_call("quorum", &["dkgstatus".into()]) else { + all_connected = false; + break; + }; + let Some(sessions) = status.get("session").and_then(|s| s.as_array()) else { + all_connected = false; + break; + }; + let has_session = sessions.iter().any(|session| { + session.get("llmqType").and_then(|t| t.as_str()) == Some(llmq_type) + && session + .get("status") + .unwrap_or(session) + .get("quorumHash") + .and_then(|h| h.as_str()) + == Some(quorum_hash) + }); + if !has_session { + continue; + } + + let Some(conn_groups) = status.get("quorumConnections").and_then(|c| c.as_array()) + else { + all_connected = false; + break; + }; + + let Some(conn_group) = conn_groups.iter().find(|conn_group| { + conn_group.get("llmqType").and_then(|t| t.as_str()) == Some(llmq_type) + && conn_group.get("quorumHash").and_then(|h| h.as_str()) + == Some(quorum_hash) + }) else { + all_connected = false; + break; + }; + + let connected = conn_group + .get("quorumConnections") + .and_then(|p| p.as_array()) + .map(|peers| { + peers + .iter() + .filter(|p| p.get("connected").and_then(|c| c.as_bool()) == Some(true)) + .count() + }) + .unwrap_or(0); + if connected < expected_connections { + all_connected = false; + break; + } + } + + if all_connected { + debug!( + "Quorum connections established for {} with {} peers per masternode", + llmq_type, expected_connections + ); + return true; + } + self.bump_mocktime(1); + std::thread::sleep(Duration::from_millis(500)); + } + false + } + + fn wait_for_masternode_probes( + &mut self, + llmq_type: &str, + quorum_hash: &str, + timeout_secs: u64, + ) -> bool { + let start = Instant::now(); + let timeout = Duration::from_secs(timeout_secs); + + while start.elapsed() < timeout { + let mut all_probed = true; + + 'nodes: for mn in &self.masternodes { + let Some(status) = mn.try_rpc_call("quorum", &["dkgstatus".into()]) else { + all_probed = false; + break; + }; + let Some(conn_groups) = status.get("quorumConnections").and_then(|c| c.as_array()) + else { + all_probed = false; + break; + }; + + for conn_group in conn_groups { + let type_matches = + conn_group.get("llmqType").and_then(|t| t.as_str()) == Some(llmq_type); + let hash_matches = + conn_group.get("quorumHash").and_then(|h| h.as_str()) == Some(quorum_hash); + if !type_matches || !hash_matches { + continue; + } + + let Some(peers) = + conn_group.get("quorumConnections").and_then(|p| p.as_array()) + else { + all_probed = false; + break 'nodes; + }; + + for peer in peers { + if peer.get("outbound").and_then(|v| v.as_bool()) != Some(false) { + continue; + } + let Some(pro_tx_hash) = peer.get("proTxHash").and_then(|v| v.as_str()) + else { + all_probed = false; + break 'nodes; + }; + let Some(info) = + mn.try_rpc_call("protx", &["info".into(), pro_tx_hash.into()]) + else { + all_probed = false; + break 'nodes; + }; + let meta = info.get("metaInfo").unwrap_or(&info); + let last_success = meta + .get("lastOutboundSuccessElapsed") + .and_then(|v| v.as_u64()) + .unwrap_or(u64::MAX); + let last_attempt = meta + .get("lastOutboundAttemptElapsed") + .and_then(|v| v.as_u64()) + .unwrap_or(u64::MAX); + let is_expected_mn = self + .metadata + .masternodes + .iter() + .any(|mn_info| mn_info.pro_tx_hash == pro_tx_hash); + if is_expected_mn { + if last_success > 55 * 60 { + all_probed = false; + break 'nodes; + } + } else if last_attempt > 55 * 60 && last_success > 55 * 60 { + all_probed = false; + break 'nodes; + } + } + } + } + + if all_probed { + return true; + } + self.bump_mocktime(1); + std::thread::sleep(Duration::from_millis(500)); + } + false + } + + fn wait_for_sporks_same(&mut self, timeout_secs: u64) -> bool { + let start = Instant::now(); + let timeout = Duration::from_secs(timeout_secs); + + while start.elapsed() < timeout { + let Some(controller_sporks) = self.controller.try_rpc_call("spork", &["show".into()]) + else { + self.bump_mocktime(1); + std::thread::sleep(Duration::from_secs(1)); + continue; + }; + let all_same = self.masternodes.iter().all(|mn| { + mn.try_rpc_call("spork", &["show".into()]).as_ref() == Some(&controller_sporks) + }); + if all_same { + return true; + } + self.bump_mocktime(1); + std::thread::sleep(Duration::from_secs(1)); + } + false + } + + /// Wait for a quorum with the given hash to appear in `quorum list` for the given type. + fn wait_for_quorum_in_list( + &self, + llmq_type: &str, + quorum_hash: &str, + timeout_secs: u64, + ) -> bool { + let start = Instant::now(); + let timeout = Duration::from_secs(timeout_secs); + + while start.elapsed() < timeout { + if let Some(qlist) = self.controller.try_rpc_call("quorum", &["list".into(), 1.into()]) + { + if let Some(arr) = qlist.get(llmq_type).and_then(|v| v.as_array()) { + if arr.iter().any(|e| e.as_str() == Some(quorum_hash)) { + return true; + } + } + } + std::thread::sleep(Duration::from_millis(300)); + } + false + } + + /// Wait for enough masternodes to report a minable commitment for the quorum. + fn wait_for_quorum_commitment( + &mut self, + llmq_type: u64, + quorum_hash: &str, + expected_members: usize, + timeout_secs: u64, + ) -> bool { + let start = Instant::now(); + let timeout = Duration::from_secs(timeout_secs); + let zero_key = + "000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000"; + + while start.elapsed() < timeout { + let mut ready_count = 0; + for mn in &self.masternodes { + if let Some(status) = mn.try_rpc_call("quorum", &["dkgstatus".into()]) { + if let Some(commits) = + status.get("minableCommitments").and_then(|c| c.as_array()) + { + let ready = commits.iter().any(|commit| { + commit.get("llmqType").and_then(|t| t.as_u64()) == Some(llmq_type) + && commit.get("quorumHash").and_then(|h| h.as_str()) + == Some(quorum_hash) + && commit.get("quorumPublicKey").and_then(|k| k.as_str()) + != Some(zero_key) + }); + if ready { + ready_count += 1; + } + } + } + } + if ready_count >= expected_members { + return true; + } + self.bump_mocktime(1); + std::thread::sleep(Duration::from_millis(300)); + } + false + } + + /// Mine a single block and then call `on_block_mined` with the controller + /// reference and the resulting block height. Helper for + /// `mine_dkg_cycle_with_hook` so each hook site stays a one-liner. + fn mine_block_then_notify(&mut self, on_block_mined: &mut F) { + self.move_blocks(1); + let height = self + .controller + .try_rpc_call("getblockcount", &[]) + .and_then(|v| v.as_u64()) + .expect("getblockcount") as u32; + on_block_mined(&self.controller, height); + } + + /// Mine a complete DKG cycle and return the quorum hash if successful. + /// + /// Orchestrates all 6 DKG phases, mines the commitment block, and verifies + /// the quorum appears in `quorum list`. + /// + /// In regtest, `llmq_test` (type 100, 3 members) and `llmq_test_platform` + /// (type 106) reliably produce commitments. `llmq_test_dip0024` (type 103, + /// 4 members, minSize=4) requires all masternodes to succeed and is fragile + /// in live orchestration (pre-generated data has DIP0024 quorums from + /// controlled generation). + /// + /// ChainLocks use `llmq_test` in regtest, so new DKG cycles enable ChainLock + /// signing. QRInfo for rotated quorums references the pre-generated DIP0024 + /// quorum history. + /// + /// Returns `None` if any phase times out. + /// Requires the full network to be running (controller + masternodes). + pub fn mine_dkg_cycle(&mut self) -> Option { + self.mine_dkg_cycle_with_hook(|_, _| {}) + } + + /// Mine a complete DKG cycle, invoking `on_block_mined` after every block + /// mined past the cycle alignment point. + /// + /// Blocks mined to align the chain to the next DKG cycle boundary do *not* + /// trigger the hook — alignment is an implementation detail. The hook is + /// called for every subsequent block that advances a DKG phase, the + /// commitment block, and every maturity block. + /// + /// The hook receives a shared reference to the controller node so it can + /// issue RPC calls (for example `send_to_address`) from inside the cycle. + pub fn mine_dkg_cycle_with_hook(&mut self, mut on_block_mined: F) -> Option + where + F: FnMut(&DashCoreNode, u32), + { + assert!(!self.masternodes.is_empty(), "mine_dkg_cycle requires masternodes to be running"); + + // Both llmq_test (100) and llmq_test_dip0024 (103) share the same DKG + // interval so their cycles run simultaneously. We track phases using + // llmq_test (3 members, easier to satisfy) and then verify that the + // rotated type also produced a quorum. + let dkg_interval = self.metadata.dkg_interval as u64; + let current_height = self + .controller + .try_rpc_call("getblockcount", &[]) + .and_then(|v| v.as_u64()) + .expect("getblockcount"); + + // Align to next DKG cycle boundary + let remainder = current_height % dkg_interval; + if remainder != 0 { + let skip = dkg_interval - remainder; + debug!("Aligning to DKG boundary: mining {} blocks", skip); + self.move_blocks(skip); + } + + // The quorum hash is the best block hash at the cycle start + let quorum_hash = self.controller.get_best_block_hash(); + let quorum_hash_str = quorum_hash.to_string(); + info!("Starting DKG cycle, quorum_hash={}", quorum_hash_str); + + if self + .controller + .try_rpc_call("sporkupdate", &["SPORK_21_QUORUM_ALL_CONNECTED".into(), 0.into()]) + .is_none() + { + warn!("Failed to activate SPORK_21_QUORUM_ALL_CONNECTED"); + return None; + } + if !self.wait_for_sporks_same(30) { + warn!("Spork synchronization timeout"); + return None; + } + + let sporks = self.controller.try_rpc_call("spork", &["show".into()])?; + let spork21_active = sporks + .get("SPORK_21_QUORUM_ALL_CONNECTED") + .and_then(|v| v.as_i64()) + .is_some_and(|value| value <= 1); + let spork23_active = sporks + .get("SPORK_23_QUORUM_POSE") + .and_then(|v| v.as_i64()) + .is_some_and(|value| value <= 1); + + // LLMQ_TEST: 3 members out of 4, threshold 2 + let expected_members = 3; + let expected_connections_test = if spork21_active { + expected_members - 1 + } else { + 2 + }; + let dip0024_size = self.metadata.masternodes.len(); + let expected_connections_dip0024 = if spork21_active { + dip0024_size - 1 + } else { + 2 + }; + + // Wait for quorum connections for both types to be established. + // DKG requires direct TCP connections between quorum members. These + // connections take real wall-clock time to establish (independent of + // mocktime), so we wait before pushing DKG phases forward. + debug!("Waiting for quorum connections..."); + if !self.wait_for_quorum_connections( + "llmq_test", + &quorum_hash_str, + expected_connections_test, + 60, + ) { + warn!("llmq_test quorum connections timeout"); + return None; + } + if !self.wait_for_quorum_connections( + "llmq_test_dip0024", + &quorum_hash_str, + expected_connections_dip0024, + 60, + ) { + warn!("llmq_test_dip0024 quorum connections timeout"); + return None; + } + if spork23_active { + if !self.wait_for_masternode_probes("llmq_test", &quorum_hash_str, 30) { + warn!("llmq_test masternode probe timeout"); + return None; + } + if !self.wait_for_masternode_probes("llmq_test_dip0024", &quorum_hash_str, 30) { + warn!("llmq_test_dip0024 masternode probe timeout"); + return None; + } + } + + // Phase 1: Initialization + debug!("DKG phase 1 (init)..."); + if !self.wait_for_quorum_phase( + "llmq_test", + &quorum_hash_str, + 1, + expected_members, + None, + 0, + 60, + ) { + warn!("DKG phase 1 timeout"); + return None; + } + self.mine_block_then_notify(&mut on_block_mined); + self.mine_block_then_notify(&mut on_block_mined); + + // Phases 2-5: Contribute, Complain, Justify, Commit + let phase_checks = [ + (2, Some("receivedContributions"), expected_members as u64, 30), + (3, Some("receivedComplaints"), 0, 30), + (4, Some("receivedJustifications"), 0, 30), + (5, Some("receivedPrematureCommitments"), expected_members as u64, 30), + ]; + for (phase, field, count, timeout_secs) in phase_checks { + debug!("DKG phase {}...", phase); + if !self.wait_for_quorum_phase( + "llmq_test", + &quorum_hash_str, + phase, + expected_members, + field, + count, + timeout_secs, + ) { + warn!("DKG phase {} timeout", phase); + return None; + } + self.mine_block_then_notify(&mut on_block_mined); + self.mine_block_then_notify(&mut on_block_mined); + } + + // Phase 6: Mining + debug!("DKG phase 6 (mining)..."); + if !self.wait_for_quorum_phase( + "llmq_test", + &quorum_hash_str, + 6, + expected_members, + None, + 0, + 60, + ) { + warn!("DKG phase 6 timeout"); + return None; + } + if !self.wait_for_quorum_commitment(100, &quorum_hash_str, expected_members, 30) { + warn!("Quorum commitment timeout"); + return None; + } + + self.bump_mocktime(1); + self.controller.get_block_template(); + self.mine_block_then_notify(&mut on_block_mined); + + if !self.wait_for_quorum_in_list("llmq_test", &quorum_hash_str, 15) { + warn!("Quorum not found in list after mining commitment"); + return None; + } + + // Mine maturity blocks + for _ in 0..8 { + self.mine_block_then_notify(&mut on_block_mined); + } + + info!("DKG cycle complete: quorum_hash={}", quorum_hash_str); + Some(quorum_hash) + } + + /// Wait for a specific block to become ChainLocked on the controller. + pub fn wait_for_chainlocked_block( + &mut self, + block_hash: &BlockHash, + timeout_secs: u64, + ) -> bool { + let start = Instant::now(); + let timeout = Duration::from_secs(timeout_secs); + let block_hash_str = block_hash.to_string(); + + while start.elapsed() < timeout { + if let Some(block) = + self.controller.try_rpc_call("getblock", &[block_hash_str.clone().into()]) + { + let confirmed = block.get("confirmations").and_then(|v| v.as_i64()).unwrap_or(0); + let chainlocked = block.get("chainlock").and_then(|v| v.as_bool()).unwrap_or(false); + if confirmed > 0 && chainlocked { + return true; + } + } + self.bump_mocktime(1); + std::thread::sleep(Duration::from_millis(500)); + } + false + } + + /// Mine blocks and wait for each newly mined block to become ChainLocked. + /// + /// Mining one block at a time matches Dash Core's own functional tests more + /// closely than mining a batch and polling `getbestchainlock`, and avoids + /// depending on an RPC that returns an error before the first ChainLock exists. + pub fn mine_blocks_and_wait_for_chainlock( + &mut self, + block_count: u64, + timeout_secs: u64, + ) -> Option { + let mut last_chainlocked_height = None; + + for _ in 0..block_count { + self.bump_mocktime(1); + let addr = self.controller.get_new_address(); + let block_hash = self + .controller + .generate_blocks(1, &addr) + .into_iter() + .next() + .expect("generated block hash"); + self.wait_for_sync(); + + if self.wait_for_chainlocked_block(&block_hash, timeout_secs) { + let height = self + .controller + .try_rpc_call("getblock", &[block_hash.to_string().into()]) + .and_then(|b| b.get("height").and_then(|h| h.as_u64())) + .map(|h| h as u32) + .expect("getblock height"); + info!("ChainLock found at height {}", height); + last_chainlocked_height = Some(height); + } + } + + last_chainlocked_height + } +} + +impl Drop for MasternodeTestContext { + fn drop(&mut self) { + retain_test_dir(self.controller.datadir(), "controller"); + for (idx, masternode) in self.masternodes.iter().enumerate() { + let label = format!("masternode-{}-{}", idx, masternode.rpc_port()); + retain_test_dir(masternode.datadir(), &label); + } + } +} + +/// Connect all nodes to each other: each masternode to the controller and to other masternodes. +/// +/// Direct connections between masternodes are important for DKG message exchange. +async fn connect_all_nodes(controller: &DashCoreNode, masternodes: &[DashCoreNode]) { + let controller_p2p: serde_json::Value = format!("127.0.0.1:{}", controller.p2p_port()).into(); + + // Connect each masternode to the controller + for mn in masternodes { + mn.try_rpc_call("addnode", &[controller_p2p.clone(), "add".into()]); + } + + // Connect each masternode to every other masternode + for i in 0..masternodes.len() { + for j in (i + 1)..masternodes.len() { + let target: serde_json::Value = + format!("127.0.0.1:{}", masternodes[j].p2p_port()).into(); + masternodes[i].try_rpc_call("addnode", &[target, "add".into()]); + } + } + + // Wait for the controller to have all expected peer connections + let expected_peers = masternodes.len(); + for _ in 0..15 { + tokio::time::sleep(Duration::from_secs(2)).await; + let count = controller + .try_rpc_call("getpeerinfo", &[]) + .and_then(|v| v.as_array().map(|a| a.len())) + .unwrap_or(0); + if count >= expected_peers { + info!("Controller has {} peers connected", count); + return; + } + } + let count = controller + .try_rpc_call("getpeerinfo", &[]) + .and_then(|v| v.as_array().map(|a| a.len())) + .unwrap_or(0); + info!("Controller has {} peers (expected {})", count, expected_peers); +} + +/// Update each masternode's registered service address to match its actual P2P port. +/// +/// The proTx entries from generation reference the original ports. After restarting +/// with different ports, we need to call `protx update_service` so that dashd can +/// establish quorum connections between masternodes at the correct addresses. +fn update_mn_service_addresses( + controller: &DashCoreNode, + masternodes: &[DashCoreNode], + metadata: &NetworkMetadata, +) { + use dashcore_rpc::{Auth, Client, RpcApi}; + + // Wallet-aware client needed because protx update_service sends a transaction + let url = + format!("http://127.0.0.1:{}/wallet/{}", controller.rpc_port(), metadata.controller.wallet); + let cookie_path = controller.datadir().join("regtest/.cookie"); + let client = Client::new(&url, Auth::CookieFile(cookie_path)).expect("rpc client"); + + for (mn, mn_info) in masternodes.iter().zip(metadata.masternodes.iter()) { + let new_addr = format!("127.0.0.1:{}", mn.p2p_port()); + info!("Updating {} service address to {}", mn_info.datadir, new_addr); + + let result: Result = client.call( + "protx", + &[ + "update_service".into(), + mn_info.pro_tx_hash.clone().into(), + new_addr.into(), + mn_info.bls_private_key.clone().into(), + ], + ); + if let Err(e) = result { + warn!("protx update_service failed for {}: {}", mn_info.datadir, e); + } + } + + // Mine a block to confirm the update transactions + let addr = controller.get_new_address(); + controller.generate_blocks(1, &addr); + info!("Service addresses updated and confirmed"); +} diff --git a/dash-spv/src/test_utils/mod.rs b/dash-spv/src/test_utils/mod.rs index 1ae7b8a89..96329f41e 100644 --- a/dash-spv/src/test_utils/mod.rs +++ b/dash-spv/src/test_utils/mod.rs @@ -5,6 +5,7 @@ mod context; mod event_handler; mod filter; mod fs_helpers; +pub(crate) mod masternode_network; mod network; mod node; mod types; @@ -17,6 +18,7 @@ pub const SYNC_TIMEOUT: Duration = Duration::from_secs(180); pub use context::DashdTestContext; pub use event_handler::TestEventHandler; pub use fs_helpers::retain_test_dir; +pub use masternode_network::MasternodeTestContext; pub use network::{test_socket_address, MockNetworkManager}; pub use node::{DashCoreNode, TestChain, WalletFile}; diff --git a/dash-spv/src/test_utils/network.rs b/dash-spv/src/test_utils/network.rs index 1eff5aecf..5ac883944 100644 --- a/dash-spv/src/test_utils/network.rs +++ b/dash-spv/src/test_utils/network.rs @@ -47,7 +47,7 @@ impl MockNetworkManager { sent_messages: Vec::new(), request_tx, request_rx: Some(request_rx), - network_event_sender: broadcast::Sender::new(100), + network_event_sender: broadcast::Sender::new(100000), } } diff --git a/dash-spv/src/test_utils/node.rs b/dash-spv/src/test_utils/node.rs index 829697c43..d23eeb17f 100644 --- a/dash-spv/src/test_utils/node.rs +++ b/dash-spv/src/test_utils/node.rs @@ -6,6 +6,7 @@ use dashcore::{Address, Amount, BlockHash, Transaction, Txid}; use dashcore_rpc::json as rpc_json; use dashcore_rpc::{Auth, Client, RpcApi}; use serde::Deserialize; +use serde_json::Value; use std::collections::HashMap; use std::fs; use std::net::SocketAddr; @@ -22,7 +23,7 @@ static NEXT_PORT: AtomicU16 = AtomicU16::new(19400); const MAX_PORT_ATTEMPTS: usize = 100; /// Allocate a unique, available TCP port for test use. -fn find_available_port() -> u16 { +pub(super) fn find_available_port() -> u16 { for _ in 0..MAX_PORT_ATTEMPTS { let port = NEXT_PORT.fetch_add(1, Ordering::Relaxed); assert!(port >= 1024, "port counter overflowed"); @@ -63,6 +64,8 @@ pub struct DashCoreConfig { pub p2p_port: u16, /// RPC port for the node pub rpc_port: u16, + /// Additional command-line arguments passed to dashd + pub extra_args: Vec, } impl DashCoreConfig { @@ -97,8 +100,15 @@ impl DashCoreConfig { wallet: "default".to_string(), p2p_port: find_available_port(), rpc_port: find_available_port(), + extra_args: Vec::new(), }) } + + /// Append additional command-line arguments passed to dashd on startup. + pub fn with_extra_args(mut self, args: Vec) -> Self { + self.extra_args.extend(args); + self + } } /// Test infrastructure for managing a Dash Core node. @@ -126,7 +136,7 @@ impl DashCoreNode { fs::create_dir_all(&self.config.datadir).expect("failed to create datadir"); - let args_vec = vec![ + let mut args_vec = vec![ "-regtest".to_string(), format!("-datadir={}", self.config.datadir.display()), format!("-port={}", self.config.p2p_port), @@ -147,8 +157,11 @@ impl DashCoreNode { "-peerbloomfilters=1".to_string(), "-whitelist=127.0.0.1".to_string(), "-debug=all".to_string(), - format!("-wallet={}", self.config.wallet), ]; + if !self.config.wallet.is_empty() { + args_vec.push(format!("-wallet={}", self.config.wallet)); + } + args_vec.extend(self.config.extra_args.iter().cloned()); let mut cmd = tokio::process::Command::new(&self.config.dashd_path); cmd.args(&args_vec) @@ -257,6 +270,11 @@ impl DashCoreNode { } } + /// Get a new address from the node's configured wallet. + pub fn get_new_address(&self) -> Address { + self.get_new_address_from_wallet(&self.config.wallet) + } + /// Get a new address from a specific dashd wallet. pub fn get_new_address_from_wallet(&self, wallet_name: &str) -> Address { let client = self.rpc_client_for_wallet(wallet_name); @@ -435,6 +453,24 @@ impl DashCoreNode { tracing::info!("Set network active={} on dashd", active); } + /// Set mock time on this node. + pub fn set_mocktime(&self, time: u64) { + let client = self.rpc_client(); + let _: Value = client.call("setmocktime", &[time.into()]).expect("setmocktime failed"); + } + + /// Get the best block hash. + pub fn get_best_block_hash(&self) -> BlockHash { + let client = self.rpc_client(); + client.get_best_block_hash().expect("getbestblockhash failed") + } + + /// Call getblocktemplate to trigger CreateNewBlock (includes quorum commitments). + pub fn get_block_template(&self) { + let client = self.rpc_client(); + let _: Result = client.call("getblocktemplate", &[]); + } + /// Disconnect all currently connected peers. pub fn disconnect_all_peers(&self) { let client = self.rpc_client(); @@ -446,6 +482,36 @@ impl DashCoreNode { } tracing::info!("Disconnected {} peers", peers.len()); } + + /// Execute an RPC call, returning None on failure instead of panicking. + /// + /// Uses the base URL (no wallet path) which works for all non-wallet RPCs. + /// Useful during DKG orchestration where transient failures are expected. + pub fn try_rpc_call(&self, method: &str, params: &[serde_json::Value]) -> Option { + let url = format!("http://127.0.0.1:{}", self.config.rpc_port); + let cookie_path = self.config.datadir.join("regtest/.cookie"); + if !cookie_path.exists() { + return None; + } + let auth = Auth::CookieFile(cookie_path); + let client = Client::new(&url, auth).ok()?; + client.call(method, params).ok() + } + + /// Get the datadir path + pub fn datadir(&self) -> &Path { + &self.config.datadir + } + + /// Get the P2P port + pub fn p2p_port(&self) -> u16 { + self.config.p2p_port + } + + /// Get the RPC port + pub fn rpc_port(&self) -> u16 { + self.config.rpc_port + } } impl Drop for DashCoreNode { diff --git a/dash-spv/tests/dashd_masternode/helpers.rs b/dash-spv/tests/dashd_masternode/helpers.rs new file mode 100644 index 000000000..233622212 --- /dev/null +++ b/dash-spv/tests/dashd_masternode/helpers.rs @@ -0,0 +1,287 @@ +use dash_spv::sync::{MasternodesProgress, SyncEvent, SyncProgress, SyncState}; +use dashcore::ephemerealdata::instant_lock::InstantLock; +use dashcore::Txid; +use key_wallet::transaction_checking::TransactionContext; +use key_wallet_manager::WalletEvent; +use std::time::Duration; +use tokio::sync::{broadcast, watch}; + +/// Wait for masternode sync to reach Synced state. +pub(super) async fn wait_for_masternode_sync( + progress_receiver: &mut watch::Receiver, + timeout_secs: u64, +) -> MasternodesProgress { + let timeout = tokio::time::sleep(Duration::from_secs(timeout_secs)); + tokio::pin!(timeout); + + loop { + tokio::select! { + _ = &mut timeout => { + let progress = progress_receiver.borrow(); + panic!( + "Timeout waiting for masternode sync. Current progress: {:?}", + progress + ); + } + result = progress_receiver.changed() => { + if result.is_err() { + panic!("Progress channel closed"); + } + let progress = progress_receiver.borrow_and_update().clone(); + + // Check if masternodes are synced + if let Ok(mn_progress) = progress.masternodes() { + if mn_progress.state() == SyncState::Synced { + tracing::info!( + "Masternode sync complete at height {}", + mn_progress.current_height() + ); + return mn_progress.clone(); + } + } + } + } + } +} + +/// Wait for the MasternodeStateUpdated sync event. +pub(super) async fn wait_for_mn_state_event( + event_receiver: &mut broadcast::Receiver, + timeout_secs: u64, +) -> u32 { + wait_for_mn_state_event_above(event_receiver, 0, timeout_secs).await +} + +/// Wait for a MasternodeStateUpdated event at a height strictly above `min_height`. +pub(super) async fn wait_for_mn_state_event_above( + event_receiver: &mut broadcast::Receiver, + min_height: u32, + timeout_secs: u64, +) -> u32 { + let timeout = tokio::time::sleep(Duration::from_secs(timeout_secs)); + tokio::pin!(timeout); + + loop { + tokio::select! { + _ = &mut timeout => { + panic!( + "Timeout waiting for MasternodeStateUpdated above height {}", + min_height + ); + } + result = event_receiver.recv() => { + match result { + Ok(SyncEvent::MasternodeStateUpdated { height }) if height > min_height => { + tracing::info!("MasternodeStateUpdated at height {} (above {})", height, min_height); + return height; + } + Ok(SyncEvent::MasternodeStateUpdated { height }) => { + tracing::debug!("MasternodeStateUpdated at height {} (waiting for > {})", height, min_height); + continue; + } + Ok(_) => continue, + Err(broadcast::error::RecvError::Lagged(n)) => { + tracing::warn!("Event receiver lagged by {} messages", n); + continue; + } + Err(broadcast::error::RecvError::Closed) => { + panic!("Sync event channel closed"); + } + } + } + } + } +} + +/// Wait for an `InstantLockReceived` sync event for `txid` with the desired validation state. +/// +/// Returns the received `InstantLock`. Ignores events for unrelated txids and events +/// whose `validated` flag does not match `want_validated`. +pub(super) async fn wait_for_instant_lock_received( + event_receiver: &mut broadcast::Receiver, + txid: Txid, + want_validated: bool, + timeout_secs: u64, +) -> InstantLock { + let timeout = tokio::time::sleep(Duration::from_secs(timeout_secs)); + tokio::pin!(timeout); + + loop { + tokio::select! { + _ = &mut timeout => { + panic!( + "Timeout waiting for InstantLockReceived(txid={}, validated={})", + txid, want_validated + ); + } + result = event_receiver.recv() => { + match result { + Ok(SyncEvent::InstantLockReceived { instant_lock, validated }) + if instant_lock.txid == txid && validated == want_validated => + { + tracing::info!( + "InstantLockReceived(txid={}, validated={})", + txid, validated + ); + return instant_lock; + } + Ok(SyncEvent::InstantLockReceived { instant_lock, validated }) => { + tracing::debug!( + "Ignoring InstantLockReceived(txid={}, validated={}) — waiting for txid={} validated={}", + instant_lock.txid, validated, txid, want_validated + ); + continue; + } + Ok(_) => continue, + Err(broadcast::error::RecvError::Lagged(n)) => { + tracing::warn!("Sync event receiver lagged by {} messages", n); + continue; + } + Err(broadcast::error::RecvError::Closed) => { + panic!("Sync event channel closed"); + } + } + } + } + } +} + +/// Wait for a wallet event about `txid` whose `TransactionContext` matches `pred`. +/// +/// Returns the matching context. Matches both `TransactionReceived` (first-time seen) +/// and `TransactionStatusChanged` (subsequent status update). +pub(super) async fn wait_for_wallet_tx_status( + event_receiver: &mut broadcast::Receiver, + txid: Txid, + pred: F, + timeout_secs: u64, +) -> TransactionContext +where + F: Fn(&TransactionContext) -> bool, +{ + let timeout = tokio::time::sleep(Duration::from_secs(timeout_secs)); + tokio::pin!(timeout); + + loop { + tokio::select! { + _ = &mut timeout => { + panic!("Timeout waiting for wallet event for txid {}", txid); + } + result = event_receiver.recv() => { + match result { + Ok(WalletEvent::TransactionReceived { record, .. }) + if record.txid == txid && pred(&record.context) => + { + tracing::info!( + "Wallet TransactionReceived(txid={}, context={})", + txid, record.context + ); + return record.context.clone(); + } + Ok(WalletEvent::TransactionStatusChanged { txid: event_txid, status, .. }) + if event_txid == txid && pred(&status) => + { + tracing::info!( + "Wallet TransactionStatusChanged(txid={}, status={})", + txid, status + ); + return status; + } + Ok(other) => { + tracing::debug!("Ignoring wallet event: {}", other.description()); + continue; + } + Err(broadcast::error::RecvError::Lagged(n)) => { + tracing::warn!("Wallet event receiver lagged by {} messages", n); + continue; + } + Err(broadcast::error::RecvError::Closed) => { + panic!("Wallet event channel closed"); + } + } + } + } + } +} + +/// Wait for `InstantSendProgress::valid()` to reach at least `min_valid`. +pub(super) async fn wait_for_instantsend_valid_at_least( + progress_receiver: &mut watch::Receiver, + min_valid: u32, + timeout_secs: u64, +) { + { + let progress = progress_receiver.borrow(); + if let Ok(is_progress) = progress.instantsend() { + if is_progress.valid() >= min_valid { + return; + } + } + } + + let timeout = tokio::time::sleep(Duration::from_secs(timeout_secs)); + tokio::pin!(timeout); + + loop { + tokio::select! { + _ = &mut timeout => { + let progress = progress_receiver.borrow(); + panic!( + "Timeout waiting for InstantSendProgress.valid >= {}. Current: {:?}", + min_valid, progress.instantsend().ok() + ); + } + result = progress_receiver.changed() => { + if result.is_err() { + panic!("Progress channel closed"); + } + let progress = progress_receiver.borrow_and_update().clone(); + if let Ok(is_progress) = progress.instantsend() { + if is_progress.valid() >= min_valid { + return; + } + } + } + } + } +} + +/// Wait for validated ChainLock progress to reach at least `min_height`. +pub(super) async fn wait_for_chainlock_height_at_least( + progress_receiver: &mut watch::Receiver, + min_height: u32, + timeout_secs: u64, +) -> u32 { + { + let progress = progress_receiver.borrow(); + if let Ok(chainlock_progress) = progress.chainlocks() { + let height = chainlock_progress.best_validated_height(); + if height >= min_height { + return height; + } + } + } + + let timeout = tokio::time::sleep(Duration::from_secs(timeout_secs)); + tokio::pin!(timeout); + + loop { + tokio::select! { + _ = &mut timeout => { + panic!("Timeout waiting for ChainLock height >= {}", min_height); + } + result = progress_receiver.changed() => { + if result.is_err() { + panic!("Progress channel closed"); + } + let progress = progress_receiver.borrow_and_update().clone(); + if let Ok(chainlock_progress) = progress.chainlocks() { + let height = chainlock_progress.best_validated_height(); + if height >= min_height { + return height; + } + } + } + } + } +} diff --git a/dash-spv/tests/dashd_masternode/main.rs b/dash-spv/tests/dashd_masternode/main.rs new file mode 100644 index 000000000..214b32964 --- /dev/null +++ b/dash-spv/tests/dashd_masternode/main.rs @@ -0,0 +1,14 @@ +//! Masternode network tests using dashd. +//! +//! These tests verify SPV behavior against a pre-generated regtest masternode +//! network (1 controller + 4 masternodes with DKG cycles). +//! +//! Required environment variables: +//! - `DASHD_PATH`: path to dashd binary +//! - `DASHD_MN_DATADIR`: path to pre-generated masternode blockchain data +//! - `SKIP_DASHD_TESTS=1`: set to skip these tests + +mod helpers; +mod setup; +mod tests_instantsend; +mod tests_sync; diff --git a/dash-spv/tests/dashd_masternode/setup.rs b/dash-spv/tests/dashd_masternode/setup.rs new file mode 100644 index 000000000..41471502f --- /dev/null +++ b/dash-spv/tests/dashd_masternode/setup.rs @@ -0,0 +1,209 @@ +use dash_spv::network::NetworkEvent; +use dash_spv::test_utils::{MasternodeTestContext, TestEventHandler}; +use dash_spv::{ + client::{ClientConfig, DashSpvClient}, + network::PeerNetworkManager, + storage::DiskStorageManager, + sync::{SyncEvent, SyncProgress}, + LevelFilter, LoggingGuard, Network, +}; +use dashcore::sml::masternode_list_engine::MasternodeListEngine; +use key_wallet::managed_account::managed_account_type::ManagedAccountType; +use key_wallet::wallet::initialization::WalletAccountCreationOptions; +use key_wallet::wallet::managed_wallet_info::wallet_info_interface::WalletInfoInterface; +use key_wallet::wallet::managed_wallet_info::ManagedWalletInfo; +use key_wallet_manager::{WalletEvent, WalletId, WalletManager}; +use std::collections::BTreeSet; +use std::path::PathBuf; +use std::sync::Arc; +use tempfile::TempDir; +use tokio::sync::{broadcast, watch, RwLock}; +use tokio_util::sync::CancellationToken; + +/// Timeout for masternode sync tests (masternode sync takes longer than wallet sync). +pub(super) const SYNC_TIMEOUT: u64 = 120; + +pub(super) type TestClient = DashSpvClient< + WalletManager, + PeerNetworkManager, + DiskStorageManager, + TestEventHandler, +>; + +pub(super) struct ClientHandle { + pub(super) client: TestClient, + pub(super) run_handle: Option>>, + pub(super) progress_receiver: watch::Receiver, + pub(super) sync_event_receiver: broadcast::Receiver, + pub(super) wallet_event_receiver: broadcast::Receiver, + pub(super) _network_event_receiver: broadcast::Receiver, + pub(super) cancel_token: CancellationToken, + pub(super) engine: Arc>, +} + +impl ClientHandle { + pub(super) async fn stop(&mut self) { + tracing::info!("Cancelling client run loop..."); + self.cancel_token.cancel(); + if let Some(handle) = self.run_handle.take() { + handle.await.expect("Run task panicked").expect("Run task returned error"); + } + } +} + +/// SPV-specific test context wrapping the masternode network infrastructure. +pub(super) struct TestContext { + pub(super) mn_ctx: MasternodeTestContext, + pub(super) storage_dir: PathBuf, + _log_guard: LoggingGuard, +} + +impl TestContext { + pub(super) async fn new(controller_only: bool) -> Option { + let storage_dir = TempDir::new().expect("Failed to create temp directory"); + let log_dir = storage_dir.path().join("logs"); + let _log_guard = dash_spv::init_logging(dash_spv::LoggingConfig { + level: Some(LevelFilter::DEBUG), + console: std::env::var("DASHD_TEST_LOG").is_ok(), + file: Some(dash_spv::LogFileConfig { + log_dir, + max_files: 1, + }), + thread_local: true, + }) + .expect("Failed to initialize test logging"); + + let mn_ctx = MasternodeTestContext::new(controller_only).await?; + + let storage_path = storage_dir.keep(); + eprintln!( + "TestContext: addr={}, blocks={}, data={}", + mn_ctx.controller_addr, + mn_ctx.expected_height, + storage_path.display(), + ); + + Some(TestContext { + mn_ctx, + storage_dir: storage_path, + _log_guard, + }) + } +} + +impl Drop for TestContext { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.storage_dir); + } +} + +/// Create a test client config with masternodes enabled. +pub(super) fn create_mn_test_config( + storage_path: PathBuf, + peer_addr: std::net::SocketAddr, +) -> ClientConfig { + let mut config = ClientConfig::regtest().with_storage_path(storage_path); + config.peers.clear(); + config.add_peer(peer_addr); + config +} + +fn account_options() -> WalletAccountCreationOptions { + WalletAccountCreationOptions::SpecificAccounts( + BTreeSet::from([0]), + BTreeSet::new(), + BTreeSet::new(), + BTreeSet::new(), + BTreeSet::new(), + None, + ) +} + +/// Create a dummy wallet (masternode sync doesn't need real wallet data). +pub(super) fn create_dummy_wallet() -> Arc>> { + let mnemonic = "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about"; + let mut wallet_manager = WalletManager::::new(Network::Regtest); + wallet_manager + .create_wallet_from_mnemonic(mnemonic, "", 0, account_options()) + .expect("Failed to create wallet"); + Arc::new(RwLock::new(wallet_manager)) +} + +/// Create a wallet from the pre-generated controller wallet's mnemonic. +/// +/// Using the same mnemonic as the dashd controller wallet means the SPV wallet +/// derives the same addresses, so any `send_to_address` call routed through the +/// controller lands in the SPV wallet as well. +pub(super) fn create_wallet_from_controller( + mn_ctx: &MasternodeTestContext, +) -> (Arc>>, WalletId) { + let mut wallet_manager = WalletManager::::new(Network::Regtest); + let wallet_id = wallet_manager + .create_wallet_from_mnemonic(&mn_ctx.wallet.mnemonic, "", 0, account_options()) + .expect("Failed to create wallet from controller mnemonic"); + (Arc::new(RwLock::new(wallet_manager)), wallet_id) +} + +/// Fetch an unused external receive address from the wallet's BIP44 account 0. +pub(super) async fn receive_address( + wallet: &Arc>>, + wallet_id: &WalletId, +) -> dashcore::Address { + let wallet_read = wallet.read().await; + let wallet_info = wallet_read.get_wallet_info(wallet_id).expect("Wallet info not found"); + let account = + wallet_info.accounts().standard_bip44_accounts.get(&0).expect("BIP44 account 0 not found"); + let ManagedAccountType::Standard { + external_addresses, + .. + } = &account.account_type + else { + panic!("Account 0 is not a Standard account type"); + }; + external_addresses + .unused_addresses() + .into_iter() + .next() + .expect("No unused receive address available") +} + +/// Start the SPV client and return handles for monitoring. +pub(super) async fn create_and_start_client( + config: &ClientConfig, + wallet: Arc>>, +) -> ClientHandle { + let network_manager = + PeerNetworkManager::new(config).await.expect("Failed to create network manager"); + let storage_manager = + DiskStorageManager::new(config).await.expect("Failed to create storage manager"); + + let handler = Arc::new(TestEventHandler::new()); + let progress_receiver = handler.subscribe_progress(); + let sync_event_receiver = handler.subscribe_sync_events(); + let wallet_event_receiver = handler.subscribe_wallet_events(); + let _network_event_receiver = handler.subscribe_network_events(); + + let client = + DashSpvClient::new(config.clone(), network_manager, storage_manager, wallet, handler) + .await + .expect("Failed to create client"); + + let engine = + client.masternode_list_engine().expect("Engine should be initialized after creation"); + let cancel_token = CancellationToken::new(); + let run_token = cancel_token.clone(); + let run_client = client.clone(); + + let run_handle = tokio::task::spawn(async move { run_client.run(run_token).await }); + + ClientHandle { + client, + run_handle: Some(run_handle), + progress_receiver, + sync_event_receiver, + wallet_event_receiver, + _network_event_receiver, + cancel_token, + engine, + } +} diff --git a/dash-spv/tests/dashd_masternode/tests_instantsend.rs b/dash-spv/tests/dashd_masternode/tests_instantsend.rs new file mode 100644 index 000000000..4e24d7399 --- /dev/null +++ b/dash-spv/tests/dashd_masternode/tests_instantsend.rs @@ -0,0 +1,452 @@ +//! InstantSend integration tests using the masternode network harness. +//! +//! These tests exercise the SPV client's InstantSend plumbing end-to-end against +//! a real dashd masternode network: `InstantSendManager` validation, the mempool +//! manager's InstantSend status propagation to the wallet, and the transition from +//! an InstantSend-locked transaction to a ChainLocked block. + +use dash_spv::sync::SyncState; +use dashcore::Amount; +use key_wallet::transaction_checking::TransactionContext; + +use super::helpers::{ + wait_for_chainlock_height_at_least, wait_for_instant_lock_received, + wait_for_instantsend_valid_at_least, wait_for_masternode_sync, wait_for_mn_state_event_above, + wait_for_wallet_tx_status, +}; +use super::setup::{ + create_and_start_client, create_mn_test_config, create_wallet_from_controller, receive_address, + TestContext, SYNC_TIMEOUT, +}; + +/// Full InstantSend lifecycle: send → validated islock → wallet IS status → +/// chainlocked block → wallet InChainLockedBlock status. +/// +/// Starts the full masternode network, drives a DKG cycle to form a live +/// `llmq_test` signing quorum, then sends three concurrent transactions from +/// the controller wallet to the SPV wallet (same mnemonic, so addresses line +/// up). For each transaction the test asserts: +/// 1. `SyncEvent::InstantLockReceived { validated: true }` fires. +/// 2. A wallet event reports `TransactionContext::InstantSend(_)` for the txid. +/// Then it mines blocks until a ChainLock is produced and asserts each tx +/// transitions to `TransactionContext::InChainLockedBlock(_)`. +/// +/// Uses multi-thread runtime because DKG orchestration makes blocking RPC calls. +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn test_instantsend_full_lifecycle() { + let Some(mut ctx) = TestContext::new(false).await else { + return; + }; + + let (wallet, wallet_id) = create_wallet_from_controller(&ctx.mn_ctx); + let config = create_mn_test_config(ctx.storage_dir.clone(), ctx.mn_ctx.controller_addr); + + let mut client_handle = create_and_start_client(&config, std::sync::Arc::clone(&wallet)).await; + + // Initial masternode sync so the engine knows about the pre-generated quorums. + let mn_progress = + wait_for_masternode_sync(&mut client_handle.progress_receiver, SYNC_TIMEOUT).await; + assert_eq!(mn_progress.state(), SyncState::Synced); + let initial_height = mn_progress.current_height(); + tracing::info!("Initial masternode sync complete at height {}", initial_height); + + // Drive a DKG cycle so the newly formed llmq_test quorum can sign islocks and + // chainlocks for subsequent transactions. Pre-generated data alone isn't enough + // because its quorums are fixed in the past and won't produce fresh signatures. + tracing::info!("Mining DKG cycle to form a live signing quorum..."); + ctx.mn_ctx.mine_dkg_cycle().expect("DKG cycle should succeed"); + + // Make sure the SPV client has caught up to the post-DKG masternode state + // before we start generating user transactions. + let post_dkg_height = wait_for_mn_state_event_above( + &mut client_handle.sync_event_receiver, + initial_height, + SYNC_TIMEOUT, + ) + .await; + tracing::info!("SPV caught up to post-DKG masternode state at height {}", post_dkg_height); + + // Send three independent transactions from the controller wallet to three + // fresh SPV receive addresses. Because the SPV wallet is derived from the + // same mnemonic as the controller wallet, these are internal self-transfers + // that still get broadcast and islocked exactly like regular sends. + const NUM_TXS: usize = 3; + const SEND_AMOUNT: Amount = Amount::from_sat(50_000_000); + let mut txids = Vec::with_capacity(NUM_TXS); + for i in 0..NUM_TXS { + let addr = receive_address(&wallet, &wallet_id).await; + let txid = ctx.mn_ctx.controller.send_to_address(&addr, SEND_AMOUNT); + tracing::info!("Sent tx {}/{}: txid={} to {}", i + 1, NUM_TXS, txid, addr); + txids.push(txid); + } + + // Assert each transaction receives a validated InstantLock. + for (i, txid) in txids.iter().enumerate() { + let lock = wait_for_instant_lock_received( + &mut client_handle.sync_event_receiver, + *txid, + true, + SYNC_TIMEOUT, + ) + .await; + assert_eq!(lock.txid, *txid); + tracing::info!("Tx {}/{} islocked (txid={})", i + 1, NUM_TXS, txid); + } + + // Progress counter must reflect all validated locks. + wait_for_instantsend_valid_at_least( + &mut client_handle.progress_receiver, + NUM_TXS as u32, + SYNC_TIMEOUT, + ) + .await; + + // Each tx must surface in the wallet with an InstantSend context. The wallet + // may report it via `TransactionReceived` (first-seen) or a subsequent + // `TransactionStatusChanged` — the helper accepts either. + for (i, txid) in txids.iter().enumerate() { + let status = wait_for_wallet_tx_status( + &mut client_handle.wallet_event_receiver, + *txid, + |ctx| matches!(ctx, TransactionContext::InstantSend(_)), + SYNC_TIMEOUT, + ) + .await; + assert!(matches!(status, TransactionContext::InstantSend(_))); + tracing::info!("Tx {}/{} wallet-observed with InstantSend context", i + 1, NUM_TXS); + } + + // Mine blocks until a ChainLock is produced and propagated. After the DKG + // cycle above, the llmq_test quorum is eligible to sign chainlocks. + tracing::info!("Mining blocks and waiting for ChainLock..."); + let cl_height = ctx + .mn_ctx + .mine_blocks_and_wait_for_chainlock(3, 60) + .expect("ChainLock should be produced after DKG cycle completion"); + + // SPV client must catch up to that ChainLock. + let cl_sync_height = wait_for_chainlock_height_at_least( + &mut client_handle.progress_receiver, + cl_height, + SYNC_TIMEOUT, + ) + .await; + assert!(cl_sync_height >= cl_height); + tracing::info!("SPV synced to ChainLocked height {}", cl_sync_height); + + // Each previously islocked tx must now appear in a chainlocked block. + for (i, txid) in txids.iter().enumerate() { + let status = wait_for_wallet_tx_status( + &mut client_handle.wallet_event_receiver, + *txid, + |ctx| matches!(ctx, TransactionContext::InChainLockedBlock(_)), + SYNC_TIMEOUT, + ) + .await; + assert!(matches!(status, TransactionContext::InChainLockedBlock(_))); + tracing::info!("Tx {}/{} transitioned to InChainLockedBlock", i + 1, NUM_TXS); + } + + client_handle.stop().await; +} + +/// InstantSend continues to work across a full DIP-0024 quorum rotation cycle. +/// +/// Drives the chain to exactly one block before the next DKG cycle boundary, +/// sends an InstantSend transaction for the boundary block, then runs a full +/// DKG cycle with a per-block hook that sends one additional InstantSend +/// transaction after every block mined during phases 1-6, the commitment +/// block, and the 8 maturity blocks. After the cycle completes and the new +/// masternode state is observed by the SPV client, a final InstantSend +/// transaction is sent on one more block. Every transaction — pre-cycle, +/// in-cycle, and post-cycle — must produce a validated `InstantLockReceived` +/// event. +/// +/// This proves that signing quorum availability remains continuous across a +/// quorum rotation: at no block within the transition window does an +/// InstantSend transaction fail to acquire a validated lock. +/// +/// Uses multi-thread runtime because DKG orchestration makes blocking RPC calls. +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn test_instantsend_across_quorum_rotation() { + let Some(mut ctx) = TestContext::new(false).await else { + return; + }; + + let (wallet, wallet_id) = create_wallet_from_controller(&ctx.mn_ctx); + let config = create_mn_test_config(ctx.storage_dir.clone(), ctx.mn_ctx.controller_addr); + + let mut client_handle = create_and_start_client(&config, std::sync::Arc::clone(&wallet)).await; + + // Initial masternode sync. + let mn_progress = + wait_for_masternode_sync(&mut client_handle.progress_receiver, SYNC_TIMEOUT).await; + assert_eq!(mn_progress.state(), SyncState::Synced); + let initial_height = mn_progress.current_height(); + tracing::info!("Initial masternode sync complete at height {}", initial_height); + + // A first DKG cycle forms a live signing quorum that will be able to sign + // InstantSend locks throughout the rotation test below. + tracing::info!("Priming a signing quorum with an initial DKG cycle..."); + ctx.mn_ctx.mine_dkg_cycle().expect("Initial DKG cycle should succeed"); + let post_initial_height = wait_for_mn_state_event_above( + &mut client_handle.sync_event_receiver, + initial_height, + SYNC_TIMEOUT, + ) + .await; + tracing::info!("SPV caught up to post-initial-DKG height {}", post_initial_height); + + // Align the chain to exactly one block before the next DKG cycle boundary. + // We do this silently (no InstantSend per block) because the alignment + // window is outside the rotation transition we care about. + let dkg_interval = ctx.mn_ctx.metadata.dkg_interval as u64; + let current_height = ctx + .mn_ctx + .controller + .try_rpc_call("getblockcount", &[]) + .and_then(|v| v.as_u64()) + .expect("getblockcount") as u32; + let remainder = current_height as u64 % dkg_interval; + let blocks_to_boundary = (dkg_interval - remainder) as u32; + // We want to stop exactly one block before the boundary, which means + // mining (blocks_to_boundary - 1) more blocks. If we are already on that + // position (remainder == dkg_interval - 1), skip is 0. + let silent_skip = blocks_to_boundary.saturating_sub(1); + tracing::info!( + "Aligning to boundary-1: current={}, dkg_interval={}, silent_skip={}", + current_height, + dkg_interval, + silent_skip + ); + if silent_skip > 0 { + ctx.mn_ctx.move_blocks(silent_skip as u64); + } + wait_for_mn_state_event_above( + &mut client_handle.sync_event_receiver, + post_initial_height, + SYNC_TIMEOUT, + ) + .await; + + // Pre-rotation IS transaction: sent now, will be mined in the block that + // crosses the DKG cycle boundary. The receive address is fetched once and + // reused for all in-cycle sends below as well — the SPV wallet's + // `receive_address` accessor keeps returning the same "first unused" + // address until the wallet processes a received tx, but multiple pays to + // the same address still yield distinct txids, which is what the test + // needs. + let send_addr = receive_address(&wallet, &wallet_id).await; + let send_amount = Amount::from_sat(50_000_000); + + let pre_rotation_txid = ctx.mn_ctx.controller.send_to_address(&send_addr, send_amount); + tracing::info!("Pre-rotation (boundary) IS tx: {}", pre_rotation_txid); + + // Mine the boundary block containing the pre-rotation tx. Note: this call + // runs before `mine_dkg_cycle_with_hook`, which will align internally (no + // alignment needed since we are already on a boundary after this block). + ctx.mn_ctx.move_blocks(1); + + // In-cycle IS sends: one per block mined during the DKG rotation. The + // closure captures `sent_txids` and the pre-fetched address, and sends + // directly via the controller RPC passed into the hook. + let mut in_cycle_txids: Vec<(u32, dashcore::Txid)> = Vec::new(); + tracing::info!("Running DKG rotation with per-block IS sends..."); + ctx.mn_ctx + .mine_dkg_cycle_with_hook(|node, height| { + let txid = node.send_to_address(&send_addr, send_amount); + tracing::info!("In-cycle IS tx at height {}: {}", height, txid); + in_cycle_txids.push((height, txid)); + }) + .expect("Rotation DKG cycle should succeed"); + + tracing::info!("Rotation cycle complete with {} in-cycle IS txs", in_cycle_txids.len()); + assert!( + !in_cycle_txids.is_empty(), + "mine_dkg_cycle_with_hook should have mined at least one hooked block" + ); + + // Wait for the SPV client to catch up to the post-rotation masternode + // state before collecting islocks — we want every event the network + // produced during the cycle to have landed. + let post_cycle_height = wait_for_mn_state_event_above( + &mut client_handle.sync_event_receiver, + post_initial_height, + SYNC_TIMEOUT, + ) + .await; + tracing::info!("SPV caught up to post-rotation height {}", post_cycle_height); + + // Assert the pre-rotation tx got a validated islock. + let _ = wait_for_instant_lock_received( + &mut client_handle.sync_event_receiver, + pre_rotation_txid, + true, + SYNC_TIMEOUT, + ) + .await; + + // Drain a validated islock for every in-cycle tx. Order is irrelevant + // because `wait_for_instant_lock_received` filters by txid and ignores + // non-matching events. + for (height, txid) in &in_cycle_txids { + let _ = wait_for_instant_lock_received( + &mut client_handle.sync_event_receiver, + *txid, + true, + SYNC_TIMEOUT, + ) + .await; + tracing::info!("In-cycle islock verified for tx {} at height {}", txid, height); + } + + // Progress counter: 1 pre-rotation + N in-cycle locks must be reflected. + let expected_valid = 1 + in_cycle_txids.len() as u32; + wait_for_instantsend_valid_at_least( + &mut client_handle.progress_receiver, + expected_valid, + SYNC_TIMEOUT, + ) + .await; + + // Post-rotation IS: one more block with an IS lock — the final assertion + // that signing keeps working after the rotation has fully completed. + let final_txid = ctx.mn_ctx.controller.send_to_address(&send_addr, send_amount); + tracing::info!("Post-rotation IS tx: {}", final_txid); + ctx.mn_ctx.move_blocks(1); + let _ = wait_for_instant_lock_received( + &mut client_handle.sync_event_receiver, + final_txid, + true, + SYNC_TIMEOUT, + ) + .await; + + // And the progress counter must now include the post-rotation lock too. + wait_for_instantsend_valid_at_least( + &mut client_handle.progress_receiver, + expected_valid + 1, + SYNC_TIMEOUT, + ) + .await; + + // Also assert every tx appears in the wallet with an InstantSend context. + // Walk the full set in order: pre-rotation, in-cycle, final. + let mut all_txids = Vec::with_capacity(in_cycle_txids.len() + 2); + all_txids.push(pre_rotation_txid); + all_txids.extend(in_cycle_txids.iter().map(|(_, t)| *t)); + all_txids.push(final_txid); + for txid in &all_txids { + let status = wait_for_wallet_tx_status( + &mut client_handle.wallet_event_receiver, + *txid, + |ctx| matches!(ctx, TransactionContext::InstantSend(_)), + SYNC_TIMEOUT, + ) + .await; + assert!(matches!(status, TransactionContext::InstantSend(_))); + } + + client_handle.stop().await; +} + +/// InstantSend lock arrives before the SPV sees the transaction. +/// +/// Drives a DKG cycle, stops the SPV client, sends a transaction from the +/// controller, and waits for the controller to record an islock for that tx. +/// A fresh SPV client is then started against the same storage: when it +/// reconnects, the controller relays both the tx inv and the islock inv and +/// there is no ordering guarantee about which message the SPV processes first. +/// In particular, the islock may reach `InstantSendManager` before the +/// transaction reaches `MempoolManager`, exercising the `pending_is_locks` +/// path where an islock is held until the matching mempool entry arrives. +/// +/// Regardless of exact ordering, the test asserts the end-to-end outcome: a +/// validated `InstantLockReceived` event plus a wallet event reporting the +/// transaction in an `InstantSend` context. +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn test_instantsend_islock_arrives_before_tx() { + let Some(mut ctx) = TestContext::new(false).await else { + return; + }; + + let (wallet, wallet_id) = create_wallet_from_controller(&ctx.mn_ctx); + let config = create_mn_test_config(ctx.storage_dir.clone(), ctx.mn_ctx.controller_addr); + + // Initial run: sync MN list and form a live signing quorum, then shut down. + let mut client_handle = create_and_start_client(&config, std::sync::Arc::clone(&wallet)).await; + let initial_mn = + wait_for_masternode_sync(&mut client_handle.progress_receiver, SYNC_TIMEOUT).await; + let initial_height = initial_mn.current_height(); + tracing::info!("Initial masternode sync at height {}", initial_height); + + tracing::info!("Mining DKG cycle to form a live signing quorum..."); + ctx.mn_ctx.mine_dkg_cycle().expect("DKG cycle should succeed"); + wait_for_mn_state_event_above( + &mut client_handle.sync_event_receiver, + initial_height, + SYNC_TIMEOUT, + ) + .await; + + tracing::info!("Stopping SPV client before sending the transaction"); + client_handle.stop().await; + drop(client_handle); + + // Send the tx and wait for the controller to record an islock for it. This + // ensures the islock exists on the network before the fresh SPV client + // reconnects, so both the tx and the islock are relayed in quick succession + // on reconnect with no guaranteed ordering. + let addr = receive_address(&wallet, &wallet_id).await; + let txid = ctx.mn_ctx.controller.send_to_address(&addr, Amount::from_sat(50_000_000)); + tracing::info!("Sent tx {} while SPV is down, waiting for controller islock...", txid); + + let island_deadline = std::time::Instant::now() + std::time::Duration::from_secs(60); + let mut saw_islock = false; + while std::time::Instant::now() < island_deadline { + if let Some(raw) = ctx + .mn_ctx + .controller + .try_rpc_call("getrawtransaction", &[txid.to_string().into(), 1.into()]) + { + if raw.get("instantlock").and_then(|v| v.as_bool()).unwrap_or(false) { + saw_islock = true; + break; + } + } + std::thread::sleep(std::time::Duration::from_millis(500)); + } + assert!(saw_islock, "Controller should produce an islock for txid {} within timeout", txid); + tracing::info!("Controller reports islock for tx {}", txid); + + // Reconnect the SPV client against the same storage. Reusing the storage + // keeps the previously synced masternode list so signature verification + // for the islock succeeds on first processing. + tracing::info!("Starting fresh SPV client to receive tx and islock together"); + let mut client_handle = create_and_start_client(&config, std::sync::Arc::clone(&wallet)).await; + + // The islock must land as validated — either on first arrival (if the tx + // landed first) or via the pending_is_locks path (if the islock landed + // first and had to wait for the tx). + let lock = wait_for_instant_lock_received( + &mut client_handle.sync_event_receiver, + txid, + true, + SYNC_TIMEOUT, + ) + .await; + assert_eq!(lock.txid, txid); + + // And the wallet must observe it with an InstantSend context regardless of + // the internal ordering. + let status = wait_for_wallet_tx_status( + &mut client_handle.wallet_event_receiver, + txid, + |ctx| matches!(ctx, TransactionContext::InstantSend(_)), + SYNC_TIMEOUT, + ) + .await; + assert!(matches!(status, TransactionContext::InstantSend(_))); + + client_handle.stop().await; +} diff --git a/dash-spv/tests/dashd_masternode/tests_sync.rs b/dash-spv/tests/dashd_masternode/tests_sync.rs new file mode 100644 index 000000000..f8f4bc29f --- /dev/null +++ b/dash-spv/tests/dashd_masternode/tests_sync.rs @@ -0,0 +1,378 @@ +//! Masternode list sync tests using dashd. +//! +//! These tests verify SPV masternode list synchronization against a pre-generated +//! regtest masternode network (1 controller + 4 masternodes with DKG cycles). + +use dash_spv::sync::{ProgressPercentage, SyncState}; +use dashcore::sml::llmq_type::LLMQType; + +use super::helpers::{ + wait_for_chainlock_height_at_least, wait_for_masternode_sync, wait_for_mn_state_event, + wait_for_mn_state_event_above, +}; +use super::setup::{ + create_and_start_client, create_dummy_wallet, create_mn_test_config, TestContext, SYNC_TIMEOUT, +}; + +/// Sync masternode list against a pre-generated regtest controller node. +/// +/// Verifies that the SPV client can complete masternode list sync (QRInfo + MnListDiff) +/// and that the MasternodeStateUpdated event fires. +#[tokio::test] +async fn test_masternode_list_sync() { + let Some(ctx) = TestContext::new(true).await else { + return; + }; + + let wallet = create_dummy_wallet(); + let config = create_mn_test_config(ctx.storage_dir.clone(), ctx.mn_ctx.controller_addr); + + let mut client_handle = create_and_start_client(&config, std::sync::Arc::clone(&wallet)).await; + + // Wait for the MasternodeStateUpdated event + let mn_height = + wait_for_mn_state_event(&mut client_handle.sync_event_receiver, SYNC_TIMEOUT).await; + assert!(mn_height > 0, "Masternode state height should be positive"); + + // Wait for full masternode sync + let mn_progress = + wait_for_masternode_sync(&mut client_handle.progress_receiver, SYNC_TIMEOUT).await; + + assert_eq!(mn_progress.state(), SyncState::Synced, "Masternode sync should reach Synced state"); + assert!(mn_progress.current_height() > 0, "Masternode sync height should be positive"); + tracing::info!( + "Masternode sync verified: state={:?}, height={}, diffs={}", + mn_progress.state(), + mn_progress.current_height(), + mn_progress.diffs_processed() + ); + + client_handle.stop().await; + + let final_progress = client_handle.client.sync_progress().await; + + // Headers should also be synced + let header_height = final_progress.headers().unwrap().current_height(); + assert!( + header_height >= ctx.mn_ctx.expected_height, + "Headers should sync to at least expected height: got {}, expected {}", + header_height, + ctx.mn_ctx.expected_height + ); +} + +/// Sync masternode list, stop, restart with same storage, verify incremental sync. +#[tokio::test] +async fn test_masternode_list_sync_with_restart() { + let Some(ctx) = TestContext::new(true).await else { + return; + }; + + let wallet = create_dummy_wallet(); + let config = create_mn_test_config(ctx.storage_dir.clone(), ctx.mn_ctx.controller_addr); + + // First sync + tracing::info!("=== Starting first masternode sync ==="); + let mut client_handle = create_and_start_client(&config, std::sync::Arc::clone(&wallet)).await; + let first_mn_progress = + wait_for_masternode_sync(&mut client_handle.progress_receiver, SYNC_TIMEOUT).await; + let first_height = first_mn_progress.current_height(); + client_handle.stop().await; + drop(client_handle); + tokio::time::sleep(std::time::Duration::from_millis(200)).await; + + // Restart with same storage + tracing::info!("=== Restarting with same storage ==="); + let mut client_handle = create_and_start_client(&config, std::sync::Arc::clone(&wallet)).await; + let second_mn_progress = + wait_for_masternode_sync(&mut client_handle.progress_receiver, SYNC_TIMEOUT).await; + let second_height = second_mn_progress.current_height(); + + assert_eq!( + second_height, first_height, + "Masternode sync height should be identical after restart" + ); + assert_eq!( + second_mn_progress.state(), + SyncState::Synced, + "Should reach Synced state after restart" + ); + + tracing::info!( + "Restart verified: first_height={}, second_height={}", + first_height, + second_height + ); + + client_handle.stop().await; +} + +/// Sync to pre-generated height, generate new blocks, verify incremental update. +/// +/// Starts the full masternode network (controller + 4 masternodes) so new blocks +/// propagate correctly. After initial sync, generates blocks and verifies the SPV +/// client receives a MasternodeStateUpdated event at the new height. +#[tokio::test] +async fn test_masternode_list_sync_with_new_blocks() { + let Some(ctx) = TestContext::new(false).await else { + return; + }; + + let initial_height = ctx.mn_ctx.expected_height; + let wallet = create_dummy_wallet(); + let config = create_mn_test_config(ctx.storage_dir.clone(), ctx.mn_ctx.controller_addr); + + let mut client_handle = create_and_start_client(&config, std::sync::Arc::clone(&wallet)).await; + + // Wait for initial masternode sync + let mn_progress = + wait_for_masternode_sync(&mut client_handle.progress_receiver, SYNC_TIMEOUT).await; + assert_eq!(mn_progress.state(), SyncState::Synced); + tracing::info!( + "Initial sync complete at height {}, generating new blocks...", + mn_progress.current_height() + ); + + // Generate new blocks on the controller + let blocks_to_generate = 10; + let addr = ctx.mn_ctx.controller.get_new_address(); + ctx.mn_ctx.controller.generate_blocks(blocks_to_generate, &addr); + + let expected_new_height = initial_height + blocks_to_generate as u32; + tracing::info!( + "Generated {} blocks, waiting for SPV update to height {}", + blocks_to_generate, + expected_new_height + ); + + // Wait for the SPV client to receive updated masternode state + let updated_height = wait_for_mn_state_event_above( + &mut client_handle.sync_event_receiver, + initial_height, + SYNC_TIMEOUT, + ) + .await; + + assert!( + updated_height >= expected_new_height, + "Updated height {} should be >= expected {}", + updated_height, + expected_new_height + ); + + tracing::info!( + "Incremental update verified: initial={}, updated={}", + initial_height, + updated_height + ); + + client_handle.stop().await; +} + +/// Mine multiple DKG cycles while the SPV client is connected and verify it keeps up. +/// +/// Starts the full masternode network, syncs to the pre-generated height, then +/// orchestrates 3 complete DKG cycles (6 phases + commitment each). After each +/// cycle, verifies the SPV client receives a MasternodeStateUpdated event at +/// the new height. +/// +/// Uses multi-thread runtime because DKG orchestration makes blocking RPC calls. +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn test_masternode_list_sync_with_quorum_rotation() { + let Some(mut ctx) = TestContext::new(false).await else { + return; + }; + + let wallet = create_dummy_wallet(); + let config = create_mn_test_config(ctx.storage_dir.clone(), ctx.mn_ctx.controller_addr); + + let mut client_handle = create_and_start_client(&config, std::sync::Arc::clone(&wallet)).await; + + // Wait for initial masternode sync + let mn_progress = + wait_for_masternode_sync(&mut client_handle.progress_receiver, SYNC_TIMEOUT).await; + assert_eq!(mn_progress.state(), SyncState::Synced); + let mut last_height = mn_progress.current_height(); + tracing::info!("Initial sync complete at height {}", last_height); + + // Mine 3 DKG cycles and verify the SPV client keeps up after each + let num_cycles = 3; + for cycle in 1..=num_cycles { + tracing::info!("Starting DKG cycle {}/{}...", cycle, num_cycles); + + let quorum_hash = + ctx.mn_ctx.mine_dkg_cycle().unwrap_or_else(|| panic!("DKG cycle {} failed", cycle)); + + // Wait for the SPV client to sync the new masternode state + let updated_height = wait_for_mn_state_event_above( + &mut client_handle.sync_event_receiver, + last_height, + SYNC_TIMEOUT, + ) + .await; + + assert!( + updated_height > last_height, + "Cycle {}: updated height {} should be greater than previous {}", + cycle, + updated_height, + last_height + ); + + tracing::info!( + "Cycle {}/{} verified: height {} -> {}, quorum={}", + cycle, + num_cycles, + last_height, + updated_height, + quorum_hash + ); + last_height = updated_height; + } + + client_handle.stop().await; +} + +/// End-to-end masternode sync test: initial sync, DKG cycle, and ChainLock. +/// +/// Starts the full masternode network with rotated quorum verification enabled. +/// After initial sync, validates the masternode list against the pre-generated +/// metadata, mines a new DKG cycle, verifies the SPV client picks up the update, +/// then mines blocks and waits for a ChainLock to propagate. +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn test_masternode_list_sync_end_to_end() { + let Some(mut ctx) = TestContext::new(false).await else { + return; + }; + + let expected_masternodes = ctx.mn_ctx.metadata.masternodes.len(); + let wallet = create_dummy_wallet(); + let config = create_mn_test_config(ctx.storage_dir.clone(), ctx.mn_ctx.controller_addr); + + let mut client_handle = create_and_start_client(&config, std::sync::Arc::clone(&wallet)).await; + + // Wait for initial masternode sync + let mn_progress = + wait_for_masternode_sync(&mut client_handle.progress_receiver, SYNC_TIMEOUT).await; + assert_eq!(mn_progress.state(), SyncState::Synced); + let initial_height = mn_progress.current_height(); + tracing::info!("Initial sync complete at height {}", initial_height); + + // Validate MN list matches pre-generated metadata + { + let engine = client_handle.engine.read().await; + + let latest_list = engine.latest_masternode_list().expect("Should have a masternode list"); + assert_eq!( + latest_list.masternodes.len(), + expected_masternodes, + "Should have {} masternodes, got {}", + expected_masternodes, + latest_list.masternodes.len() + ); + + // Verify each pro_tx_hash from metadata is present + for mn_info in &ctx.mn_ctx.metadata.masternodes { + let pro_tx_hash: dashcore::ProTxHash = + mn_info.pro_tx_hash.parse().unwrap_or_else(|e| { + panic!("Failed to parse pro_tx_hash {}: {}", mn_info.pro_tx_hash, e) + }); + assert!( + latest_list.masternodes.contains_key(&pro_tx_hash), + "Masternode {} not found in engine's list", + mn_info.pro_tx_hash + ); + } + + // Non-rotating quorums (llmq_test, type 100) + let non_rotating_quorums = + latest_list.quorums.get(&LLMQType::LlmqtypeTest).map(|q| q.len()).unwrap_or(0); + assert!(non_rotating_quorums > 0, "Should have llmq_test (type 100) quorums"); + + // Rotated quorum cycles are NOT stored yet — initial sync completed + // without rotation validation because the tip is mid-cycle (before the + // DKG commitment is mined). The mining window mechanism populates them + // after the DKG cycle below. + assert_eq!( + engine.rotated_quorums_per_cycle.len(), + 0, + "Rotated quorum cycles should be empty before DKG cycle is mined" + ); + + tracing::info!( + "Validated: {} masternodes, {} non-rotating quorums, no rotated quorums yet", + latest_list.masternodes.len(), + non_rotating_quorums, + ); + } + + // Mine a DKG cycle and verify the SPV client picks up the update + tracing::info!("Mining DKG cycle..."); + let _quorum_hash = ctx.mn_ctx.mine_dkg_cycle().expect("DKG cycle should succeed"); + + let updated_height = wait_for_mn_state_event_above( + &mut client_handle.sync_event_receiver, + initial_height, + SYNC_TIMEOUT, + ) + .await; + assert!( + updated_height > initial_height, + "Post-DKG height {} should be greater than initial {}", + updated_height, + initial_height + ); + + // Verify engine has masternode list at the new height + { + let engine = client_handle.engine.read().await; + let latest_list = engine.latest_masternode_list().expect("Should have a masternode list"); + assert_eq!( + latest_list.masternodes.len(), + expected_masternodes, + "MN count should remain {} after DKG", + expected_masternodes + ); + } + tracing::info!("Post-DKG verified at height {}", updated_height); + + // Mine blocks and wait for ChainLock — required, not optional. + // After a completed DKG cycle, the llmq_test quorum should be signing ChainLocks. + tracing::info!("Mining blocks and waiting for ChainLock..."); + let cl_height = ctx + .mn_ctx + .mine_blocks_and_wait_for_chainlock(3, 60) + .expect("ChainLock should be produced after DKG cycle completion"); + + tracing::info!("ChainLock received at height {}", cl_height); + + // Wait for the SPV ChainLock manager to validate the new ChainLock. + let cl_sync_height = wait_for_chainlock_height_at_least( + &mut client_handle.progress_receiver, + cl_height, + SYNC_TIMEOUT, + ) + .await; + assert!( + cl_sync_height >= cl_height, + "SPV should sync to at least ChainLock height {}, got {}", + cl_height, + cl_sync_height + ); + tracing::info!("SPV synced to ChainLocked height {}", cl_sync_height); + + // After the DKG cycle + ChainLock, the mining window mechanism may or may + // not have fired a successful QRInfo depending on P2P timing — mine_dkg_cycle + // mines 19 blocks which can overshoot the mining window (ends at offset 18). + // The rotated quorum assertion is best-effort here; the primary purpose of + // this test is the DKG → MN list update → ChainLock flow. + { + let engine = client_handle.engine.read().await; + tracing::info!( + "Post-DKG rotated quorum cycles: {}", + engine.rotated_quorums_per_cycle.len() + ); + } + + client_handle.stop().await; +} diff --git a/key-wallet-ffi/src/account.rs b/key-wallet-ffi/src/account.rs index 773eaed05..e1862e2bd 100644 --- a/key-wallet-ffi/src/account.rs +++ b/key-wallet-ffi/src/account.rs @@ -90,7 +90,20 @@ pub unsafe extern "C" fn wallet_get_account( } let wallet = &*wallet; - let account_type_rust = account_type.to_account_type(account_index); + let account_type_rust = match account_type.to_account_type(account_index) { + Ok(t) => t, + Err(mut err) => { + let code = err.code; + let message = if err.message.is_null() { + "Invalid account type".to_string() + } else { + let msg = std::ffi::CStr::from_ptr(err.message).to_string_lossy().to_string(); + err.free_message(); + msg + }; + return FFIAccountResult::error(code, message); + } + }; match wallet.inner().accounts.account_of_type(account_type_rust) { Some(account) => { diff --git a/key-wallet-ffi/src/address_pool.rs b/key-wallet-ffi/src/address_pool.rs index 3dd8f8667..0e96ab4bb 100644 --- a/key-wallet-ffi/src/address_pool.rs +++ b/key-wallet-ffi/src/address_pool.rs @@ -46,6 +46,12 @@ fn get_managed_account_by_type<'a>( collection.identity_topup_not_bound.as_ref() } AccountType::IdentityInvitation => collection.identity_invitation.as_ref(), + AccountType::IdentityAuthenticationEcdsa { + identity_index, + } => collection.identity_authentication_ecdsa.get(identity_index), + AccountType::IdentityAuthenticationBls { + identity_index, + } => collection.identity_authentication_bls.get(identity_index), AccountType::AssetLockAddressTopUp => collection.asset_lock_address_topup.as_ref(), AccountType::AssetLockShieldedAddressTopUp => { collection.asset_lock_shielded_address_topup.as_ref() @@ -99,6 +105,12 @@ fn get_managed_account_by_type_mut<'a>( collection.identity_topup_not_bound.as_mut() } AccountType::IdentityInvitation => collection.identity_invitation.as_mut(), + AccountType::IdentityAuthenticationEcdsa { + identity_index, + } => collection.identity_authentication_ecdsa.get_mut(identity_index), + AccountType::IdentityAuthenticationBls { + identity_index, + } => collection.identity_authentication_bls.get_mut(identity_index), AccountType::AssetLockAddressTopUp => collection.asset_lock_address_topup.as_mut(), AccountType::AssetLockShieldedAddressTopUp => { collection.asset_lock_shielded_address_topup.as_mut() @@ -295,7 +307,20 @@ pub unsafe extern "C" fn managed_wallet_get_address_pool_info( check_ptr!(info_out, error); let managed_wallet = wrapper.inner(); - let account_type_rust = account_type.to_account_type(account_index); + let account_type_rust = match account_type.to_account_type(account_index) { + Ok(t) => t, + Err(mut e) => { + let msg = if e.message.is_null() { + "Invalid account type".to_string() + } else { + let m = std::ffi::CStr::from_ptr(e.message).to_string_lossy().to_string(); + e.free_message(); + m + }; + FFIError::set_error(error, e.code, msg); + return false; + } + }; // Get the specific managed account let managed_account = @@ -384,7 +409,20 @@ pub unsafe extern "C" fn managed_wallet_set_gap_limit( ) -> bool { let managed_wallet = deref_ptr_mut!(managed_wallet, error).inner_mut(); - let account_type_rust = account_type.to_account_type(account_index); + let account_type_rust = match account_type.to_account_type(account_index) { + Ok(t) => t, + Err(mut e) => { + let msg = if e.message.is_null() { + "Invalid account type".to_string() + } else { + let m = std::ffi::CStr::from_ptr(e.message).to_string_lossy().to_string(); + e.free_message(); + m + }; + FFIError::set_error(error, e.code, msg); + return false; + } + }; // Get the specific managed account let managed_account = @@ -464,7 +502,20 @@ pub unsafe extern "C" fn managed_wallet_generate_addresses_to_index( let managed_wallet = deref_ptr_mut!(managed_wallet, error).inner_mut(); let wallet = deref_ptr!(wallet, error); - let account_type_rust = account_type.to_account_type(account_index); + let account_type_rust = match account_type.to_account_type(account_index) { + Ok(t) => t, + Err(mut e) => { + let msg = if e.message.is_null() { + "Invalid account type".to_string() + } else { + let m = std::ffi::CStr::from_ptr(e.message).to_string_lossy().to_string(); + e.free_message(); + m + }; + FFIError::set_error(error, e.code, msg); + return false; + } + }; let account_type_to_check = match account_type_rust.try_into() { Ok(check_type) => check_type, @@ -513,7 +564,7 @@ pub unsafe extern "C" fn managed_wallet_generate_addresses_to_index( let current = external_addresses.highest_generated.unwrap_or(0); if target_index > current { let needed = target_index - current; - external_addresses.generate_addresses(needed, &key_source, true) + external_addresses.generate_addresses(needed, &key_source) } else { Ok(Vec::new()) } @@ -533,7 +584,7 @@ pub unsafe extern "C" fn managed_wallet_generate_addresses_to_index( let current = internal_addresses.highest_generated.unwrap_or(0); if target_index > current { let needed = target_index - current; - internal_addresses.generate_addresses(needed, &key_source, true) + internal_addresses.generate_addresses(needed, &key_source) } else { Ok(Vec::new()) } @@ -555,7 +606,7 @@ pub unsafe extern "C" fn managed_wallet_generate_addresses_to_index( let current = pool.highest_generated.unwrap_or(0); if target_index > current { let needed = target_index - current; - pool.generate_addresses(needed, &key_source, true) + pool.generate_addresses(needed, &key_source) } else { Ok(Vec::new()) } @@ -620,14 +671,14 @@ pub unsafe extern "C" fn managed_wallet_mark_address_used( let mut found = false; // Check all accounts for the address for account in collection.standard_bip44_accounts.values_mut() { - if account.mark_address_used(&address) { + if account.mark_address_used(&address).0 { found = true; break; } } if !found { for account in collection.standard_bip32_accounts.values_mut() { - if account.mark_address_used(&address) { + if account.mark_address_used(&address).0 { found = true; break; } @@ -635,7 +686,7 @@ pub unsafe extern "C" fn managed_wallet_mark_address_used( } if !found { for account in collection.coinjoin_accounts.values_mut() { - if account.mark_address_used(&address) { + if account.mark_address_used(&address).0 { found = true; break; } @@ -643,14 +694,14 @@ pub unsafe extern "C" fn managed_wallet_mark_address_used( } if !found { if let Some(account) = &mut collection.identity_registration { - if account.mark_address_used(&address) { + if account.mark_address_used(&address).0 { found = true; } } } if !found { for account in collection.identity_topup.values_mut() { - if account.mark_address_used(&address) { + if account.mark_address_used(&address).0 { found = true; break; } @@ -658,63 +709,79 @@ pub unsafe extern "C" fn managed_wallet_mark_address_used( } if !found { if let Some(account) = &mut collection.identity_topup_not_bound { - if account.mark_address_used(&address) { + if account.mark_address_used(&address).0 { found = true; } } } if !found { if let Some(account) = &mut collection.identity_invitation { - if account.mark_address_used(&address) { + if account.mark_address_used(&address).0 { found = true; } } } + if !found { + for account in collection.identity_authentication_ecdsa.values_mut() { + if account.mark_address_used(&address).0 { + found = true; + break; + } + } + } + if !found { + for account in collection.identity_authentication_bls.values_mut() { + if account.mark_address_used(&address).0 { + found = true; + break; + } + } + } if !found { if let Some(account) = &mut collection.asset_lock_address_topup { - if account.mark_address_used(&address) { + if account.mark_address_used(&address).0 { found = true; } } } if !found { if let Some(account) = &mut collection.asset_lock_shielded_address_topup { - if account.mark_address_used(&address) { + if account.mark_address_used(&address).0 { found = true; } } } if !found { if let Some(account) = &mut collection.provider_voting_keys { - if account.mark_address_used(&address) { + if account.mark_address_used(&address).0 { found = true; } } } if !found { if let Some(account) = &mut collection.provider_owner_keys { - if account.mark_address_used(&address) { + if account.mark_address_used(&address).0 { found = true; } } } if !found { if let Some(account) = &mut collection.provider_operator_keys { - if account.mark_address_used(&address) { + if account.mark_address_used(&address).0 { found = true; } } } if !found { if let Some(account) = &mut collection.provider_platform_keys { - if account.mark_address_used(&address) { + if account.mark_address_used(&address).0 { found = true; } } } if !found { for account in collection.dashpay_receival_accounts.values_mut() { - if account.mark_address_used(&address) { + if account.mark_address_used(&address).0 { found = true; break; } @@ -722,7 +789,7 @@ pub unsafe extern "C" fn managed_wallet_mark_address_used( } if !found { for account in collection.dashpay_external_accounts.values_mut() { - if account.mark_address_used(&address) { + if account.mark_address_used(&address).0 { found = true; break; } diff --git a/key-wallet-ffi/src/error.rs b/key-wallet-ffi/src/error.rs index c46cf0ef8..641f0ac9d 100644 --- a/key-wallet-ffi/src/error.rs +++ b/key-wallet-ffi/src/error.rs @@ -178,6 +178,67 @@ impl FFIError { self.message = ptr::null_mut(); } } + + /// Create a success result + pub fn success() -> Self { + FFIError { + code: FFIErrorCode::Success, + message: ptr::null_mut(), + } + } + + /// Create an error with code and message + pub fn error(code: FFIErrorCode, msg: String) -> Self { + FFIError { + code, + message: CString::new(msg).unwrap_or_default().into_raw(), + } + } + + /// Set error on a mutable pointer if it's not null. + /// Frees any previous error message before setting the new one. + #[allow(clippy::not_unsafe_ptr_arg_deref)] + pub fn set_error(error_ptr: *mut FFIError, code: FFIErrorCode, msg: String) { + if !error_ptr.is_null() { + unsafe { + // Free previous message if present + let prev = &mut *error_ptr; + if !prev.message.is_null() { + let _ = CString::from_raw(prev.message); + } + *error_ptr = Self::error(code, msg); + } + } + } + + /// Set success on a mutable pointer if it's not null. + /// Frees any previous error message before setting success. + #[allow(clippy::not_unsafe_ptr_arg_deref)] + pub fn set_success(error_ptr: *mut FFIError) { + if !error_ptr.is_null() { + unsafe { + // Free previous message if present + let prev = &mut *error_ptr; + if !prev.message.is_null() { + let _ = CString::from_raw(prev.message); + } + *error_ptr = Self::success(); + } + } + } + + /// Free the error message if present. + /// Use this in tests to prevent memory leaks. + /// + /// # Safety + /// + /// The message pointer must have been allocated by this library. + pub unsafe fn free_message(&mut self) { + if !self.message.is_null() { + let _ = CString::from_raw(self.message); + self.message = ptr::null_mut(); + } + } } impl Default for FFIError { @@ -295,6 +356,8 @@ impl From for FFIError { WalletError::InvalidParameter(_) => FFIErrorCode::InvalidInput, WalletError::TransactionBuild(_) => FFIErrorCode::InvalidTransaction, WalletError::InsufficientFunds => FFIErrorCode::InvalidState, + WalletError::ApplyChangeSet(_) => FFIErrorCode::WalletError, + WalletError::Persistence(_) => FFIErrorCode::WalletError, }; FFIError { @@ -382,3 +445,51 @@ pub unsafe extern "C" fn error_message_free(message: *mut c_char) { let _ = CString::from_raw(message); } } + +/// Helper macro to convert any error that implements `Into` and set it on the error pointer. +/// Frees any previous error message before setting the new one. +#[macro_export] +macro_rules! ffi_error_set { + ($error_ptr:expr, $err:expr) => {{ + let ffi_error: $crate::error::FFIError = $err.into(); + if !$error_ptr.is_null() { + unsafe { + // Free previous message if present + let prev = &mut *$error_ptr; + if !prev.message.is_null() { + let _ = std::ffi::CString::from_raw(prev.message); + } + *$error_ptr = ffi_error; + } + } + }}; +} + +/// Helper macro to handle Result types in FFI functions +#[macro_export] +macro_rules! ffi_result { + ($error_ptr:expr, $result:expr) => { + match $result { + Ok(val) => { + $crate::error::FFIError::set_success($error_ptr); + val + } + Err(err) => { + ffi_error_set!($error_ptr, err); + return std::ptr::null_mut(); + } + } + }; + ($error_ptr:expr, $result:expr, $default:expr) => { + match $result { + Ok(val) => { + $crate::error::FFIError::set_success($error_ptr); + val + } + Err(err) => { + ffi_error_set!($error_ptr, err); + return $default; + } + } + }; +} diff --git a/key-wallet-ffi/src/managed_account.rs b/key-wallet-ffi/src/managed_account.rs index 0feda56a5..ed3ebf1f3 100644 --- a/key-wallet-ffi/src/managed_account.rs +++ b/key-wallet-ffi/src/managed_account.rs @@ -220,7 +220,20 @@ pub unsafe extern "C" fn managed_wallet_get_account( } let managed_wallet = &*managed_wallet_ptr; - let account_type_rust = account_type.to_account_type(account_index); + let account_type_rust = match account_type.to_account_type(account_index) { + Ok(t) => t, + Err(mut e) => { + let code = e.code; + let message = if e.message.is_null() { + "Invalid account type".to_string() + } else { + let m = std::ffi::CStr::from_ptr(e.message).to_string_lossy().to_string(); + e.free_message(); + m + }; + return FFIManagedCoreAccountResult::error(code, message); + } + }; let result = { use key_wallet::account::StandardAccountType; @@ -249,6 +262,12 @@ pub unsafe extern "C" fn managed_wallet_get_account( managed_collection.identity_topup_not_bound.as_ref() } AccountType::IdentityInvitation => managed_collection.identity_invitation.as_ref(), + AccountType::IdentityAuthenticationEcdsa { + identity_index, + } => managed_collection.identity_authentication_ecdsa.get(&identity_index), + AccountType::IdentityAuthenticationBls { + identity_index, + } => managed_collection.identity_authentication_bls.get(&identity_index), AccountType::AssetLockAddressTopUp => { managed_collection.asset_lock_address_topup.as_ref() } @@ -566,6 +585,12 @@ pub unsafe extern "C" fn managed_core_account_get_account_type( FFIAccountType::IdentityTopUpNotBoundToIdentity } AccountType::IdentityInvitation => FFIAccountType::IdentityInvitation, + AccountType::IdentityAuthenticationEcdsa { + .. + } => FFIAccountType::IdentityAuthenticationEcdsa, + AccountType::IdentityAuthenticationBls { + .. + } => FFIAccountType::IdentityAuthenticationBls, AccountType::AssetLockAddressTopUp => FFIAccountType::AssetLockAddressTopUp, AccountType::AssetLockShieldedAddressTopUp => FFIAccountType::AssetLockShieldedAddressTopUp, AccountType::ProviderVotingKeys => FFIAccountType::ProviderVotingKeys, @@ -1135,6 +1160,14 @@ pub unsafe extern "C" fn managed_core_account_get_address_pool( addresses, .. } => addresses, + ManagedAccountType::IdentityAuthenticationEcdsa { + addresses, + .. + } => addresses, + ManagedAccountType::IdentityAuthenticationBls { + addresses, + .. + } => addresses, }; let ffi_pool = FFIAddressPool { diff --git a/key-wallet-ffi/src/managed_wallet.rs b/key-wallet-ffi/src/managed_wallet.rs index bb240cf60..a54ed0788 100644 --- a/key-wallet-ffi/src/managed_wallet.rs +++ b/key-wallet-ffi/src/managed_wallet.rs @@ -88,7 +88,7 @@ pub unsafe extern "C" fn managed_wallet_get_next_bip44_receive_address( // Generate the next receive address let xpub = account.extended_public_key(); - match managed_account.next_receive_address(Some(&xpub), true) { + match managed_account.next_receive_address(Some(&xpub)) { Ok(address) => { let address_str = address.to_string(); match CString::new(address_str) { @@ -161,7 +161,7 @@ pub unsafe extern "C" fn managed_wallet_get_next_bip44_change_address( // Generate the next change address let xpub = account.extended_public_key(); - match managed_account.next_change_address(Some(&xpub), true) { + match managed_account.next_change_address(Some(&xpub)) { Ok(address) => { let address_str = address.to_string(); match CString::new(address_str) { diff --git a/key-wallet-ffi/src/types.rs b/key-wallet-ffi/src/types.rs index 58447e4f2..cd619b940 100644 --- a/key-wallet-ffi/src/types.rs +++ b/key-wallet-ffi/src/types.rs @@ -1,5 +1,6 @@ //! Common types for FFI interface +use crate::error::{FFIError, FFIErrorCode}; use dashcore::ephemerealdata::instant_lock::InstantLock; use dashcore::hashes::Hash; use key_wallet::account::{InputDetail, OutputDetail}; @@ -236,14 +237,24 @@ pub enum FFIAccountType { AssetLockAddressTopUp = 14, /// Asset lock shielded address top-up funding (subfeature 5) AssetLockShieldedAddressTopUp = 15, + /// Per-identity ECDSA authentication keys (DIP-13, sub-feature 0', key type 0'). + /// Path prefix: `m/9'/coin_type'/5'/0'/0'/identity_index'`. + IdentityAuthenticationEcdsa = 16, + /// Per-identity BLS authentication keys (DIP-13, sub-feature 0', key type 1'). + /// Path prefix: `m/9'/coin_type'/5'/0'/1'/identity_index'`. + IdentityAuthenticationBls = 17, } impl FFIAccountType { /// Convert to AccountType with the provided index (used where applicable). /// For types needing an index (e.g., IdentityTopUp.registration_index), the provided index is used. - pub fn to_account_type(self, index: u32) -> key_wallet::AccountType { + /// + /// Returns `Err` for variants that cannot be represented with a single `u32` index + /// (DashpayReceivingFunds, DashpayExternalAccount, PlatformPayment), since those + /// require additional data not carried by this conversion signature. + pub fn to_account_type(self, index: u32) -> Result { use key_wallet::account::account_type::StandardAccountType; - match self { + Ok(match self { FFIAccountType::StandardBIP44 => key_wallet::AccountType::Standard { index, standard_account_type: StandardAccountType::BIP44Account, @@ -274,42 +285,50 @@ impl FFIAccountType { FFIAccountType::ProviderOwnerKeys => key_wallet::AccountType::ProviderOwnerKeys, FFIAccountType::ProviderOperatorKeys => key_wallet::AccountType::ProviderOperatorKeys, FFIAccountType::ProviderPlatformKeys => key_wallet::AccountType::ProviderPlatformKeys, - // DashPay variants require additional identity IDs (user_identity_id and friend_identity_id) - // that are not part of the current FFI API. These types cannot be constructed via this - // conversion path. Attempting to use them is a programming error. - // - // TODO: Extend the FFI API to accept identity IDs for DashPay account creation: - // - Add new FFI functions like: - // * ffi_account_type_to_dashpay_receiving_funds(index, user_id[32], friend_id[32]) - // * ffi_account_type_to_dashpay_external_account(index, user_id[32], friend_id[32]) - // - Or extend to_account_type to accept optional identity ID parameters - // - // Until then, attempting to convert these variants will panic to prevent silent misrouting. + // DIP-13 authentication accounts use the provided `index` as + // `identity_index` (the hardened child at path level 6). + FFIAccountType::IdentityAuthenticationEcdsa => { + key_wallet::AccountType::IdentityAuthenticationEcdsa { + identity_index: index, + } + } + FFIAccountType::IdentityAuthenticationBls => { + key_wallet::AccountType::IdentityAuthenticationBls { + identity_index: index, + } + } + // DashPay and PlatformPayment variants require additional data (identity IDs + // or account/key_class indices) that are not part of this single-u32 + // conversion. Callers must use the dedicated FFI entry points (e.g. + // `wallet_add_dashpay_receiving_account`, `wallet_add_platform_payment_account`). FFIAccountType::DashpayReceivingFunds => { - panic!( + return Err(FFIError::error( + FFIErrorCode::InvalidInput, "FFIAccountType::DashpayReceivingFunds cannot be converted to AccountType \ - without user_identity_id and friend_identity_id. The FFI API does not yet \ - support passing these 32-byte identity IDs. This is a programming error - \ - DashPay account creation must use a different API path." - ); + without user_identity_id and friend_identity_id. Use \ + wallet_add_dashpay_receiving_account() instead." + .to_string(), + )); } FFIAccountType::DashpayExternalAccount => { - panic!( + return Err(FFIError::error( + FFIErrorCode::InvalidInput, "FFIAccountType::DashpayExternalAccount cannot be converted to AccountType \ - without user_identity_id and friend_identity_id. The FFI API does not yet \ - support passing these 32-byte identity IDs. This is a programming error - \ - DashPay account creation must use a different API path." - ); + without user_identity_id and friend_identity_id. Use \ + wallet_add_dashpay_external_account_with_xpub_bytes() instead." + .to_string(), + )); } FFIAccountType::PlatformPayment => { - panic!( + return Err(FFIError::error( + FFIErrorCode::InvalidInput, "FFIAccountType::PlatformPayment cannot be converted to AccountType \ - without account and key_class indices. The FFI API does not yet \ - support passing these values. This is a programming error - \ - Platform Payment account creation must use a different API path." - ); + without account and key_class indices. Use \ + wallet_add_platform_payment_account() instead." + .to_string(), + )); } - } + }) } /// Convert from AccountType to FFI representation @@ -367,6 +386,12 @@ impl FFIAccountType { key_wallet::AccountType::ProviderPlatformKeys => { (FFIAccountType::ProviderPlatformKeys, 0, None) } + key_wallet::AccountType::IdentityAuthenticationEcdsa { + identity_index, + } => (FFIAccountType::IdentityAuthenticationEcdsa, *identity_index, None), + key_wallet::AccountType::IdentityAuthenticationBls { + identity_index, + } => (FFIAccountType::IdentityAuthenticationBls, *identity_index, None), key_wallet::AccountType::DashpayReceivingFunds { index, user_identity_id, @@ -642,7 +667,13 @@ impl FFIWalletAccountCreationOptions { } } - // Convert special account types if provided + // Convert special account types if provided. + // + // Variants that cannot be represented with a single `u32` index + // (DashpayReceivingFunds, DashpayExternalAccount, PlatformPayment) + // are silently skipped here because callers must use the dedicated + // entry points (e.g. `wallet_add_dashpay_receiving_account`). This + // conversion path has no error-return channel. let special_accounts = if !self.special_account_types.is_null() && self.special_account_types_count > 0 { @@ -652,7 +683,16 @@ impl FFIWalletAccountCreationOptions { ); let mut accounts = Vec::new(); for &ffi_type in slice { - accounts.push(ffi_type.to_account_type(0)); + match ffi_type.to_account_type(0) { + Ok(account_type) => accounts.push(account_type), + Err(mut err) => { + // Free the error message allocated by + // FFIError::error to avoid a leak since we + // cannot propagate the error from this + // signature. + err.free_message(); + } + } } Some(accounts) } else { @@ -953,24 +993,39 @@ mod tests { } #[test] - #[should_panic(expected = "DashpayReceivingFunds cannot be converted to AccountType")] - fn test_dashpay_receiving_funds_to_account_type_panics() { - // This should panic because we cannot construct a DashPay account without identity IDs - let _ = FFIAccountType::DashpayReceivingFunds.to_account_type(0); + fn test_dashpay_receiving_funds_to_account_type_returns_err() { + // Cannot construct a DashPay account without identity IDs + let result = FFIAccountType::DashpayReceivingFunds.to_account_type(0); + let err = result.expect_err("should be an error"); + assert_eq!(err.code, FFIErrorCode::InvalidInput); + unsafe { + let mut err = err; + err.free_message(); + } } #[test] - #[should_panic(expected = "DashpayExternalAccount cannot be converted to AccountType")] - fn test_dashpay_external_account_to_account_type_panics() { - // This should panic because we cannot construct a DashPay account without identity IDs - let _ = FFIAccountType::DashpayExternalAccount.to_account_type(0); + fn test_dashpay_external_account_to_account_type_returns_err() { + // Cannot construct a DashPay account without identity IDs + let result = FFIAccountType::DashpayExternalAccount.to_account_type(0); + let err = result.expect_err("should be an error"); + assert_eq!(err.code, FFIErrorCode::InvalidInput); + unsafe { + let mut err = err; + err.free_message(); + } } #[test] - #[should_panic(expected = "PlatformPayment cannot be converted to AccountType")] - fn test_platform_payment_to_account_type_panics() { - // This should panic because we cannot construct a Platform Payment account without indices - let _ = FFIAccountType::PlatformPayment.to_account_type(0); + fn test_platform_payment_to_account_type_returns_err() { + // Cannot construct a Platform Payment account without account/key_class indices + let result = FFIAccountType::PlatformPayment.to_account_type(0); + let err = result.expect_err("should be an error"); + assert_eq!(err.code, FFIErrorCode::InvalidInput); + unsafe { + let mut err = err; + err.free_message(); + } } #[test] @@ -1000,7 +1055,8 @@ mod tests { #[test] fn test_non_dashpay_conversions_work() { // Verify that non-DashPay types still convert correctly - let standard_bip44 = FFIAccountType::StandardBIP44.to_account_type(5); + let standard_bip44 = + FFIAccountType::StandardBIP44.to_account_type(5).expect("StandardBIP44 should convert"); assert!(matches!( standard_bip44, key_wallet::AccountType::Standard { @@ -1009,7 +1065,8 @@ mod tests { } )); - let coinjoin = FFIAccountType::CoinJoin.to_account_type(3); + let coinjoin = + FFIAccountType::CoinJoin.to_account_type(3).expect("CoinJoin should convert"); assert!(matches!( coinjoin, key_wallet::AccountType::CoinJoin { diff --git a/key-wallet-ffi/src/wallet.rs b/key-wallet-ffi/src/wallet.rs index 83ad81306..cfc24d593 100644 --- a/key-wallet-ffi/src/wallet.rs +++ b/key-wallet-ffi/src/wallet.rs @@ -378,7 +378,20 @@ pub unsafe extern "C" fn wallet_add_account( let wallet = &mut *wallet; - let account_type_rust = account_type.to_account_type(account_index); + let account_type_rust = match account_type.to_account_type(account_index) { + Ok(t) => t, + Err(mut e) => { + let code = e.code; + let message = if e.message.is_null() { + "Invalid account type".to_string() + } else { + let m = std::ffi::CStr::from_ptr(e.message).to_string_lossy().to_string(); + e.free_message(); + m + }; + return crate::types::FFIAccountResult::error(code, message); + } + }; match wallet.inner_mut() { Some(w) => { @@ -607,7 +620,20 @@ pub unsafe extern "C" fn wallet_add_account_with_xpub_bytes( use key_wallet::ExtendedPubKey; - let account_type_rust = account_type.to_account_type(account_index); + let account_type_rust = match account_type.to_account_type(account_index) { + Ok(t) => t, + Err(mut e) => { + let code = e.code; + let message = if e.message.is_null() { + "Invalid account type".to_string() + } else { + let m = std::ffi::CStr::from_ptr(e.message).to_string_lossy().to_string(); + e.free_message(); + m + }; + return crate::types::FFIAccountResult::error(code, message); + } + }; // Parse the xpub from bytes (assuming it's a string representation) let xpub_slice = slice::from_raw_parts(xpub_bytes, xpub_len); @@ -730,7 +756,20 @@ pub unsafe extern "C" fn wallet_add_account_with_string_xpub( use key_wallet::ExtendedPubKey; - let account_type_rust = account_type.to_account_type(account_index); + let account_type_rust = match account_type.to_account_type(account_index) { + Ok(t) => t, + Err(mut e) => { + let code = e.code; + let message = if e.message.is_null() { + "Invalid account type".to_string() + } else { + let m = std::ffi::CStr::from_ptr(e.message).to_string_lossy().to_string(); + e.free_message(); + m + }; + return crate::types::FFIAccountResult::error(code, message); + } + }; // Parse the xpub from C string let xpub_str = match CStr::from_ptr(xpub_string).to_str() { diff --git a/key-wallet-ffi/src/wallet_manager.rs b/key-wallet-ffi/src/wallet_manager.rs index 8e52734bd..e2ff6173f 100644 --- a/key-wallet-ffi/src/wallet_manager.rs +++ b/key-wallet-ffi/src/wallet_manager.rs @@ -527,7 +527,7 @@ pub unsafe extern "C" fn wallet_manager_process_transaction( }; // Process the transaction using async runtime - let result = manager_ref.runtime.block_on(async { + let (result, _changesets) = manager_ref.runtime.block_on(async { let mut manager_guard = manager_ref.manager.write().await; manager_guard .check_transaction_in_all_wallets(&tx, context, update_state_if_found, true) diff --git a/key-wallet-manager/Cargo.toml b/key-wallet-manager/Cargo.toml index 46e201200..d84b6d7a6 100644 --- a/key-wallet-manager/Cargo.toml +++ b/key-wallet-manager/Cargo.toml @@ -12,6 +12,8 @@ default = [] parallel-filters = ["dep:rayon"] bincode = ["key-wallet/bincode", "dep:bincode"] test-utils = ["key-wallet/test-utils"] +bls = ["key-wallet/bls"] +eddsa = ["key-wallet/eddsa"] [dependencies] key-wallet = { path = "../key-wallet", default-features = false } diff --git a/key-wallet-manager/src/accessors.rs b/key-wallet-manager/src/accessors.rs index 46e5cf842..8da4ef609 100644 --- a/key-wallet-manager/src/accessors.rs +++ b/key-wallet-manager/src/accessors.rs @@ -33,6 +33,40 @@ impl WalletManager { } } + /// Get immutable wallet + mutable info by ID (split borrow on two maps). + pub fn get_wallet_and_info_mut(&mut self, wallet_id: &WalletId) -> Option<(&Wallet, &mut T)> { + match (self.wallets.get(wallet_id), self.wallet_infos.get_mut(wallet_id)) { + (Some(wallet), Some(info)) => Some((wallet, info)), + _ => None, + } + } + + /// Get mutable wallet + mutable info by ID (split borrow on two maps). + /// + /// Used by `apply_changeset` which needs `&mut Wallet` to idempotently + /// re-derive HD accounts via `Wallet::add_account` during restore. + pub fn get_wallet_mut_and_info_mut( + &mut self, + wallet_id: &WalletId, + ) -> Option<(&mut Wallet, &mut T)> { + match (self.wallets.get_mut(wallet_id), self.wallet_infos.get_mut(wallet_id)) { + (Some(wallet), Some(info)) => Some((wallet, info)), + _ => None, + } + } + + /// Insert a pre-built wallet and info pair. + pub fn insert_wallet(&mut self, wallet: Wallet, info: T) -> Result { + let wallet_id = wallet.compute_wallet_id(); + if self.wallets.contains_key(&wallet_id) { + return Err(WalletError::WalletExists(wallet_id)); + } + self.wallets.insert(wallet_id, wallet); + self.wallet_infos.insert(wallet_id, info); + self.bump_structural_revision(); + Ok(wallet_id) + } + /// Remove a wallet pub fn remove_wallet(&mut self, wallet_id: &WalletId) -> Result<(Wallet, T), WalletError> { let wallet = @@ -211,3 +245,106 @@ impl WalletManager { outpoints } } + +// --------------------------------------------------------------------------- +// apply_changeset — concrete-type wrapper for WalletManager. +// --------------------------------------------------------------------------- + +impl WalletManager { + /// Apply a [`key_wallet::changeset::WalletChangeSet`] to the given + /// wallet, replaying its deltas idempotently onto the in-memory state. + /// + /// This is the restore path: a persister reads a persisted changeset + /// from disk and hands it here to converge the runtime state. Calling + /// `apply_changeset` twice with the same changeset produces the same + /// state as calling it once. + /// + /// - Returns [`WalletError::WalletNotFound`] if the wallet is not in + /// this manager. The wallet must have been inserted via + /// `insert_wallet` before apply can route into it. + /// - Returns [`WalletError::ApplyChangeSet`] if re-deriving an account + /// from `cs.account_keys` fails (watch-only wallet without the xpub, + /// or network mismatch). Orphan UTXOs / transaction records that + /// fail to route are silently skipped, not reported. + /// - Bumps the structural revision only when the changeset is + /// non-empty, so observers don't get poked for pure no-op restores. + pub fn apply_changeset( + &mut self, + wallet_id: &WalletId, + changeset: key_wallet::changeset::WalletChangeSet, + ) -> Result<(), WalletError> { + use key_wallet::changeset::Merge; + // Capture is_empty before consuming so we can decide whether to + // bump the structural revision. After this point `changeset` is + // moved into the apply path; no clones happen anywhere on the + // way down. + let was_non_empty = !changeset.is_empty(); + let (wallet, info) = self + .get_wallet_mut_and_info_mut(wallet_id) + .ok_or(WalletError::WalletNotFound(*wallet_id))?; + info.apply_changeset(wallet, changeset)?; + if was_non_empty { + self.bump_structural_revision(); + } + Ok(()) + } +} + +#[cfg(test)] +mod apply_tests { + use super::*; + use key_wallet::changeset::{BalanceChangeSet, WalletChangeSet}; + use key_wallet::wallet::initialization::WalletAccountCreationOptions; + use key_wallet::wallet::managed_wallet_info::ManagedWalletInfo; + use key_wallet::Network as KwNetwork; + + #[test] + fn apply_changeset_returns_not_found_for_unknown_wallet() { + let mut wm: WalletManager = WalletManager::new(KwNetwork::Testnet); + let unknown_id = [0u8; 32]; + let cs = WalletChangeSet::default(); + + let err = wm.apply_changeset(&unknown_id, cs).unwrap_err(); + assert!(matches!(err, WalletError::WalletNotFound(_))); + } + + #[test] + fn apply_empty_changeset_does_not_bump_revision() { + let mut wm: WalletManager = WalletManager::new(KwNetwork::Testnet); + let wallet = Wallet::new_random(KwNetwork::Testnet, WalletAccountCreationOptions::Default) + .expect("wallet"); + let info = ManagedWalletInfo::from_wallet(&wallet); + let wallet_id = wm.insert_wallet(wallet, info).expect("insert"); + + let rev_before = wm.structural_revision; + wm.apply_changeset(&wallet_id, WalletChangeSet::default()).expect("apply"); + assert_eq!( + wm.structural_revision, rev_before, + "empty changeset must not bump structural_revision" + ); + } + + #[test] + fn apply_non_empty_changeset_bumps_revision() { + let mut wm: WalletManager = WalletManager::new(KwNetwork::Testnet); + let wallet = Wallet::new_random(KwNetwork::Testnet, WalletAccountCreationOptions::Default) + .expect("wallet"); + let info = ManagedWalletInfo::from_wallet(&wallet); + let wallet_id = wm.insert_wallet(wallet, info).expect("insert"); + + let rev_before = wm.structural_revision; + // A BalanceChangeSet with any non-zero delta counts as non-empty. + let cs = WalletChangeSet { + balance: Some(BalanceChangeSet { + confirmed_delta: 1, + ..Default::default() + }), + ..Default::default() + }; + wm.apply_changeset(&wallet_id, cs).expect("apply"); + assert!( + wm.structural_revision > rev_before, + "non-empty changeset must bump structural_revision" + ); + } +} diff --git a/key-wallet-manager/src/error.rs b/key-wallet-manager/src/error.rs index 9f96b7e2c..3c96931a6 100644 --- a/key-wallet-manager/src/error.rs +++ b/key-wallet-manager/src/error.rs @@ -27,6 +27,10 @@ pub enum WalletError { TransactionBuild(String), /// Insufficient funds InsufficientFunds, + /// Applying a persisted changeset to a wallet failed. + ApplyChangeSet(String), + /// Persistence backend returned an error during block processing. + Persistence(Box), } impl core::fmt::Display for WalletError { @@ -55,10 +59,18 @@ impl core::fmt::Display for WalletError { WalletError::InvalidParameter(msg) => write!(f, "Invalid parameter: {}", msg), WalletError::TransactionBuild(err) => write!(f, "Transaction build failed: {}", err), WalletError::InsufficientFunds => write!(f, "Insufficient funds"), + WalletError::ApplyChangeSet(msg) => write!(f, "Apply changeset failed: {}", msg), + WalletError::Persistence(e) => write!(f, "Persistence error: {}", e), } } } +impl From for WalletError { + fn from(err: key_wallet::wallet::managed_wallet_info::apply::ApplyError) -> Self { + WalletError::ApplyChangeSet(err.to_string()) + } +} + impl std::error::Error for WalletError {} /// Conversion from key_wallet::Error to WalletError diff --git a/key-wallet-manager/src/event_tests.rs b/key-wallet-manager/src/event_tests.rs index 3e851cad7..42d675fae 100644 --- a/key-wallet-manager/src/event_tests.rs +++ b/key-wallet-manager/src/event_tests.rs @@ -19,7 +19,9 @@ async fn test_mempool_to_confirmed_event_flow() { let tx = create_tx_paying_to(&addr, 0xaa); // First time in mempool — validate all event fields - manager.check_transaction_in_all_wallets(&tx, TransactionContext::Mempool, true, true).await; + let _ = manager + .check_transaction_in_all_wallets(&tx, TransactionContext::Mempool, true, true) + .await; let event = assert_single_event(&mut rx); match event { WalletEvent::TransactionReceived { @@ -41,7 +43,7 @@ async fn test_mempool_to_confirmed_event_flow() { BlockHash::from_byte_array([0xaa; 32]), 1000, )); - manager.check_transaction_in_all_wallets(&tx, block_ctx, true, true).await; + let _ = manager.check_transaction_in_all_wallets(&tx, block_ctx, true, true).await; let event = assert_single_event(&mut rx); match event { WalletEvent::TransactionStatusChanged { @@ -374,7 +376,7 @@ async fn test_process_instant_send_lock_after_block_confirmation() { BlockHash::from_byte_array([0xe2; 32]), 5000, )); - manager.check_transaction_in_all_wallets(&tx, block_ctx, true, true).await; + let _ = manager.check_transaction_in_all_wallets(&tx, block_ctx, true, true).await; // IS lock after block confirmation is a no-op (already tracked via mempool IS) let mut rx = manager.subscribe_events(); @@ -395,7 +397,9 @@ async fn test_mixed_instantsend_paths_no_duplicate_events() { let tx = create_tx_paying_to(&addr, 0xf0); // Mempool first - manager.check_transaction_in_all_wallets(&tx, TransactionContext::Mempool, true, true).await; + let _ = manager + .check_transaction_in_all_wallets(&tx, TransactionContext::Mempool, true, true) + .await; drain_events(&mut rx); // IS lock via process_instant_send_lock (network IS lock message) @@ -417,7 +421,7 @@ async fn test_mixed_instantsend_paths_no_duplicate_events() { // Same IS lock via check_transaction_in_all_wallets (block/tx processing path) // should be suppressed — no duplicate event let is_lock = dummy_instant_lock(tx.txid()); - manager + let _ = manager .check_transaction_in_all_wallets(&tx, TransactionContext::InstantSend(is_lock), true, true) .await; assert_no_events(&mut rx); @@ -430,12 +434,14 @@ async fn test_mixed_instantsend_paths_reverse_no_duplicate_events() { let tx = create_tx_paying_to(&addr, 0xf1); // Mempool first - manager.check_transaction_in_all_wallets(&tx, TransactionContext::Mempool, true, true).await; + let _ = manager + .check_transaction_in_all_wallets(&tx, TransactionContext::Mempool, true, true) + .await; drain_events(&mut rx); // IS lock via check_transaction_in_all_wallets first let is_lock = dummy_instant_lock(tx.txid()); - manager + let _ = manager .check_transaction_in_all_wallets( &tx, TransactionContext::InstantSend(is_lock.clone()), @@ -484,7 +490,7 @@ async fn test_process_block_emits_events() { txdata: vec![tx], }; - let result = manager.process_block(&block, 1000).await; + let result = manager.process_block(&block, 1000).await.unwrap(); assert_eq!(result.new_txids.len(), 1); let events = drain_events(&mut rx); @@ -589,7 +595,9 @@ async fn test_mempool_to_block_to_chainlocked_event_flow() { let tx = create_tx_paying_to(&addr, 0xc4); // Step 1: mempool — emits TransactionReceived - manager.check_transaction_in_all_wallets(&tx, TransactionContext::Mempool, true, true).await; + let _ = manager + .check_transaction_in_all_wallets(&tx, TransactionContext::Mempool, true, true) + .await; let event = assert_single_event(&mut rx); assert!( matches!( @@ -607,7 +615,7 @@ async fn test_mempool_to_block_to_chainlocked_event_flow() { BlockHash::from_byte_array([0xc4; 32]), 17000, )); - manager.check_transaction_in_all_wallets(&tx, block_ctx, true, true).await; + let _ = manager.check_transaction_in_all_wallets(&tx, block_ctx, true, true).await; let event = assert_single_event(&mut rx); assert!( matches!( @@ -628,7 +636,7 @@ async fn test_mempool_to_block_to_chainlocked_event_flow() { BlockHash::from_byte_array([0xc4; 32]), 17000, )); - manager.check_transaction_in_all_wallets(&tx, cl_ctx, true, true).await; + let _ = manager.check_transaction_in_all_wallets(&tx, cl_ctx, true, true).await; assert_no_events(&mut rx); } @@ -643,7 +651,7 @@ async fn test_chainlocked_block_event_flow() { BlockHash::from_byte_array([0xc1; 32]), 20000, )); - manager.check_transaction_in_all_wallets(&tx, ctx, true, true).await; + let _ = manager.check_transaction_in_all_wallets(&tx, ctx, true, true).await; let event = assert_single_event(&mut rx); assert!( matches!( @@ -663,7 +671,7 @@ async fn test_check_transaction_dry_run_does_not_persist_state() { let tx = create_tx_paying_to(&addr, 0xd1); // Dry run: update_state_if_found = false - let result = manager + let (result, _) = manager .check_transaction_in_all_wallets(&tx, TransactionContext::Mempool, false, false) .await; @@ -672,7 +680,7 @@ async fn test_check_transaction_dry_run_does_not_persist_state() { assert_no_events(&mut rx); // Call again — should still report as relevant (state not persisted) - let result2 = manager + let (result2, _) = manager .check_transaction_in_all_wallets(&tx, TransactionContext::Mempool, false, false) .await; assert!(!result2.affected_wallets.is_empty()); @@ -680,7 +688,7 @@ async fn test_check_transaction_dry_run_does_not_persist_state() { assert_no_events(&mut rx); // Now persist — should still report as new since dry runs didn't record it - let result3 = manager + let (result3, _) = manager .check_transaction_in_all_wallets(&tx, TransactionContext::Mempool, true, true) .await; assert!(result3.is_new_transaction); diff --git a/key-wallet-manager/src/lib.rs b/key-wallet-manager/src/lib.rs index ccb293edd..88ee884d7 100644 --- a/key-wallet-manager/src/lib.rs +++ b/key-wallet-manager/src/lib.rs @@ -16,17 +16,20 @@ mod accessors; mod error; mod events; mod matching; +mod persistence; mod process_block; mod wallet_interface; pub use error::WalletError; pub use events::WalletEvent; pub use matching::{check_compact_filters_for_addresses, FilterMatchKey}; +pub use persistence::{NoWalletPersistence, WalletPersistence}; pub use wallet_interface::{BlockProcessingResult, MempoolTransactionResult, WalletInterface}; use dashcore::blockdata::transaction::Transaction; use dashcore::prelude::CoreBlockHeight; use key_wallet::account::AccountCollection; +use key_wallet::changeset::{Merge, WalletChangeSet}; use key_wallet::transaction_checking::TransactionContext; use key_wallet::wallet::managed_wallet_info::transaction_building::AccountTypePreference; use key_wallet::wallet::managed_wallet_info::wallet_info_interface::WalletInfoInterface; @@ -35,6 +38,7 @@ use key_wallet::{AccountType, Address, ExtendedPrivKey, Mnemonic, Network, Walle use key_wallet::{ExtendedPubKey, WalletCoreBalance}; use std::collections::BTreeMap; use std::str::FromStr; +use std::sync::Arc; use tokio::sync::broadcast; @@ -86,7 +90,6 @@ pub struct CheckTransactionsResult { /// /// Each wallet can contain multiple accounts following BIP44 standard. /// This is the main entry point for wallet operations. -#[derive(Debug)] pub struct WalletManager { /// Network the managed wallets are used for network: Network, @@ -104,11 +107,32 @@ pub struct WalletManager { structural_revision: u64, /// Event sender for wallet events event_sender: broadcast::Sender, + /// Persistence backend — called after each block to durably store + /// UTXO/transaction/balance changes. Defaults to [`NoWalletPersistence`]. + persister: Arc, +} + +impl std::fmt::Debug for WalletManager { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("WalletManager") + .field("network", &self.network) + .field("synced_height", &self.synced_height) + .field("filter_committed_height", &self.filter_committed_height) + .field("wallets", &self.wallets) + .field("wallet_infos", &self.wallet_infos) + .field("structural_revision", &self.structural_revision) + .finish_non_exhaustive() + } } impl WalletManager { - /// Create a new wallet manager + /// Create a new wallet manager with the no-op [`NoWalletPersistence`]. pub fn new(network: Network) -> Self { + Self::new_with_persister(network, Arc::new(NoWalletPersistence)) + } + + /// Create a new wallet manager with a custom [`WalletPersistence`] backend. + pub fn new_with_persister(network: Network, persister: Arc) -> Self { Self { network, synced_height: 0, @@ -117,6 +141,7 @@ impl WalletManager { wallet_infos: BTreeMap::new(), structural_revision: 0, event_sender: broadcast::Sender::new(DEFAULT_WALLET_EVENT_CAPACITY), + persister, } } @@ -448,15 +473,19 @@ impl WalletManager { } /// Check a transaction against all wallets and update their states if relevant. - /// Returns affected wallets and any new addresses generated during gap limit maintenance. + /// + /// Returns `(CheckTransactionsResult, BTreeMap)`. + /// The changeset map contains one entry per wallet that had relevant state changes; + /// callers (e.g. `process_block`) should persist these via [`WalletPersistence`]. pub async fn check_transaction_in_all_wallets( &mut self, tx: &Transaction, context: TransactionContext, update_state_if_found: bool, update_balance: bool, - ) -> CheckTransactionsResult { + ) -> (CheckTransactionsResult, BTreeMap) { let mut result = CheckTransactionsResult::default(); + let mut changesets: BTreeMap = BTreeMap::new(); // We need to iterate carefully since we're mutating let wallet_ids: Vec = self.wallets.keys().cloned().collect(); @@ -514,13 +543,23 @@ impl WalletManager { }; let _ = self.event_sender.send(event); } + + // Accumulate the changeset for this wallet so the caller can persist it. + match changesets.entry(wallet_id) { + std::collections::btree_map::Entry::Occupied(mut e) => { + e.get_mut().merge(check_result.changeset); + } + std::collections::btree_map::Entry::Vacant(e) => { + e.insert(check_result.changeset); + } + } } result.new_addresses.extend(check_result.new_addresses); } } - result + (result, changesets) } /// Create an account in a specific wallet @@ -566,9 +605,7 @@ impl WalletManager { collection.standard_bip44_accounts.get_mut(&account_index), wallet.get_bip44_account(account_index), ) { - match managed_account - .next_receive_address(Some(&wallet_account.account_xpub), true) - { + match managed_account.next_receive_address(Some(&wallet_account.account_xpub)) { Ok(addr) => (Some(addr), Some(AccountTypeUsed::BIP44)), Err(_) => (None, None), } @@ -581,9 +618,7 @@ impl WalletManager { collection.standard_bip32_accounts.get_mut(&account_index), wallet.get_bip32_account(account_index), ) { - match managed_account - .next_receive_address(Some(&wallet_account.account_xpub), true) - { + match managed_account.next_receive_address(Some(&wallet_account.account_xpub)) { Ok(addr) => (Some(addr), Some(AccountTypeUsed::BIP32)), Err(_) => (None, None), } @@ -597,9 +632,7 @@ impl WalletManager { collection.standard_bip44_accounts.get_mut(&account_index), wallet.get_bip44_account(account_index), ) { - match managed_account - .next_receive_address(Some(&wallet_account.account_xpub), true) - { + match managed_account.next_receive_address(Some(&wallet_account.account_xpub)) { Ok(addr) => (Some(addr), Some(AccountTypeUsed::BIP44)), Err(_) => { // Fallback to BIP32 @@ -608,7 +641,7 @@ impl WalletManager { wallet.get_bip32_account(account_index), ) { match managed_account - .next_receive_address(Some(&wallet_account.account_xpub), true) + .next_receive_address(Some(&wallet_account.account_xpub)) { Ok(addr) => (Some(addr), Some(AccountTypeUsed::BIP32)), Err(_) => (None, None), @@ -622,9 +655,7 @@ impl WalletManager { collection.standard_bip32_accounts.get_mut(&account_index), wallet.get_bip32_account(account_index), ) { - match managed_account - .next_receive_address(Some(&wallet_account.account_xpub), true) - { + match managed_account.next_receive_address(Some(&wallet_account.account_xpub)) { Ok(addr) => (Some(addr), Some(AccountTypeUsed::BIP32)), Err(_) => (None, None), } @@ -638,9 +669,7 @@ impl WalletManager { collection.standard_bip32_accounts.get_mut(&account_index), wallet.get_bip32_account(account_index), ) { - match managed_account - .next_receive_address(Some(&wallet_account.account_xpub), true) - { + match managed_account.next_receive_address(Some(&wallet_account.account_xpub)) { Ok(addr) => (Some(addr), Some(AccountTypeUsed::BIP32)), Err(_) => { // Fallback to BIP44 @@ -649,7 +678,7 @@ impl WalletManager { wallet.get_bip44_account(account_index), ) { match managed_account - .next_receive_address(Some(&wallet_account.account_xpub), true) + .next_receive_address(Some(&wallet_account.account_xpub)) { Ok(addr) => (Some(addr), Some(AccountTypeUsed::BIP44)), Err(_) => (None, None), @@ -663,9 +692,7 @@ impl WalletManager { collection.standard_bip44_accounts.get_mut(&account_index), wallet.get_bip44_account(account_index), ) { - match managed_account - .next_receive_address(Some(&wallet_account.account_xpub), true) - { + match managed_account.next_receive_address(Some(&wallet_account.account_xpub)) { Ok(addr) => (Some(addr), Some(AccountTypeUsed::BIP44)), Err(_) => (None, None), } @@ -686,14 +713,14 @@ impl WalletManager { if let Some(account) = collection.standard_bip44_accounts.get_mut(&account_index) { - account.mark_address_used(address); + let _ = account.mark_address_used(address); } } Some(AccountTypeUsed::BIP32) => { if let Some(account) = collection.standard_bip32_accounts.get_mut(&account_index) { - account.mark_address_used(address); + let _ = account.mark_address_used(address); } } None => {} @@ -730,9 +757,7 @@ impl WalletManager { collection.standard_bip44_accounts.get_mut(&account_index), wallet.get_bip44_account(account_index), ) { - match managed_account - .next_change_address(Some(&wallet_account.account_xpub), true) - { + match managed_account.next_change_address(Some(&wallet_account.account_xpub)) { Ok(addr) => (Some(addr), Some(AccountTypeUsed::BIP44)), Err(_) => (None, None), } @@ -745,9 +770,7 @@ impl WalletManager { collection.standard_bip32_accounts.get_mut(&account_index), wallet.get_bip32_account(account_index), ) { - match managed_account - .next_change_address(Some(&wallet_account.account_xpub), true) - { + match managed_account.next_change_address(Some(&wallet_account.account_xpub)) { Ok(addr) => (Some(addr), Some(AccountTypeUsed::BIP32)), Err(_) => (None, None), } @@ -761,9 +784,7 @@ impl WalletManager { collection.standard_bip44_accounts.get_mut(&account_index), wallet.get_bip44_account(account_index), ) { - match managed_account - .next_change_address(Some(&wallet_account.account_xpub), true) - { + match managed_account.next_change_address(Some(&wallet_account.account_xpub)) { Ok(addr) => (Some(addr), Some(AccountTypeUsed::BIP44)), Err(_) => { // Fallback to BIP32 @@ -772,7 +793,7 @@ impl WalletManager { wallet.get_bip32_account(account_index), ) { match managed_account - .next_change_address(Some(&wallet_account.account_xpub), true) + .next_change_address(Some(&wallet_account.account_xpub)) { Ok(addr) => (Some(addr), Some(AccountTypeUsed::BIP32)), Err(_) => (None, None), @@ -786,9 +807,7 @@ impl WalletManager { collection.standard_bip32_accounts.get_mut(&account_index), wallet.get_bip32_account(account_index), ) { - match managed_account - .next_change_address(Some(&wallet_account.account_xpub), true) - { + match managed_account.next_change_address(Some(&wallet_account.account_xpub)) { Ok(addr) => (Some(addr), Some(AccountTypeUsed::BIP32)), Err(_) => (None, None), } @@ -802,9 +821,7 @@ impl WalletManager { collection.standard_bip32_accounts.get_mut(&account_index), wallet.get_bip32_account(account_index), ) { - match managed_account - .next_change_address(Some(&wallet_account.account_xpub), true) - { + match managed_account.next_change_address(Some(&wallet_account.account_xpub)) { Ok(addr) => (Some(addr), Some(AccountTypeUsed::BIP32)), Err(_) => { // Fallback to BIP44 @@ -813,7 +830,7 @@ impl WalletManager { wallet.get_bip44_account(account_index), ) { match managed_account - .next_change_address(Some(&wallet_account.account_xpub), true) + .next_change_address(Some(&wallet_account.account_xpub)) { Ok(addr) => (Some(addr), Some(AccountTypeUsed::BIP44)), Err(_) => (None, None), @@ -827,9 +844,7 @@ impl WalletManager { collection.standard_bip44_accounts.get_mut(&account_index), wallet.get_bip44_account(account_index), ) { - match managed_account - .next_change_address(Some(&wallet_account.account_xpub), true) - { + match managed_account.next_change_address(Some(&wallet_account.account_xpub)) { Ok(addr) => (Some(addr), Some(AccountTypeUsed::BIP44)), Err(_) => (None, None), } @@ -850,14 +865,14 @@ impl WalletManager { if let Some(account) = collection.standard_bip44_accounts.get_mut(&account_index) { - account.mark_address_used(address); + let _ = account.mark_address_used(address); } } Some(AccountTypeUsed::BIP32) => { if let Some(account) = collection.standard_bip32_accounts.get_mut(&account_index) { - account.mark_address_used(address); + let _ = account.mark_address_used(address); } } None => {} diff --git a/key-wallet-manager/src/persistence.rs b/key-wallet-manager/src/persistence.rs new file mode 100644 index 000000000..30b95c9f3 --- /dev/null +++ b/key-wallet-manager/src/persistence.rs @@ -0,0 +1,48 @@ +//! Wallet persistence trait. +//! +//! [`WalletPersistence`] is the single hook point through which +//! [`WalletManager`](crate::WalletManager) writes core wallet state +//! (UTXOs, transactions, balances, address watermarks) to durable storage. +//! +//! Implementations supply their own backend (SQLite, SwiftData, memory, …). +//! The no-op default [`NoWalletPersistence`] is used when persistence is not +//! needed (e.g. standalone tests or in-process wallets without a DB). +//! +//! ## Contract +//! +//! - `store` is called once per wallet per block with an accumulated changeset +//! containing all transaction state plus the synced height. +//! - The implementation decides its own flush strategy — it may write +//! immediately (like SQLite) or buffer for later. + +use crate::WalletId; +use key_wallet::changeset::WalletChangeSet; + +/// Persistence backend for wallet state. +pub trait WalletPersistence: Send + Sync { + /// Persist a changeset for `wallet_id`. + /// + /// Called once per wallet per block with the accumulated changeset. + /// Implementations decide their own flush strategy — they may write + /// inline or buffer for a later batch write. + fn store( + &self, + wallet_id: WalletId, + cs: WalletChangeSet, + ) -> Result<(), Box>; +} + +/// No-op persistence — discards all changesets silently. +/// +/// Default used by [`WalletManager::new`](crate::WalletManager::new). +pub struct NoWalletPersistence; + +impl WalletPersistence for NoWalletPersistence { + fn store( + &self, + _wallet_id: WalletId, + _cs: WalletChangeSet, + ) -> Result<(), Box> { + Ok(()) + } +} diff --git a/key-wallet-manager/src/process_block.rs b/key-wallet-manager/src/process_block.rs index 2e5d27cb2..68f6a025a 100644 --- a/key-wallet-manager/src/process_block.rs +++ b/key-wallet-manager/src/process_block.rs @@ -1,10 +1,11 @@ use crate::wallet_interface::{BlockProcessingResult, MempoolTransactionResult, WalletInterface}; -use crate::{WalletEvent, WalletManager}; +use crate::{WalletError, WalletEvent, WalletManager}; use async_trait::async_trait; use core::fmt::Write as _; use dashcore::ephemerealdata::instant_lock::InstantLock; use dashcore::prelude::CoreBlockHeight; use dashcore::{Address, Block, Transaction}; +use key_wallet::changeset::{ChainChangeSet, Merge, WalletChangeSet}; use key_wallet::transaction_checking::{BlockInfo, TransactionContext}; use key_wallet::wallet::managed_wallet_info::wallet_info_interface::WalletInfoInterface; use tokio::sync::broadcast; @@ -15,15 +16,18 @@ impl WalletInterface for WalletM &mut self, block: &Block, height: CoreBlockHeight, - ) -> BlockProcessingResult { + ) -> Result { let mut result = BlockProcessingResult::default(); - let info = BlockInfo::new(height, block.block_hash(), block.header.time); + let block_hash = block.block_hash(); + let info = BlockInfo::new(height, block_hash, block.header.time); + let mut block_changesets: std::collections::BTreeMap = + std::collections::BTreeMap::new(); // Process each transaction using the base manager for tx in &block.txdata { let context = TransactionContext::InBlock(info); - let check_result = + let (check_result, tx_changesets) = self.check_transaction_in_all_wallets(tx, context, true, false).await; if !check_result.affected_wallets.is_empty() { @@ -35,11 +39,44 @@ impl WalletInterface for WalletM } result.new_addresses.extend(check_result.new_addresses); + + // Accumulate per-wallet changesets across all transactions in this block. + for (wallet_id, cs) in tx_changesets { + match block_changesets.entry(wallet_id) { + std::collections::btree_map::Entry::Occupied(mut e) => e.get_mut().merge(cs), + std::collections::btree_map::Entry::Vacant(e) => { + e.insert(cs); + } + } + } } self.update_synced_height(height); - result + // Every wallet gets a height changeset regardless of whether it had + // relevant transactions, so the synced height is always persisted. + let height_cs = WalletChangeSet { + chain: Some(ChainChangeSet { + synced_height: Some(height), + block_hash: Some(block_hash), + }), + ..WalletChangeSet::default() + }; + for wallet_id in self.wallet_infos.keys().cloned().collect::>() { + block_changesets + .entry(wallet_id) + .and_modify(|existing| existing.merge(height_cs.clone())) + .or_insert(height_cs.clone()); + } + + // Persist accumulated changesets (tx state + height) for every wallet. + // Only calls store — the persister decides its own flush strategy + // (e.g. SQLite flushes inline on each store call). + for (wallet_id, cs) in block_changesets { + self.persister.store(wallet_id, cs).map_err(WalletError::Persistence)?; + } + + Ok(result) } async fn process_mempool_transaction( @@ -55,7 +92,8 @@ impl WalletInterface for WalletM None => TransactionContext::Mempool, }; let snapshot = self.snapshot_balances(); - let check_result = self.check_transaction_in_all_wallets(tx, context, true, false).await; + let (check_result, _) = + self.check_transaction_in_all_wallets(tx, context, true, false).await; let is_relevant = !check_result.affected_wallets.is_empty(); let net_amount = if is_relevant { @@ -67,7 +105,7 @@ impl WalletInterface for WalletM // Refresh cached balances only for affected wallets for wallet_id in &check_result.affected_wallets { if let Some(info) = self.wallet_infos.get_mut(wallet_id) { - info.update_balance(); + let _ = info.update_balance(); } } self.emit_balance_changes(&snapshot); @@ -134,7 +172,8 @@ impl WalletInterface for WalletM let mut affected_wallets = Vec::new(); for (wallet_id, info) in self.wallet_infos.iter_mut() { - if info.mark_instant_send_utxos(&txid, &instant_lock) { + let (changed, _cs) = info.mark_instant_send_utxos(&txid, &instant_lock); + if changed { affected_wallets.push(*wallet_id); } } @@ -286,7 +325,7 @@ mod tests { let block = make_block(vec![tx]); let mut rx = manager.subscribe_events(); - manager.process_block(&block, 100).await; + manager.process_block(&block, 100).await.unwrap(); let mut found = false; while let Ok(event) = rx.try_recv() { @@ -321,7 +360,7 @@ mod tests { let (mut manager, _wallet_id, addr) = setup_manager_with_wallet(); let tx = create_tx_paying_to(&addr, 0xf0); - let result = manager + let (result, _changesets) = manager .check_transaction_in_all_wallets(&tx, TransactionContext::Mempool, true, true) .await; diff --git a/key-wallet-manager/src/test_helpers.rs b/key-wallet-manager/src/test_helpers.rs index f70cef633..324b4e9de 100644 --- a/key-wallet-manager/src/test_helpers.rs +++ b/key-wallet-manager/src/test_helpers.rs @@ -84,7 +84,7 @@ pub(crate) async fn assert_lifecycle_flow(contexts: &[TransactionContext], input let tx = create_tx_paying_to(&addr, input_seed); for (i, ctx) in contexts.iter().enumerate() { - manager.check_transaction_in_all_wallets(&tx, ctx.clone(), true, true).await; + let _ = manager.check_transaction_in_all_wallets(&tx, ctx.clone(), true, true).await; let event = assert_single_event(&mut rx); if i == 0 { @@ -121,11 +121,11 @@ pub(crate) async fn assert_context_suppressed( let tx = create_tx_paying_to(&addr, input_seed); for ctx in setup_contexts { - manager.check_transaction_in_all_wallets(&tx, ctx.clone(), true, true).await; + let _ = manager.check_transaction_in_all_wallets(&tx, ctx.clone(), true, true).await; drain_events(&mut rx); } - manager.check_transaction_in_all_wallets(&tx, suppressed_context, true, true).await; + let _ = manager.check_transaction_in_all_wallets(&tx, suppressed_context, true, true).await; assert_no_events(&mut rx); let history = manager.wallet_transaction_history(&wallet_id).unwrap(); diff --git a/key-wallet-manager/src/test_utils/mock_wallet.rs b/key-wallet-manager/src/test_utils/mock_wallet.rs index d17cb1ce3..7d4995d51 100644 --- a/key-wallet-manager/src/test_utils/mock_wallet.rs +++ b/key-wallet-manager/src/test_utils/mock_wallet.rs @@ -108,15 +108,19 @@ impl MockWallet { #[async_trait::async_trait] impl WalletInterface for MockWallet { - async fn process_block(&mut self, block: &Block, height: u32) -> BlockProcessingResult { + async fn process_block( + &mut self, + block: &Block, + height: u32, + ) -> Result { let mut processed = self.processed_blocks.lock().await; processed.push((block.block_hash(), height)); - BlockProcessingResult { + Ok(BlockProcessingResult { new_txids: block.txdata.iter().map(|tx| tx.txid()).collect(), existing_txids: Vec::new(), new_addresses: Vec::new(), - } + }) } async fn process_mempool_transaction( @@ -211,8 +215,12 @@ impl NonMatchingMockWallet { #[async_trait::async_trait] impl WalletInterface for NonMatchingMockWallet { - async fn process_block(&mut self, _block: &Block, _height: u32) -> BlockProcessingResult { - BlockProcessingResult::default() + async fn process_block( + &mut self, + _block: &Block, + _height: u32, + ) -> Result { + Ok(BlockProcessingResult::default()) } async fn process_mempool_transaction( diff --git a/key-wallet-manager/src/wallet_interface.rs b/key-wallet-manager/src/wallet_interface.rs index 85a066368..dbd8a796d 100644 --- a/key-wallet-manager/src/wallet_interface.rs +++ b/key-wallet-manager/src/wallet_interface.rs @@ -2,7 +2,7 @@ //! //! This module defines the trait that SPV clients use to interact with wallets. -use crate::WalletEvent; +use crate::{WalletError, WalletEvent}; use async_trait::async_trait; use dashcore::ephemerealdata::instant_lock::InstantLock; use dashcore::prelude::CoreBlockHeight; @@ -51,13 +51,15 @@ impl BlockProcessingResult { #[async_trait] pub trait WalletInterface: Send + Sync + 'static { /// Called when a new block is received that may contain relevant transactions. - /// Returns processing result including relevant transactions and any new addresses - /// generated during gap limit maintenance. + /// + /// Returns `Ok(result)` on success. Returns `Err(WalletError::Persistence(_))` if + /// the persistence backend fails to store or flush the block's changesets — the + /// in-memory wallet state is already updated at that point, but durability is lost. async fn process_block( &mut self, block: &Block, height: CoreBlockHeight, - ) -> BlockProcessingResult; + ) -> Result; /// Called when a transaction is seen in the mempool. /// Returns whether the transaction was relevant and any new addresses generated. diff --git a/key-wallet-manager/tests/spv_integration_tests.rs b/key-wallet-manager/tests/spv_integration_tests.rs index ddcffc29a..bd3cf39e7 100644 --- a/key-wallet-manager/tests/spv_integration_tests.rs +++ b/key-wallet-manager/tests/spv_integration_tests.rs @@ -29,7 +29,7 @@ async fn test_block_processing() { let tx3 = Transaction::dummy(&external, 0..0, &[300_000]); let block = Block::dummy(100, vec![tx1.clone(), tx2.clone(), tx3.clone()]); - let result = manager.process_block(&block, 100).await; + let result = manager.process_block(&block, 100).await.unwrap(); // Both transactions should be new (first time seen) assert_eq!(result.new_txids.len(), 2); @@ -61,7 +61,7 @@ async fn test_block_processing_result_empty() { let tx2 = Transaction::dummy(&external, 0..0, &[200_000]); let block = Block::dummy(100, vec![tx1, tx2]); - let result = manager.process_block(&block, 100).await; + let result = manager.process_block(&block, 100).await.unwrap(); assert!(result.new_txids.is_empty()); assert!(result.existing_txids.is_empty()); @@ -96,7 +96,7 @@ async fn test_height_updated_after_block_processing() { for height in [1000, 2000, 3000] { let tx = Transaction::dummy(&Address::dummy(Network::Testnet, 0), 0..0, &[100000]); let block = Block::dummy(height, vec![tx]); - manager.process_block(&block, height).await; + manager.process_block(&block, height).await.unwrap(); assert_wallet_heights(&manager, height); } } @@ -122,7 +122,7 @@ async fn test_immature_balance_matures_during_block_processing() { wallet_info .first_bip44_managed_account_mut() .expect("Should have managed account") - .next_receive_address(Some(&account_xpub), true) + .next_receive_address(Some(&account_xpub)) .expect("Should get address") }; @@ -133,7 +133,7 @@ async fn test_immature_balance_matures_during_block_processing() { // Process the coinbase at height 1000 let coinbase_height = 1000; let coinbase_block = Block::dummy(coinbase_height, vec![coinbase_tx.clone()]); - manager.process_block(&coinbase_block, coinbase_height).await; + manager.process_block(&coinbase_block, coinbase_height).await.unwrap(); // Verify the coinbase is detected and stored as immature let wallet_info = manager.get_wallet_info(&wallet_id).expect("Wallet info should exist"); @@ -152,7 +152,7 @@ async fn test_immature_balance_matures_during_block_processing() { let tx = Transaction::dummy(&Address::dummy(Network::Regtest, 0), 0..0, &[1000]); for height in (coinbase_height + 1)..maturity_height { let block = Block::dummy(height, vec![tx.clone()]); - manager.process_block(&block, height).await; + manager.process_block(&block, height).await.unwrap(); } // Verify still immature just before maturity @@ -165,7 +165,7 @@ async fn test_immature_balance_matures_during_block_processing() { // Process the maturity block let maturity_block = Block::dummy(maturity_height, vec![tx.clone()]); - manager.process_block(&maturity_block, maturity_height).await; + manager.process_block(&maturity_block, maturity_height).await.unwrap(); // Verify the coinbase has matured let wallet_info = manager.get_wallet_info(&wallet_id).expect("Wallet info should exist"); @@ -196,7 +196,7 @@ async fn test_block_rescan_marks_transactions_as_existing() { let block = Block::dummy(100, vec![tx1.clone()]); // First processing - transaction should be new - let result1 = manager.process_block(&block, 100).await; + let result1 = manager.process_block(&block, 100).await.unwrap(); assert_eq!(result1.new_txids.len(), 1, "First processing should have 1 new transaction"); assert!( @@ -210,7 +210,7 @@ async fn test_block_rescan_marks_transactions_as_existing() { let tx_history_count = wallet_info.transaction_history().len(); // Second processing (simulating rescan) - transaction should be existing - let result2 = manager.process_block(&block, 100).await; + let result2 = manager.process_block(&block, 100).await.unwrap(); assert!(result2.new_txids.is_empty(), "Rescan should have no new transactions"); assert_eq!(result2.existing_txids.len(), 1, "Rescan should have 1 existing transaction"); diff --git a/key-wallet/src/account/account_collection.rs b/key-wallet/src/account/account_collection.rs index f430a13e8..eb0f13db2 100644 --- a/key-wallet/src/account/account_collection.rs +++ b/key-wallet/src/account/account_collection.rs @@ -58,6 +58,14 @@ pub struct AccountCollection { pub identity_topup_not_bound: Option, /// Identity invitation account (optional) pub identity_invitation: Option, + /// Per-identity ECDSA authentication accounts (DIP-13, sub-feature 0', + /// key type 0'), keyed by `identity_index`. Platform-only — these carry no + /// L1 UTXOs. + pub identity_authentication_ecdsa: BTreeMap, + /// Per-identity BLS authentication accounts (DIP-13, sub-feature 0', + /// key type 1'), keyed by `identity_index`. Platform-only. + #[cfg(feature = "bls")] + pub identity_authentication_bls: BTreeMap, /// Asset lock address top-up account (optional) pub asset_lock_address_topup: Option, /// Asset lock shielded address top-up account (optional) @@ -91,6 +99,9 @@ impl AccountCollection { identity_topup: BTreeMap::new(), identity_topup_not_bound: None, identity_invitation: None, + identity_authentication_ecdsa: BTreeMap::new(), + #[cfg(feature = "bls")] + identity_authentication_bls: BTreeMap::new(), asset_lock_address_topup: None, asset_lock_shielded_address_topup: None, provider_voting_keys: None, @@ -141,6 +152,18 @@ impl AccountCollection { AccountType::IdentityInvitation => { self.identity_invitation = Some(account); } + AccountType::IdentityAuthenticationEcdsa { + identity_index, + } => { + self.identity_authentication_ecdsa.insert(*identity_index, account); + } + AccountType::IdentityAuthenticationBls { + .. + } => { + return Err( + "IdentityAuthenticationBls requires BLSAccount, use insert_bls_account", + ); + } AccountType::AssetLockAddressTopUp => { self.asset_lock_address_topup = Some(account); } @@ -197,14 +220,29 @@ impl AccountCollection { Ok(()) } - /// Insert a BLS account for provider operator keys + /// Insert a BLS account for provider operator keys or identity + /// authentication. + /// + /// Accepts [`AccountType::ProviderOperatorKeys`] or + /// [`AccountType::IdentityAuthenticationBls`]. Rejects any other account + /// type. #[cfg(feature = "bls")] pub fn insert_bls_account(&mut self, account: BLSAccount) -> Result<(), &'static str> { - if !matches!(account.account_type, AccountType::ProviderOperatorKeys) { - return Err("BLS account must have ProviderOperatorKeys type"); + match account.account_type { + AccountType::ProviderOperatorKeys => { + self.provider_operator_keys = Some(account); + Ok(()) + } + AccountType::IdentityAuthenticationBls { + identity_index, + } => { + self.identity_authentication_bls.insert(identity_index, account); + Ok(()) + } + _ => { + Err("BLS account must have ProviderOperatorKeys or IdentityAuthenticationBls type") + } } - self.provider_operator_keys = Some(account); - Ok(()) } /// Insert an EdDSA account for provider platform keys @@ -242,6 +280,17 @@ impl AccountCollection { } => self.identity_topup.contains_key(registration_index), AccountType::IdentityTopUpNotBoundToIdentity => self.identity_topup_not_bound.is_some(), AccountType::IdentityInvitation => self.identity_invitation.is_some(), + AccountType::IdentityAuthenticationEcdsa { + identity_index, + } => self.identity_authentication_ecdsa.contains_key(identity_index), + #[cfg(feature = "bls")] + AccountType::IdentityAuthenticationBls { + identity_index, + } => self.identity_authentication_bls.contains_key(identity_index), + #[cfg(not(feature = "bls"))] + AccountType::IdentityAuthenticationBls { + .. + } => false, AccountType::AssetLockAddressTopUp => self.asset_lock_address_topup.is_some(), AccountType::AssetLockShieldedAddressTopUp => { self.asset_lock_shielded_address_topup.is_some() @@ -315,6 +364,12 @@ impl AccountCollection { } => self.identity_topup.get(®istration_index), AccountType::IdentityTopUpNotBoundToIdentity => self.identity_topup_not_bound.as_ref(), AccountType::IdentityInvitation => self.identity_invitation.as_ref(), + AccountType::IdentityAuthenticationEcdsa { + identity_index, + } => self.identity_authentication_ecdsa.get(&identity_index), + AccountType::IdentityAuthenticationBls { + .. + } => None, // BLSAccount, use bls_account_of_type AccountType::AssetLockAddressTopUp => self.asset_lock_address_topup.as_ref(), AccountType::AssetLockShieldedAddressTopUp => { self.asset_lock_shielded_address_topup.as_ref() @@ -382,6 +437,12 @@ impl AccountCollection { } => self.identity_topup.get_mut(®istration_index), AccountType::IdentityTopUpNotBoundToIdentity => self.identity_topup_not_bound.as_mut(), AccountType::IdentityInvitation => self.identity_invitation.as_mut(), + AccountType::IdentityAuthenticationEcdsa { + identity_index, + } => self.identity_authentication_ecdsa.get_mut(&identity_index), + AccountType::IdentityAuthenticationBls { + .. + } => None, // BLSAccount, use bls_account_of_type_mut AccountType::AssetLockAddressTopUp => self.asset_lock_address_topup.as_mut(), AccountType::AssetLockShieldedAddressTopUp => { self.asset_lock_shielded_address_topup.as_mut() @@ -449,6 +510,8 @@ impl AccountCollection { accounts.push(account); } + accounts.extend(self.identity_authentication_ecdsa.values()); + if let Some(account) = &self.asset_lock_address_topup { accounts.push(account); } @@ -497,6 +560,8 @@ impl AccountCollection { accounts.push(account); } + accounts.extend(self.identity_authentication_ecdsa.values_mut()); + if let Some(account) = &mut self.asset_lock_address_topup { accounts.push(account); } @@ -523,16 +588,27 @@ impl AccountCollection { accounts } - /// Get the BLS account (provider operator keys) + /// Get a BLS account by type. + /// + /// Supports [`AccountType::ProviderOperatorKeys`] and + /// [`AccountType::IdentityAuthenticationBls`] — returns `None` for other + /// types. #[cfg(feature = "bls")] pub fn bls_account_of_type(&self, account_type: AccountType) -> Option<&BLSAccount> { match account_type { AccountType::ProviderOperatorKeys => self.provider_operator_keys.as_ref(), + AccountType::IdentityAuthenticationBls { + identity_index, + } => self.identity_authentication_bls.get(&identity_index), _ => None, } } - /// Get the BLS account mutably (provider operator keys) + /// Get a BLS account by type mutably. + /// + /// Supports [`AccountType::ProviderOperatorKeys`] and + /// [`AccountType::IdentityAuthenticationBls`] — returns `None` for other + /// types. #[cfg(feature = "bls")] pub fn bls_account_of_type_mut( &mut self, @@ -540,6 +616,9 @@ impl AccountCollection { ) -> Option<&mut BLSAccount> { match account_type { AccountType::ProviderOperatorKeys => self.provider_operator_keys.as_mut(), + AccountType::IdentityAuthenticationBls { + identity_index, + } => self.identity_authentication_bls.get_mut(&identity_index), _ => None, } } @@ -575,6 +654,11 @@ impl AccountCollection { count += 1; } + #[cfg(feature = "bls")] + { + count += self.identity_authentication_bls.len(); + } + #[cfg(feature = "eddsa")] if self.provider_platform_keys.is_some() { count += 1; @@ -605,6 +689,7 @@ impl AccountCollection { && self.identity_topup.is_empty() && self.identity_topup_not_bound.is_none() && self.identity_invitation.is_none() + && self.identity_authentication_ecdsa.is_empty() && self.asset_lock_address_topup.is_none() && self.asset_lock_shielded_address_topup.is_none() && self.provider_voting_keys.is_none() @@ -612,7 +697,9 @@ impl AccountCollection { #[cfg(feature = "bls")] { - is_empty = is_empty && self.provider_operator_keys.is_none(); + is_empty = is_empty + && self.provider_operator_keys.is_none() + && self.identity_authentication_bls.is_empty(); } #[cfg(feature = "eddsa")] @@ -632,6 +719,7 @@ impl AccountCollection { self.identity_topup.clear(); self.identity_topup_not_bound = None; self.identity_invitation = None; + self.identity_authentication_ecdsa.clear(); self.asset_lock_address_topup = None; self.asset_lock_shielded_address_topup = None; self.provider_voting_keys = None; @@ -639,6 +727,7 @@ impl AccountCollection { #[cfg(feature = "bls")] { self.provider_operator_keys = None; + self.identity_authentication_bls.clear(); } #[cfg(feature = "eddsa")] { diff --git a/key-wallet/src/account/account_collection_test.rs b/key-wallet/src/account/account_collection_test.rs index dead953e0..813dc8a82 100644 --- a/key-wallet/src/account/account_collection_test.rs +++ b/key-wallet/src/account/account_collection_test.rs @@ -125,7 +125,10 @@ mod tests { let result = collection.insert_bls_account(bls_account); assert!(result.is_err()); - assert_eq!(result.unwrap_err(), "BLS account must have ProviderOperatorKeys type"); + assert_eq!( + result.unwrap_err(), + "BLS account must have ProviderOperatorKeys or IdentityAuthenticationBls type" + ); } #[test] diff --git a/key-wallet/src/account/account_type.rs b/key-wallet/src/account/account_type.rs index ca3cd08b5..ba837feee 100644 --- a/key-wallet/src/account/account_type.rs +++ b/key-wallet/src/account/account_type.rs @@ -14,7 +14,7 @@ use bincode_derive::{Decode, Encode}; use serde::{Deserialize, Serialize}; /// Account types supported by the wallet -#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Default)] #[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] #[cfg_attr(feature = "bincode", derive(Encode, Decode))] pub enum StandardAccountType { @@ -25,8 +25,20 @@ pub enum StandardAccountType { BIP32Account, } -/// Account types supported by the wallet -#[derive(Debug, Clone, Copy, PartialEq, Eq)] +/// Account types supported by the wallet. +/// +/// # Ordering and storage format stability +/// +/// `AccountType` derives `PartialOrd`/`Ord`/`Hash` because it's used as +/// the key in `WalletChangeSet::per_account` (a `BTreeMap`) +/// and must serialize deterministically under bincode. **The derived order +/// follows variant declaration order, then field declaration order.** +/// Reordering variants or fields here is a persistent storage-format +/// break: it silently changes `BTreeMap` iteration order (affecting any +/// serialization that depends on it) and flips bincode variant +/// discriminants, corrupting on-disk data. Add new variants at the end +/// only; never reorder, never insert in the middle. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] #[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] #[cfg_attr(feature = "bincode", derive(Encode, Decode))] pub enum AccountType { @@ -53,6 +65,32 @@ pub enum AccountType { IdentityTopUpNotBoundToIdentity, /// Identity invitation funding IdentityInvitation, + /// Per-identity authentication keys using ECDSA (DIP-13, sub-feature 0', key type 0'). + /// + /// Account-level path: `m/9'/coin_type'/5'/0'/0'/identity_index'`. Individual + /// keys live at `.../identity_index'/key_index'` and are managed sequentially + /// by the `AddressPool` below this account. These accounts carry no L1 + /// balance — they are pure signing-key chains the user employs to sign Dash + /// Platform state transitions. + IdentityAuthenticationEcdsa { + /// Which identity in this wallet these keys belong to (hardened). + identity_index: u32, + }, + /// Per-identity authentication keys using BLS (DIP-13, sub-feature 0', key type 1'). + /// + /// Account-level path: `m/9'/coin_type'/5'/0'/1'/identity_index'`. When the + /// `bls` feature is enabled this is backed by + /// [`BLSAccount`](crate::account::BLSAccount); like + /// [`AccountType::IdentityAuthenticationEcdsa`] these carry no L1 balance. + /// + /// The variant itself is always present so that downstream pattern matches + /// remain exhaustive regardless of features; the BLS-typed storage it maps + /// into (see [`crate::account::AccountCollection::identity_authentication_bls`]) + /// is what is gated on the `bls` feature. + IdentityAuthenticationBls { + /// Which identity in this wallet these keys belong to (hardened). + identity_index: u32, + }, /// Asset lock address top-up funding (subfeature 4) /// Path: m/9'/coinType'/5'/4'/index' AssetLockAddressTopUp, @@ -125,6 +163,16 @@ impl TryFrom for AccountTypeToCheck { Ok(AccountTypeToCheck::IdentityTopUpNotBound) } AccountType::IdentityInvitation => Ok(AccountTypeToCheck::IdentityInvitation), + AccountType::IdentityAuthenticationEcdsa { + .. + } + | AccountType::IdentityAuthenticationBls { + .. + } => { + // DIP-13 per-identity authentication accounts are Platform-only, + // operating on Dash Platform rather than the Core chain. + Err(PlatformAccountConversionError) + } AccountType::AssetLockAddressTopUp => Ok(AccountTypeToCheck::AssetLockAddressTopUp), AccountType::AssetLockShieldedAddressTopUp => { Ok(AccountTypeToCheck::AssetLockShieldedAddressTopUp) @@ -149,6 +197,32 @@ impl TryFrom for AccountTypeToCheck { } } +#[cfg(feature = "bincode")] +impl AccountType { + /// Encode as a stable byte key for persistent storage (e.g. as a + /// BLOB primary key in a database table). + /// + /// Uses bincode 2.x standard config. The stability contract lives + /// on the type declaration: variants are append-only, field order + /// is frozen. Encoding is infallible for this type (no `Vec` or + /// heap types that can fail to encode). + /// + /// Paired with [`AccountType::from_db_key`] for round-trip. + pub fn to_db_key(&self) -> Vec { + bincode::encode_to_vec(self, bincode::config::standard()) + .expect("AccountType bincode encoding is infallible") + } + + /// Decode an `AccountType` from the byte form produced by + /// [`AccountType::to_db_key`]. Returns a bincode error for + /// corrupted bytes or an unrecognized variant discriminant + /// (which can happen if the DB was written by a newer version + /// of the crate). + pub fn from_db_key(bytes: &[u8]) -> Result { + bincode::decode_from_slice(bytes, bincode::config::standard()).map(|(v, _)| v) + } +} + impl AccountType { /// Get the primary index for this account type /// Returns None for provider key types and identity types that don't have account indices @@ -180,6 +254,12 @@ impl AccountType { } | Self::IdentityTopUpNotBoundToIdentity | Self::IdentityInvitation + | Self::IdentityAuthenticationEcdsa { + .. + } + | Self::IdentityAuthenticationBls { + .. + } | Self::AssetLockAddressTopUp | Self::AssetLockShieldedAddressTopUp | Self::ProviderVotingKeys @@ -225,6 +305,12 @@ impl AccountType { Self::IdentityInvitation { .. } => DerivationPathReference::BlockchainIdentityCreditInvitationFunding, + Self::IdentityAuthenticationEcdsa { + .. + } => DerivationPathReference::BlockchainIdentityAuthenticationEcdsa, + Self::IdentityAuthenticationBls { + .. + } => DerivationPathReference::BlockchainIdentityAuthenticationBls, Self::AssetLockAddressTopUp { .. } => DerivationPathReference::BlockchainAssetLockAddressTopupFunding, @@ -348,6 +434,42 @@ impl AccountType { } } } + Self::IdentityAuthenticationEcdsa { + identity_index, + } => { + // DIP-13: m/9'/coin_type'/5'/0'/0'/identity_index' + // The base const supplies the first 5 hardened levels; we append identity_index'. + let base_path = match network { + Network::Mainnet => crate::dip9::IDENTITY_AUTHENTICATION_ECDSA_PATH_MAINNET, + Network::Testnet | Network::Devnet | Network::Regtest => { + crate::dip9::IDENTITY_AUTHENTICATION_ECDSA_PATH_TESTNET + } + }; + let mut path = DerivationPath::from(base_path); + path.push( + ChildNumber::from_hardened_idx(*identity_index) + .map_err(crate::error::Error::Bip32)?, + ); + Ok(path) + } + Self::IdentityAuthenticationBls { + identity_index, + } => { + // DIP-13: m/9'/coin_type'/5'/0'/1'/identity_index' + // The base const supplies the first 5 hardened levels; we append identity_index'. + let base_path = match network { + Network::Mainnet => crate::dip9::IDENTITY_AUTHENTICATION_BLS_PATH_MAINNET, + Network::Testnet | Network::Devnet | Network::Regtest => { + crate::dip9::IDENTITY_AUTHENTICATION_BLS_PATH_TESTNET + } + }; + let mut path = DerivationPath::from(base_path); + path.push( + ChildNumber::from_hardened_idx(*identity_index) + .map_err(crate::error::Error::Bip32)?, + ); + Ok(path) + } Self::AssetLockAddressTopUp => { // Base path without index - actual key index added when deriving match network { @@ -484,3 +606,236 @@ impl AccountType { } } } + +#[cfg(test)] +mod identity_authentication_tests { + use super::*; + + /// Helper: build a `DerivationPath` of hardened children from the given + /// indices. Keeps tests readable. + fn hardened_path(indices: &[u32]) -> DerivationPath { + DerivationPath::from( + indices.iter().map(|i| ChildNumber::from_hardened_idx(*i).unwrap()).collect::>(), + ) + } + + #[test] + fn test_identity_authentication_ecdsa_mainnet_path() { + let account_type = AccountType::IdentityAuthenticationEcdsa { + identity_index: 0, + }; + let path = account_type.derivation_path(Network::Mainnet).unwrap(); + // m/9'/5'/5'/0'/0'/0' + assert_eq!(path, hardened_path(&[9, 5, 5, 0, 0, 0])); + } + + #[test] + fn test_identity_authentication_ecdsa_testnet_path() { + let account_type = AccountType::IdentityAuthenticationEcdsa { + identity_index: 7, + }; + let path = account_type.derivation_path(Network::Testnet).unwrap(); + // m/9'/1'/5'/0'/0'/7' + assert_eq!(path, hardened_path(&[9, 1, 5, 0, 0, 7])); + } + + #[test] + fn test_identity_authentication_ecdsa_index_is_none() { + // `index()` is for BIP44-style account indices, not identity indices. + let account_type = AccountType::IdentityAuthenticationEcdsa { + identity_index: 42, + }; + assert!(account_type.index().is_none()); + } + + #[test] + fn test_identity_authentication_ecdsa_derivation_path_reference() { + let account_type = AccountType::IdentityAuthenticationEcdsa { + identity_index: 0, + }; + assert_eq!( + account_type.derivation_path_reference(), + crate::dip9::DerivationPathReference::BlockchainIdentityAuthenticationEcdsa, + ); + } + + #[test] + fn test_identity_authentication_ecdsa_to_account_type_to_check_errs() { + // DIP-13 identity-authentication accounts are Platform-only and must + // never be mapped onto a Core-chain [`AccountTypeToCheck`] variant. + let account_type = AccountType::IdentityAuthenticationEcdsa { + identity_index: 3, + }; + let result: Result = account_type.try_into(); + assert_eq!(result, Err(PlatformAccountConversionError)); + } + + #[test] + fn test_identity_authentication_bls_mainnet_path() { + let account_type = AccountType::IdentityAuthenticationBls { + identity_index: 0, + }; + let path = account_type.derivation_path(Network::Mainnet).unwrap(); + // m/9'/5'/5'/0'/1'/0' + assert_eq!(path, hardened_path(&[9, 5, 5, 0, 1, 0])); + } + + #[test] + fn test_identity_authentication_bls_testnet_path() { + let account_type = AccountType::IdentityAuthenticationBls { + identity_index: 2, + }; + let path = account_type.derivation_path(Network::Testnet).unwrap(); + // m/9'/1'/5'/0'/1'/2' + assert_eq!(path, hardened_path(&[9, 1, 5, 0, 1, 2])); + } + + #[test] + fn test_identity_authentication_bls_regtest_uses_testnet_coin_type() { + let account_type = AccountType::IdentityAuthenticationBls { + identity_index: 5, + }; + let path = account_type.derivation_path(Network::Regtest).unwrap(); + // Regtest/Devnet use the same coin_type (1') as Testnet. + assert_eq!(path, hardened_path(&[9, 1, 5, 0, 1, 5])); + } + + #[test] + fn test_identity_authentication_bls_index_is_none() { + let account_type = AccountType::IdentityAuthenticationBls { + identity_index: 99, + }; + assert!(account_type.index().is_none()); + } + + #[test] + fn test_identity_authentication_bls_derivation_path_reference() { + let account_type = AccountType::IdentityAuthenticationBls { + identity_index: 0, + }; + assert_eq!( + account_type.derivation_path_reference(), + crate::dip9::DerivationPathReference::BlockchainIdentityAuthenticationBls, + ); + } + + #[test] + fn test_identity_authentication_bls_to_account_type_to_check_errs() { + // DIP-13 identity-authentication accounts are Platform-only and must + // never be mapped onto a Core-chain [`AccountTypeToCheck`] variant. + let account_type = AccountType::IdentityAuthenticationBls { + identity_index: 3, + }; + let result: Result = account_type.try_into(); + assert_eq!(result, Err(PlatformAccountConversionError)); + } + + /// End-to-end: insert an ECDSA identity-authentication account into an + /// `AccountCollection` and round-trip through `contains_account_type` / + /// `account_of_type`. + #[test] + fn test_account_collection_round_trip_ecdsa() { + use crate::account::{Account, AccountCollection}; + use crate::bip32::{ExtendedPrivKey, ExtendedPubKey}; + use crate::mnemonic::{Language, Mnemonic}; + use secp256k1::Secp256k1; + + let mut collection = AccountCollection::new(); + + let mnemonic = Mnemonic::from_phrase( + "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon \ + abandon about", + Language::English, + ) + .unwrap(); + let seed = mnemonic.to_seed(""); + let master = ExtendedPrivKey::new_master(Network::Testnet, &seed).unwrap(); + let secp = Secp256k1::new(); + let xpub = ExtendedPubKey::from_priv(&secp, &master); + + let account_type = AccountType::IdentityAuthenticationEcdsa { + identity_index: 4, + }; + let account = Account::new(None, account_type, xpub, Network::Testnet).unwrap(); + + assert!(!collection.contains_account_type(&account_type)); + assert!(collection.insert(account).is_ok()); + assert!(collection.contains_account_type(&account_type)); + assert!(collection.account_of_type(account_type).is_some()); + + // Sanity: the ECDSA variant is *not* routed to the BLS map, so the BLS + // lookup on an ECDSA variant returns None regardless of features. + #[cfg(feature = "bls")] + { + let bls_probe = AccountType::IdentityAuthenticationBls { + identity_index: 4, + }; + assert!(collection.bls_account_of_type(bls_probe).is_none()); + } + } + + /// End-to-end: insert a BLS identity-authentication account into an + /// `AccountCollection` via `insert_bls_account`, and round-trip through + /// `contains_account_type` / `bls_account_of_type`. + #[cfg(feature = "bls")] + #[test] + fn test_account_collection_round_trip_bls() { + use crate::account::{AccountCollection, BLSAccount}; + + let mut collection = AccountCollection::new(); + + let account_type = AccountType::IdentityAuthenticationBls { + identity_index: 11, + }; + let bls_account = + BLSAccount::from_seed(None, account_type, [7u8; 32], Network::Testnet).unwrap(); + + assert!(!collection.contains_account_type(&account_type)); + assert!(collection.insert_bls_account(bls_account).is_ok()); + assert!(collection.contains_account_type(&account_type)); + assert!(collection.bls_account_of_type(account_type).is_some()); + + // ECDSA `account_of_type` should refuse BLS lookups via the plain + // accessor. + assert!(collection.account_of_type(account_type).is_none()); + } + + /// Inserting a BLS account via the plain `insert` path must fail with a + /// pointer to `insert_bls_account`. + #[cfg(feature = "bls")] + #[test] + fn test_account_collection_rejects_bls_via_plain_insert() { + use crate::account::{Account, AccountCollection}; + use crate::bip32::{ExtendedPrivKey, ExtendedPubKey}; + use crate::mnemonic::{Language, Mnemonic}; + use secp256k1::Secp256k1; + + let mut collection = AccountCollection::new(); + + let mnemonic = Mnemonic::from_phrase( + "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon \ + abandon about", + Language::English, + ) + .unwrap(); + let seed = mnemonic.to_seed(""); + let master = ExtendedPrivKey::new_master(Network::Testnet, &seed).unwrap(); + let secp = Secp256k1::new(); + let xpub = ExtendedPubKey::from_priv(&secp, &master); + + // Using an Account (ECDSA) constructor but with a BLS auth type is a + // programming error — insert() routes it to the BLS error arm. + let account = Account::new( + None, + AccountType::IdentityAuthenticationBls { + identity_index: 0, + }, + xpub, + Network::Testnet, + ) + .unwrap(); + + let err = collection.insert(account).unwrap_err(); + assert!(err.contains("insert_bls_account")); + } +} diff --git a/key-wallet/src/account/bls_account.rs b/key-wallet/src/account/bls_account.rs index 12a1c031d..445d984e4 100644 --- a/key-wallet/src/account/bls_account.rs +++ b/key-wallet/src/account/bls_account.rs @@ -247,6 +247,11 @@ impl } => Some(ChildNumber::Hardened { index: registration_index, }), + AccountType::IdentityAuthenticationBls { + identity_index, + } => Some(ChildNumber::Hardened { + index: identity_index, + }), _ => None, } } diff --git a/key-wallet/src/account/mod.rs b/key-wallet/src/account/mod.rs index 177a42747..dd5d0cea6 100644 --- a/key-wallet/src/account/mod.rs +++ b/key-wallet/src/account/mod.rs @@ -179,6 +179,11 @@ impl AccountDerivation Some(ChildNumber::Hardened { index: registration_index, }), + AccountType::IdentityAuthenticationEcdsa { + identity_index, + } => Some(ChildNumber::Hardened { + index: identity_index, + }), _ => None, } } diff --git a/key-wallet/src/changeset/changeset.rs b/key-wallet/src/changeset/changeset.rs new file mode 100644 index 000000000..8d07ea74a --- /dev/null +++ b/key-wallet/src/changeset/changeset.rs @@ -0,0 +1,379 @@ +//! Wallet-level changeset types. +//! +//! Changesets carry the wallet's **native types** directly (`Utxo`, +//! `TransactionRecord`, etc.) rather than flattened persistence-friendly +//! representations. Persistence backends (e.g. SQLite) translate from the +//! native types to their schema as part of their own code — that's a +//! persister concern, not a changeset concern. +//! +//! # Shape +//! +//! The top-level [`WalletChangeSet`] carries: +//! - **Wallet-scoped** sub-changesets that have no per-account meaning: +//! [`AccountKeyChangeSet`] (which HD account types exist), +//! [`ChainChangeSet`] (synced height), and [`BalanceChangeSet`] (cached +//! balance delta). +//! - **Per-account** deltas in `per_account: BTreeMap`. +//! Each bucket is an [`AccountChangeSet`] that scopes its own +//! `addresses_used`, `highest_used`, `utxos_*`, and `transactions` to +//! one `ManagedCoreAccount`. +//! +//! The per-account bucketing is intentional: at emission time each mutation +//! knows which account it's modifying, so it writes directly into +//! `per_account[key]`. At apply time each bucket is routed to its owning +//! account by a single `accounts.get_by_account_type_mut(&key)` lookup. No +//! address-based scanning, no ambiguity for multi-account transactions +//! (CoinJoin, cross-account transfers) where the same `Txid` carries +//! different per-account views. +//! +//! Every type in this module implements [`Merge`] so that multiple deltas +//! produced during processing can be accumulated into a single changeset +//! before being persisted. + +use std::collections::{BTreeMap, BTreeSet}; + +use dashcore::{BlockHash, OutPoint, Txid}; +#[cfg(feature = "serde")] +use serde::{Deserialize, Serialize}; + +use crate::account::AccountType; +use crate::managed_account::address_pool::AddressPoolType; +use crate::managed_account::transaction_record::TransactionRecord; +use crate::utxo::Utxo; +use crate::Address; + +use super::merge::Merge; + +// --------------------------------------------------------------------------- +// Top-level WalletChangeSet +// --------------------------------------------------------------------------- + +/// Atomic delta of wallet state produced by a mutation. +/// +/// See the module docs for the overall shape. Wallet-scoped sub-changesets +/// are `Option`; per-account deltas live in `per_account`, keyed by +/// `AccountType`. +#[derive(Debug, Clone, Default, PartialEq)] +#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] +pub struct WalletChangeSet { + /// HD account structure (account types added to the `Wallet`). + /// On apply, re-derived from the seed via `wallet.add_account(ty, None)`. + pub account_keys: Option, + + /// Core chain state (synced height, latest block hash). + pub chain: Option, + + /// Cached balance delta (signed, wallet-wide). + pub balance: Option, + + /// Per-account deltas, keyed by [`AccountType`]. Each bucket is + /// populated by the owning `ManagedCoreAccount` at emission time and + /// routed directly on apply — no address-based fallback scanning. + pub per_account: BTreeMap, +} + +impl Merge for WalletChangeSet { + fn merge(&mut self, other: Self) { + self.account_keys.merge(other.account_keys); + self.chain.merge(other.chain); + self.balance.merge(other.balance); + for (key, incoming) in other.per_account { + self.per_account.entry(key).or_default().merge(incoming); + } + } + + fn is_empty(&self) -> bool { + self.account_keys.is_empty() + && self.chain.is_empty() + && self.balance.is_empty() + && self.per_account.values().all(|cs| cs.is_empty()) + } +} + +impl WalletChangeSet { + /// Get (or create) the per-account bucket for `account_type`. + /// + /// Used by the orchestrator during mutation accumulation to merge an + /// [`AccountChangeSet`] returned by a per-account mutation method into + /// the correct bucket. + pub fn account_bucket(&mut self, account_type: AccountType) -> &mut AccountChangeSet { + self.per_account.entry(account_type).or_default() + } +} + +// --------------------------------------------------------------------------- +// AccountChangeSet — per-account delta +// --------------------------------------------------------------------------- + +/// All state changes belonging to a single [`ManagedCoreAccount`]. +/// +/// Produced by the per-account mutation methods on `ManagedCoreAccount` +/// (e.g. `mark_address_used`, `record_transaction`, `update_utxos`, +/// `mark_utxos_instant_send`, `update_transaction_context`). The owning +/// account type is stored outside this struct, as the key in +/// [`WalletChangeSet::per_account`]. +#[derive(Debug, Clone, Default, PartialEq)] +#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] +pub struct AccountChangeSet { + /// Addresses marked as used during processing. `BTreeSet` so duplicate + /// marks within a single changeset collapse automatically. + pub addresses_used: BTreeSet
, + + /// `pool_type → highest_used_index` after gap limit maintenance. + /// Max-wins on merge. Mirrors `AddressPool::highest_used`. + pub highest_used: BTreeMap, + + /// UTXOs received or upgraded (mempool → confirmed, unlocked → IS-locked). + pub utxos_added: BTreeMap, + + /// UTXOs spent. + pub utxos_spent: BTreeSet, + + /// UTXOs that transitioned to InstantSend-locked. + pub utxos_instant_locked: BTreeSet, + + /// Transaction records inserted or updated, keyed by `Txid`. A single + /// account holds at most one record per txid, so the map encoding + /// gives last-write-wins dedup via `extend` on merge for free. + pub transactions: BTreeMap, +} + +impl Merge for AccountChangeSet { + fn merge(&mut self, other: Self) { + self.addresses_used.extend(other.addresses_used); + for (pool_type, new_highest) in other.highest_used { + self.highest_used + .entry(pool_type) + .and_modify(|current| *current = (*current).max(new_highest)) + .or_insert(new_highest); + } + self.utxos_added.extend(other.utxos_added); + self.utxos_spent.extend(other.utxos_spent); + self.utxos_instant_locked.extend(other.utxos_instant_locked); + // Last-write-wins dedup per txid, free via `BTreeMap::extend`. + self.transactions.extend(other.transactions); + } + + fn is_empty(&self) -> bool { + self.addresses_used.is_empty() + && self.highest_used.is_empty() + && self.utxos_added.is_empty() + && self.utxos_spent.is_empty() + && self.utxos_instant_locked.is_empty() + && self.transactions.is_empty() + } +} + +// --------------------------------------------------------------------------- +// AccountKeyChangeSet — HD account structure (keys derived from seed) +// --------------------------------------------------------------------------- + +/// HD account types added to `Wallet.accounts`. +/// +/// On restore, each account type is re-derived from the seed via +/// `Wallet::add_account(account_type, None)`. The keys themselves are not +/// persisted — only the fact that the account exists. +#[derive(Debug, Clone, Default, PartialEq)] +#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] +pub struct AccountKeyChangeSet { + pub added: BTreeSet, +} + +impl Merge for AccountKeyChangeSet { + fn merge(&mut self, other: Self) { + self.added.extend(other.added); + } + + fn is_empty(&self) -> bool { + self.added.is_empty() + } +} + +// --------------------------------------------------------------------------- +// ChainChangeSet — synced chain state +// --------------------------------------------------------------------------- + +/// Core chain state changes (last processed height + block hash). +#[derive(Debug, Clone, Default, PartialEq)] +#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] +pub struct ChainChangeSet { + pub synced_height: Option, + pub block_hash: Option, +} + +impl Merge for ChainChangeSet { + fn merge(&mut self, other: Self) { + // Later height wins. + if let Some(h) = other.synced_height { + match self.synced_height { + Some(current) if current >= h => {} + _ => self.synced_height = Some(h), + } + } + if other.block_hash.is_some() { + self.block_hash = other.block_hash; + } + } + + fn is_empty(&self) -> bool { + self.synced_height.is_none() && self.block_hash.is_none() + } +} + +// --------------------------------------------------------------------------- +// BalanceChangeSet — cached balance deltas +// --------------------------------------------------------------------------- + +/// Signed deltas to the wallet's cached balance buckets. +/// +/// Deltas are composable: multiple changesets merged add up. +#[derive(Debug, Clone, Default, PartialEq)] +#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] +pub struct BalanceChangeSet { + pub confirmed_delta: i64, + pub unconfirmed_delta: i64, + pub immature_delta: i64, + pub locked_delta: i64, +} + +impl Merge for BalanceChangeSet { + fn merge(&mut self, other: Self) { + self.confirmed_delta = self.confirmed_delta.saturating_add(other.confirmed_delta); + self.unconfirmed_delta = self.unconfirmed_delta.saturating_add(other.unconfirmed_delta); + self.immature_delta = self.immature_delta.saturating_add(other.immature_delta); + self.locked_delta = self.locked_delta.saturating_add(other.locked_delta); + } + + fn is_empty(&self) -> bool { + self.confirmed_delta == 0 + && self.unconfirmed_delta == 0 + && self.immature_delta == 0 + && self.locked_delta == 0 + } +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::*; + use crate::account::StandardAccountType; + + fn bip44_account_0() -> AccountType { + AccountType::Standard { + index: 0, + standard_account_type: StandardAccountType::BIP44Account, + } + } + + fn bip44_account_1() -> AccountType { + AccountType::Standard { + index: 1, + standard_account_type: StandardAccountType::BIP44Account, + } + } + + #[test] + fn wallet_changeset_is_empty_by_default() { + let cs = WalletChangeSet::default(); + assert!(cs.is_empty()); + } + + #[test] + fn wallet_changeset_merge_keeps_latest_chain_height() { + let mut a = WalletChangeSet::default(); + let mut b = WalletChangeSet::default(); + a.chain = Some(ChainChangeSet { + synced_height: Some(100), + block_hash: None, + }); + b.chain = Some(ChainChangeSet { + synced_height: Some(150), + block_hash: None, + }); + a.merge(b); + assert_eq!(a.chain.as_ref().unwrap().synced_height, Some(150)); + } + + #[test] + fn balance_changeset_merge_sums_deltas() { + let mut a = BalanceChangeSet { + confirmed_delta: 100, + ..Default::default() + }; + let b = BalanceChangeSet { + confirmed_delta: -50, + unconfirmed_delta: 200, + ..Default::default() + }; + a.merge(b); + assert_eq!(a.confirmed_delta, 50); + assert_eq!(a.unconfirmed_delta, 200); + } + + #[test] + fn account_changeset_merge_keeps_highest_used() { + let mut a = AccountChangeSet::default(); + a.highest_used.insert(AddressPoolType::External, 5); + a.highest_used.insert(AddressPoolType::Internal, 2); + + let mut b = AccountChangeSet::default(); + b.highest_used.insert(AddressPoolType::External, 3); // lower — must not overwrite + b.highest_used.insert(AddressPoolType::Internal, 8); // higher — must win + + a.merge(b); + assert_eq!(a.highest_used.get(&AddressPoolType::External), Some(&5)); + assert_eq!(a.highest_used.get(&AddressPoolType::Internal), Some(&8)); + } + + // Dedicated dedup test removed: `AccountChangeSet::transactions` is a + // `BTreeMap`, so last-write-wins on merge is + // guaranteed by `BTreeMap::extend` — no custom dedup logic to cover. + + #[test] + fn wallet_changeset_merge_merges_per_account_buckets() { + let mut a = WalletChangeSet::default(); + a.account_bucket(bip44_account_0()).highest_used.insert(AddressPoolType::External, 5); + + let mut b = WalletChangeSet::default(); + b.account_bucket(bip44_account_0()).highest_used.insert(AddressPoolType::External, 8); + b.account_bucket(bip44_account_1()).highest_used.insert(AddressPoolType::External, 2); + + a.merge(b); + + // Same bucket merged max-wins on highest_used. + assert_eq!( + a.per_account + .get(&bip44_account_0()) + .unwrap() + .highest_used + .get(&AddressPoolType::External), + Some(&8) + ); + // Second bucket added. + assert_eq!( + a.per_account + .get(&bip44_account_1()) + .unwrap() + .highest_used + .get(&AddressPoolType::External), + Some(&2) + ); + } + + #[test] + fn chain_changeset_merge_keeps_highest_height() { + let mut a = ChainChangeSet { + synced_height: Some(200), + block_hash: None, + }; + let b = ChainChangeSet { + synced_height: Some(150), + block_hash: None, + }; + a.merge(b); + assert_eq!(a.synced_height, Some(200)); + } +} diff --git a/key-wallet/src/changeset/merge.rs b/key-wallet/src/changeset/merge.rs new file mode 100644 index 000000000..8e37ad534 --- /dev/null +++ b/key-wallet/src/changeset/merge.rs @@ -0,0 +1,189 @@ +//! Merge trait for composing incremental changesets. +//! +//! Types that implement [`Merge`] can accumulate successive deltas +//! and be inspected to see whether they carry any data. + +use std::collections::{BTreeMap, BTreeSet}; + +/// A type that can absorb another instance of itself, accumulating changes. +pub trait Merge: Default { + /// Merge `other` into `self`, consuming `other`. + fn merge(&mut self, other: Self); + + /// Returns `true` when the value is semantically empty (no changes). + fn is_empty(&self) -> bool; + + /// Take the contents out of `self` (leaving it empty/default) if it is + /// non-empty, otherwise return `None`. + fn take(&mut self) -> Option + where + Self: Sized, + { + if self.is_empty() { + None + } else { + Some(std::mem::take(self)) + } + } +} + +// --------------------------------------------------------------------------- +// Blanket / standard impls +// --------------------------------------------------------------------------- + +impl Merge for BTreeMap { + fn merge(&mut self, other: Self) { + self.extend(other); + } + + fn is_empty(&self) -> bool { + self.is_empty() + } +} + +impl Merge for BTreeSet { + fn merge(&mut self, other: Self) { + for item in other { + self.insert(item); + } + } + + fn is_empty(&self) -> bool { + self.is_empty() + } +} + +impl Merge for Option { + fn merge(&mut self, other: Self) { + match (self.as_mut(), other) { + (Some(existing), Some(other_val)) => existing.merge(other_val), + (None, Some(other_val)) => *self = Some(other_val), + _ => {} + } + } + + fn is_empty(&self) -> bool { + match self { + Some(inner) => inner.is_empty(), + None => true, + } + } +} + +impl Merge for Vec { + fn merge(&mut self, other: Self) { + self.extend(other); + } + + fn is_empty(&self) -> bool { + self.is_empty() + } +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn btreemap_merge_extends() { + let mut a: BTreeMap<&str, i32> = [("x", 1)].into_iter().collect(); + let b: BTreeMap<&str, i32> = [("y", 2), ("x", 3)].into_iter().collect(); + Merge::merge(&mut a, b); + assert_eq!(a.get("x"), Some(&3)); + assert_eq!(a.get("y"), Some(&2)); + assert_eq!(a.len(), 2); + } + + #[test] + fn btreemap_is_empty() { + let empty: BTreeMap = BTreeMap::new(); + assert!(Merge::is_empty(&empty)); + + let non_empty: BTreeMap = [("a".into(), 1)].into_iter().collect(); + assert!(!Merge::is_empty(&non_empty)); + } + + #[test] + fn btreeset_merge_unions() { + let mut a: BTreeSet = [1, 2, 3].into_iter().collect(); + let b: BTreeSet = [3, 4, 5].into_iter().collect(); + a.merge(b); + assert_eq!(a, [1, 2, 3, 4, 5].into_iter().collect()); + } + + #[test] + fn btreeset_is_empty() { + let empty: BTreeSet = BTreeSet::new(); + assert!(Merge::is_empty(&empty)); + } + + #[test] + fn vec_merge_appends() { + let mut a = vec![1, 2]; + let b = vec![3, 4]; + a.merge(b); + assert_eq!(a, vec![1, 2, 3, 4]); + } + + #[test] + fn vec_is_empty() { + let empty: Vec = vec![]; + assert!(Merge::is_empty(&empty)); + let non_empty = vec![42]; + assert!(!Merge::is_empty(&non_empty)); + } + + #[test] + fn option_merge_some_into_none() { + let mut a: Option> = None; + let b: Option> = Some(vec![1]); + a.merge(b); + assert_eq!(a, Some(vec![1])); + } + + #[test] + fn option_merge_some_into_some() { + let mut a: Option> = Some(vec![1]); + let b: Option> = Some(vec![2]); + a.merge(b); + assert_eq!(a, Some(vec![1, 2])); + } + + #[test] + fn option_merge_none_into_some() { + let mut a: Option> = Some(vec![1]); + let b: Option> = None; + a.merge(b); + assert_eq!(a, Some(vec![1])); + } + + #[test] + fn option_is_empty() { + let none: Option> = None; + assert!(Merge::is_empty(&none)); + + let some_empty: Option> = Some(vec![]); + assert!(Merge::is_empty(&some_empty)); + + let some_non_empty: Option> = Some(vec![1]); + assert!(!Merge::is_empty(&some_non_empty)); + } + + #[test] + fn take_returns_none_when_empty() { + let mut v: Vec = vec![]; + assert_eq!(v.take(), None); + } + + #[test] + fn take_returns_some_and_resets() { + let mut v = vec![1, 2, 3]; + let taken = v.take(); + assert_eq!(taken, Some(vec![1, 2, 3])); + assert!(v.is_empty()); + } +} diff --git a/key-wallet/src/changeset/mod.rs b/key-wallet/src/changeset/mod.rs new file mode 100644 index 000000000..0450f2d42 --- /dev/null +++ b/key-wallet/src/changeset/mod.rs @@ -0,0 +1,21 @@ +//! Atomic changeset types for wallet state mutations. +//! +//! Every wallet mutation produces a [`WalletChangeSet`] capturing what changed. +//! Changesets are composable via the [`Merge`] trait — multiple deltas can be +//! batched before being applied or persisted. +//! +//! This module is about **atomicity and consistency**, not persistence. +//! Persistence is a separate layer that consumes changesets. +//! +//! Changesets carry the wallet's **native types** (`Utxo`, `TransactionRecord`, +//! etc.) directly rather than flattened persistence-friendly representations. +//! Persistence backends translate from native types to their own schema. + +#[allow(clippy::module_inception)] +mod changeset; +mod merge; + +pub use changeset::{ + AccountChangeSet, AccountKeyChangeSet, BalanceChangeSet, ChainChangeSet, WalletChangeSet, +}; +pub use merge::Merge; diff --git a/key-wallet/src/dip9.rs b/key-wallet/src/dip9.rs index 276972df7..4c56dee8c 100644 --- a/key-wallet/src/dip9.rs +++ b/key-wallet/src/dip9.rs @@ -30,6 +30,12 @@ pub enum DerivationPathReference { PlatformPayment = 16, BlockchainAssetLockAddressTopupFunding = 17, BlockchainAssetLockShieldedAddressTopupFunding = 18, + /// Per-identity authentication keys using ECDSA (DIP-13, sub-feature 0', key type 0'). + /// Path prefix: m/9'/coin_type'/5'/0'/0'/identity_index' + BlockchainIdentityAuthenticationEcdsa = 19, + /// Per-identity authentication keys using BLS (DIP-13, sub-feature 0', key type 1'). + /// Path prefix: m/9'/coin_type'/5'/0'/1'/identity_index' + BlockchainIdentityAuthenticationBls = 20, Root = 255, } @@ -423,6 +429,104 @@ pub const ASSET_LOCK_SHIELDED_ADDRESS_TOPUP_PATH_TESTNET: IndexConstPath<4> = In path_type: DerivationPathType::CREDIT_FUNDING, }; +// DIP-13 Identity Authentication (ECDSA) paths — 5 hardened levels: +// m/9'/coin_type'/5'/0'/0' (key_type = 0' = ECDSA). +// The account level adds identity_index' on top (see `AccountType::derivation_path`). +pub const IDENTITY_AUTHENTICATION_ECDSA_PATH_MAINNET: IndexConstPath<5> = IndexConstPath { + indexes: [ + ChildNumber::Hardened { + index: FEATURE_PURPOSE, + }, + ChildNumber::Hardened { + index: DASH_COIN_TYPE, + }, + ChildNumber::Hardened { + index: FEATURE_PURPOSE_IDENTITIES, + }, + ChildNumber::Hardened { + index: FEATURE_PURPOSE_IDENTITIES_SUBFEATURE_AUTHENTICATION, + }, + ChildNumber::Hardened { + // key_type = 0' for ECDSA + index: 0, + }, + ], + reference: DerivationPathReference::BlockchainIdentityAuthenticationEcdsa, + path_type: DerivationPathType::SINGLE_USER_AUTHENTICATION, +}; + +pub const IDENTITY_AUTHENTICATION_ECDSA_PATH_TESTNET: IndexConstPath<5> = IndexConstPath { + indexes: [ + ChildNumber::Hardened { + index: FEATURE_PURPOSE, + }, + ChildNumber::Hardened { + index: DASH_TESTNET_COIN_TYPE, + }, + ChildNumber::Hardened { + index: FEATURE_PURPOSE_IDENTITIES, + }, + ChildNumber::Hardened { + index: FEATURE_PURPOSE_IDENTITIES_SUBFEATURE_AUTHENTICATION, + }, + ChildNumber::Hardened { + // key_type = 0' for ECDSA + index: 0, + }, + ], + reference: DerivationPathReference::BlockchainIdentityAuthenticationEcdsa, + path_type: DerivationPathType::SINGLE_USER_AUTHENTICATION, +}; + +// DIP-13 Identity Authentication (BLS) paths — 5 hardened levels: +// m/9'/coin_type'/5'/0'/1' (key_type = 1' = BLS). +// The account level adds identity_index' on top (see `AccountType::derivation_path`). +pub const IDENTITY_AUTHENTICATION_BLS_PATH_MAINNET: IndexConstPath<5> = IndexConstPath { + indexes: [ + ChildNumber::Hardened { + index: FEATURE_PURPOSE, + }, + ChildNumber::Hardened { + index: DASH_COIN_TYPE, + }, + ChildNumber::Hardened { + index: FEATURE_PURPOSE_IDENTITIES, + }, + ChildNumber::Hardened { + index: FEATURE_PURPOSE_IDENTITIES_SUBFEATURE_AUTHENTICATION, + }, + ChildNumber::Hardened { + // key_type = 1' for BLS + index: 1, + }, + ], + reference: DerivationPathReference::BlockchainIdentityAuthenticationBls, + path_type: DerivationPathType::SINGLE_USER_AUTHENTICATION, +}; + +pub const IDENTITY_AUTHENTICATION_BLS_PATH_TESTNET: IndexConstPath<5> = IndexConstPath { + indexes: [ + ChildNumber::Hardened { + index: FEATURE_PURPOSE, + }, + ChildNumber::Hardened { + index: DASH_TESTNET_COIN_TYPE, + }, + ChildNumber::Hardened { + index: FEATURE_PURPOSE_IDENTITIES, + }, + ChildNumber::Hardened { + index: FEATURE_PURPOSE_IDENTITIES_SUBFEATURE_AUTHENTICATION, + }, + ChildNumber::Hardened { + // key_type = 1' for BLS + index: 1, + }, + ], + reference: DerivationPathReference::BlockchainIdentityAuthenticationBls, + path_type: DerivationPathType::SINGLE_USER_AUTHENTICATION, +}; + // Authentication Keys Paths pub const IDENTITY_AUTHENTICATION_PATH_MAINNET: IndexConstPath<4> = IndexConstPath { indexes: [ diff --git a/key-wallet/src/lib.rs b/key-wallet/src/lib.rs index 4b5e89e15..ca7563420 100644 --- a/key-wallet/src/lib.rs +++ b/key-wallet/src/lib.rs @@ -26,6 +26,7 @@ pub mod account; pub mod bip32; #[cfg(feature = "bip38")] pub mod bip38; +pub mod changeset; pub mod derivation; #[cfg(feature = "bls")] pub mod derivation_bls_bip32; diff --git a/key-wallet/src/managed_account/address_pool.rs b/key-wallet/src/managed_account/address_pool.rs index 36fd1edf6..c18dc5cd7 100644 --- a/key-wallet/src/managed_account/address_pool.rs +++ b/key-wallet/src/managed_account/address_pool.rs @@ -31,7 +31,7 @@ pub enum PublicKeyType { } /// Type of address pool (external, internal, or absent/single-pool) -#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] #[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] #[cfg_attr(feature = "bincode", derive(Encode, Decode))] pub enum AddressPoolType { @@ -45,6 +45,33 @@ pub enum AddressPoolType { AbsentHardened, } +impl AddressPoolType { + /// Stable u8 discriminant for persistent storage. Unit-enum + /// discriminants are append-only (never reorder the match arms) + /// so existing DB rows keep their meaning across version bumps. + pub fn db_discriminant(&self) -> u8 { + match self { + Self::External => 0, + Self::Internal => 1, + Self::Absent => 2, + Self::AbsentHardened => 3, + } + } + + /// Decode an `AddressPoolType` from its `db_discriminant` form. + /// Returns `None` for unrecognized discriminants (e.g. a row + /// written by a newer crate version that added a new variant). + pub fn from_db_discriminant(d: u8) -> Option { + match d { + 0 => Some(Self::External), + 1 => Some(Self::Internal), + 2 => Some(Self::Absent), + 3 => Some(Self::AbsentHardened), + _ => None, + } + } +} + #[cfg(feature = "serde")] impl Serialize for PublicKeyType { fn serialize(&self, serializer: S) -> core::result::Result @@ -307,7 +334,7 @@ impl AddressInfo { fn mark_used(&mut self) { if !self.used { self.used = true; - self.used_at = Some(0); // Should use actual timestamp + self.used_at = Some(crate::managed_account::ManagedCoreAccount::current_timestamp()); } } @@ -361,7 +388,7 @@ impl AddressPool { // Generate addresses up to the gap limit if we have a key source if !matches!(key_source, KeySource::NoKeySource) { - pool.generate_addresses(gap_limit, key_source, true)?; + pool.generate_addresses(gap_limit, key_source)?; } Ok(pool) @@ -409,26 +436,27 @@ impl AddressPool { &mut self, count: u32, key_source: &KeySource, - add_to_state: bool, ) -> Result> { let mut new_addresses = Vec::new(); let start_index = self.highest_generated.map(|h| h + 1).unwrap_or(0); let end_index = start_index + count; for index in start_index..end_index { - let address = self.generate_address_at_index(index, key_source, add_to_state)?; + let address = self.generate_address_at_index(index, key_source)?; new_addresses.push(address); } Ok(new_addresses) } - /// Generate a specific address at an index + /// Generate a specific address at an index. Always persists the + /// derived address and its indices into the pool state; there is no + /// peek variant. If a future caller needs a read-only derivation, + /// add one then. pub(crate) fn generate_address_at_index( &mut self, index: u32, key_source: &KeySource, - add_to_state: bool, ) -> Result
{ // Check if already generated if let Some(info) = self.addresses.get(&index) { @@ -504,22 +532,21 @@ impl AddressPool { let info = AddressInfo::new_with_public_key(address.clone(), index, full_path, public_key_type); let script_pubkey = info.script_pubkey.clone(); - if add_to_state { - self.addresses.insert(index, info); - self.address_index.insert(address.clone(), index); - self.script_pubkey_index.insert(script_pubkey, index); - - // Update highest generated - if self.highest_generated.map(|h| index > h).unwrap_or(true) { - self.highest_generated = Some(index); - } + self.addresses.insert(index, info); + self.address_index.insert(address.clone(), index); + self.script_pubkey_index.insert(script_pubkey, index); + + // Update highest generated + if self.highest_generated.map(|h| index > h).unwrap_or(true) { + self.highest_generated = Some(index); } Ok(address) } - /// Get the next unused address - pub fn next_unused(&mut self, key_source: &KeySource, add_to_state: bool) -> Result
{ + /// Get the next unused address, generating and persisting a new one + /// if none of the currently-generated addresses are unused. + pub fn next_unused(&mut self, key_source: &KeySource) -> Result
{ // First, try to find an already generated unused address for i in 0..=self.highest_generated.unwrap_or(0) { if let Some(info) = self.addresses.get(&i) { @@ -536,15 +563,12 @@ impl AddressPool { // Generate a new address let next_index = self.highest_generated.map(|h| h + 1).unwrap_or(0); - self.generate_address_at_index(next_index, key_source, add_to_state) + self.generate_address_at_index(next_index, key_source) } - /// Get the next unused address info - pub fn next_unused_with_info( - &mut self, - key_source: &KeySource, - add_to_state: bool, - ) -> Result { + /// Get the next unused address info, generating and persisting a new + /// one if none of the currently-generated addresses are unused. + pub fn next_unused_with_info(&mut self, key_source: &KeySource) -> Result { // First, try to find an already generated unused address for i in 0..=self.highest_generated.unwrap_or(0) { if let Some(info) = self.addresses.get(&i) { @@ -561,7 +585,7 @@ impl AddressPool { // Generate a new address let next_index = self.highest_generated.map(|h| h + 1).unwrap_or(0); - self.generate_address_at_index(next_index, key_source, add_to_state)?; + self.generate_address_at_index(next_index, key_source)?; // Return the AddressInfo we just created self.addresses.get(&next_index).cloned().ok_or_else(|| { @@ -569,17 +593,12 @@ impl AddressPool { }) } - /// Get multiple unused addresses at once + /// Get multiple unused addresses at once. /// - /// Returns the requested number of unused addresses, generating new ones if needed. - /// This is more efficient than calling `next_unused` multiple times as it minimizes - /// the search through existing addresses. - pub fn next_unused_multiple( - &mut self, - count: usize, - key_source: &KeySource, - add_to_state: bool, - ) -> Vec
{ + /// Returns the requested number of unused addresses, generating new + /// ones if needed. More efficient than calling `next_unused` multiple + /// times because it minimises the search through existing addresses. + pub fn next_unused_multiple(&mut self, count: usize, key_source: &KeySource) -> Vec
{ let mut addresses = Vec::with_capacity(count); // First, collect existing unused addresses @@ -612,8 +631,7 @@ impl AddressPool { let start_index = self.highest_generated.map(|h| h + 1).unwrap_or(0); for i in 0..remaining { - if let Ok(address) = - self.generate_address_at_index(start_index + i as u32, key_source, add_to_state) + if let Ok(address) = self.generate_address_at_index(start_index + i as u32, key_source) { addresses.push(address); } else { @@ -625,16 +643,15 @@ impl AddressPool { addresses } - /// Get multiple unused addresses with their info at once + /// Get multiple unused addresses with their info at once. /// - /// Returns the requested number of unused addresses with their full information, - /// generating new ones if needed. This is more efficient than calling - /// `next_unused_with_info` multiple times. + /// Returns the requested number of unused addresses with their full + /// information, generating new ones if needed. More efficient than + /// calling `next_unused_with_info` multiple times. pub fn next_unused_multiple_with_info( &mut self, count: usize, key_source: &KeySource, - add_to_state: bool, ) -> Vec<(Address, AddressInfo)> { let mut result = Vec::with_capacity(count); @@ -669,7 +686,7 @@ impl AddressPool { for i in 0..remaining { let index = start_index + i as u32; - if self.generate_address_at_index(index, key_source, add_to_state).is_ok() { + if self.generate_address_at_index(index, key_source).is_ok() { if let Some(info) = self.addresses.get(&index) { result.push((info.address.clone(), info.clone())); } @@ -706,7 +723,7 @@ impl AddressPool { // Generate more if needed while unused.len() < count as usize { let next_index = self.highest_generated.map(|h| h + 1).unwrap_or(0); - let address = self.generate_address_at_index(next_index, key_source, true)?; + let address = self.generate_address_at_index(next_index, key_source)?; unused.push(address); } @@ -753,6 +770,35 @@ impl AddressPool { false } + /// Set the highest-used index for this pool. + /// + /// Idempotent. Used by `apply(changeset)` during wallet restore to + /// replay `highest_used` without re-triggering address generation. + /// Updates `highest_used`, inserts `index` into `used_indices`, and + /// marks the address at `index` as used if it exists in the pool. + /// No-op if the index is already at or below the current + /// `highest_used` and already in `used_indices`. + pub fn set_highest_used(&mut self, index: u32) -> bool { + let already_at_or_above = matches!(self.highest_used, Some(current) if current >= index); + if already_at_or_above && self.used_indices.contains(&index) { + return false; + } + + self.used_indices.insert(index); + self.highest_used = match self.highest_used { + None => Some(index), + Some(current) => Some(current.max(index)), + }; + + if let Some(info) = self.addresses.get_mut(&index) { + if !info.used { + info.mark_used(); + } + } + + true + } + /// Scan addresses for usage using a check function pub fn scan_for_usage(&mut self, check_fn: F) -> Vec
where @@ -855,7 +901,7 @@ impl AddressPool { if end_index > current_highest + 1 { // Generate from current_highest + 1 to end_index - 1 for index in (current_highest + 1)..end_index { - self.generate_address_at_index(index, key_source, true)?; + self.generate_address_at_index(index, key_source)?; } } @@ -887,7 +933,7 @@ impl AddressPool { let mut new_addresses = Vec::new(); while self.highest_generated.unwrap_or(0) < target { let next_index = self.highest_generated.map(|h| h + 1).unwrap_or(0); - let address = self.generate_address_at_index(next_index, key_source, true)?; + let address = self.generate_address_at_index(next_index, key_source)?; new_addresses.push(address); } @@ -1097,7 +1143,7 @@ impl AddressPoolBuilder { // Generate addresses if a key source was provided if let Some(key_source) = self.key_source { if !matches!(key_source, KeySource::NoKeySource) { - pool.generate_addresses(self.gap_limit, &key_source, true)?; + pool.generate_addresses(self.gap_limit, &key_source)?; } } @@ -1146,7 +1192,7 @@ mod tests { ); let key_source = test_key_source(); - let addresses = pool.generate_addresses(10, &key_source, true).unwrap(); + let addresses = pool.generate_addresses(10, &key_source).unwrap(); assert_eq!(addresses.len(), 10); assert_eq!(pool.highest_generated, Some(9)); assert_eq!(pool.addresses.len(), 10); @@ -1163,7 +1209,7 @@ mod tests { ); let key_source = test_key_source(); - let addresses = pool.generate_addresses(5, &key_source, true).unwrap(); + let addresses = pool.generate_addresses(5, &key_source).unwrap(); let first_addr = &addresses[0]; assert!(pool.mark_used(first_addr)); @@ -1186,12 +1232,12 @@ mod tests { ); let key_source = test_key_source(); - let addr1 = pool.next_unused(&key_source, true).unwrap(); - let addr2 = pool.next_unused(&key_source, true).unwrap(); + let addr1 = pool.next_unused(&key_source).unwrap(); + let addr2 = pool.next_unused(&key_source).unwrap(); assert_eq!(addr1, addr2); // Should return same unused address pool.mark_used(&addr1); - let addr3 = pool.next_unused(&key_source, true).unwrap(); + let addr3 = pool.next_unused(&key_source).unwrap(); assert_ne!(addr1, addr3); // Should return different address after marking used } @@ -1270,7 +1316,7 @@ mod tests { ); let key_source = test_key_source(); - let addresses = pool.generate_addresses(10, &key_source, true).unwrap(); + let addresses = pool.generate_addresses(10, &key_source).unwrap(); // Simulate checking for usage - mark addresses at indices 2, 5, 7 as used let check_fn = |addr: &Address| { @@ -1282,4 +1328,97 @@ mod tests { assert_eq!(pool.used_indices.len(), 3); assert_eq!(pool.highest_used, Some(7)); } + + #[test] + fn test_mark_used_sets_real_timestamp() { + // Regression test: `AddressInfo::mark_used()` used to hardcode + // `used_at = Some(0)` which made timestamps useless. Verify the fix + // captures a real unix timestamp. + let base_path = DerivationPath::from(vec![ChildNumber::from_normal_idx(0).unwrap()]); + let mut pool = AddressPool::new_without_generation( + base_path, + AddressPoolType::External, + 5, + Network::Testnet, + ); + let key_source = test_key_source(); + + let addresses = pool.generate_addresses(5, &key_source).unwrap(); + assert!(pool.mark_used(&addresses[0])); + + let info = pool.addresses.get(&0).unwrap(); + let ts = info.used_at.expect("used_at should be set after mark_used"); + assert!(ts > 0, "used_at should be a real timestamp, not 0"); + // Sanity: timestamp should be after year 2020 (1577836800 = 2020-01-01). + assert!(ts > 1_577_836_800, "timestamp should look like a real unix second"); + } + + #[test] + fn test_set_highest_used_idempotent() { + let base_path = DerivationPath::from(vec![ChildNumber::from_normal_idx(0).unwrap()]); + let mut pool = AddressPool::new_without_generation( + base_path, + AddressPoolType::External, + 10, + Network::Testnet, + ); + let key_source = test_key_source(); + let _ = pool.generate_addresses(10, &key_source).unwrap(); + + // First call marks index 5 — returns true (changed). + assert!(pool.set_highest_used(5)); + assert_eq!(pool.highest_used, Some(5)); + assert!(pool.used_indices.contains(&5)); + assert!(pool.addresses.get(&5).unwrap().used); + + // Second call with same index is a no-op — returns false. + assert!(!pool.set_highest_used(5)); + assert_eq!(pool.highest_used, Some(5)); + } + + #[test] + fn test_set_highest_used_keeps_max() { + let base_path = DerivationPath::from(vec![ChildNumber::from_normal_idx(0).unwrap()]); + let mut pool = AddressPool::new_without_generation( + base_path, + AddressPoolType::External, + 10, + Network::Testnet, + ); + let key_source = test_key_source(); + let _ = pool.generate_addresses(10, &key_source).unwrap(); + + // Set to 7 first. + assert!(pool.set_highest_used(7)); + assert_eq!(pool.highest_used, Some(7)); + + // A lower index still records as used, but highest_used stays at 7. + assert!(pool.set_highest_used(3)); + assert_eq!(pool.highest_used, Some(7)); + assert!(pool.used_indices.contains(&3)); + assert!(pool.used_indices.contains(&7)); + + // A higher index bumps highest_used. + assert!(pool.set_highest_used(9)); + assert_eq!(pool.highest_used, Some(9)); + } + + #[test] + fn test_set_last_revealed_without_generated_address() { + // Even if the index isn't in the generated addresses map yet, we + // still track it in highest_used / used_indices for restore. + let base_path = DerivationPath::from(vec![ChildNumber::from_normal_idx(0).unwrap()]); + let mut pool = AddressPool::new_without_generation( + base_path, + AddressPoolType::External, + 10, + Network::Testnet, + ); + // Don't generate — pool.addresses is empty. + + assert!(pool.set_highest_used(3)); + assert_eq!(pool.highest_used, Some(3)); + assert!(pool.used_indices.contains(&3)); + assert!(!pool.addresses.contains_key(&3)); + } } diff --git a/key-wallet/src/managed_account/managed_account_collection.rs b/key-wallet/src/managed_account/managed_account_collection.rs index 10f5adcb4..badd4a55e 100644 --- a/key-wallet/src/managed_account/managed_account_collection.rs +++ b/key-wallet/src/managed_account/managed_account_collection.rs @@ -22,7 +22,7 @@ use crate::{KeySource, Network}; use serde::{Deserialize, Serialize}; /// Macro to look up an account by CoreAccountTypeMatch, parameterized by accessor methods -macro_rules! get_by_account_type_match_impl { +macro_rules! get_by_account_type_matching_impl { ($self:expr, $match:expr, $get:ident, $as_opt:ident, $values:ident) => { match $match { CoreAccountTypeMatch::StandardBIP44 { @@ -126,6 +126,16 @@ pub struct ManagedAccountCollection { pub identity_topup_not_bound: Option, /// Identity invitation account (optional) pub identity_invitation: Option, + /// Per-identity ECDSA authentication accounts (DIP-13), keyed by + /// `identity_index`. Platform-only — carries no L1 UTXOs. + pub identity_authentication_ecdsa: BTreeMap, + /// Per-identity BLS authentication accounts (DIP-13), keyed by + /// `identity_index`. Platform-only. + /// + /// Storage type is `ManagedCoreAccount`, which is not BLS-dependent, so the + /// field is available regardless of the `bls` feature — mirroring how + /// `provider_operator_keys` is handled at the managed-account level. + pub identity_authentication_bls: BTreeMap, /// Asset lock address top-up account (optional) pub asset_lock_address_topup: Option, /// Asset lock shielded address top-up account (optional) @@ -158,6 +168,8 @@ impl ManagedAccountCollection { identity_topup: BTreeMap::new(), identity_topup_not_bound: None, identity_invitation: None, + identity_authentication_ecdsa: BTreeMap::new(), + identity_authentication_bls: BTreeMap::new(), asset_lock_address_topup: None, asset_lock_shielded_address_topup: None, provider_voting_keys: None, @@ -204,6 +216,14 @@ impl ManagedAccountCollection { ManagedAccountType::IdentityInvitation { .. } => self.identity_invitation.is_some(), + ManagedAccountType::IdentityAuthenticationEcdsa { + identity_index, + .. + } => self.identity_authentication_ecdsa.contains_key(identity_index), + ManagedAccountType::IdentityAuthenticationBls { + identity_index, + .. + } => self.identity_authentication_bls.contains_key(identity_index), ManagedAccountType::AssetLockAddressTopUp { .. } => self.asset_lock_address_topup.is_some(), @@ -309,6 +329,18 @@ impl ManagedAccountCollection { } => { self.identity_invitation = Some(account); } + ManagedAccountType::IdentityAuthenticationEcdsa { + identity_index, + .. + } => { + self.identity_authentication_ecdsa.insert(*identity_index, account); + } + ManagedAccountType::IdentityAuthenticationBls { + identity_index, + .. + } => { + self.identity_authentication_bls.insert(*identity_index, account); + } ManagedAccountType::AssetLockAddressTopUp { .. } => { @@ -438,6 +470,21 @@ impl ManagedAccountCollection { } } + // Convert per-identity ECDSA authentication accounts + for (index, account) in &account_collection.identity_authentication_ecdsa { + if let Ok(managed_account) = Self::create_managed_account_from_account(account) { + managed_collection.identity_authentication_ecdsa.insert(*index, managed_account); + } + } + + // Convert per-identity BLS authentication accounts + #[cfg(feature = "bls")] + for (index, account) in &account_collection.identity_authentication_bls { + if let Ok(managed_account) = Self::create_managed_account_from_bls_account(account) { + managed_collection.identity_authentication_bls.insert(*index, managed_account); + } + } + if let Some(account) = &account_collection.asset_lock_address_topup { if let Ok(managed_account) = Self::create_managed_account_from_account(account) { managed_collection.asset_lock_address_topup = Some(managed_account); @@ -665,6 +712,38 @@ impl ManagedAccountCollection { addresses, } } + AccountType::IdentityAuthenticationEcdsa { + identity_index, + } => { + // DIP-13: hardened leaves per the spec. + let addresses = AddressPool::new( + base_path, + AddressPoolType::AbsentHardened, + DEFAULT_SPECIAL_GAP_LIMIT, + network, + key_source, + )?; + ManagedAccountType::IdentityAuthenticationEcdsa { + identity_index, + addresses, + } + } + AccountType::IdentityAuthenticationBls { + identity_index, + } => { + // DIP-13: hardened leaves per the spec. + let addresses = AddressPool::new( + base_path, + AddressPoolType::AbsentHardened, + DEFAULT_SPECIAL_GAP_LIMIT, + network, + key_source, + )?; + ManagedAccountType::IdentityAuthenticationBls { + identity_index, + addresses, + } + } AccountType::AssetLockAddressTopUp => { let addresses = AddressPool::new( base_path, @@ -868,20 +947,226 @@ impl ManagedAccountCollection { None } - /// Get an account reference by CoreAccountTypeMatch - pub fn get_by_account_type_match( + /// Get an account reference by pattern-matching against a + /// [`CoreAccountTypeMatch`]. Used by the transaction checker to route + /// account_match results. + pub fn get_by_account_type_matching( &self, account_type_match: &CoreAccountTypeMatch, ) -> Option<&ManagedCoreAccount> { - get_by_account_type_match_impl!(self, account_type_match, get, as_ref, values) + get_by_account_type_matching_impl!(self, account_type_match, get, as_ref, values) } - /// Get a mutable account reference by AccountTypeMatch - pub fn get_by_account_type_match_mut( + /// Get a mutable account reference by pattern-matching against a + /// [`CoreAccountTypeMatch`]. + pub fn get_by_account_type_matching_mut( &mut self, account_type_match: &CoreAccountTypeMatch, ) -> Option<&mut ManagedCoreAccount> { - get_by_account_type_match_impl!(self, account_type_match, get_mut, as_mut, values_mut) + get_by_account_type_matching_impl!(self, account_type_match, get_mut, as_mut, values_mut) + } + + /// Check whether an account of the given [`AccountType`] exists + /// anywhere in this collection, including the separate + /// [`ManagedPlatformAccount`] storage for + /// [`AccountType::PlatformPayment`]. + /// + /// Used by `apply_changeset` to decide whether re-deriving an HD + /// account would be idempotent (already present) or would fail via + /// the "already exists" check inside `add_managed_account`. + pub fn contains_account_type(&self, account_type: AccountType) -> bool { + use crate::account::StandardAccountType; + match account_type { + AccountType::Standard { + index, + standard_account_type: StandardAccountType::BIP44Account, + } => self.standard_bip44_accounts.contains_key(&index), + AccountType::Standard { + index, + standard_account_type: StandardAccountType::BIP32Account, + } => self.standard_bip32_accounts.contains_key(&index), + AccountType::CoinJoin { + index, + } => self.coinjoin_accounts.contains_key(&index), + AccountType::IdentityRegistration => self.identity_registration.is_some(), + AccountType::IdentityTopUp { + registration_index, + } => self.identity_topup.contains_key(®istration_index), + AccountType::IdentityTopUpNotBoundToIdentity => self.identity_topup_not_bound.is_some(), + AccountType::IdentityInvitation => self.identity_invitation.is_some(), + AccountType::AssetLockAddressTopUp => self.asset_lock_address_topup.is_some(), + AccountType::AssetLockShieldedAddressTopUp => { + self.asset_lock_shielded_address_topup.is_some() + } + AccountType::ProviderVotingKeys => self.provider_voting_keys.is_some(), + AccountType::ProviderOwnerKeys => self.provider_owner_keys.is_some(), + AccountType::ProviderOperatorKeys => self.provider_operator_keys.is_some(), + AccountType::ProviderPlatformKeys => self.provider_platform_keys.is_some(), + AccountType::DashpayReceivingFunds { + index, + user_identity_id, + friend_identity_id, + } => self.dashpay_receival_accounts.contains_key(&DashpayAccountKey { + index, + user_identity_id, + friend_identity_id, + }), + AccountType::DashpayExternalAccount { + index, + user_identity_id, + friend_identity_id, + } => self.dashpay_external_accounts.contains_key(&DashpayAccountKey { + index, + user_identity_id, + friend_identity_id, + }), + AccountType::PlatformPayment { + account, + key_class, + } => self.platform_payment_accounts.contains_key(&PlatformPaymentAccountKey { + account, + key_class, + }), + AccountType::IdentityAuthenticationEcdsa { + identity_index, + } => self.identity_authentication_ecdsa.contains_key(&identity_index), + AccountType::IdentityAuthenticationBls { + identity_index, + } => self.identity_authentication_bls.contains_key(&identity_index), + } + } + + /// Get an immutable account reference by exact [`AccountType`]. + /// + /// Companion to [`Self::get_by_account_type_mut`]. Returns `None` + /// for [`AccountType::PlatformPayment`] because those accounts live + /// in the separate `platform_payment_accounts` map with a different + /// managed type; use [`Self::contains_account_type`] for a uniform + /// existence check. + pub fn get_by_account_type(&self, account_type: AccountType) -> Option<&ManagedCoreAccount> { + use crate::account::StandardAccountType; + match account_type { + AccountType::Standard { + index, + standard_account_type: StandardAccountType::BIP44Account, + } => self.standard_bip44_accounts.get(&index), + AccountType::Standard { + index, + standard_account_type: StandardAccountType::BIP32Account, + } => self.standard_bip32_accounts.get(&index), + AccountType::CoinJoin { + index, + } => self.coinjoin_accounts.get(&index), + AccountType::IdentityRegistration => self.identity_registration.as_ref(), + AccountType::IdentityTopUp { + registration_index, + } => self.identity_topup.get(®istration_index), + AccountType::IdentityTopUpNotBoundToIdentity => self.identity_topup_not_bound.as_ref(), + AccountType::IdentityInvitation => self.identity_invitation.as_ref(), + AccountType::AssetLockAddressTopUp => self.asset_lock_address_topup.as_ref(), + AccountType::AssetLockShieldedAddressTopUp => { + self.asset_lock_shielded_address_topup.as_ref() + } + AccountType::ProviderVotingKeys => self.provider_voting_keys.as_ref(), + AccountType::ProviderOwnerKeys => self.provider_owner_keys.as_ref(), + AccountType::ProviderOperatorKeys => self.provider_operator_keys.as_ref(), + AccountType::ProviderPlatformKeys => self.provider_platform_keys.as_ref(), + AccountType::DashpayReceivingFunds { + index, + user_identity_id, + friend_identity_id, + } => self.dashpay_receival_accounts.get(&DashpayAccountKey { + index, + user_identity_id, + friend_identity_id, + }), + AccountType::DashpayExternalAccount { + index, + user_identity_id, + friend_identity_id, + } => self.dashpay_external_accounts.get(&DashpayAccountKey { + index, + user_identity_id, + friend_identity_id, + }), + AccountType::PlatformPayment { + .. + } => None, + AccountType::IdentityAuthenticationEcdsa { + identity_index, + } => self.identity_authentication_ecdsa.get(&identity_index), + AccountType::IdentityAuthenticationBls { + identity_index, + } => self.identity_authentication_bls.get(&identity_index), + } + } + + /// Get a mutable account reference by exact [`AccountType`]. + /// + /// Used by `apply_changeset` to route per-account buckets from a + /// [`crate::changeset::WalletChangeSet`] directly to the owning + /// managed account without address-based scanning. Returns `None` + /// for [`AccountType::PlatformPayment`] — see + /// [`Self::get_by_account_type`] and [`Self::contains_account_type`]. + pub fn get_by_account_type_mut( + &mut self, + account_type: AccountType, + ) -> Option<&mut ManagedCoreAccount> { + use crate::account::StandardAccountType; + match account_type { + AccountType::Standard { + index, + standard_account_type: StandardAccountType::BIP44Account, + } => self.standard_bip44_accounts.get_mut(&index), + AccountType::Standard { + index, + standard_account_type: StandardAccountType::BIP32Account, + } => self.standard_bip32_accounts.get_mut(&index), + AccountType::CoinJoin { + index, + } => self.coinjoin_accounts.get_mut(&index), + AccountType::IdentityRegistration => self.identity_registration.as_mut(), + AccountType::IdentityTopUp { + registration_index, + } => self.identity_topup.get_mut(®istration_index), + AccountType::IdentityTopUpNotBoundToIdentity => self.identity_topup_not_bound.as_mut(), + AccountType::IdentityInvitation => self.identity_invitation.as_mut(), + AccountType::AssetLockAddressTopUp => self.asset_lock_address_topup.as_mut(), + AccountType::AssetLockShieldedAddressTopUp => { + self.asset_lock_shielded_address_topup.as_mut() + } + AccountType::ProviderVotingKeys => self.provider_voting_keys.as_mut(), + AccountType::ProviderOwnerKeys => self.provider_owner_keys.as_mut(), + AccountType::ProviderOperatorKeys => self.provider_operator_keys.as_mut(), + AccountType::ProviderPlatformKeys => self.provider_platform_keys.as_mut(), + AccountType::DashpayReceivingFunds { + index, + user_identity_id, + friend_identity_id, + } => self.dashpay_receival_accounts.get_mut(&DashpayAccountKey { + index, + user_identity_id, + friend_identity_id, + }), + AccountType::DashpayExternalAccount { + index, + user_identity_id, + friend_identity_id, + } => self.dashpay_external_accounts.get_mut(&DashpayAccountKey { + index, + user_identity_id, + friend_identity_id, + }), + AccountType::PlatformPayment { + .. + } => None, + AccountType::IdentityAuthenticationEcdsa { + identity_index, + } => self.identity_authentication_ecdsa.get_mut(&identity_index), + AccountType::IdentityAuthenticationBls { + identity_index, + } => self.identity_authentication_bls.get_mut(&identity_index), + } } /// Remove an account from the collection @@ -962,6 +1247,10 @@ impl ManagedAccountCollection { accounts.push(account); } + accounts.extend(self.identity_authentication_ecdsa.values()); + + accounts.extend(self.identity_authentication_bls.values()); + if let Some(account) = &self.asset_lock_address_topup { accounts.push(account); } @@ -1021,6 +1310,10 @@ impl ManagedAccountCollection { accounts.push(account); } + accounts.extend(self.identity_authentication_ecdsa.values_mut()); + + accounts.extend(self.identity_authentication_bls.values_mut()); + if let Some(account) = &mut self.asset_lock_address_topup { accounts.push(account); } @@ -1085,6 +1378,8 @@ impl ManagedAccountCollection { && self.identity_topup.is_empty() && self.identity_topup_not_bound.is_none() && self.identity_invitation.is_none() + && self.identity_authentication_ecdsa.is_empty() + && self.identity_authentication_bls.is_empty() && self.asset_lock_address_topup.is_none() && self.asset_lock_shielded_address_topup.is_none() && self.provider_voting_keys.is_none() @@ -1105,6 +1400,8 @@ impl ManagedAccountCollection { self.identity_topup.clear(); self.identity_topup_not_bound = None; self.identity_invitation = None; + self.identity_authentication_ecdsa.clear(); + self.identity_authentication_bls.clear(); self.asset_lock_address_topup = None; self.asset_lock_shielded_address_topup = None; self.provider_voting_keys = None; diff --git a/key-wallet/src/managed_account/managed_account_type.rs b/key-wallet/src/managed_account/managed_account_type.rs index d86c6812f..bb492585d 100644 --- a/key-wallet/src/managed_account/managed_account_type.rs +++ b/key-wallet/src/managed_account/managed_account_type.rs @@ -58,6 +58,27 @@ pub enum ManagedAccountType { /// Identity invitation address pool addresses: AddressPool, }, + /// Per-identity ECDSA authentication keys (DIP-13). + /// Path: `m/9'/coin_type'/5'/0'/0'/identity_index'`. Platform-only. + IdentityAuthenticationEcdsa { + /// Which identity in this wallet these keys belong to (hardened). + identity_index: u32, + /// Address pool for sequential key_index'. + addresses: AddressPool, + }, + /// Per-identity BLS authentication keys (DIP-13). + /// Path: `m/9'/coin_type'/5'/0'/1'/identity_index'`. Platform-only. + /// + /// The variant is always present regardless of the `bls` feature — only + /// `BLSAccount`-typed storage is cfg-gated. This keeps downstream pattern + /// matches on `ManagedAccountType` exhaustive in both feature + /// configurations. + IdentityAuthenticationBls { + /// Which identity in this wallet these keys belong to (hardened). + identity_index: u32, + /// Address pool for sequential key_index'. + addresses: AddressPool, + }, /// Asset lock address top-up funding (subfeature 4) /// Path: m/9'/coinType'/5'/4'/index' AssetLockAddressTopUp { @@ -155,6 +176,9 @@ impl ManagedAccountType { | Self::IdentityInvitation { .. } + | Self::IdentityAuthenticationEcdsa { + .. + } | Self::AssetLockAddressTopUp { .. } @@ -185,6 +209,9 @@ impl ManagedAccountType { account, .. } => Some(*account), + Self::IdentityAuthenticationBls { + .. + } => None, } } @@ -234,6 +261,10 @@ impl ManagedAccountType { addresses, .. } + | Self::IdentityAuthenticationEcdsa { + addresses, + .. + } | Self::AssetLockAddressTopUp { addresses, .. @@ -271,6 +302,10 @@ impl ManagedAccountType { | Self::PlatformPayment { addresses, .. + } + | Self::IdentityAuthenticationBls { + addresses, + .. } => vec![addresses], } } @@ -305,6 +340,10 @@ impl ManagedAccountType { addresses, .. } + | Self::IdentityAuthenticationEcdsa { + addresses, + .. + } | Self::AssetLockAddressTopUp { addresses, .. @@ -342,6 +381,10 @@ impl ManagedAccountType { | Self::PlatformPayment { addresses, .. + } + | Self::IdentityAuthenticationBls { + addresses, + .. } => vec![addresses], } } @@ -426,6 +469,18 @@ impl ManagedAccountType { Self::IdentityInvitation { .. } => AccountType::IdentityInvitation, + Self::IdentityAuthenticationEcdsa { + identity_index, + .. + } => AccountType::IdentityAuthenticationEcdsa { + identity_index: *identity_index, + }, + Self::IdentityAuthenticationBls { + identity_index, + .. + } => AccountType::IdentityAuthenticationBls { + identity_index: *identity_index, + }, Self::AssetLockAddressTopUp { .. } => AccountType::AssetLockAddressTopUp, @@ -607,6 +662,52 @@ impl ManagedAccountType { addresses: pool, }) } + AccountType::IdentityAuthenticationEcdsa { + identity_index, + } => { + // Identity authentication keys are signing/authentication + // material; falling back to the master path on a derivation + // error would produce a subtly-wrong pool at `m/`, so we + // propagate the error instead. + let path = account_type.derivation_path(network)?; + // Hardened leaves per DIP-13 — addresses must be generated from + // a private-key-bearing source (watch-only/no-key setups will + // create an empty pool that can still track derivation paths). + let pool = AddressPool::new( + path, + AddressPoolType::AbsentHardened, + DEFAULT_SPECIAL_GAP_LIMIT, + network, + key_source, + )?; + + Ok(Self::IdentityAuthenticationEcdsa { + identity_index, + addresses: pool, + }) + } + AccountType::IdentityAuthenticationBls { + identity_index, + } => { + // Identity authentication keys are signing/authentication + // material; falling back to the master path on a derivation + // error would produce a subtly-wrong pool at `m/`, so we + // propagate the error instead. + let path = account_type.derivation_path(network)?; + // Hardened leaves per DIP-13. + let pool = AddressPool::new( + path, + AddressPoolType::AbsentHardened, + DEFAULT_SPECIAL_GAP_LIMIT, + network, + key_source, + )?; + + Ok(Self::IdentityAuthenticationBls { + identity_index, + addresses: pool, + }) + } AccountType::AssetLockAddressTopUp => { let path = account_type .derivation_path(network) diff --git a/key-wallet/src/managed_account/managed_platform_account.rs b/key-wallet/src/managed_account/managed_platform_account.rs index aa85ce5b1..5c47570e6 100644 --- a/key-wallet/src/managed_account/managed_platform_account.rs +++ b/key-wallet/src/managed_account/managed_platform_account.rs @@ -231,24 +231,24 @@ impl ManagedPlatformAccount { self.addresses.all_addresses() } - /// Get the next unused address from the pool + /// Get the next unused address from the pool, persisting it into + /// the pool's state. pub fn next_unused_address( &mut self, key_source: &super::address_pool::KeySource, - add_to_state: bool, ) -> Result
{ self.addresses - .next_unused(key_source, add_to_state) + .next_unused(key_source) .map_err(|e| Error::InvalidParameter(format!("Failed to get next address: {}", e))) } - /// Get the next unused platform address + /// Get the next unused platform address, persisting it into the + /// pool's state. pub fn next_unused_platform_address( &mut self, key_source: &super::address_pool::KeySource, - add_to_state: bool, ) -> Result { - let addr = self.next_unused_address(key_source, add_to_state)?; + let addr = self.next_unused_address(key_source)?; PlatformP2PKHAddress::from_address(&addr) } diff --git a/key-wallet/src/managed_account/mod.rs b/key-wallet/src/managed_account/mod.rs index c8064dae1..662e15324 100644 --- a/key-wallet/src/managed_account/mod.rs +++ b/key-wallet/src/managed_account/mod.rs @@ -4,12 +4,14 @@ //! kept separate from the immutable Account structure. use crate::account::AccountMetadata; +use crate::account::AccountType; #[cfg(feature = "bls")] use crate::account::BLSAccount; #[cfg(feature = "eddsa")] use crate::account::EdDSAAccount; use crate::account::ManagedAccountTrait; use crate::account::TransactionRecord; +use crate::changeset::{AccountChangeSet, BalanceChangeSet, Merge, WalletChangeSet}; #[cfg(feature = "bls")] use crate::derivation_bls_bip32::ExtendedBLSPubKey; #[cfg(any(feature = "bls", feature = "eddsa"))] @@ -254,6 +256,10 @@ impl ManagedCoreAccount { addresses, .. } + | ManagedAccountType::IdentityAuthenticationEcdsa { + addresses, + .. + } | ManagedAccountType::AssetLockAddressTopUp { addresses, .. @@ -289,31 +295,52 @@ impl ManagedCoreAccount { | ManagedAccountType::PlatformPayment { addresses, .. + } + | ManagedAccountType::IdentityAuthenticationBls { + addresses, + .. } => { addresses.unused_addresses().first().and_then(|addr| addresses.address_index(addr)) } } } - /// Mark an address as used - pub fn mark_address_used(&mut self, address: &Address) -> bool { + /// Mark an address as used, returning a changeset describing the effect. + /// + /// Returns `(changed, cs)` where the per-account delta lives at + /// `cs.per_account[self.account_type.to_account_type()].addresses_used`. + /// `changed` is `false` on replay or for unknown addresses, and the + /// changeset is empty in that case (idempotent). + pub fn mark_address_used(&mut self, address: &Address) -> (bool, WalletChangeSet) { // Update metadata timestamp self.metadata.last_used = Some(Self::current_timestamp()); - // Use the account type's mark_address_used method - // The address pools already track gap limits internally - self.account_type.mark_address_used(address) + let changed = self.account_type.mark_address_used(address); + let mut cs = WalletChangeSet::default(); + if changed { + cs.account_bucket(self.account_type.to_account_type()) + .addresses_used + .insert(address.clone()); + } + (changed, cs) } - /// Add new ones for received outputs, remove spent ones + /// Add new UTXOs for received outputs and remove spent ones. + /// + /// Returns a [`WalletChangeSet`] whose per-account bucket carries + /// the additions and removals. Non-spendable account types + /// (Identity*, Provider*, etc.) return an empty changeset. fn update_utxos( &mut self, tx: &Transaction, account_match: &AccountMatch, context: TransactionContext, - ) { - // Update UTXOs only for spendable account types - match &mut self.account_type { + ) -> WalletChangeSet { + let mut cs = WalletChangeSet::default(); + + // Non-spendable account types (Identity*, Provider*, …) have + // nothing to record. + match &self.account_type { ManagedAccountType::Standard { .. } @@ -325,94 +352,119 @@ impl ManagedCoreAccount { } | ManagedAccountType::DashpayExternalAccount { .. - } => { - let involved_addrs: BTreeSet<_> = account_match - .account_type_match - .all_involved_addresses() - .iter() - .map(|info| info.address.clone()) - .collect(); - - let txid = tx.txid(); - let mut utxos_changed = false; - - // Insert UTXOs for outputs paying to our addresses - for (vout, output) in tx.output.iter().enumerate() { - if let Ok(addr) = Address::from_script(&output.script_pubkey, self.network) { - if involved_addrs.contains(&addr) { - let outpoint = OutPoint { - txid, - vout: vout as u32, - }; - - // Check if this outpoint was already spent by a transaction we've seen. - // This handles out-of-order block processing during rescan where a - // spending transaction at a higher height may be processed before - // the transaction that created the UTXO. - // TODO: This is mostly needed for wallet rescan from storage with the - // there is a timing issue with event processing which might lead to - // invalid UTXO set / balances. There might be a way around it. - if self.is_outpoint_spent(&outpoint) { - tracing::debug!( - outpoint = %outpoint, - "Skipping UTXO already spent by previously processed transaction" - ); - continue; - } - - let txout = dashcore::TxOut { - value: output.value, - script_pubkey: output.script_pubkey.clone(), - }; - let block_height = context.block_info().map_or(0, |info| info.height); - let mut utxo = - Utxo::new(outpoint, txout, addr, block_height, tx.is_coin_base()); - utxo.is_confirmed = context.confirmed(); - utxo.is_instantlocked = - matches!(context, TransactionContext::InstantSend(_)); - self.utxos.insert(outpoint, utxo); - utxos_changed = true; - } - } - } + } => {} + _ => return cs, + } - // Remove UTXOs spent by this transaction and track spent outpoints - for input in &tx.input { - self.spent_outpoints.insert(input.previous_output); - - if self.utxos.remove(&input.previous_output).is_some() { - tracing::debug!( - outpoint = %input.previous_output, - txid = %tx.txid(), - "Removed spent UTXO" - ); - utxos_changed = true; - } - } + let involved_addrs: BTreeSet<_> = account_match + .account_type_match + .all_involved_addresses() + .iter() + .map(|info| info.address.clone()) + .collect(); + + let txid = tx.txid(); + let account_type_key = self.account_type.to_account_type(); + let mut utxos_changed = false; + + // Insert UTXOs for outputs paying to our addresses. + for (vout, output) in tx.output.iter().enumerate() { + let Ok(addr) = Address::from_script(&output.script_pubkey, self.network) else { + continue; + }; + if !involved_addrs.contains(&addr) { + continue; + } + let outpoint = OutPoint { + txid, + vout: vout as u32, + }; + + // Out-of-order block processing: if a spending tx at a higher + // height was processed before this creation, skip to avoid + // resurrecting a dead UTXO. + // TODO: timing issue in wallet rescan from storage; revisit. + if self.is_outpoint_spent(&outpoint) { + tracing::debug!( + outpoint = %outpoint, + "Skipping UTXO already spent by previously processed transaction" + ); + continue; + } - if utxos_changed { - self.monitor_revision += 1; + let txout = dashcore::TxOut { + value: output.value, + script_pubkey: output.script_pubkey.clone(), + }; + let block_height = context.block_info().map_or(0, |info| info.height); + let mut utxo = Utxo::new(outpoint, txout, addr, block_height, tx.is_coin_base()); + utxo.is_confirmed = context.confirmed(); + utxo.is_instantlocked = matches!(context, TransactionContext::InstantSend(_)); + + // Emit on state transition: either a new UTXO, or an existing + // one whose `is_confirmed` / `is_instantlocked` flags flipped + // (e.g. mempool → InBlock upgrade). Both reach the persister + // so the on-disk row is upserted — otherwise replay sees a + // stale Utxo. + let needs_write = match self.utxos.get(&outpoint) { + None => true, + Some(existing) => { + existing.is_confirmed != utxo.is_confirmed + || existing.is_instantlocked != utxo.is_instantlocked } + }; + if needs_write { + cs.account_bucket(account_type_key).utxos_added.insert(outpoint, utxo.clone()); + self.utxos.insert(outpoint, utxo); + utxos_changed = true; + } + } + + // Remove UTXOs spent by this transaction and track spent outpoints. + for input in &tx.input { + self.spent_outpoints.insert(input.previous_output); + + if self.utxos.remove(&input.previous_output).is_some() { + tracing::debug!( + outpoint = %input.previous_output, + txid = %tx.txid(), + "Removed spent UTXO" + ); + cs.account_bucket(account_type_key).utxos_spent.insert(input.previous_output); + utxos_changed = true; } - _ => {} } + + if utxos_changed { + self.monitor_revision += 1; + } + + cs } /// Re-process an existing transaction with updated context (e.g., mempool→block confirmation) /// and potentially new address matches from gap limit rescans. + /// + /// Returns `(changed, cs)`: + /// - `changed` is `true` iff the confirmation status transitioned from + /// unconfirmed → confirmed (not for InBlock → InChainLockedBlock). + /// - `cs` carries any transaction context update and any UTXO delta from + /// `update_utxos`. pub(crate) fn confirm_transaction( &mut self, tx: &Transaction, account_match: &AccountMatch, context: TransactionContext, transaction_type: TransactionType, - ) -> bool { + ) -> (bool, WalletChangeSet) { if !self.transactions.contains_key(&tx.txid()) { - self.record_transaction(tx, account_match, context, transaction_type); - return true; + let (_record, cs) = + self.record_transaction(tx, account_match, context, transaction_type); + return (true, cs); } let mut changed = false; + let mut cs = WalletChangeSet::default(); if let Some(tx_record) = self.transactions.get_mut(&tx.txid()) { debug_assert_eq!( tx_record.transaction_type, @@ -423,6 +475,9 @@ impl ManagedCoreAccount { if tx_record.context != context { let was_confirmed = tx_record.context.confirmed(); tx_record.update_context(context.clone()); + cs.account_bucket(self.account_type.to_account_type()) + .transactions + .insert(tx.txid(), tx_record.clone()); // Only signal a change when confirmation status actually changes, // not for upgrades within the confirmed state (e.g. InBlock → InChainLockedBlock). // TODO: emit a change event for InBlock → InChainLockedBlock once chainlock @@ -430,18 +485,25 @@ impl ManagedCoreAccount { changed = !was_confirmed; } } - self.update_utxos(tx, account_match, context); - changed + cs.merge(self.update_utxos(tx, account_match, context)); + (changed, cs) } - /// Record a new transaction and update UTXOs for spendable account types + /// Record a new transaction and update UTXOs for spendable account types. + /// + /// Returns `(record, cs)`: + /// - `record` is the new [`TransactionRecord`] for caller-side reporting. + /// It is also present in + /// `cs.per_account[self.account_type.to_account_type()].transactions`. + /// - `cs` carries both the transaction record insertion and any UTXO + /// delta from `update_utxos`. pub(crate) fn record_transaction( &mut self, tx: &Transaction, account_match: &AccountMatch, context: TransactionContext, transaction_type: TransactionType, - ) -> TransactionRecord { + ) -> (TransactionRecord, WalletChangeSet) { let net_amount = account_match.received as i64 - account_match.sent as i64; let receive_addrs: HashSet<_> = account_match @@ -534,24 +596,66 @@ impl ManagedCoreAccount { net_amount, ); - let record = tx_record.clone(); - self.transactions.insert(tx.txid(), tx_record); - - self.update_utxos(tx, account_match, context); - record + let txid = tx.txid(); + let mut cs = WalletChangeSet::default(); + cs.account_bucket(self.account_type.to_account_type()) + .transactions + .insert(txid, tx_record.clone()); + self.transactions.insert(txid, tx_record.clone()); + cs.merge(self.update_utxos(tx, account_match, context)); + (tx_record, cs) } /// Mark all UTXOs belonging to a transaction as InstantSend-locked. - /// Returns `true` if any UTXO was newly marked. - pub(crate) fn mark_utxos_instant_send(&mut self, txid: &Txid) -> bool { + /// + /// Returns `(changed, cs)`: + /// - `changed` is `true` iff at least one UTXO transitioned from + /// not-locked to locked. + /// - `cs.per_account[self.account_type.to_account_type()].utxos_instant_locked` + /// contains the newly-locked outpoints. + /// + /// Note: this method only emits entries for UTXOs still present in + /// `self.utxos`. If an outpoint has already been spent, it's no + /// longer in the map, so no spurious `instant_locked` entry can be + /// produced for a spent outpoint. This is why + /// `ManagedCoreAccount::apply_changeset` can safely process + /// `utxos_spent` before `utxos_instant_locked` without losing state. + pub(crate) fn mark_utxos_instant_send(&mut self, txid: &Txid) -> (bool, WalletChangeSet) { + let mut cs = WalletChangeSet::default(); + let account_type_key = self.account_type.to_account_type(); let mut any_changed = false; for utxo in self.utxos.values_mut() { if utxo.outpoint.txid == *txid && !utxo.is_instantlocked { utxo.is_instantlocked = true; + cs.account_bucket(account_type_key).utxos_instant_locked.insert(utxo.outpoint); any_changed = true; } } - any_changed + (any_changed, cs) + } + + /// Update the stored [`TransactionContext`] on an existing transaction + /// record and return a changeset describing the change. + /// + /// Idempotent: if the record does not exist, or its context already + /// equals `new_context`, returns an empty [`WalletChangeSet`]. + /// Otherwise updates the record in place and emits it in + /// `cs.per_account[self.account_type.to_account_type()].transactions`. + pub(crate) fn update_transaction_context( + &mut self, + txid: &Txid, + new_context: TransactionContext, + ) -> WalletChangeSet { + let mut cs = WalletChangeSet::default(); + if let Some(record) = self.transactions.get_mut(txid) { + if record.context != new_context { + record.update_context(new_context); + cs.account_bucket(self.account_type.to_account_type()) + .transactions + .insert(*txid, record.clone()); + } + } + cs } /// Return the UTXOs of this account for which @@ -563,17 +667,21 @@ impl ManagedCoreAccount { self.utxos.values().filter(|utxo| utxo.is_spendable(synced_height)).collect() } - /// Update the account balance. + /// Recompute the account balance from its UTXO set. /// /// Mature, non-locked UTXOs land in either the `confirmed` bucket /// (in a block or InstantSend-locked) or the `unconfirmed` bucket /// (mempool only). Both are spendable per [`Utxo::is_spendable`]; /// the split is only for display. - pub fn update_balance(&mut self, synced_height: u32) { - let mut confirmed = 0; - let mut unconfirmed = 0; - let mut immature = 0; - let mut locked = 0; + /// + /// Returns a [`WalletChangeSet`] carrying the signed balance delta + /// between the new and old balance buckets. An empty changeset + /// means the balance did not change. + pub fn update_balance(&mut self, synced_height: u32) -> WalletChangeSet { + let mut confirmed = 0u64; + let mut unconfirmed = 0u64; + let mut immature = 0u64; + let mut locked = 0u64; for utxo in self.utxos.values() { let value = utxo.txout.value; if utxo.is_locked { @@ -586,8 +694,104 @@ impl ManagedCoreAccount { unconfirmed += value; } } - self.balance = WalletCoreBalance::new(confirmed, unconfirmed, immature, locked); + + let old = self.balance; + let new = WalletCoreBalance::new(confirmed, unconfirmed, immature, locked); + self.balance = new; self.metadata.last_used = Some(Self::current_timestamp()); + + let bal = BalanceChangeSet { + confirmed_delta: new.confirmed() as i64 - old.confirmed() as i64, + unconfirmed_delta: new.unconfirmed() as i64 - old.unconfirmed() as i64, + immature_delta: new.immature() as i64 - old.immature() as i64, + locked_delta: new.locked() as i64 - old.locked() as i64, + }; + let mut cs = WalletChangeSet::default(); + if !bal.is_empty() { + cs.balance = Some(bal); + } + cs + } + + /// Apply an [`AccountChangeSet`] to this account during restore. + /// + /// Idempotent: replaying the same changeset twice converges to the + /// same state as applying it once. State-flag transitions on UTXOs + /// (`is_confirmed`, `is_instantlocked`) are monotonic — true wins, + /// false loses — so a stale persisted changeset can't downgrade + /// in-memory state under a race. + /// + /// This method does not emit a new changeset. It's the inverse of + /// the mutation methods on this type, called from + /// [`crate::wallet::managed_wallet_info::ManagedWalletInfo::apply_changeset`] + /// once a per-account bucket has been routed. + /// + /// `expected_account_type` is the `AccountType` key under which `cs` + /// was stored in [`crate::changeset::WalletChangeSet::per_account`]. + /// Used only by a `debug_assert` that the caller routed the bucket + /// to the correct account — a mismatch indicates a routing bug at + /// the call site. + pub fn apply_changeset(&mut self, expected_account_type: AccountType, cs: AccountChangeSet) { + debug_assert_eq!( + self.account_type.to_account_type(), + expected_account_type, + "apply_changeset called on the wrong account — routing bug" + ); + // Destructure the changeset so every field is a fresh owned value + // we can drain into the wallet maps. No clones — `Utxo` and + // `TransactionRecord` (which contains a full `Transaction`) move + // straight from the persisted blob into in-memory state. + let AccountChangeSet { + addresses_used, + highest_used, + utxos_added, + utxos_spent, + utxos_instant_locked, + transactions, + } = cs; + + // Addresses marked used — mark_address_used is idempotent. + for address in addresses_used { + let _ = self.account_type.mark_address_used(&address); + } + // Highest-used watermark per pool type — max-wins via the + // pool's own `set_highest_used`. + for pool in self.account_type.address_pools_mut() { + if let Some(highest_used) = highest_used.get(&pool.pool_type).copied() { + pool.set_highest_used(highest_used); + } + } + // UTXO additions / upgrades. Monotonic state flags: true wins. + // Move each `Utxo` directly into the map; if an entry already + // exists at this outpoint, OR its existing flags into the + // incoming UTXO before insertion (no clone of either side). + for (outpoint, mut utxo) in utxos_added { + if let Some(existing) = self.utxos.get(&outpoint) { + utxo.is_confirmed |= existing.is_confirmed; + utxo.is_instantlocked |= existing.is_instantlocked; + } + self.utxos.insert(outpoint, utxo); + } + // UTXO removals. + for outpoint in utxos_spent { + self.utxos.remove(&outpoint); + self.spent_outpoints.insert(outpoint); + } + // UTXO instant-lock transitions. Only flips false → true; a + // UTXO already spent (removed above) is silently skipped, which + // is correct because we don't track IS state for spent outputs. + for outpoint in utxos_instant_locked { + if let Some(utxo) = self.utxos.get_mut(&outpoint) { + utxo.is_instantlocked = true; + } + } + // Transaction records. Last write wins per txid — apply is + // idempotent because identical records insert over themselves. + // Move each `TransactionRecord` (and its embedded `Transaction`) + // directly from the changeset into the map. + for (txid, record) in transactions { + self.transactions.insert(txid, record); + } } /// Get all addresses from all pools @@ -610,34 +814,32 @@ impl ManagedCoreAccount { self.account_type.get_address_info(address) } - /// Generate the next receive address using the optionally provided extended public key - /// If no key is provided, can only return pre-generated unused addresses - /// This method derives a new address from the account's xpub but does not add it to the pool - /// The address must be added to the pool separately with proper tracking + /// Generate the next receive address, persisting it into the external + /// pool's state. + /// + /// If no xpub is provided, only returns a pre-generated unused address + /// from the pool; if the pool needs to derive, this fails with + /// `NoKeySource`. pub fn next_receive_address( &mut self, account_xpub: Option<&ExtendedPubKey>, - add_to_state: bool, ) -> Result { - // For standard accounts, use the address pool to get the next unused address if let ManagedAccountType::Standard { external_addresses, .. } = &mut self.account_type { - // Create appropriate key source based on whether xpub is provided let key_source = match account_xpub { Some(xpub) => address_pool::KeySource::Public(*xpub), None => address_pool::KeySource::NoKeySource, }; - let addr = - external_addresses.next_unused(&key_source, add_to_state).map_err(|e| match e { - crate::error::Error::NoKeySource => { - "No unused addresses available and no key source provided" - } - _ => "Failed to generate receive address", - })?; + let addr = external_addresses.next_unused(&key_source).map_err(|e| match e { + crate::error::Error::NoKeySource => { + "No unused addresses available and no key source provided" + } + _ => "Failed to generate receive address", + })?; self.monitor_revision += 1; Ok(addr) } else { @@ -645,33 +847,32 @@ impl ManagedCoreAccount { } } - /// Generate the next change address using the optionally provided extended public key - /// If no key is provided, can only return pre-generated unused addresses - /// This method uses the address pool to properly track and generate addresses + /// Generate the next change address, persisting it into the internal + /// pool's state. + /// + /// If no xpub is provided, only returns a pre-generated unused address + /// from the pool; if the pool needs to derive, this fails with + /// `NoKeySource`. pub fn next_change_address( &mut self, account_xpub: Option<&ExtendedPubKey>, - add_to_state: bool, ) -> Result { - // For standard accounts, use the address pool to get the next unused address if let ManagedAccountType::Standard { internal_addresses, .. } = &mut self.account_type { - // Create appropriate key source based on whether xpub is provided let key_source = match account_xpub { Some(xpub) => address_pool::KeySource::Public(*xpub), None => address_pool::KeySource::NoKeySource, }; - let addr = - internal_addresses.next_unused(&key_source, add_to_state).map_err(|e| match e { - crate::error::Error::NoKeySource => { - "No unused addresses available and no key source provided" - } - _ => "Failed to generate change address", - })?; + let addr = internal_addresses.next_unused(&key_source).map_err(|e| match e { + crate::error::Error::NoKeySource => { + "No unused addresses available and no key source provided" + } + _ => "Failed to generate change address", + })?; self.monitor_revision += 1; Ok(addr) } else { @@ -679,31 +880,27 @@ impl ManagedCoreAccount { } } - /// Generate multiple receive addresses at once using the optionally provided extended public key + /// Generate multiple receive addresses at once, persisting each into + /// the external pool's state. /// - /// Returns the requested number of unused receive addresses, generating new ones if needed. - /// This is more efficient than calling `next_receive_address` multiple times. - /// If no key is provided, can only return pre-generated unused addresses. + /// More efficient than calling `next_receive_address` multiple times. + /// If no xpub is provided, only returns pre-generated unused addresses. pub fn next_receive_addresses( &mut self, account_xpub: Option<&ExtendedPubKey>, count: usize, - add_to_state: bool, ) -> Result, String> { - // For standard accounts, use the address pool to get multiple unused addresses if let ManagedAccountType::Standard { external_addresses, .. } = &mut self.account_type { - // Create appropriate key source based on whether xpub is provided let key_source = match account_xpub { Some(xpub) => address_pool::KeySource::Public(*xpub), None => address_pool::KeySource::NoKeySource, }; - let addresses = - external_addresses.next_unused_multiple(count, &key_source, add_to_state); + let addresses = external_addresses.next_unused_multiple(count, &key_source); if addresses.is_empty() && count > 0 { Err("Failed to generate any receive addresses".to_string()) } else if addresses.len() < count @@ -722,31 +919,27 @@ impl ManagedCoreAccount { } } - /// Generate multiple change addresses at once using the optionally provided extended public key + /// Generate multiple change addresses at once, persisting each into + /// the internal pool's state. /// - /// Returns the requested number of unused change addresses, generating new ones if needed. - /// This is more efficient than calling `next_change_address` multiple times. - /// If no key is provided, can only return pre-generated unused addresses. + /// More efficient than calling `next_change_address` multiple times. + /// If no xpub is provided, only returns pre-generated unused addresses. pub fn next_change_addresses( &mut self, account_xpub: Option<&ExtendedPubKey>, count: usize, - add_to_state: bool, ) -> Result, String> { - // For standard accounts, use the address pool to get multiple unused addresses if let ManagedAccountType::Standard { internal_addresses, .. } = &mut self.account_type { - // Create appropriate key source based on whether xpub is provided let key_source = match account_xpub { Some(xpub) => address_pool::KeySource::Public(*xpub), None => address_pool::KeySource::NoKeySource, }; - let addresses = - internal_addresses.next_unused_multiple(count, &key_source, add_to_state); + let addresses = internal_addresses.next_unused_multiple(count, &key_source); if addresses.is_empty() && count > 0 { Err("Failed to generate any change addresses".to_string()) } else if addresses.len() < count @@ -765,13 +958,15 @@ impl ManagedCoreAccount { } } - /// Generate the next address for non-standard accounts - /// This method is for special accounts like Identity, Provider accounts, etc. - /// Standard accounts (BIP44/BIP32) should use next_receive_address or next_change_address + /// Generate the next address for non-standard accounts, persisting + /// it into the single pool's state. + /// + /// This method is for special accounts like Identity, Provider + /// accounts, etc. Standard accounts (BIP44/BIP32) should use + /// `next_receive_address` or `next_change_address`. pub fn next_address( &mut self, account_xpub: Option<&ExtendedPubKey>, - add_to_state: bool, ) -> Result { match &mut self.account_type { ManagedAccountType::Standard { @@ -828,6 +1023,10 @@ impl ManagedCoreAccount { | ManagedAccountType::PlatformPayment { addresses, .. + } + | ManagedAccountType::IdentityAuthenticationEcdsa { + addresses, + .. } => { // Create appropriate key source based on whether xpub is provided let key_source = match account_xpub { @@ -835,7 +1034,24 @@ impl ManagedCoreAccount { None => address_pool::KeySource::NoKeySource, }; - addresses.next_unused(&key_source, add_to_state).map_err(|e| match e { + addresses.next_unused(&key_source).map_err(|e| match e { + crate::error::Error::NoKeySource => { + "No unused addresses available and no key source provided" + } + _ => "Failed to generate address", + }) + } + ManagedAccountType::IdentityAuthenticationBls { + addresses, + .. + } => { + // `account_xpub` is an ECDSA extended pubkey and is useless for a + // BLS key pool. Callers that need to generate BLS identity-auth + // addresses must go through a dedicated BLS-aware API path + // (similar to `next_bls_operator_key` for ProviderOperatorKeys). + // Here we only allow progression when the pool already has a + // pre-derived address cached (NoKeySource). + addresses.next_unused(&address_pool::KeySource::NoKeySource).map_err(|e| match e { crate::error::Error::NoKeySource => { "No unused addresses available and no key source provided" } @@ -852,7 +1068,7 @@ impl ManagedCoreAccount { None => address_pool::KeySource::NoKeySource, }; - addresses.next_unused(&key_source, add_to_state).map_err(|e| match e { + addresses.next_unused(&key_source).map_err(|e| match e { crate::error::Error::NoKeySource => { "No unused addresses available and no key source provided" } @@ -862,13 +1078,15 @@ impl ManagedCoreAccount { } } - /// Generate the next address with full info for non-standard accounts - /// This method is for special accounts like Identity, Provider accounts, etc. - /// Standard accounts (BIP44/BIP32) should use next_receive_address_with_info or next_change_address_with_info + /// Generate the next address with full info for non-standard + /// accounts, persisting it into the single pool's state. + /// + /// This method is for special accounts like Identity, Provider + /// accounts, etc. Standard accounts (BIP44/BIP32) should use + /// `next_receive_address_with_info` or `next_change_address_with_info`. pub fn next_address_with_info( &mut self, account_xpub: Option<&ExtendedPubKey>, - add_to_state: bool, ) -> Result { match &mut self.account_type { ManagedAccountType::Standard { @@ -925,6 +1143,10 @@ impl ManagedCoreAccount { | ManagedAccountType::PlatformPayment { addresses, .. + } + | ManagedAccountType::IdentityAuthenticationEcdsa { + addresses, + .. } => { // Create appropriate key source based on whether xpub is provided let key_source = match account_xpub { @@ -932,13 +1154,32 @@ impl ManagedCoreAccount { None => address_pool::KeySource::NoKeySource, }; - addresses.next_unused_with_info(&key_source, add_to_state).map_err(|e| match e { + addresses.next_unused_with_info(&key_source).map_err(|e| match e { crate::error::Error::NoKeySource => { "No unused addresses available and no key source provided" } _ => "Failed to generate address with info", }) } + ManagedAccountType::IdentityAuthenticationBls { + addresses, + .. + } => { + // `account_xpub` is an ECDSA extended pubkey and is useless for a + // BLS key pool. Callers that need to generate BLS identity-auth + // addresses must go through a dedicated BLS-aware API path + // (similar to `next_bls_operator_key` for ProviderOperatorKeys). + // Here we only allow progression when the pool already has a + // pre-derived address cached (NoKeySource). + addresses + .next_unused_with_info(&address_pool::KeySource::NoKeySource) + .map_err(|e| match e { + crate::error::Error::NoKeySource => { + "No unused addresses available and no key source provided" + } + _ => "Failed to generate address with info", + }) + } ManagedAccountType::IdentityTopUp { addresses, .. @@ -949,7 +1190,7 @@ impl ManagedCoreAccount { None => address_pool::KeySource::NoKeySource, }; - addresses.next_unused_with_info(&key_source, add_to_state).map_err(|e| match e { + addresses.next_unused_with_info(&key_source).map_err(|e| match e { crate::error::Error::NoKeySource => { "No unused addresses available and no key source provided" } @@ -965,7 +1206,6 @@ impl ManagedCoreAccount { pub fn next_bls_operator_key( &mut self, account_xpub: Option, - add_to_state: bool, ) -> Result, &'static str> { match &mut self.account_type { ManagedAccountType::ProviderOperatorKeys { @@ -980,7 +1220,7 @@ impl ManagedCoreAccount { // Use next_unused_with_info to get the next address (handles caching and derivation) let info = addresses - .next_unused_with_info(&key_source, add_to_state) + .next_unused_with_info(&key_source) .map_err(|_| "Failed to get next unused address")?; // Extract the BLS public key from the address info @@ -1011,7 +1251,6 @@ impl ManagedCoreAccount { pub fn next_eddsa_platform_key( &mut self, account_xpriv: crate::derivation_slip10::ExtendedEd25519PrivKey, - add_to_state: bool, ) -> Result<(crate::derivation_slip10::VerifyingKey, AddressInfo), &'static str> { match &mut self.account_type { ManagedAccountType::ProviderPlatformKeys { @@ -1023,7 +1262,7 @@ impl ManagedCoreAccount { // Use next_unused_with_info to get the next address (handles caching and derivation) let info = addresses - .next_unused_with_info(&key_source, add_to_state) + .next_unused_with_info(&key_source) .map_err(|_| "Failed to get next unused address")?; // Extract the EdDSA public key from the address info @@ -1064,7 +1303,7 @@ impl ManagedCoreAccount { let pool = pools.first_mut().ok_or("Account has no address pool")?; let info = pool - .next_unused_with_info(&address_pool::KeySource::NoKeySource, false) + .next_unused_with_info(&address_pool::KeySource::NoKeySource) .map_err(|_| "No unused address available")?; pool.mark_index_used(info.index); @@ -1099,7 +1338,7 @@ impl ManagedCoreAccount { let pool = pools.first_mut().ok_or("Account has no address pool")?; let info = pool - .next_unused_with_info(&address_pool::KeySource::NoKeySource, false) + .next_unused_with_info(&address_pool::KeySource::NoKeySource) .map_err(|_| "No unused address available")?; Ok((info.path, info.index)) @@ -1148,7 +1387,7 @@ impl ManagedCoreAccount { } /// Get the current timestamp (for metadata) - fn current_timestamp() -> u64 { + pub fn current_timestamp() -> u64 { std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .unwrap_or_default() @@ -1252,9 +1491,32 @@ impl ManagedCoreAccount { | ManagedAccountType::PlatformPayment { addresses, .. + } + | ManagedAccountType::IdentityAuthenticationEcdsa { + addresses, + .. + } + | ManagedAccountType::IdentityAuthenticationBls { + addresses, + .. } => Some(addresses.gap_limit), } } + + /// Idempotent UTXO insert. Returns true if this is a new UTXO, false if + /// an entry already existed for this outpoint (in which case the old + /// value is replaced). Used by `apply(changeset)` to route UTXO additions + /// from a persisted changeset back into the account state. + pub fn insert_utxo(&mut self, outpoint: OutPoint, utxo: Utxo) -> bool { + self.utxos.insert(outpoint, utxo).is_none() + } + + /// Idempotent UTXO remove. Returns the removed UTXO if it was present, + /// or `None` if the outpoint wasn't in this account. Used by + /// `apply(changeset)` to route UTXO spends. + pub fn remove_utxo(&mut self, outpoint: &OutPoint) -> Option { + self.utxos.remove(outpoint) + } } impl ManagedAccountTrait for ManagedCoreAccount { diff --git a/key-wallet/src/managed_account/transaction_record.rs b/key-wallet/src/managed_account/transaction_record.rs index e4eca2c36..bcb4b3204 100644 --- a/key-wallet/src/managed_account/transaction_record.rs +++ b/key-wallet/src/managed_account/transaction_record.rs @@ -8,7 +8,7 @@ use crate::transaction_checking::transaction_router::TransactionType; use crate::transaction_checking::{BlockInfo, TransactionContext}; use crate::Address; use dashcore::blockdata::transaction::Transaction; -use dashcore::Txid; +use dashcore::{BlockHash, Txid}; #[cfg(feature = "serde")] use serde::{Deserialize, Serialize}; @@ -17,7 +17,7 @@ pub const MAX_LABEL_LENGTH: usize = 256; /// Wallet-context metadata for a transaction input. /// The index references `transaction.input[index]`. -#[derive(Debug, Clone)] +#[derive(Debug, Clone, PartialEq, Eq)] #[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] pub struct InputDetail { /// Index into the transaction's input array @@ -30,7 +30,7 @@ pub struct InputDetail { /// Wallet-context metadata for a transaction output. /// The index references `transaction.output[index]`. -#[derive(Debug, Clone)] +#[derive(Debug, Clone, PartialEq, Eq)] #[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] pub struct OutputDetail { /// Index into the transaction's output array @@ -72,7 +72,7 @@ pub enum TransactionDirection { } /// Transaction record with full details -#[derive(Debug, Clone)] +#[derive(Debug, Clone, PartialEq, Eq)] #[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] pub struct TransactionRecord { /// The transaction @@ -93,8 +93,15 @@ pub struct TransactionRecord { pub net_amount: i64, /// Fee paid (if we created it) pub fee: Option, - /// Transaction label + /// Transaction label (empty string = no label). Changed from + /// `Option` to `String` in upstream PR #624. pub label: String, + /// Unix timestamp (seconds) when this transaction was first observed by the wallet. + /// Set by `TransactionRecord::new()` and never mutated thereafter. Provides a stable + /// timestamp for mempool transactions which have no block-level timestamp — used by + /// UI/persistence layers for ordering and display. Restores behavior from before the + /// `TransactionContext` refactor (#582) when a flat `timestamp` field existed. + pub first_seen: u64, } impl TransactionRecord { @@ -120,6 +127,7 @@ impl TransactionRecord { net_amount, fee: None, label: String::new(), + first_seen: crate::managed_account::ManagedCoreAccount::current_timestamp(), } } @@ -151,6 +159,26 @@ impl TransactionRecord { self.context.block_info().map(|info| info.height) } + /// Block hash if confirmed + pub fn block_hash(&self) -> Option { + self.context.block_info().map(|info| info.block_hash()) + } + + /// Block height if confirmed (alias of `height()` for symmetry with `block_hash()`). + pub fn block_height(&self) -> Option { + self.height() + } + + /// Whether this transaction has an InstantSend lock. + pub fn is_instant_locked(&self) -> bool { + matches!(self.context, TransactionContext::InstantSend(_)) + } + + /// Whether this transaction is in a chain-locked block. + pub fn is_chain_locked(&self) -> bool { + matches!(self.context, TransactionContext::InChainLockedBlock(_)) + } + /// Set the fee for this transaction pub fn set_fee(&mut self, fee: u64) { self.fee = Some(fee); diff --git a/key-wallet/src/test_utils/wallet.rs b/key-wallet/src/test_utils/wallet.rs index 139bca640..9b20b11af 100644 --- a/key-wallet/src/test_utils/wallet.rs +++ b/key-wallet/src/test_utils/wallet.rs @@ -43,7 +43,7 @@ impl TestWalletContext { let receive_address = managed_wallet .first_bip44_managed_account_mut() .expect("Should have managed account") - .next_receive_address(Some(&xpub), true) + .next_receive_address(Some(&xpub)) .expect("Should get address"); Self { diff --git a/key-wallet/src/tests/address_pool_tests.rs b/key-wallet/src/tests/address_pool_tests.rs index 25ec66b1a..625b1d998 100644 --- a/key-wallet/src/tests/address_pool_tests.rs +++ b/key-wallet/src/tests/address_pool_tests.rs @@ -42,7 +42,7 @@ fn test_next_unused_multiple() { let (key_source, _) = test_key_source(); // Test getting multiple unused addresses - let addresses = pool.next_unused_multiple(5, &key_source, true); + let addresses = pool.next_unused_multiple(5, &key_source); assert_eq!(addresses.len(), 5); assert_eq!(pool.highest_generated, Some(4)); @@ -55,7 +55,7 @@ fn test_next_unused_multiple() { pool.mark_used(&addresses[2]); // Request more addresses - should get 3 unused + 2 new - let more_addresses = pool.next_unused_multiple(5, &key_source, true); + let more_addresses = pool.next_unused_multiple(5, &key_source); assert_eq!(more_addresses.len(), 5); assert_eq!(more_addresses[0], addresses[1]); // First unused assert_eq!(more_addresses[1], addresses[3]); // Second unused @@ -77,7 +77,7 @@ fn test_next_unused_multiple_with_info() { let (key_source, _) = test_key_source(); // Test getting multiple addresses with info - let address_infos = pool.next_unused_multiple_with_info(3, &key_source, true); + let address_infos = pool.next_unused_multiple_with_info(3, &key_source); assert_eq!(address_infos.len(), 3); // Verify the info contains correct data @@ -92,7 +92,7 @@ fn test_next_unused_multiple_with_info() { pool.mark_used(&address_infos[0].0); // Get more with info - should skip the used one - let more_infos = pool.next_unused_multiple_with_info(3, &key_source, true); + let more_infos = pool.next_unused_multiple_with_info(3, &key_source); assert_eq!(more_infos.len(), 3); assert_eq!(more_infos[0].0, address_infos[1].0); // Should skip the first (used) one assert_eq!(more_infos[1].0, address_infos[2].0); @@ -111,22 +111,22 @@ fn test_next_unused_multiple_no_key_source() { let no_key_source = KeySource::NoKeySource; // With NoKeySource and no pre-generated addresses, should return empty vec - let addresses = pool.next_unused_multiple(5, &no_key_source, true); + let addresses = pool.next_unused_multiple(5, &no_key_source); assert_eq!(addresses.len(), 0); // Generate some addresses first with a real key source let (key_source, _) = test_key_source(); - pool.generate_addresses(3, &key_source, true).unwrap(); + pool.generate_addresses(3, &key_source).unwrap(); // Now with NoKeySource, should return existing unused addresses - let addresses = pool.next_unused_multiple(5, &no_key_source, true); + let addresses = pool.next_unused_multiple(5, &no_key_source); assert_eq!(addresses.len(), 3); // Only the 3 we generated // Mark one as used pool.mark_used(&addresses[0]); // Should now return only 2 unused addresses - let addresses = pool.next_unused_multiple(5, &no_key_source, true); + let addresses = pool.next_unused_multiple(5, &no_key_source); assert_eq!(addresses.len(), 2); } @@ -142,7 +142,7 @@ fn test_next_unused_multiple_large_batch() { let (key_source, _) = test_key_source(); // Test generating a large batch efficiently - let addresses = pool.next_unused_multiple(100, &key_source, true); + let addresses = pool.next_unused_multiple(100, &key_source); assert_eq!(addresses.len(), 100); assert_eq!(pool.highest_generated, Some(99)); @@ -163,7 +163,7 @@ fn test_next_unused_multiple_mixed_usage() { let (key_source, _) = test_key_source(); // Generate initial batch - let initial = pool.next_unused_multiple(10, &key_source, true); + let initial = pool.next_unused_multiple(10, &key_source); assert_eq!(initial.len(), 10); // Mark every other address as used @@ -172,7 +172,7 @@ fn test_next_unused_multiple_mixed_usage() { } // Request 8 addresses - should get 5 unused + 3 new - let next_batch = pool.next_unused_multiple(8, &key_source, true); + let next_batch = pool.next_unused_multiple(8, &key_source); assert_eq!(next_batch.len(), 8); // First 5 should be the unused ones from initial batch diff --git a/key-wallet/src/tests/changeset_mutation_tests.rs b/key-wallet/src/tests/changeset_mutation_tests.rs new file mode 100644 index 000000000..5c0676c7e --- /dev/null +++ b/key-wallet/src/tests/changeset_mutation_tests.rs @@ -0,0 +1,280 @@ +//! Tests for BDK-style mutate-and-return changeset APIs on `ManagedCoreAccount` +//! and `ManagedWalletInfo`. +//! +//! All mutation methods return [`crate::changeset::WalletChangeSet`] with +//! the per-account delta already written into `cs.per_account[self_type]`. +//! Orchestrators accumulate via uniform `result.changeset.merge(cs)`; no +//! address-based routing happens at accumulation time. + +use crate::account::{AccountType, StandardAccountType}; +use crate::changeset::Merge; +use crate::managed_account::address_pool::AddressPoolType; +use crate::managed_account::ManagedCoreAccount; +use crate::test_utils::TestWalletContext; +use crate::transaction_checking::TransactionContext; +use crate::Utxo; + +/// The default BIP44-0 account type produced by `TestWalletContext::new_random`. +fn bip44_0() -> AccountType { + AccountType::Standard { + index: 0, + standard_account_type: StandardAccountType::BIP44Account, + } +} + +// --------------------------------------------------------------------------- +// mark_address_used +// --------------------------------------------------------------------------- + +#[tokio::test] +async fn mark_address_used_populates_bucket_then_becomes_idempotent() { + let mut ctx = TestWalletContext::new_random(); + let addr = ctx.receive_address.clone(); + + // First call — address transitions from unused → used, per-account + // bucket carries it. + let (changed, cs) = ctx + .managed_wallet + .first_bip44_managed_account_mut() + .expect("bip44 account") + .mark_address_used(&addr); + assert!(changed, "first call must mark the address"); + + let bucket = cs.per_account.get(&bip44_0()).expect("bip44-0 bucket must exist"); + assert_eq!(bucket.addresses_used.len(), 1); + assert!(bucket.addresses_used.contains(&addr)); + assert!(bucket.highest_used.is_empty()); + assert!(bucket.utxos_added.is_empty()); + assert!(bucket.transactions.is_empty()); + // No wallet-level sub-changesets for a pure address-mark. + assert!(cs.balance.is_none()); + assert!(cs.chain.is_none()); + + // Second call on the same address — no-op, empty changeset. + let (changed, cs) = ctx + .managed_wallet + .first_bip44_managed_account_mut() + .expect("bip44 account") + .mark_address_used(&addr); + assert!(!changed); + assert!(cs.is_empty(), "no-op must produce an empty changeset"); +} + +// --------------------------------------------------------------------------- +// mark_utxos_instant_send +// --------------------------------------------------------------------------- + +#[test] +fn mark_utxos_instant_send_emits_outpoints_then_is_idempotent() { + let mut account = ManagedCoreAccount::dummy_bip44(); + let utxo = Utxo::dummy(1, 100_000, 10, false, true); + let outpoint = utxo.outpoint; + let txid = outpoint.txid; + account.insert_utxo(outpoint, utxo); + + // First call — UTXO transitions to IS-locked. + let (changed, cs) = account.mark_utxos_instant_send(&txid); + assert!(changed); + let bucket = cs.per_account.get(&bip44_0()).expect("bip44-0 bucket"); + assert_eq!(bucket.utxos_instant_locked.len(), 1); + assert!(bucket.utxos_instant_locked.contains(&outpoint)); + assert!(bucket.utxos_added.is_empty()); + assert!(bucket.utxos_spent.is_empty()); + + // Second call — no state change, empty changeset. + let (changed, cs) = account.mark_utxos_instant_send(&txid); + assert!(!changed); + assert!(cs.is_empty()); +} + +// --------------------------------------------------------------------------- +// update_transaction_context — in-place record update helper +// --------------------------------------------------------------------------- + +#[tokio::test] +async fn update_transaction_context_emits_record_only_when_context_changes() { + use crate::transaction_checking::BlockInfo; + use dashcore::BlockHash; + use dashcore_hashes::Hash; + + let (mut ctx, tx) = TestWalletContext::new_random().with_mempool_funding(50_000).await; + let txid = tx.txid(); + + let account = ctx.managed_wallet.first_bip44_managed_account_mut().expect("bip44 account"); + + let block_hash = BlockHash::from_slice(&[42u8; 32]).expect("hash"); + let block_ctx = TransactionContext::InBlock(BlockInfo::new(500, block_hash, 1_700_000_000)); + + // First call — context transitions mempool → block. + let cs = account.update_transaction_context(&txid, block_ctx.clone()); + let bucket = cs.per_account.get(&bip44_0()).expect("bip44-0 bucket"); + assert_eq!(bucket.transactions.len(), 1); + assert!(bucket.transactions.get(&txid).expect("record").is_confirmed()); + + // Second call with the same context — no-op. + let cs = account.update_transaction_context(&txid, block_ctx); + assert!(cs.is_empty(), "same-context update must be a no-op"); + + // Unknown txid — no-op. + let phantom = dashcore::Txid::from_slice(&[99u8; 32]).expect("hash"); + let cs = account.update_transaction_context(&phantom, TransactionContext::Mempool); + assert!(cs.is_empty()); +} + +// --------------------------------------------------------------------------- +// update_balance +// --------------------------------------------------------------------------- + +#[test] +fn update_balance_emits_signed_deltas_for_grows_and_shrinks() { + let mut account = ManagedCoreAccount::dummy_bip44(); + + // Baseline with one confirmed mature UTXO at 100k. + let utxo = Utxo::dummy(1, 100_000, 10, false, true); + let outpoint = utxo.outpoint; + account.insert_utxo(outpoint, utxo); + let cs = account.update_balance(100); + let bal_cs = cs.balance.expect("first call must emit delta from zero baseline"); + assert_eq!(bal_cs.confirmed_delta, 100_000); + + // No state change → empty changeset. + let cs = account.update_balance(100); + assert!(cs.is_empty()); + + // Add a second UTXO → positive delta. + let utxo2 = Utxo::dummy(2, 250_000, 10, false, true); + account.insert_utxo(utxo2.outpoint, utxo2); + let cs = account.update_balance(100); + let bal_cs = cs.balance.expect("balance must be populated"); + assert_eq!(bal_cs.confirmed_delta, 250_000); + + // Remove the first UTXO → negative delta. + account.remove_utxo(&outpoint); + let cs = account.update_balance(100); + let bal_cs = cs.balance.expect("balance must be populated"); + assert_eq!(bal_cs.confirmed_delta, -100_000); +} + +// --------------------------------------------------------------------------- +// update_utxos (via confirm_transaction): context upgrade must emit a +// bucket entry so a persister replay doesn't leave a stale Utxo. +// --------------------------------------------------------------------------- + +#[tokio::test] +async fn confirming_mempool_tx_emits_utxo_delta_for_context_upgrade() { + use crate::transaction_checking::BlockInfo; + use dashcore::BlockHash; + use dashcore_hashes::Hash; + + let (mut ctx, tx) = TestWalletContext::new_random().with_mempool_funding(300_000).await; + + let block_hash = BlockHash::from_slice(&[7u8; 32]).expect("hash"); + let block_ctx = TransactionContext::InBlock(BlockInfo::new(600, block_hash, 1_700_000_000)); + let result = ctx.check_transaction(&tx, block_ctx).await; + assert!(result.is_relevant); + + let bucket = result + .changeset + .per_account + .get(&bip44_0()) + .expect("bip44-0 bucket must be populated on confirmation upgrade"); + assert_eq!(bucket.utxos_added.len(), 1, "upgraded UTXO must be in utxos_added"); + let upgraded = bucket.utxos_added.values().next().expect("utxo"); + assert!(upgraded.is_confirmed); +} + +// --------------------------------------------------------------------------- +// Orchestrator: check_core_transaction accumulates per_account deltas +// --------------------------------------------------------------------------- + +#[tokio::test] +async fn check_core_transaction_populates_bucket_and_balance_on_new_funding() { + let mut ctx = TestWalletContext::new_random(); + let funding_tx = dashcore::Transaction::dummy(&ctx.receive_address, 0..1, &[500_000]); + let result = ctx.check_transaction(&funding_tx, TransactionContext::Mempool).await; + assert!(result.is_relevant); + assert!(result.is_new_transaction); + + let bucket = + result.changeset.per_account.get(&bip44_0()).expect("bip44-0 bucket must be populated"); + assert_eq!(bucket.transactions.len(), 1); + assert!(bucket.transactions.contains_key(&funding_tx.txid())); + assert_eq!(bucket.utxos_added.len(), 1); + assert!(bucket.utxos_spent.is_empty()); + assert!( + bucket.addresses_used.contains(&ctx.receive_address), + "funded address must be in addresses_used" + ); + assert!( + !bucket.highest_used.is_empty(), + "gap-limit pass must record highest_used for the external pool" + ); + assert!(bucket.highest_used.contains_key(&AddressPoolType::External)); + + // Balance lives at the wallet level, not per-account. + let bal_cs = result.changeset.balance.as_ref().expect("balance must be populated"); + assert_eq!(bal_cs.unconfirmed_delta, 500_000); +} + +#[tokio::test] +async fn check_core_transaction_confirmation_emits_transaction_delta() { + use crate::transaction_checking::BlockInfo; + use dashcore::BlockHash; + use dashcore_hashes::Hash; + + let (mut ctx, tx) = TestWalletContext::new_random().with_mempool_funding(180_000).await; + let txid = tx.txid(); + + let block_hash = BlockHash::from_slice(&[17u8; 32]).expect("hash"); + let block_ctx = TransactionContext::InBlock(BlockInfo::new(700, block_hash, 1_700_000_000)); + let result = ctx.check_transaction(&tx, block_ctx).await; + assert!(result.is_relevant); + assert!(!result.is_new_transaction); + + let bucket = result + .changeset + .per_account + .get(&bip44_0()) + .expect("bip44-0 bucket must carry the updated record"); + let updated = bucket.transactions.get(&txid).expect("updated record must be present"); + assert!(updated.is_confirmed()); +} + +// --------------------------------------------------------------------------- +// Merge semantics: multiple mutations accumulate into one changeset +// --------------------------------------------------------------------------- + +#[test] +fn wallet_changeset_merge_combines_mutations_from_separate_sources() { + use crate::changeset::{AccountChangeSet, BalanceChangeSet, WalletChangeSet}; + + let a_utxo = Utxo::dummy(1, 100, 1, false, true); + let b_utxo = Utxo::dummy(2, 200, 1, false, true); + + let mut cs1 = WalletChangeSet::default(); + cs1.account_bucket(bip44_0()).utxos_added.insert(a_utxo.outpoint, a_utxo.clone()); + cs1.balance = Some(BalanceChangeSet { + confirmed_delta: 100, + ..Default::default() + }); + + let mut cs2 = WalletChangeSet::default(); + { + let bucket: &mut AccountChangeSet = cs2.account_bucket(bip44_0()); + bucket.utxos_added.insert(b_utxo.outpoint, b_utxo.clone()); + bucket.utxos_instant_locked.insert(a_utxo.outpoint); + } + cs2.balance = Some(BalanceChangeSet { + confirmed_delta: 200, + ..Default::default() + }); + + cs1.merge(cs2); + + let merged_bucket = cs1.per_account.get(&bip44_0()).expect("bip44-0 bucket"); + assert_eq!(merged_bucket.utxos_added.len(), 2); + assert_eq!(merged_bucket.utxos_instant_locked.len(), 1); + + let merged_bal = cs1.balance.expect("balance should be present"); + assert_eq!(merged_bal.confirmed_delta, 300); +} diff --git a/key-wallet/src/tests/changeset_support_tests.rs b/key-wallet/src/tests/changeset_support_tests.rs new file mode 100644 index 000000000..68ba7b036 --- /dev/null +++ b/key-wallet/src/tests/changeset_support_tests.rs @@ -0,0 +1,167 @@ +//! Tests for methods added to support changeset-based persistence: +//! - `ManagedCoreAccount::insert_utxo` / `remove_utxo` (idempotent wrappers) +//! - `ManagedAccountCollection::get_by_account_type_mut` (apply routing) +//! - `Wallet::add_account` idempotency (deterministic derivation path) + +use crate::account::{AccountType, StandardAccountType}; +use crate::managed_account::ManagedCoreAccount; +use crate::wallet::managed_wallet_info::ManagedWalletInfo; +use crate::wallet::{initialization::WalletAccountCreationOptions, Wallet}; +use crate::{Network, Utxo}; + +// --------------------------------------------------------------------------- +// ManagedCoreAccount::insert_utxo / remove_utxo +// --------------------------------------------------------------------------- + +#[test] +fn insert_utxo_returns_true_on_new_entry() { + let mut account = ManagedCoreAccount::dummy_bip44(); + let utxo = Utxo::dummy(1, 100_000, 10, false, true); + let outpoint = utxo.outpoint; + + assert!(account.insert_utxo(outpoint, utxo), "new insert should return true"); + assert_eq!(account.utxos.len(), 1); + assert!(account.utxos.contains_key(&outpoint)); +} + +#[test] +fn insert_utxo_returns_false_on_existing_entry() { + let mut account = ManagedCoreAccount::dummy_bip44(); + let utxo = Utxo::dummy(1, 100_000, 10, false, true); + let outpoint = utxo.outpoint; + + // First insert — new + assert!(account.insert_utxo(outpoint, utxo.clone())); + // Second insert with same outpoint — replace, returns false + assert!(!account.insert_utxo(outpoint, utxo), "duplicate insert should return false"); + // Still only one UTXO in the map + assert_eq!(account.utxos.len(), 1); +} + +#[test] +fn remove_utxo_returns_the_removed_value() { + let mut account = ManagedCoreAccount::dummy_bip44(); + let utxo = Utxo::dummy(1, 100_000, 10, false, true); + let outpoint = utxo.outpoint; + let expected_value = utxo.txout.value; + account.insert_utxo(outpoint, utxo); + + let removed = account.remove_utxo(&outpoint); + assert!(removed.is_some()); + assert_eq!(removed.unwrap().txout.value, expected_value); + assert!(account.utxos.is_empty()); +} + +#[test] +fn remove_utxo_returns_none_for_missing_entry() { + let mut account = ManagedCoreAccount::dummy_bip44(); + let utxo = Utxo::dummy(1, 100_000, 10, false, true); + let outpoint = utxo.outpoint; + + // No insert — removal is a no-op that returns None. + assert!(account.remove_utxo(&outpoint).is_none()); +} + +#[test] +fn insert_remove_is_idempotent_for_replay() { + // Simulates `apply(changeset)` replaying the same changeset twice: + // the end state must be identical. + let mut account = ManagedCoreAccount::dummy_bip44(); + let utxo = Utxo::dummy(1, 100_000, 10, false, true); + let outpoint = utxo.outpoint; + + // First replay + account.insert_utxo(outpoint, utxo.clone()); + let snapshot_after_first = account.utxos.clone(); + + // Second replay with same data — should be a no-op semantically + account.insert_utxo(outpoint, utxo); + assert_eq!(account.utxos, snapshot_after_first); +} + +// --------------------------------------------------------------------------- +// Wallet::add_account idempotency (Phase 1 change) +// --------------------------------------------------------------------------- + +#[test] +fn add_account_idempotent_when_xpub_is_none() { + let mut wallet = + Wallet::new_random(Network::Testnet, WalletAccountCreationOptions::None).unwrap(); + + let acct_type = AccountType::Standard { + index: 0, + standard_account_type: StandardAccountType::BIP44Account, + }; + + // First call creates the account. + assert!(wallet.add_account(acct_type, None).is_ok()); + let count_after_first = wallet.accounts.count(); + + // Second call with None xpub is idempotent (derived from seed → same result). + assert!(wallet.add_account(acct_type, None).is_ok()); + assert_eq!( + wallet.accounts.count(), + count_after_first, + "idempotent add should not create a duplicate" + ); + + // Third call — same idempotency. + assert!(wallet.add_account(acct_type, None).is_ok()); + assert_eq!(wallet.accounts.count(), count_after_first); +} + +#[test] +fn add_account_errors_when_explicit_xpub_collides() { + // If an explicit xpub is provided for an already-existing account type, + // we must reject it to avoid silently overwriting with different keys. + let mut wallet = + Wallet::new_random(Network::Testnet, WalletAccountCreationOptions::None).unwrap(); + + let acct_type = AccountType::Standard { + index: 0, + standard_account_type: StandardAccountType::BIP44Account, + }; + + // Create the account via derivation. + wallet.add_account(acct_type, None).unwrap(); + + // Get the derived xpub so we can try to re-add with it. + let existing_xpub = + wallet.accounts.account_of_type(acct_type).expect("account should exist").account_xpub; + + // Explicit xpub for existing account type → error (even if it's the same xpub). + let result = wallet.add_account(acct_type, Some(existing_xpub)); + assert!(result.is_err(), "explicit xpub for existing account type must return an error"); +} + +// --------------------------------------------------------------------------- +// ManagedAccountCollection::get_by_account_type_mut — direct routing for +// apply_changeset's per_account bucket delegation. +// --------------------------------------------------------------------------- + +#[test] +fn get_by_account_type_mut_finds_bip44_account() { + let wallet = Wallet::new_random(Network::Testnet, WalletAccountCreationOptions::Default) + .expect("wallet"); + let mut info = ManagedWalletInfo::from_wallet(&wallet); + + let ty = AccountType::Standard { + index: 0, + standard_account_type: StandardAccountType::BIP44Account, + }; + let found = info.accounts.get_by_account_type_mut(ty); + assert!(found.is_some(), "default wallet must expose BIP44-0 by AccountType"); +} + +#[test] +fn get_by_account_type_mut_returns_none_for_missing_account_type() { + let wallet = + Wallet::new_random(Network::Testnet, WalletAccountCreationOptions::None).expect("wallet"); + let mut info = ManagedWalletInfo::from_wallet(&wallet); + + let ty = AccountType::Standard { + index: 42, + standard_account_type: StandardAccountType::BIP44Account, + }; + assert!(info.accounts.get_by_account_type_mut(ty).is_none()); +} diff --git a/key-wallet/src/tests/edge_case_tests.rs b/key-wallet/src/tests/edge_case_tests.rs index a65d0fac1..60b0d6007 100644 --- a/key-wallet/src/tests/edge_case_tests.rs +++ b/key-wallet/src/tests/edge_case_tests.rs @@ -150,12 +150,12 @@ fn test_duplicate_account_handling() { // First addition should succeed (wallet was created with None, so no accounts exist) let result1 = wallet.add_account(account_type, None); - // Duplicate addition should be handled gracefully + // Duplicate addition with derivation (no explicit xpub) is now idempotent. + // This allows `apply(changeset)` to replay account_keys without erroring. let result2 = wallet.add_account(account_type, None); - // First should succeed, second should fail due to duplicate assert!(result1.is_ok(), "First attempt to add account 0 should succeed"); - assert!(result2.is_err(), "Second attempt to add duplicate account 0 should error"); + assert!(result2.is_ok(), "Duplicate add with derivation should be a no-op (idempotent)"); } #[test] diff --git a/key-wallet/src/tests/mod.rs b/key-wallet/src/tests/mod.rs index d92850c79..6b1cb3664 100644 --- a/key-wallet/src/tests/mod.rs +++ b/key-wallet/src/tests/mod.rs @@ -8,6 +8,10 @@ mod balance_tests; mod address_pool_tests; +mod changeset_mutation_tests; + +mod changeset_support_tests; + mod advanced_transaction_tests; mod backup_restore_tests; diff --git a/key-wallet/src/tests/performance_tests.rs b/key-wallet/src/tests/performance_tests.rs index 9285ee54c..9b45e01e5 100644 --- a/key-wallet/src/tests/performance_tests.rs +++ b/key-wallet/src/tests/performance_tests.rs @@ -193,7 +193,7 @@ fn test_address_generation_batch_performance() { for batch_size in batch_sizes { let start = Instant::now(); - let _addresses = pool.generate_addresses(batch_size, &key_source, true).unwrap(); + let _addresses = pool.generate_addresses(batch_size, &key_source).unwrap(); let elapsed = start.elapsed(); let ops_per_second = batch_size as f64 / elapsed.as_secs_f64(); @@ -339,7 +339,7 @@ fn test_gap_limit_scan_performance() { .unwrap(); // Generate addresses with gaps - pool.generate_addresses(100, &key_source, true).unwrap(); + pool.generate_addresses(100, &key_source).unwrap(); // Mark some as used (with gaps) let used_indices = vec![0, 1, 5, 10, 25, 50, 75]; diff --git a/key-wallet/src/tests/wallet_tests.rs b/key-wallet/src/tests/wallet_tests.rs index 3c4546eda..5b993a63e 100644 --- a/key-wallet/src/tests/wallet_tests.rs +++ b/key-wallet/src/tests/wallet_tests.rs @@ -290,7 +290,8 @@ fn test_wallet_duplicate_account_error() { ) .unwrap(); - // Try to add the same account twice + // Try to add the same account twice with derivation — now idempotent + // to support `apply(changeset)` replay. let result = wallet.add_account( AccountType::Standard { index: 0, @@ -299,7 +300,7 @@ fn test_wallet_duplicate_account_error() { None, ); - assert!(result.is_err()); + assert!(result.is_ok(), "Duplicate add with derivation should be a no-op"); } #[test] diff --git a/key-wallet/src/transaction_checking/account_checker.rs b/key-wallet/src/transaction_checking/account_checker.rs index a9750f1aa..bb21d1430 100644 --- a/key-wallet/src/transaction_checking/account_checker.rs +++ b/key-wallet/src/transaction_checking/account_checker.rs @@ -7,6 +7,7 @@ use std::collections::BTreeMap; use super::transaction_router::AccountTypeToCheck; use crate::account::{ManagedAccountCollection, ManagedCoreAccount}; +use crate::changeset::WalletChangeSet; use crate::managed_account::address_pool::{AddressInfo, PublicKeyType}; use crate::managed_account::managed_account_type::ManagedAccountType; use crate::managed_account::transaction_record::TransactionRecord; @@ -28,7 +29,7 @@ pub enum AddressClassification { } /// Result of checking a transaction against accounts -#[derive(Debug, Clone)] +#[derive(Debug, Clone, Default)] pub struct TransactionCheckResult { /// Whether the transaction belongs to any account pub is_relevant: bool, @@ -48,6 +49,11 @@ pub struct TransactionCheckResult { pub new_addresses: Vec
, /// Transaction records created for new transactions, paired with their account index pub new_records: Vec<(u32, TransactionRecord)>, + /// Atomic changeset describing all state mutations from this check. + /// Accumulated from sub-changesets returned by `record_transaction`, + /// `confirm_transaction`, `mark_address_used`, `update_balance`, etc. + /// Empty when `state_modified` is false. + pub changeset: WalletChangeSet, } /// Enum representing the type of Core account that matched with embedded data @@ -366,17 +372,7 @@ impl ManagedAccountCollection { tx: &Transaction, account_types: &[AccountTypeToCheck], ) -> TransactionCheckResult { - let mut result = TransactionCheckResult { - is_relevant: false, - is_new_transaction: false, - state_modified: false, - affected_accounts: Vec::new(), - total_received: 0, - total_sent: 0, - total_received_for_credit_conversion: 0, - new_addresses: Vec::new(), - new_records: Vec::new(), - }; + let mut result = TransactionCheckResult::default(); for account_type in account_types { let matches = self.check_account_type(tx, *account_type); @@ -760,6 +756,17 @@ impl ManagedCoreAccount { // They should never be checked for Core chain transactions. return None; } + ManagedAccountType::IdentityAuthenticationEcdsa { + .. + } + | ManagedAccountType::IdentityAuthenticationBls { + .. + } => { + // DIP-13 identity authentication keys are Platform-only — + // they never hold L1 UTXOs and should not be reported as + // relevant for Core chain transactions. + return None; + } }; Some(AccountMatch { diff --git a/key-wallet/src/transaction_checking/transaction_router/mod.rs b/key-wallet/src/transaction_checking/transaction_router/mod.rs index e26152648..e39df1678 100644 --- a/key-wallet/src/transaction_checking/transaction_router/mod.rs +++ b/key-wallet/src/transaction_checking/transaction_router/mod.rs @@ -169,8 +169,11 @@ impl TransactionRouter { /// Core account types that can be checked for transactions /// -/// Note: Platform Payment accounts (DIP-17) are NOT included here as they -/// operate on Dash Platform, not the Core chain. +/// Note: Platform Payment accounts (DIP-17) and DIP-13 identity authentication +/// accounts (sub-feature 0') are NOT included here — they operate on Dash +/// Platform, not the Core chain, so the conversions from +/// [`ManagedAccountType`]/[`crate::AccountType`] explicitly return +/// [`PlatformAccountConversionError`] for those variants. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum AccountTypeToCheck { StandardBIP44, @@ -190,13 +193,14 @@ pub enum AccountTypeToCheck { DashpayExternalAccount, } -/// Error returned when trying to convert a Platform Payment account to AccountTypeToCheck +/// Error returned when trying to convert a Platform-only account (Platform +/// Payment or DIP-13 identity authentication) to [`AccountTypeToCheck`]. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct PlatformAccountConversionError; impl core::fmt::Display for PlatformAccountConversionError { fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { - write!(f, "PlatformPayment accounts cannot be converted to AccountTypeToCheck") + write!(f, "Platform-only accounts cannot be converted to AccountTypeToCheck") } } @@ -231,6 +235,16 @@ impl TryFrom for AccountTypeToCheck { ManagedAccountType::IdentityInvitation { .. } => Ok(AccountTypeToCheck::IdentityInvitation), + ManagedAccountType::IdentityAuthenticationEcdsa { + .. + } + | ManagedAccountType::IdentityAuthenticationBls { + .. + } => { + // DIP-13 per-identity authentication accounts are Platform-only, + // operating on Dash Platform rather than the Core chain. + Err(PlatformAccountConversionError) + } ManagedAccountType::AssetLockAddressTopUp { .. } => Ok(AccountTypeToCheck::AssetLockAddressTopUp), @@ -296,6 +310,16 @@ impl TryFrom<&ManagedAccountType> for AccountTypeToCheck { ManagedAccountType::IdentityInvitation { .. } => Ok(AccountTypeToCheck::IdentityInvitation), + ManagedAccountType::IdentityAuthenticationEcdsa { + .. + } + | ManagedAccountType::IdentityAuthenticationBls { + .. + } => { + // DIP-13 per-identity authentication accounts are Platform-only, + // operating on Dash Platform rather than the Core chain. + Err(PlatformAccountConversionError) + } ManagedAccountType::AssetLockAddressTopUp { .. } => Ok(AccountTypeToCheck::AssetLockAddressTopUp), diff --git a/key-wallet/src/transaction_checking/transaction_router/tests/coinbase.rs b/key-wallet/src/transaction_checking/transaction_router/tests/coinbase.rs index d918d8865..718009688 100644 --- a/key-wallet/src/transaction_checking/transaction_router/tests/coinbase.rs +++ b/key-wallet/src/transaction_checking/transaction_router/tests/coinbase.rs @@ -106,7 +106,7 @@ async fn test_coinbase_transaction_routing_to_bip44_change_address() { let change_address = managed_wallet_info .first_bip44_managed_account_mut() .expect("Failed to get first BIP44 managed account") - .next_change_address(Some(&xpub), true) + .next_change_address(Some(&xpub)) .expect("Failed to generate change address from BIP44 account"); // Create a coinbase transaction that pays to our change address diff --git a/key-wallet/src/transaction_checking/transaction_router/tests/identity_transactions.rs b/key-wallet/src/transaction_checking/transaction_router/tests/identity_transactions.rs index adcbd8717..5f1b52952 100644 --- a/key-wallet/src/transaction_checking/transaction_router/tests/identity_transactions.rs +++ b/key-wallet/src/transaction_checking/transaction_router/tests/identity_transactions.rs @@ -59,7 +59,7 @@ async fn test_identity_registration_account_routing() { .expect("Failed to get identity registration managed account"); // Use the new next_address method for identity registration account - let address = managed_account.next_address(Some(&xpub), true).expect("expected an address"); + let address = managed_account.next_address(Some(&xpub)).expect("expected an address"); // Create an Asset Lock transaction that funds identity registration use dashcore::opcodes; @@ -169,7 +169,7 @@ async fn test_normal_payment_to_identity_address_not_detected() { .expect("Failed to get identity registration managed account"); // Get an identity registration address - let address = managed_account.next_address(Some(&xpub), true).unwrap_or_else(|_| { + let address = managed_account.next_address(Some(&xpub)).unwrap_or_else(|_| { // Generate a dummy address for testing dashcore::Address::p2pkh( &dashcore::PublicKey::from_slice(&[0x03; 33]) diff --git a/key-wallet/src/transaction_checking/transaction_router/tests/provider.rs b/key-wallet/src/transaction_checking/transaction_router/tests/provider.rs index e257b8538..cde4c32b0 100644 --- a/key-wallet/src/transaction_checking/transaction_router/tests/provider.rs +++ b/key-wallet/src/transaction_checking/transaction_router/tests/provider.rs @@ -145,25 +145,25 @@ async fn test_provider_registration_transaction_routing_check_owner_only() { let managed_owner = managed_wallet_info .provider_owner_keys_managed_account_mut() .expect("Failed to get provider owner keys managed account"); - let owner_address = managed_owner.next_address(None, true).expect("expected owner address"); + let owner_address = managed_owner.next_address(None).expect("expected owner address"); let voting_address = other_managed_wallet_info .provider_voting_keys_managed_account_mut() .expect("Failed to get provider voting keys managed account") - .next_address(None, true) + .next_address(None) .expect("expected voting address"); let operator_public_key = other_managed_wallet_info .provider_operator_keys_managed_account_mut() .expect("Failed to get provider operator keys managed account") - .next_bls_operator_key(None, true) + .next_bls_operator_key(None) .expect("expected voting address"); // Payout addresses for providers are just regular addresses, not a separate account // For testing, we'll use the first standard account's address let payout_address = other_managed_wallet_info .first_bip44_managed_account_mut() - .and_then(|acc| acc.next_receive_address(None, true).ok()) + .and_then(|acc| acc.next_receive_address(None).ok()) .unwrap_or_else(|| { dashcore::Address::p2pkh( &dashcore::PublicKey::from_slice(&[0x02; 33]) @@ -280,25 +280,25 @@ async fn test_provider_registration_transaction_routing_check_voting_only() { let owner_address = other_managed_wallet_info .provider_owner_keys_managed_account_mut() .expect("Failed to get provider owner keys managed account") - .next_address(None, true) + .next_address(None) .expect("expected owner address"); let managed_voting = managed_wallet_info .provider_voting_keys_managed_account_mut() .expect("Failed to get provider voting keys managed account"); - let voting_address = managed_voting.next_address(None, true).expect("expected voting address"); + let voting_address = managed_voting.next_address(None).expect("expected voting address"); let operator_public_key = other_managed_wallet_info .provider_operator_keys_managed_account_mut() .expect("Failed to get provider operator keys managed account") - .next_bls_operator_key(None, true) + .next_bls_operator_key(None) .expect("expected operator key"); // Payout addresses for providers are just regular addresses, not a separate account // For testing, we'll use the first standard account's address let payout_address = other_managed_wallet_info .first_bip44_managed_account_mut() - .and_then(|acc| acc.next_receive_address(None, true).ok()) + .and_then(|acc| acc.next_receive_address(None).ok()) .unwrap_or_else(|| { dashcore::Address::p2pkh( &dashcore::PublicKey::from_slice(&[0x02; 33]) @@ -415,26 +415,26 @@ async fn test_provider_registration_transaction_routing_check_operator_only() { let owner_address = other_managed_wallet_info .provider_owner_keys_managed_account_mut() .expect("Failed to get provider owner keys managed account") - .next_address(None, true) + .next_address(None) .expect("expected owner address"); let voting_address = other_managed_wallet_info .provider_voting_keys_managed_account_mut() .expect("Failed to get provider voting keys managed account") - .next_address(None, true) + .next_address(None) .expect("expected voting address"); let managed_operator = managed_wallet_info .provider_operator_keys_managed_account_mut() .expect("Failed to get provider operator keys managed account"); let operator_public_key = - managed_operator.next_bls_operator_key(None, true).expect("expected operator key"); + managed_operator.next_bls_operator_key(None).expect("expected operator key"); // Payout addresses for providers are just regular addresses, not a separate account // For testing, we'll use the first standard account's address let payout_address = other_managed_wallet_info .first_bip44_managed_account_mut() - .and_then(|acc| acc.next_receive_address(None, true).ok()) + .and_then(|acc| acc.next_receive_address(None).ok()) .unwrap_or_else(|| { dashcore::Address::p2pkh( &dashcore::PublicKey::from_slice(&[0x02; 33]) @@ -596,19 +596,19 @@ async fn test_provider_registration_transaction_routing_check_platform_only() { let owner_address = other_managed_wallet_info .provider_owner_keys_managed_account_mut() .expect("Failed to get provider owner keys managed account") - .next_address(None, true) + .next_address(None) .expect("expected owner address"); let voting_address = other_managed_wallet_info .provider_voting_keys_managed_account_mut() .expect("Failed to get provider voting keys managed account") - .next_address(None, true) + .next_address(None) .expect("expected voting address"); let operator_public_key = other_managed_wallet_info .provider_operator_keys_managed_account_mut() .expect("Failed to get provider operator keys managed account") - .next_bls_operator_key(None, true) + .next_bls_operator_key(None) .expect("expected operator key"); // Get platform key from our wallet @@ -623,7 +623,7 @@ async fn test_provider_registration_transaction_routing_check_platform_only() { let eddsa_extended_key = root_key.to_eddsa_extended_priv_key(network).expect("expected EdDSA key"); let (_platform_key, info) = managed_platform - .next_eddsa_platform_key(eddsa_extended_key, true) + .next_eddsa_platform_key(eddsa_extended_key) .expect("expected platform key"); let platform_node_id = info.address; @@ -632,7 +632,7 @@ async fn test_provider_registration_transaction_routing_check_platform_only() { // For testing, we'll use the first standard account's address let payout_address = other_managed_wallet_info .first_bip44_managed_account_mut() - .and_then(|acc| acc.next_receive_address(None, true).ok()) + .and_then(|acc| acc.next_receive_address(None).ok()) .unwrap_or_else(|| { dashcore::Address::p2pkh( &dashcore::PublicKey::from_slice(&[0x02; 33]) @@ -791,14 +791,14 @@ async fn test_provider_update_registrar_with_voting_and_operator() { let voting_address = managed_wallet_info .provider_voting_keys_managed_account_mut() .expect("Failed to get provider voting keys managed account") - .next_address(None, true) + .next_address(None) .expect("expected voting address"); // Get BLS operator key let operator_public_key = managed_wallet_info .provider_operator_keys_managed_account_mut() .expect("Failed to get provider operator keys managed account") - .next_bls_operator_key(None, true) + .next_bls_operator_key(None) .expect("expected operator key"); let addr = test_addr(); @@ -873,7 +873,7 @@ async fn test_provider_revocation_classification_and_routing() { .expect("Failed to get first BIP44 managed account"); let return_address = managed_account - .next_receive_address(Some(&xpub), true) + .next_receive_address(Some(&xpub)) .expect("Failed to generate receive address"); let addr = test_addr(); diff --git a/key-wallet/src/transaction_checking/transaction_router/tests/routing.rs b/key-wallet/src/transaction_checking/transaction_router/tests/routing.rs index 85a9ce6df..482171a39 100644 --- a/key-wallet/src/transaction_checking/transaction_router/tests/routing.rs +++ b/key-wallet/src/transaction_checking/transaction_router/tests/routing.rs @@ -93,7 +93,7 @@ async fn test_transaction_routing_to_bip32_account() { .first_bip32_managed_account_mut() .expect("Failed to get first BIP32 managed account"); managed_account - .next_receive_address(Some(&xpub), true) + .next_receive_address(Some(&xpub)) .expect("Failed to generate receive address from BIP32 account") }; @@ -185,7 +185,7 @@ async fn test_transaction_routing_to_coinjoin_account() { .. } = &mut managed_account.account_type { - addresses.next_unused(&KeySource::Public(xpub), true).unwrap_or_else(|_| { + addresses.next_unused(&KeySource::Public(xpub)).unwrap_or_else(|_| { // If that fails, generate a dummy address for testing dashcore::Address::p2pkh( &dashcore::PublicKey::from_slice(&[0x02; 33]) @@ -273,7 +273,7 @@ async fn test_transaction_affects_multiple_accounts() { .bip44_managed_account_at_index_mut(0) .expect("Failed to get BIP44 managed account at index 0"); let address0 = managed_account0 - .next_receive_address(Some(&xpub0), true) + .next_receive_address(Some(&xpub0)) .expect("Failed to generate receive address for account 0"); // BIP44 account 1 @@ -287,7 +287,7 @@ async fn test_transaction_affects_multiple_accounts() { .bip44_managed_account_at_index_mut(1) .expect("Failed to get BIP44 managed account at index 1"); let address1 = managed_account1 - .next_receive_address(Some(&xpub1), true) + .next_receive_address(Some(&xpub1)) .expect("Failed to generate receive address for account 1"); // BIP32 account @@ -301,7 +301,7 @@ async fn test_transaction_affects_multiple_accounts() { .first_bip32_managed_account_mut() .expect("Failed to get first BIP32 managed account"); let address2 = managed_account2 - .next_receive_address(Some(&xpub2), true) + .next_receive_address(Some(&xpub2)) .expect("Failed to generate receive address for BIP32 account"); // Create a transaction that sends to multiple accounts @@ -376,7 +376,7 @@ fn test_next_address_method_restrictions() { .first_bip44_managed_account_mut() .expect("Failed to get first BIP44 managed account"); - let result = managed_account.next_address(Some(&xpub), true); + let result = managed_account.next_address(Some(&xpub)); assert!(result.is_err(), "Standard BIP44 accounts should reject next_address"); assert_eq!( result.expect_err("Expected an error when calling next_address on BIP44 account"), @@ -384,15 +384,15 @@ fn test_next_address_method_restrictions() { ); // But next_receive_address and next_change_address should work - assert!(managed_account.next_receive_address(Some(&xpub), true).is_ok()); - assert!(managed_account.next_change_address(Some(&xpub), true).is_ok()); + assert!(managed_account.next_receive_address(Some(&xpub)).is_ok()); + assert!(managed_account.next_change_address(Some(&xpub)).is_ok()); } // Test that standard BIP32 accounts reject next_address (if present) if let Some(bip32_account) = wallet.accounts.standard_bip32_accounts.get(&0) { let xpub = bip32_account.account_xpub; if let Some(managed_account) = managed_wallet_info.first_bip32_managed_account_mut() { - let result = managed_account.next_address(Some(&xpub), true); + let result = managed_account.next_address(Some(&xpub)); assert!(result.is_err(), "Standard BIP32 accounts should reject next_address"); assert_eq!( result.expect_err("Expected an error when calling next_address on BIP44 account"), @@ -408,7 +408,7 @@ fn test_next_address_method_restrictions() { .identity_registration_managed_account_mut() .expect("Failed to get identity registration managed account"); - let result = managed_account.next_address(Some(&xpub), true); + let result = managed_account.next_address(Some(&xpub)); // This should either succeed or fail with "No unused addresses available" // but NOT with "Standard accounts must use..." if let Err(e) = result { diff --git a/key-wallet/src/transaction_checking/wallet_checker.rs b/key-wallet/src/transaction_checking/wallet_checker.rs index 05a8625a1..8365b1d44 100644 --- a/key-wallet/src/transaction_checking/wallet_checker.rs +++ b/key-wallet/src/transaction_checking/wallet_checker.rs @@ -6,6 +6,7 @@ pub(crate) use super::account_checker::TransactionCheckResult; use super::transaction_context::TransactionContext; use super::transaction_router::TransactionRouter; +use crate::changeset::Merge; use crate::wallet::managed_wallet_info::wallet_info_interface::WalletInfoInterface; use crate::wallet::managed_wallet_info::ManagedWalletInfo; use crate::{KeySource, Wallet}; @@ -68,7 +69,7 @@ impl WalletTransactionChecker for ManagedWalletInfo { let mut is_new = true; for account_match in &result.affected_accounts { if let Some(account) = - self.accounts.get_by_account_type_match(&account_match.account_type_match) + self.accounts.get_by_account_type_matching(&account_match.account_type_match) { if account.transactions.contains_key(&txid) { is_new = false; @@ -87,7 +88,7 @@ impl WalletTransactionChecker for ManagedWalletInfo { // Only accept IS transitions for unconfirmed transactions let already_confirmed = result.affected_accounts.iter().any(|am| { self.accounts - .get_by_account_type_match(&am.account_type_match) + .get_by_account_type_matching(&am.account_type_match) .and_then(|a| a.transactions.get(&txid)) .map_or(false, |r| r.is_confirmed()) }); @@ -98,16 +99,16 @@ impl WalletTransactionChecker for ManagedWalletInfo { for account_match in &result.affected_accounts { if let Some(account) = self .accounts - .get_by_account_type_match_mut(&account_match.account_type_match) + .get_by_account_type_matching_mut(&account_match.account_type_match) { - account.mark_utxos_instant_send(&txid); - if let Some(record) = account.transactions.get_mut(&txid) { - record.update_context(context.clone()); - } + let (_, cs) = account.mark_utxos_instant_send(&txid); + result.changeset.merge(cs); + let cs = account.update_transaction_context(&txid, context.clone()); + result.changeset.merge(cs); } } if update_balance { - self.update_balance(); + result.changeset.merge(self.update_balance()); } result.state_modified = true; return result; @@ -121,24 +122,31 @@ impl WalletTransactionChecker for ManagedWalletInfo { // Process each affected account for account_match in result.affected_accounts.clone() { let Some(account) = - self.accounts.get_by_account_type_match_mut(&account_match.account_type_match) + self.accounts.get_by_account_type_matching_mut(&account_match.account_type_match) else { continue; }; if is_new { - let record = + let (record, cs) = account.record_transaction(tx, &account_match, context.clone(), tx_type); + result.changeset.merge(cs); if let Some(account_index) = account_match.account_type_match.account_index() { result.new_records.push((account_index, record)); } result.state_modified = true; - } else if account.confirm_transaction(tx, &account_match, context.clone(), tx_type) { - result.state_modified = true; + } else { + let (changed, cs) = + account.confirm_transaction(tx, &account_match, context.clone(), tx_type); + result.changeset.merge(cs); + if changed { + result.state_modified = true; + } } for address_info in account_match.account_type_match.all_involved_addresses() { - account.mark_address_used(&address_info.address); + let (_, cs) = account.mark_address_used(&address_info.address); + result.changeset.merge(cs); } let Some(xpub) = wallet.extended_public_key_for_account_type( @@ -150,6 +158,7 @@ impl WalletTransactionChecker for ManagedWalletInfo { let key_source = KeySource::Public(xpub); let rev_before = result.new_addresses.len(); + let account_type_for_bucket = account.account_type.to_account_type(); for pool in account.account_type.address_pools_mut() { match pool.maintain_gap_limit(&key_source) { Ok(addrs) => result.new_addresses.extend(addrs), @@ -162,6 +171,17 @@ impl WalletTransactionChecker for ManagedWalletInfo { ); } } + // Record the pool's highest-used index in the per-account + // bucket so `apply()` can restore the used watermark + // without re-scanning. + if let Some(highest_used) = pool.highest_used { + let bucket = result.changeset.account_bucket(account_type_for_bucket); + bucket + .highest_used + .entry(pool.pool_type) + .and_modify(|current| *current = (*current).max(highest_used)) + .or_insert(highest_used); + } } if result.new_addresses.len() > rev_before { account.bump_monitor_revision(); @@ -187,7 +207,7 @@ impl WalletTransactionChecker for ManagedWalletInfo { } if update_balance { - self.update_balance(); + result.changeset.merge(self.update_balance()); } result @@ -293,7 +313,7 @@ mod tests { let xpub = bip32_account.account_xpub; if let Some(managed_account) = managed_wallet.first_bip32_managed_account_mut() { let address = managed_account - .next_receive_address(Some(&xpub), true) + .next_receive_address(Some(&xpub)) .expect("Should get BIP32 address"); (Some(xpub), Some(address)) } else { @@ -328,7 +348,7 @@ mod tests { let xpub = coinjoin_account.account_xpub; if let Some(managed_account) = managed_wallet.first_coinjoin_managed_account_mut() { let address = managed_account - .next_address(Some(&xpub), true) + .next_address(Some(&xpub)) .expect("Should get CoinJoin address"); (Some(xpub), Some(address)) } else { @@ -748,7 +768,7 @@ mod tests { let change_address = managed_wallet .first_bip44_managed_account_mut() .expect("Should have managed account") - .next_change_address(Some(&xpub), true) + .next_change_address(Some(&xpub)) .expect("Should get change address"); // Create the funding transaction @@ -1054,7 +1074,8 @@ mod tests { let block_context = TransactionContext::InBlock(BlockInfo::new(600, block_hash, 1700000000)); let tx_type = TransactionRouter::classify_transaction(&tx); - let changed = account.confirm_transaction(&tx, &account_match, block_context, tx_type); + let (changed, _cs) = + account.confirm_transaction(&tx, &account_match, block_context, tx_type); assert!(changed, "Should return true when backfilling a missing record"); // Verify the transaction was recorded with block context @@ -1105,7 +1126,8 @@ mod tests { .first_bip44_managed_account_mut() .expect("Should have BIP44 account"); let tx_type = TransactionRouter::classify_transaction(&tx); - let changed = account.confirm_transaction(&tx, &account_match, block_context, tx_type); + let (changed, _cs) = + account.confirm_transaction(&tx, &account_match, block_context, tx_type); assert!(changed, "Should return true when confirming unconfirmed tx"); let record = account.transactions.get(&txid).expect("Should have record"); @@ -1162,7 +1184,7 @@ mod tests { .managed_wallet .first_bip44_managed_account_mut() .expect("account") - .next_receive_address(Some(&ctx.xpub), true) + .next_receive_address(Some(&ctx.xpub)) .expect("second receive address"); let multi_tx = Transaction { @@ -1209,7 +1231,7 @@ mod tests { .managed_wallet .first_bip44_managed_account_mut() .expect("account") - .next_change_address(Some(&ctx.xpub), true) + .next_change_address(Some(&ctx.xpub)) .expect("change address"); let send_amount = 600_000u64; @@ -1265,13 +1287,13 @@ mod tests { .managed_wallet .first_bip44_managed_account_mut() .expect("account") - .next_receive_address(Some(&ctx.xpub), true) + .next_receive_address(Some(&ctx.xpub)) .expect("self address"); let change_address = ctx .managed_wallet .first_bip44_managed_account_mut() .expect("account") - .next_change_address(Some(&ctx.xpub), true) + .next_change_address(Some(&ctx.xpub)) .expect("change address"); let self_amount = 800_000u64; @@ -1358,7 +1380,7 @@ mod tests { .managed_wallet .first_bip44_managed_account_mut() .expect("account") - .next_change_address(Some(&ctx.xpub), true) + .next_change_address(Some(&ctx.xpub)) .expect("change address"); let send_amount = 400_000u64; @@ -1511,7 +1533,7 @@ mod tests { .. } = &mut managed_account.account_type { - addresses.next_unused(&KeySource::Public(xpub), true).expect("coinjoin address") + addresses.next_unused(&KeySource::Public(xpub)).expect("coinjoin address") } else { panic!("Expected CoinJoin account type"); }; diff --git a/key-wallet/src/wallet/accounts.rs b/key-wallet/src/wallet/accounts.rs index f6c4041f1..8afb5e30e 100644 --- a/key-wallet/src/wallet/accounts.rs +++ b/key-wallet/src/wallet/accounts.rs @@ -30,6 +30,27 @@ impl Wallet { account_type: AccountType, account_xpub: Option, ) -> Result<()> { + let already_exists = self.accounts.contains_account_type(&account_type); + + // Idempotent for deterministic derivation: if the same account type + // already exists and no explicit xpub was provided, return Ok without + // re-deriving. The xpub is deterministic from (seed, derivation_path), + // so re-adding would produce the exact same account. This allows + // `apply(changeset)` to replay account_keys without erroring. + if already_exists && account_xpub.is_none() { + return Ok(()); + } + + // If an explicit xpub is provided for an account type that already + // exists, reject to prevent silently overwriting it with different + // keys. + if already_exists && account_xpub.is_some() { + return Err(Error::InvalidParameter(format!( + "Account type {:?} already exists for network {:?}", + account_type, self.network + ))); + } + // Get a unique wallet ID for this wallet first let wallet_id = self.get_wallet_id(); @@ -51,14 +72,6 @@ impl Wallet { Account::from_xpriv(Some(wallet_id), account_type, account_xpriv, self.network)? }; - // Check if account already exists - if self.accounts.contains_account_type(&account_type) { - return Err(Error::InvalidParameter(format!( - "Account type {:?} already exists for network {:?}", - account_type, self.network - ))); - } - // Insert into the collection self.accounts.insert(account).map_err(|e| Error::InvalidParameter(e.to_string())) } @@ -124,10 +137,13 @@ impl Wallet { /// Add a new BLS account to the wallet /// - /// BLS accounts are used for Platform/masternode operations. + /// BLS accounts are used for Platform/masternode operations. Accepts + /// [`AccountType::ProviderOperatorKeys`] and + /// [`AccountType::IdentityAuthenticationBls`]. /// /// # Arguments - /// * `account_type` - The type of account (must be ProviderOperatorKeys) + /// * `account_type` - The type of account (must be ProviderOperatorKeys + /// or IdentityAuthenticationBls) /// * `bls_seed` - Optional 32-byte seed for BLS key generation. If not provided, /// the account will be derived from the wallet's private key. /// @@ -140,9 +156,13 @@ impl Wallet { bls_seed: Option<[u8; 32]>, ) -> Result<()> { // Validate account type - if !matches!(account_type, AccountType::ProviderOperatorKeys) { + if !matches!( + account_type, + AccountType::ProviderOperatorKeys | AccountType::IdentityAuthenticationBls { .. } + ) { return Err(Error::InvalidParameter( - "BLS accounts can only be ProviderOperatorKeys".to_string(), + "BLS accounts can only be ProviderOperatorKeys or IdentityAuthenticationBls" + .to_string(), )); } @@ -200,9 +220,13 @@ impl Wallet { passphrase: &str, ) -> Result<()> { // Validate account type - if !matches!(account_type, AccountType::ProviderOperatorKeys) { + if !matches!( + account_type, + AccountType::ProviderOperatorKeys | AccountType::IdentityAuthenticationBls { .. } + ) { return Err(Error::InvalidParameter( - "BLS accounts can only be ProviderOperatorKeys".to_string(), + "BLS accounts can only be ProviderOperatorKeys or IdentityAuthenticationBls" + .to_string(), )); } diff --git a/key-wallet/src/wallet/managed_wallet_info/apply.rs b/key-wallet/src/wallet/managed_wallet_info/apply.rs new file mode 100644 index 000000000..1172304fb --- /dev/null +++ b/key-wallet/src/wallet/managed_wallet_info/apply.rs @@ -0,0 +1,432 @@ +//! Apply a [`WalletChangeSet`] onto a [`ManagedWalletInfo`] (and its +//! companion [`Wallet`]) during restore. +//! +//! This is the inverse of the mutation methods that emit changesets. Given a +//! persisted changeset, it replays each sub-changeset onto the in-memory +//! state so the wallet converges to the same state the original mutations +//! produced. +//! +//! # Invariants +//! +//! - **Idempotent.** Applying the same changeset N times produces the same +//! state as applying it once. Callers may re-run apply on startup or after +//! a partial write without additional bookkeeping. +//! - **Monotonic on state flags.** UTXO `is_confirmed` and `is_instantlocked` +//! never regress on replay: if an in-memory UTXO is already confirmed or +//! IS-locked and the changeset carries an earlier snapshot, the existing +//! state wins. Prevents a stale persisted changeset from reverting live +//! state under a race. +//! - **No re-emission.** `apply` does not return a new changeset. If it did, +//! `apply(apply(cs))` would cascade. All mutation bookkeeping done here +//! discards any inner changesets returned by helpers. +//! - **Best-effort routing for data entries.** UTXO / transaction changes +//! are routed to the owning account via the helpers in +//! `managed_wallet_info::helpers`. Entries that cannot be routed (e.g. an +//! orphaned UTXO whose address doesn't match any pool) are silently +//! skipped — the persister is authoritative and the runtime can't do +//! anything else with them. +//! - **Loud on account-key failures.** If re-deriving an HD account from +//! `account_keys.added` fails (for example, a watch-only wallet missing +//! the required xpub), `apply_changeset` returns an error rather than +//! silently skipping. A missing account cascades into dropping every +//! downstream entry, so callers need to know. + +use crate::account::AccountType; +use crate::changeset::WalletChangeSet; +use crate::wallet::managed_wallet_info::managed_account_operations::ManagedAccountOperations; +use crate::wallet::managed_wallet_info::wallet_info_interface::WalletInfoInterface; +use crate::wallet::managed_wallet_info::ManagedWalletInfo; +use crate::wallet::Wallet; + +/// Errors returned by [`ManagedWalletInfo::apply_changeset`]. +/// +/// Only restore failures that *cascade* (i.e. would cause downstream entries +/// to be silently dropped) are surfaced as errors. Orphan UTXOs / transaction +/// records that fail to route are logged and skipped, not reported. +#[derive(Debug, Clone)] +pub enum ApplyError { + /// Re-deriving an HD account from the changeset failed. Usually means + /// the target wallet lacks the key material (watch-only without the + /// xpub) or is on the wrong network. + AccountDerivationFailed { + account_type: AccountType, + reason: String, + }, +} + +impl core::fmt::Display for ApplyError { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + match self { + ApplyError::AccountDerivationFailed { + account_type, + reason, + } => write!( + f, + "failed to re-derive account {:?} during restore: {}", + account_type, reason + ), + } + } +} + +impl std::error::Error for ApplyError {} + +impl ManagedWalletInfo { + /// Apply a [`WalletChangeSet`] onto this managed wallet info, using + /// `wallet` as the source of HD key material for any account types + /// that need to be re-derived. + /// + /// Consumes the changeset by value so every owned field + /// (`Utxo`s, `TransactionRecord`s, etc.) moves directly into the + /// in-memory maps with no clones. The borrow form was deliberately + /// removed — the persister-load case (deserialize → apply once → + /// drop) makes any clone pure waste, and a `&` variant existing + /// alongside this one would just invite the wrong overload to be + /// reached for. Tests that need to apply the same changeset twice + /// `.clone()` it explicitly at the call site. + /// + /// See the module docs for invariants. Typical caller is + /// `WalletManager::apply_changeset`, which performs the split borrow + /// of `(&mut Wallet, &mut ManagedWalletInfo)` under a single write + /// lock. + pub fn apply_changeset( + &mut self, + wallet: &mut Wallet, + cs: WalletChangeSet, + ) -> Result<(), ApplyError> { + let WalletChangeSet { + account_keys, + chain, + balance: _, // see step 4 — recomputed from UTXOs, deltas ignored + per_account, + } = cs; + + // ------------------------------------------------------------------ + // 1. account_keys — re-derive HD accounts from the seed and mirror + // them into `self.accounts`. Must run first so every subsequent + // per-account bucket has an owning account to route into. + // Errors are fatal because a missing account cascades into + // dropping every downstream bucket. + // ------------------------------------------------------------------ + if let Some(account_keys) = account_keys { + for account_type in account_keys.added { + if let Err(e) = wallet.add_account(account_type, None) { + return Err(ApplyError::AccountDerivationFailed { + account_type, + reason: e.to_string(), + }); + } + // Mirror into the managed collection if not already present. + // `contains_account_type` checks both the ManagedCoreAccount + // collections and the separate ManagedPlatformAccount map, + // so PlatformPayment replays don't double-call add and hit + // the "already exists" error from add_managed_account. + if !self.accounts.contains_account_type(account_type) { + if let Err(e) = self.add_managed_account(wallet, account_type) { + return Err(ApplyError::AccountDerivationFailed { + account_type, + reason: e.to_string(), + }); + } + } + } + } + + // ------------------------------------------------------------------ + // 2. per_account buckets — each sub-changeset was emitted into its + // owning account's bucket at mutation time, so routing is a + // direct `get_by_account_type_mut` lookup. No address scanning, + // no txid scanning, no fallbacks. Buckets that don't resolve + // to an account (orphaned persisted data, wrong-network replay, + // split-brain between step 1 and the bucket loop) are skipped + // with a warning so the condition is observable. + // ------------------------------------------------------------------ + for (account_type, bucket) in per_account { + match self.accounts.get_by_account_type_mut(account_type) { + Some(account) => account.apply_changeset(account_type, bucket), + None => { + tracing::warn!( + ?account_type, + "dropping per_account bucket during apply: no owning managed account" + ); + } + } + } + + // ------------------------------------------------------------------ + // 3. chain — set synced height. `block_hash` is informational: the + // authoritative block hash lives on the confirmed transactions' + // contexts, already restored via the per_account buckets. + // ------------------------------------------------------------------ + if let Some(chain) = chain { + if let Some(height) = chain.synced_height { + if height > self.metadata.synced_height { + self.metadata.synced_height = height; + } + } + } + + // ------------------------------------------------------------------ + // 4. balance — recompute from the now-restored UTXO set. The + // cached deltas in `cs.balance` are ignored because UTXO-driven + // recomputation is authoritative after the UTXO set is in + // place. Discard the returned changeset (apply does not + // re-emit). + // ------------------------------------------------------------------ + let _ = self.update_balance(); + + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::managed_account::address_pool::AddressPoolType; + use crate::test_utils::TestWalletContext; + use crate::transaction_checking::{BlockInfo, TransactionContext}; + use dashcore::BlockHash; + use dashcore_hashes::Hash; + + /// Round-trip: mutate wallet A via check_core_transaction, capture the + /// changeset, apply it to a sibling wallet B built from the same + /// `Wallet` (so derivation paths match), and assert that B converges to + /// A's state. + #[tokio::test] + async fn apply_funding_changeset_mirrors_state_to_sibling_wallet() { + let mut ctx = TestWalletContext::new_random(); + + // Sibling B: a fresh ManagedWalletInfo built from the same Wallet. + // Same HD keys (so addresses match) but zero runtime state. + let mut wallet_b = ctx.wallet.clone(); + let mut info_b = ManagedWalletInfo::from_wallet(&wallet_b); + + // Fund A so its changeset carries every sub-field. + let funding_tx = dashcore::Transaction::dummy(&ctx.receive_address, 0..1, &[750_000]); + let result = ctx.check_transaction(&funding_tx, TransactionContext::Mempool).await; + assert!(result.is_relevant); + assert!(result.is_new_transaction); + + // Apply A's changeset to B. + info_b.apply_changeset(&mut wallet_b, result.changeset).expect("apply"); + + // Balance must match. + assert_eq!(info_b.balance.unconfirmed(), ctx.managed_wallet.balance.unconfirmed()); + assert_eq!(info_b.balance.spendable(), ctx.managed_wallet.balance.spendable()); + + // The UTXO must be present on B with the same value. + let account_b = info_b.first_bip44_managed_account().expect("bip44 on b"); + assert_eq!(account_b.utxos.len(), 1); + assert_eq!(account_b.utxos.values().next().expect("utxo").txout.value, 750_000); + + // The transaction record must be present on B. + assert!(account_b.transactions.contains_key(&funding_tx.txid())); + + // G1: pool.highest_used must match between A and B after apply. + let highest_a = ctx + .managed_wallet + .first_bip44_managed_account() + .expect("bip44 on a") + .account_type + .address_pools() + .iter() + .find(|p| p.pool_type == AddressPoolType::External) + .and_then(|p| p.highest_used); + let highest_b = account_b + .account_type + .address_pools() + .iter() + .find(|p| p.pool_type == AddressPoolType::External) + .and_then(|p| p.highest_used); + assert_eq!(highest_a, highest_b, "external pool highest_used must match"); + + // The address must be marked used on B — `mark_address_used` + // returns false on a second call. + let account_b_mut = info_b.first_bip44_managed_account_mut().expect("bip44 on b"); + let (changed, _) = account_b_mut.mark_address_used(&ctx.receive_address); + assert!(!changed, "address must already be marked used after apply"); + } + + #[tokio::test] + async fn apply_is_idempotent_on_sibling_wallet() { + let mut ctx = TestWalletContext::new_random(); + let mut wallet_b = ctx.wallet.clone(); + let mut info_b = ManagedWalletInfo::from_wallet(&wallet_b); + + let funding_tx = dashcore::Transaction::dummy(&ctx.receive_address, 0..1, &[321_000]); + let result = ctx.check_transaction(&funding_tx, TransactionContext::Mempool).await; + + // First apply. Clone explicitly because the second apply below + // needs the same changeset — apply consumes by value to avoid + // hidden clones in the persister-load hot path. + info_b.apply_changeset(&mut wallet_b, result.changeset.clone()).expect("apply"); + let snapshot_balance = info_b.balance; + let snapshot_utxo_count = info_b.first_bip44_managed_account().expect("bip44").utxos.len(); + let snapshot_tx_count = + info_b.first_bip44_managed_account().expect("bip44").transactions.len(); + + // Second apply — state must be identical. + info_b.apply_changeset(&mut wallet_b, result.changeset).expect("re-apply"); + assert_eq!(info_b.balance, snapshot_balance); + assert_eq!( + info_b.first_bip44_managed_account().expect("bip44").utxos.len(), + snapshot_utxo_count + ); + assert_eq!( + info_b.first_bip44_managed_account().expect("bip44").transactions.len(), + snapshot_tx_count + ); + } + + /// A mixed changeset with one bucket the target owns and one it + /// doesn't must apply the known bucket and silently skip the + /// unknown one — no partial failure, no exception. + #[tokio::test] + async fn apply_routes_known_bucket_and_skips_unknown() { + use crate::account::StandardAccountType; + use crate::changeset::{AccountChangeSet, WalletChangeSet}; + use crate::managed_account::address_pool::AddressPoolType; + + let ctx = TestWalletContext::new_random(); + let mut wallet_b = ctx.wallet.clone(); + let mut info_b = ManagedWalletInfo::from_wallet(&wallet_b); + + // Target owns BIP44-0 (installed by TestWalletContext::new_random). + let known_type = AccountType::Standard { + index: 0, + standard_account_type: StandardAccountType::BIP44Account, + }; + // Target does NOT own BIP44-99. + let unknown_type = AccountType::Standard { + index: 99, + standard_account_type: StandardAccountType::BIP44Account, + }; + + let mut cs = WalletChangeSet::default(); + // Known bucket: bump external pool's highest_used to 5. + let mut known = AccountChangeSet::default(); + known.highest_used.insert(AddressPoolType::External, 5); + cs.per_account.insert(known_type, known); + // Unknown bucket: should be silently dropped. + let mut unknown = AccountChangeSet::default(); + unknown.addresses_used.insert(dashcore::Address::dummy(dashcore::Network::Testnet, 7)); + cs.per_account.insert(unknown_type, unknown); + + info_b.apply_changeset(&mut wallet_b, cs).expect("apply"); + + // Known bucket landed: BIP44-0 external pool's highest_used == 5. + let account = info_b.first_bip44_managed_account().expect("bip44"); + let pool = account + .account_type + .address_pools() + .into_iter() + .find(|p| p.pool_type == AddressPoolType::External) + .expect("external pool"); + assert_eq!(pool.highest_used, Some(5)); + } + + /// G2: applying a changeset whose `account_keys.added` carries a new + /// account type must derive and install it on the target. + #[tokio::test] + async fn apply_account_keys_creates_new_managed_account() { + use crate::account::StandardAccountType; + use crate::wallet::initialization::WalletAccountCreationOptions; + + // Target starts with no BIP44 accounts at all. + let mut wallet_b = crate::wallet::Wallet::new_random( + crate::Network::Testnet, + WalletAccountCreationOptions::None, + ) + .expect("wallet b"); + let mut info_b = ManagedWalletInfo::from_wallet(&wallet_b); + assert!(info_b.first_bip44_managed_account().is_none()); + + // Build a changeset that adds the BIP44 account type. + let new_account_type = AccountType::Standard { + index: 0, + standard_account_type: StandardAccountType::BIP44Account, + }; + let cs = WalletChangeSet { + account_keys: Some(crate::changeset::AccountKeyChangeSet { + added: [new_account_type].into(), + }), + ..Default::default() + }; + + info_b.apply_changeset(&mut wallet_b, cs).expect("apply"); + + // The account must now exist on the target. + assert!( + info_b.first_bip44_managed_account().is_some(), + "account_keys replay must install the managed account" + ); + // And the wallet side must have it too. + assert!(wallet_b.accounts.contains_account_type(&new_account_type)); + } + + /// G3: applying a mempool→confirmed UTXO upgrade must flip + /// `is_confirmed` on the stored UTXO without losing the entry. + #[tokio::test] + async fn apply_upgrades_mempool_utxo_to_confirmed() { + let mut ctx = TestWalletContext::new_random(); + + // Sibling wallet B built from the same Wallet as ctx (identical + // HD keys → addresses match → apply can route). + let mut wallet_b = ctx.wallet.clone(); + let mut info_b = ManagedWalletInfo::from_wallet(&wallet_b); + + // Step 1: fund via mempool on A, capture the changeset, replay + // onto B. B ends up with an unconfirmed UTXO. + let funding_tx = dashcore::Transaction::dummy(&ctx.receive_address, 0..1, &[600_000]); + let mempool_cs = + ctx.check_transaction(&funding_tx, TransactionContext::Mempool).await.changeset; + info_b.apply_changeset(&mut wallet_b, mempool_cs).expect("apply mempool"); + let account_b = info_b.first_bip44_managed_account().expect("bip44"); + let utxo_b = account_b.utxos.values().next().expect("utxo"); + assert!(!utxo_b.is_confirmed, "precondition: mempool utxo is unconfirmed"); + + // Step 2: re-process the same tx with a block context on A and + // apply the resulting changeset to B. + let block_hash = BlockHash::from_slice(&[3u8; 32]).expect("hash"); + let block_ctx = TransactionContext::InBlock(BlockInfo::new(800, block_hash, 1_700_000_000)); + let block_cs = ctx.check_transaction(&funding_tx, block_ctx).await.changeset; + info_b.apply_changeset(&mut wallet_b, block_cs).expect("apply block"); + + // The UTXO must still be present and now confirmed. + let account_b = info_b.first_bip44_managed_account().expect("bip44"); + assert_eq!(account_b.utxos.len(), 1); + let utxo_b = account_b.utxos.values().next().expect("utxo"); + assert!(utxo_b.is_confirmed, "utxo must be upgraded to confirmed after apply"); + } + + /// F3 regression: a stale changeset carrying an unconfirmed UTXO must + /// not downgrade a live UTXO that has already advanced to confirmed + /// in memory. Flags are monotonic — true wins, false loses. + #[tokio::test] + async fn apply_does_not_downgrade_confirmed_utxo_on_stale_replay() { + let mut ctx = TestWalletContext::new_random(); + let mut wallet_b = ctx.wallet.clone(); + let mut info_b = ManagedWalletInfo::from_wallet(&wallet_b); + + // Capture the early mempool changeset. + let funding_tx = dashcore::Transaction::dummy(&ctx.receive_address, 0..1, &[400_000]); + let mempool_cs = + ctx.check_transaction(&funding_tx, TransactionContext::Mempool).await.changeset; + + // B lands the mempool tx first, then confirms it live. + info_b.apply_changeset(&mut wallet_b, mempool_cs.clone()).expect("apply mempool"); + { + let account = info_b.first_bip44_managed_account_mut().expect("bip44"); + for utxo in account.utxos.values_mut() { + utxo.is_confirmed = true; + } + } + + // Re-applying the stale mempool changeset must NOT regress + // is_confirmed back to false. + info_b.apply_changeset(&mut wallet_b, mempool_cs).expect("apply stale"); + let account_b = info_b.first_bip44_managed_account().expect("bip44"); + let utxo_b = account_b.utxos.values().next().expect("utxo"); + assert!(utxo_b.is_confirmed, "stale replay must not downgrade confirmed utxo"); + } +} diff --git a/key-wallet/src/wallet/managed_wallet_info/asset_lock_builder.rs b/key-wallet/src/wallet/managed_wallet_info/asset_lock_builder.rs index 1ebeaabda..517fdceba 100644 --- a/key-wallet/src/wallet/managed_wallet_info/asset_lock_builder.rs +++ b/key-wallet/src/wallet/managed_wallet_info/asset_lock_builder.rs @@ -235,7 +235,7 @@ impl ManagedWalletInfo { .accounts .standard_bip44_accounts .get_mut(&account_index) - .and_then(|account| account.next_change_address(xpub.as_ref(), true).ok()) + .and_then(|account| account.next_change_address(xpub.as_ref()).ok()) .ok_or(AssetLockError::NoChangeAddress)?; let synced_height = self.synced_height(); @@ -370,7 +370,7 @@ impl ManagedWalletInfo { .accounts .standard_bip44_accounts .get_mut(&account_index) - .and_then(|account| account.next_change_address(xpub.as_ref(), true).ok()) + .and_then(|account| account.next_change_address(xpub.as_ref()).ok()) .ok_or(AssetLockError::NoChangeAddress)?; let synced_height = self.synced_height(); @@ -764,7 +764,7 @@ mod tests { .standard_bip44_accounts .get_mut(&0) .unwrap() - .next_receive_address(Some(&account_xpub), true) + .next_receive_address(Some(&account_xpub)) .unwrap(); let utxo = Utxo { diff --git a/key-wallet/src/wallet/managed_wallet_info/mod.rs b/key-wallet/src/wallet/managed_wallet_info/mod.rs index 2fdf07666..8852a54fb 100644 --- a/key-wallet/src/wallet/managed_wallet_info/mod.rs +++ b/key-wallet/src/wallet/managed_wallet_info/mod.rs @@ -3,6 +3,7 @@ //! This module contains the mutable metadata and information about a wallet //! that is managed separately from the core wallet structure. +pub mod apply; pub mod asset_lock_builder; pub mod coin_selection; pub mod fee; diff --git a/key-wallet/src/wallet/managed_wallet_info/wallet_info_interface.rs b/key-wallet/src/wallet/managed_wallet_info/wallet_info_interface.rs index 33f7aba6c..99cbca2d2 100644 --- a/key-wallet/src/wallet/managed_wallet_info/wallet_info_interface.rs +++ b/key-wallet/src/wallet/managed_wallet_info/wallet_info_interface.rs @@ -6,6 +6,7 @@ use std::collections::BTreeSet; use super::managed_account_operations::ManagedAccountOperations; use crate::account::ManagedAccountTrait; +use crate::changeset::{Merge, WalletChangeSet}; use crate::managed_account::managed_account_collection::ManagedAccountCollection; use crate::transaction_checking::TransactionContext; use crate::transaction_checking::WalletTransactionChecker; @@ -68,8 +69,11 @@ pub trait WalletInfoInterface: Sized + WalletTransactionChecker + ManagedAccount /// Get the wallet balance fn balance(&self) -> WalletCoreBalance; - /// Update the wallet balance - fn update_balance(&mut self); + /// Recompute the wallet balance from per-account balances. + /// + /// Returns a [`WalletChangeSet`] carrying the signed balance delta between + /// the new and old balance. An empty changeset means no change. + fn update_balance(&mut self) -> WalletChangeSet; /// Get transaction history fn transaction_history(&self) -> Vec<&TransactionRecord>; @@ -92,8 +96,15 @@ pub trait WalletInfoInterface: Sized + WalletTransactionChecker + ManagedAccount /// Mark UTXOs for a transaction as InstantSend-locked across all accounts /// and update the corresponding transaction record context. - /// Returns `true` if any UTXO was newly marked. - fn mark_instant_send_utxos(&mut self, txid: &Txid, lock: &InstantLock) -> bool; + /// + /// Returns `(changed, cs)`: + /// - `changed` is `true` iff any UTXO transitioned to instant-locked. + /// - `cs` carries the UTXO + transaction + balance deltas for persistence. + fn mark_instant_send_utxos( + &mut self, + txid: &Txid, + lock: &InstantLock, + ) -> (bool, WalletChangeSet); /// Return the aggregated monitor revision across all accounts. /// Increments whenever the monitored address set changes. @@ -180,14 +191,16 @@ impl WalletInfoInterface for ManagedWalletInfo { self.balance } - fn update_balance(&mut self) { + fn update_balance(&mut self) -> WalletChangeSet { let mut balance = WalletCoreBalance::default(); let synced_height = self.synced_height(); + let mut cs = WalletChangeSet::default(); for account in self.accounts.all_accounts_mut() { - account.update_balance(synced_height); + cs.merge(account.update_balance(synced_height)); balance += *account.balance(); } self.balance = balance; + cs } fn transaction_history(&self) -> Vec<&TransactionRecord> { @@ -236,23 +249,27 @@ impl WalletInfoInterface for ManagedWalletInfo { self.update_balance(); } - fn mark_instant_send_utxos(&mut self, txid: &Txid, lock: &InstantLock) -> bool { + fn mark_instant_send_utxos( + &mut self, + txid: &Txid, + lock: &InstantLock, + ) -> (bool, WalletChangeSet) { + let mut cs = WalletChangeSet::default(); if !self.instant_send_locks.insert(*txid) { - return false; + return (false, cs); } + let new_context = TransactionContext::InstantSend(lock.clone()); let mut any_changed = false; for account in self.accounts.all_accounts_mut() { - if account.mark_utxos_instant_send(txid) { - any_changed = true; - } - if let Some(record) = account.transactions_mut().get_mut(txid) { - record.update_context(TransactionContext::InstantSend(lock.clone())); - } + let (changed, delta) = account.mark_utxos_instant_send(txid); + any_changed |= changed; + cs.merge(delta); + cs.merge(account.update_transaction_context(txid, new_context.clone())); } if any_changed { - self.update_balance(); + cs.merge(self.update_balance()); } - any_changed + (any_changed, cs) } fn monitor_revision(&self) -> u64 { diff --git a/key-wallet/src/wallet/mod.rs b/key-wallet/src/wallet/mod.rs index 91281bfa5..7c460a808 100644 --- a/key-wallet/src/wallet/mod.rs +++ b/key-wallet/src/wallet/mod.rs @@ -558,7 +558,7 @@ mod tests { ) .unwrap(); - // Test duplicate account creation should fail + // Duplicate account creation with derivation is idempotent (no-op) let result = wallet.add_account( AccountType::Standard { index: 0, @@ -566,7 +566,7 @@ mod tests { }, None, ); - assert!(result.is_err()); // Account 0 already exists + assert!(result.is_ok(), "Duplicate add with derivation should be a no-op"); // Default creates multiple accounts assert!(wallet.accounts.count() >= 2);