diff --git a/dash-spv-ffi/src/bin/ffi_cli.rs b/dash-spv-ffi/src/bin/ffi_cli.rs index bff43c352..c80b23bde 100644 --- a/dash-spv-ffi/src/bin/ffi_cli.rs +++ b/dash-spv-ffi/src/bin/ffi_cli.rs @@ -249,14 +249,23 @@ extern "C" fn on_wallet_block_processed( account_balances_count: u32, _addresses_derived: *const dash_spv_ffi::FFIDerivedAddress, addresses_derived_count: u32, + cl_height: u32, + cl_hash: *const [u8; 32], + _cl_signature: *const [u8; 96], _user_data: *mut c_void, ) { let wallet_short = short_wallet(wallet_id); let b = read_balance(balance); + let chainlocked = if cl_hash.is_null() { + "no".to_string() + } else { + format!("yes@{}", cl_height) + }; println!( - "[Wallet] Block processed: wallet={}..., height={}, inserted={}, updated={}, matured={}, balance[confirmed={}, unconfirmed={}, immature={}, locked={}], changed_accounts={}, derived={}", + "[Wallet] Block processed: wallet={}..., height={}, chainlock={}, inserted={}, updated={}, matured={}, balance[confirmed={}, unconfirmed={}, immature={}, locked={}], changed_accounts={}, derived={}", wallet_short, height, + chainlocked, inserted_count, updated_count, matured_count, @@ -269,6 +278,22 @@ extern "C" fn on_wallet_block_processed( ); } +extern "C" fn on_wallet_transactions_chainlocked( + wallet_id: *const c_char, + cl_height: u32, + _cl_hash: *const [u8; 32], + _cl_signature: *const [u8; 96], + _finalized: *const dash_spv_ffi::FFIChainlockedTxid, + finalized_count: u32, + _user_data: *mut c_void, +) { + let wallet_short = short_wallet(wallet_id); + println!( + "[Wallet] Transactions chainlocked: wallet={}..., cl_height={}, finalized={}", + wallet_short, cl_height, finalized_count, + ); +} + extern "C" fn on_sync_height_advanced( wallet_id: *const c_char, height: u32, @@ -503,6 +528,7 @@ fn main() { on_transaction_instant_locked: Some(on_transaction_instant_locked), on_block_processed: Some(on_wallet_block_processed), on_sync_height_advanced: Some(on_sync_height_advanced), + on_transactions_chainlocked: Some(on_wallet_transactions_chainlocked), user_data: ptr::null_mut(), }, error: FFIClientErrorCallback { diff --git a/dash-spv-ffi/src/callbacks.rs b/dash-spv-ffi/src/callbacks.rs index e62ec454b..0a56e4029 100644 --- a/dash-spv-ffi/src/callbacks.rs +++ b/dash-spv-ffi/src/callbacks.rs @@ -746,6 +746,13 @@ pub type OnTransactionInstantLockedCallback = Option< /// case (null pointer with zero count). Persisters should write these /// rows transactionally with the inserted/updated records. /// +/// `cl_hash` and `cl_signature` are non-null iff the processed block is +/// covered by the wallet's chainlock at processing time. When non-null, +/// every record in this event has an `InChainLockedBlock` context and +/// the carried chainlock is the proof that established it (`cl_height +/// >= height` by construction). When null, the block is above the +/// wallet's finality boundary and records are `InBlock`. +/// /// All array pointers and their contents are borrowed and only valid for the /// duration of the callback. pub type OnWalletBlockProcessedCallback = Option< @@ -763,6 +770,9 @@ pub type OnWalletBlockProcessedCallback = Option< account_balances_count: u32, addresses_derived: *const FFIDerivedAddress, addresses_derived_count: u32, + cl_height: u32, + cl_hash: *const [u8; 32], + cl_signature: *const [u8; 96], user_data: *mut c_void, ), >; @@ -776,6 +786,61 @@ pub type OnWalletBlockProcessedCallback = Option< pub type OnSyncHeightAdvancedCallback = Option; +/// One net-new chainlock-finalized txid, scoped to the account it was +/// promoted on. `WalletEvent::TransactionsChainlocked` delivers an +/// array of these — one entry per (account, txid) pair promoted by +/// the chainlock. +/// +/// `account_type` follows the same memory rules as on +/// [`FFIAccountBalance`]: the embedded `identity_user` / +/// `identity_friend` pointers (non-null only for Dashpay variants) +/// are owned by the `FFIAccountType` and freed when the array is +/// dropped after the callback returns. +#[repr(C)] +pub struct FFIChainlockedTxid { + /// Owning-account descriptor. + pub account_type: FFIAccountType, + /// Promoted transaction id. + pub txid: [u8; 32], +} + +impl FFIChainlockedTxid { + fn from_map(map: &BTreeMap>) -> Vec { + let mut out = Vec::new(); + for (account_type, txids) in map { + for txid in txids { + out.push(FFIChainlockedTxid { + account_type: FFIAccountType::from(account_type), + txid: *txid.as_byte_array(), + }); + } + } + out + } +} + +/// Callback for `WalletEvent::TransactionsChainlocked`. +/// +/// Fires once per wallet when a chainlock promotes one or more +/// previously-`InBlock` records to `InChainLockedBlock`. Carries the +/// signing proof and the net-new finalized txids grouped per account +/// in a flat array. No balance is delivered because a chainlock does +/// not change UTXO state, so the wallet balance is unchanged. +/// +/// All pointers are borrowed and only valid for the duration of the +/// callback. +pub type OnWalletTransactionsChainlockedCallback = Option< + extern "C" fn( + wallet_id: *const c_char, + cl_height: u32, + cl_hash: *const [u8; 32], + cl_signature: *const [u8; 96], + finalized: *const FFIChainlockedTxid, + finalized_count: u32, + user_data: *mut c_void, + ), +>; + /// Wallet event callbacks - one callback per WalletEvent variant. /// /// Set only the callbacks you're interested in; unset callbacks will be ignored. @@ -790,6 +855,7 @@ pub struct FFIWalletEventCallbacks { pub on_transaction_instant_locked: OnTransactionInstantLockedCallback, pub on_block_processed: OnWalletBlockProcessedCallback, pub on_sync_height_advanced: OnSyncHeightAdvancedCallback, + pub on_transactions_chainlocked: OnWalletTransactionsChainlockedCallback, pub user_data: *mut c_void, } @@ -804,6 +870,7 @@ impl Default for FFIWalletEventCallbacks { on_transaction_instant_locked: None, on_block_processed: None, on_sync_height_advanced: None, + on_transactions_chainlocked: None, user_data: std::ptr::null_mut(), } } @@ -981,6 +1048,7 @@ impl FFIWalletEventCallbacks { balance, account_balances, addresses_derived, + chain_lock, } => { if let Some(cb) = self.on_block_processed { let wallet_id_hex = hex::encode(wallet_id); @@ -1023,6 +1091,17 @@ impl FFIWalletEventCallbacks { } else { ffi_addresses_derived.as_ptr() }; + // Null pointers (and `cl_height=0`) when the block isn't + // chainlocked; non-null hash + signature pointers borrow + // from `chain_lock` for the duration of the callback. + let (cl_height_arg, cl_hash_arg, cl_signature_arg) = match chain_lock { + Some(cl) => ( + cl.block_height, + cl.block_hash.as_byte_array() as *const [u8; 32], + cl.signature.as_bytes() as *const [u8; 96], + ), + None => (0, ptr::null(), ptr::null()), + }; cb( c_wallet_id.as_ptr(), @@ -1038,6 +1117,9 @@ impl FFIWalletEventCallbacks { ffi_account_balances.len() as u32, addresses_derived_ptr, ffi_addresses_derived.len() as u32, + cl_height_arg, + cl_hash_arg, + cl_signature_arg, self.user_data, ); @@ -1058,6 +1140,34 @@ impl FFIWalletEventCallbacks { cb(c_wallet_id.as_ptr(), *height, self.user_data); } } + WalletEvent::TransactionsChainlocked { + wallet_id, + chain_lock, + per_account, + } => { + if let Some(cb) = self.on_transactions_chainlocked { + let wallet_id_hex = hex::encode(wallet_id); + let c_wallet_id = CString::new(wallet_id_hex).unwrap_or_default(); + let ffi_finalized = FFIChainlockedTxid::from_map(per_account); + let finalized_ptr = if ffi_finalized.is_empty() { + ptr::null() + } else { + ffi_finalized.as_ptr() + }; + + cb( + c_wallet_id.as_ptr(), + chain_lock.block_height, + chain_lock.block_hash.as_byte_array() as *const [u8; 32], + chain_lock.signature.as_bytes() as *const [u8; 96], + finalized_ptr, + ffi_finalized.len() as u32, + self.user_data, + ); + + drop(ffi_finalized); + } + } } } } diff --git a/dash-spv-ffi/tests/dashd_sync/callbacks.rs b/dash-spv-ffi/tests/dashd_sync/callbacks.rs index b207a5977..2311278f0 100644 --- a/dash-spv-ffi/tests/dashd_sync/callbacks.rs +++ b/dash-spv-ffi/tests/dashd_sync/callbacks.rs @@ -497,6 +497,9 @@ extern "C" fn on_wallet_block_processed( account_balances_count: u32, _addresses_derived: *const dash_spv_ffi::FFIDerivedAddress, _addresses_derived_count: u32, + _cl_height: u32, + _cl_hash: *const [u8; 32], + _cl_signature: *const [u8; 96], user_data: *mut c_void, ) { let Some(tracker) = (unsafe { tracker_from(user_data) }) else { @@ -617,6 +620,7 @@ pub(super) fn create_wallet_callbacks(tracker: &Arc) -> FFIWall on_transaction_instant_locked: Some(on_transaction_instant_locked), on_block_processed: Some(on_wallet_block_processed), on_sync_height_advanced: Some(on_sync_height_advanced), + on_transactions_chainlocked: None, user_data: Arc::as_ptr(tracker) as *mut c_void, } } diff --git a/dash-spv/src/client/event_handler.rs b/dash-spv/src/client/event_handler.rs index 537388aa4..68d89d2b3 100644 --- a/dash-spv/src/client/event_handler.rs +++ b/dash-spv/src/client/event_handler.rs @@ -6,13 +6,14 @@ use std::sync::Arc; -use tokio::sync::{broadcast, mpsc, watch}; +use tokio::sync::{broadcast, mpsc, watch, RwLock}; use tokio::task::JoinHandle; use tokio_util::sync::CancellationToken; use crate::network::NetworkEvent; use crate::sync::{SyncEvent, SyncProgress}; -use key_wallet_manager::WalletEvent; +use dashcore::ephemerealdata::chain_lock::ChainLock; +use key_wallet_manager::{WalletEvent, WalletInterface}; /// Trait for receiving SPV client events. /// @@ -155,6 +156,79 @@ pub(crate) fn spawn_progress_monitor( }) } +/// Spawns a task that drives chainlock-driven wallet promotion. +/// +/// Subscribes to [`SyncEvent::ChainLockReceived`] and +/// [`SyncEvent::SyncComplete`], serializing all `apply_chain_lock` +/// calls on a single task. During the initial sync cycle (cycle 0) +/// chainlock arrivals are buffered as the latest-seen height rather +/// than applied. At `SyncComplete { cycle: 0 }` the buffered height +/// is applied once, then every subsequent validated chainlock is +/// applied directly. Only validated chainlocks advance the wallet. +/// +/// This is the single-writer point that the architecture relies on: +/// promotions never interleave with each other. +pub(crate) fn spawn_chainlock_wallet_dispatch( + mut sync_event_rx: broadcast::Receiver, + wallet: Arc>, + shutdown: CancellationToken, + on_failure: mpsc::Sender, +) -> JoinHandle<()> +where + W: WalletInterface + 'static, +{ + const NAME: &str = "ChainLock→wallet dispatch"; + tokio::spawn(async move { + tracing::debug!("{} task started", NAME); + let mut initial_sync_complete = false; + let mut deferred_chain_lock: Option = None; + loop { + tokio::select! { + result = sync_event_rx.recv() => { + match result { + Ok(SyncEvent::ChainLockReceived { chain_lock, validated }) => { + if !validated { + continue; + } + if initial_sync_complete { + wallet.write().await.apply_chain_lock(chain_lock); + } else if deferred_chain_lock + .as_ref() + .is_none_or(|buffered| chain_lock.block_height > buffered.block_height) + { + deferred_chain_lock = Some(chain_lock); + } + } + Ok(SyncEvent::SyncComplete { cycle: 0, .. }) => { + initial_sync_complete = true; + if let Some(chain_lock) = deferred_chain_lock.take() { + wallet.write().await.apply_chain_lock(chain_lock); + } + } + Ok(_) => {} + Err(broadcast::error::RecvError::Closed) if shutdown.is_cancelled() => break, + Err(broadcast::error::RecvError::Closed) => { + let msg = format!("{} channel closed unexpectedly", NAME); + tracing::error!("{}", msg); + let _ = on_failure.try_send(msg); + break; + } + Err(broadcast::error::RecvError::Lagged(_)) if shutdown.is_cancelled() => break, + Err(broadcast::error::RecvError::Lagged(n)) => { + let msg = format!("{} lagged, missed {} events", NAME, n); + tracing::error!("{}", msg); + let _ = on_failure.try_send(msg); + break; + } + } + } + _ = shutdown.cancelled() => break, + } + } + tracing::debug!("{} task exiting", NAME); + }) +} + #[cfg(test)] mod tests { use std::net::SocketAddr; diff --git a/dash-spv/src/client/sync_coordinator.rs b/dash-spv/src/client/sync_coordinator.rs index d07644cc4..97a141a5e 100644 --- a/dash-spv/src/client/sync_coordinator.rs +++ b/dash-spv/src/client/sync_coordinator.rs @@ -5,7 +5,9 @@ use std::time::Duration; use tokio::sync::mpsc; use tokio_util::sync::CancellationToken; -use super::event_handler::{spawn_broadcast_monitor, spawn_progress_monitor}; +use super::event_handler::{ + spawn_broadcast_monitor, spawn_chainlock_wallet_dispatch, spawn_progress_monitor, +}; use super::DashSpvClient; use crate::error::Result; use crate::network::NetworkManager; @@ -35,6 +37,7 @@ impl DashSpvClient DashSpvClient DashSpvClient DashSpvClient { pub(super) requested_chainlocks: HashSet, /// Whether masternode sync is complete and we can validate signatures. pub(super) masternode_ready: bool, + /// Highest chainlock that arrived before `masternode_ready` and + /// therefore could not be validated yet. Re-validated on the + /// not-ready → ready transition (see [`Self::on_masternode_ready`]) + /// so we don't lose a chainlock that landed during the gap between + /// the chainlock manager starting and masternode sync completing. + pending_validation: Option, } impl ChainLockManager { @@ -59,6 +65,7 @@ impl ChainLockManager { best_chainlock: None, requested_chainlocks: HashSet::new(), masternode_ready: false, + pending_validation: None, }; // TODO: Move load_best_chainlock() and save_best_chainlock() to MetadataStorage trait. @@ -67,9 +74,39 @@ impl ChainLockManager { manager } - /// Notify the manager that masternode sync is complete. - pub(super) fn set_masternode_ready(&mut self) { + /// Apply the masternode-ready transition. + /// + /// Validates any chainlock cached in `pending_validation` (i.e. a + /// chainlock that arrived before masternode state was available) + /// and promotes it to `best_chainlock` on success. Returns the + /// chainlock that should be re-broadcast to downstream consumers, + /// preferring a freshly-promoted one over the persisted-from-disk + /// `best_chainlock`. Returns `None` if there's nothing to surface. + /// + /// Re-runs `verify_block_hash` on the pending chainlock before + /// validating the BLS signature: at the time the chainlock was + /// cached the header for that height may still have been missing + /// (in which case `verify_block_hash` returned `true` permissively), + /// but by the time masternode state is ready the header has + /// usually arrived. If the resolved header's hash now disagrees + /// with the chainlock's claimed block hash, the chainlock is + /// dropped instead of moving the finality boundary onto a block + /// the local chain doesn't match. + pub(super) async fn on_masternode_ready(&mut self) -> Option { self.masternode_ready = true; + + if let Some(pending) = self.pending_validation.take() { + if self.verify_block_hash(&pending).await && self.validate_signature(&pending).await { + self.progress.add_valid(1); + self.progress.update_best_validated_height(pending.block_height); + self.best_chainlock = Some(pending); + self.save_best_chainlock().await; + } else { + self.progress.add_invalid(1); + } + } + + self.best_chainlock.clone() } /// Process an incoming ChainLock message. @@ -100,12 +137,22 @@ impl ChainLockManager { return Ok(vec![]); } - // Only validate if masternode sync is complete + // Only validate if masternode sync is complete. Cache the + // highest pre-ready chainlock so the masternode-ready + // transition can retry validation rather than discarding it + // (`on_masternode_ready`). if !self.masternode_ready { tracing::debug!( - "Skipping ChainLock validation at height {} (masternode sync not complete)", + "Caching ChainLock at height {} for validation once masternode sync completes", height ); + let replace = self + .pending_validation + .as_ref() + .is_none_or(|existing| height > existing.block_height); + if replace { + self.pending_validation = Some(chainlock.clone()); + } return Ok(vec![SyncEvent::ChainLockReceived { chain_lock: chainlock.clone(), validated: false, @@ -301,12 +348,78 @@ mod tests { assert_eq!(manager.progress.valid(), 0); assert_eq!(manager.progress.invalid(), 0); assert!(manager.best_chainlock().is_none()); + // But the chainlock must be cached for retry once masternode + // state arrives, rather than discarded. + assert!(manager.pending_validation.is_some()); + } + + #[tokio::test] + async fn test_pending_validation_keeps_highest() { + let mut manager = create_test_manager().await; + + // Lower height first, then higher — pending_validation tracks + // the highest seen pre-ready chainlock so the retry on + // masternode-ready always validates the most recent. + let _ = manager.process_chainlock(&create_test_chainlock(100)).await.unwrap(); + let _ = manager.process_chainlock(&create_test_chainlock(200)).await.unwrap(); + let _ = manager.process_chainlock(&create_test_chainlock(150)).await.unwrap(); + + assert_eq!(manager.pending_validation.as_ref().map(|cl| cl.block_height), Some(200)); + } + + #[tokio::test] + async fn test_on_masternode_ready_rejects_pending_chainlock_on_block_hash_mismatch() { + let storage = DiskStorageManager::with_temp_dir().await.unwrap(); + let mut manager = create_test_manager_with_storage(&storage).await; + + // Cache a chainlock for height 100 BEFORE any header exists. + // `process_chainlock`'s permissive `verify_block_hash` lets it + // through and it lands in `pending_validation`. + let _ = manager.process_chainlock(&create_test_chainlock(100)).await.unwrap(); + assert!(manager.pending_validation.is_some()); + + // Header for height 100 resolves later with a hash that differs + // from the cached chainlock's `BlockHash::all_zeros()`. The + // readiness transition must re-check `verify_block_hash` and + // drop the chainlock instead of moving the finality boundary. + let header = dashcore::block::Header::dummy(100); + storage + .block_headers() + .write() + .await + .store_headers_at_height(&[header], 100) + .await + .expect("store header at 100"); + + let rebroadcast = manager.on_masternode_ready().await; + assert!(rebroadcast.is_none(), "mismatched chainlock must not be re-broadcast"); + assert!(manager.best_chainlock().is_none(), "mismatched chainlock must not be persisted"); + assert!(manager.pending_validation.is_none(), "pending_validation must be consumed"); + assert_eq!(manager.progress.invalid(), 1); + assert_eq!(manager.progress.valid(), 0); + } + + #[tokio::test] + async fn test_on_masternode_ready_retries_pending_validation() { + let mut manager = create_test_manager().await; + let _ = manager.process_chainlock(&create_test_chainlock(100)).await.unwrap(); + assert!(manager.pending_validation.is_some()); + + // With the default empty engine, validation fails — the + // pending chainlock is consumed (cleared) and counted as + // invalid; `best_chainlock` stays `None`. + let rebroadcast = manager.on_masternode_ready().await; + assert!(rebroadcast.is_none()); + assert!(manager.pending_validation.is_none()); + assert!(manager.best_chainlock().is_none()); + assert_eq!(manager.progress.invalid(), 1); + assert!(manager.masternode_ready); } #[tokio::test] async fn test_chainlock_validates_after_masternode_ready() { let mut manager = create_test_manager().await; - manager.set_masternode_ready(); + let _ = manager.on_masternode_ready().await; // After masternode sync, ChainLocks should be validated (will fail with empty engine) let chainlock = create_test_chainlock(100); diff --git a/dash-spv/src/sync/chainlock/sync_manager.rs b/dash-spv/src/sync/chainlock/sync_manager.rs index f9f6f3e18..2de260169 100644 --- a/dash-spv/src/sync/chainlock/sync_manager.rs +++ b/dash-spv/src/sync/chainlock/sync_manager.rs @@ -78,16 +78,28 @@ impl SyncManager for ChainLockManager event: &SyncEvent, _requests: &RequestSender, ) -> SyncResult> { - // Enable ChainLock validation when masternode state is available - if let SyncEvent::MasternodeStateUpdated { - .. - } = event - { - self.set_masternode_ready(); - if matches!(self.state(), SyncState::Syncing | SyncState::WaitForEvents) { - self.set_state(SyncState::Synced); - tracing::info!("ChainLock manager synced (masternode data available)"); - } + // `MasternodeStateUpdated` fires on every MnListDiff / QRInfo + // update; the work below is strictly one-shot startup work, so + // gate the entire branch on the not-ready transition. + if !matches!(event, SyncEvent::MasternodeStateUpdated { .. }) || self.masternode_ready { + return Ok(vec![]); + } + + let chainlock = self.on_masternode_ready().await; + self.set_state(SyncState::Synced); + tracing::info!("ChainLock manager synced (masternode data available)"); + + // Re-broadcast the best chainlock we know about so downstream + // consumers (e.g. the wallet manager's record promotion) learn + // pre-ready state without waiting for a fresh CLSig from the + // network. Covers both the persisted-from-disk case and a + // chainlock that arrived during initial sync but couldn't be + // validated until now. + if let Some(chain_lock) = chainlock { + return Ok(vec![SyncEvent::ChainLockReceived { + chain_lock, + validated: true, + }]); } Ok(vec![]) diff --git a/dash-spv/tests/dashd_masternode/helpers.rs b/dash-spv/tests/dashd_masternode/helpers.rs index c3cdb5cfb..9efcc9ac4 100644 --- a/dash-spv/tests/dashd_masternode/helpers.rs +++ b/dash-spv/tests/dashd_masternode/helpers.rs @@ -5,6 +5,7 @@ use dashcore::sml::masternode_list_engine::MasternodeListEngine; use dashcore::Txid; use key_wallet::transaction_checking::TransactionContext; use key_wallet_manager::WalletEvent; +use std::collections::HashSet; use std::time::Duration; use tokio::sync::{broadcast, watch}; use tokio::time; @@ -251,6 +252,142 @@ pub(super) async fn wait_for_instant_lock_received( } } +/// Wait for every txid in `txids` to be surfaced by the wallet as +/// chainlock-finalized, via either a +/// [`WalletEvent::TransactionsChainlocked`] event whose `per_account` +/// includes the txid (across any account) or a +/// [`WalletEvent::BlockProcessed`] event with `chain_lock = Some(..)` +/// whose `inserted` / `updated` list includes the txid. +/// +/// A single event can cover multiple txids at once (e.g. one chainlock +/// promotes several `InBlock` records into `InChainLockedBlock` in one +/// pass, or one chainlocked block contains several IS-locked txs that +/// confirm together), which is why this consumes the receiver in a +/// loop until every txid has been observed. +pub(super) async fn wait_for_wallet_txs_chainlocked( + event_receiver: &mut broadcast::Receiver, + txids: &[Txid], + timeout_secs: u64, +) { + let mut pending: HashSet = txids.iter().copied().collect(); + let timeout = time::sleep(Duration::from_secs(timeout_secs)); + tokio::pin!(timeout); + + while !pending.is_empty() { + tokio::select! { + _ = &mut timeout => { + panic!( + "Timeout waiting for chainlock finalization, {} txids still pending: {:?}", + pending.len(), pending, + ); + } + result = event_receiver.recv() => { + match result { + Ok(WalletEvent::TransactionsChainlocked { + chain_lock, + per_account, + .. + }) => { + for finalized in per_account.values().flatten() { + if pending.remove(finalized) { + tracing::info!( + "Wallet TransactionsChainlocked(chainlock_height={}, txid={})", + chain_lock.block_height, finalized, + ); + } + } + } + Ok(WalletEvent::BlockProcessed { + chain_lock: Some(cl), + inserted, + updated, + .. + }) => { + for record in inserted.iter().chain(updated.iter()) { + if pending.remove(&record.txid) { + tracing::info!( + "Wallet BlockProcessed(chainlock_height={}, txid={})", + cl.block_height, record.txid, + ); + } + } + } + Ok(other) => { + tracing::debug!("Ignoring wallet event: {}", other.description()); + } + Err(err) => { + panic!("Wallet event receiver failed: {}", err); + } + } + } + } + } +} + +/// Single-txid variant of [`wait_for_wallet_txs_chainlocked`] that +/// also returns the chainlock height that drove the promotion. Use +/// this when the test asserts on the promotion height. Otherwise +/// prefer the plural form which consumes events more robustly. +pub(super) async fn wait_for_wallet_tx_chainlocked( + event_receiver: &mut broadcast::Receiver, + txid: Txid, + timeout_secs: u64, +) -> u32 { + let timeout = time::sleep(Duration::from_secs(timeout_secs)); + tokio::pin!(timeout); + + loop { + tokio::select! { + _ = &mut timeout => { + panic!("Timeout waiting for TransactionsChainlocked carrying txid {}", txid); + } + result = event_receiver.recv() => { + match result { + Ok(WalletEvent::TransactionsChainlocked { + chain_lock, + per_account, + .. + }) if per_account + .values() + .any(|txids| txids.contains(&txid)) => + { + tracing::info!( + "Wallet TransactionsChainlocked(chainlock_height={}, txid={})", + chain_lock.block_height, txid + ); + return chain_lock.block_height; + } + Ok(WalletEvent::BlockProcessed { + chain_lock: Some(_), + inserted, + updated, + .. + }) if inserted.iter().chain(updated.iter()).any(|r| r.txid == txid) => + { + tracing::info!( + "Wallet BlockProcessed(chainlocked, txid={})", + txid + ); + return inserted + .iter() + .chain(updated.iter()) + .find(|r| r.txid == txid) + .and_then(|r| r.context.block_info().map(|i| i.height())) + .unwrap_or_default(); + } + Ok(other) => { + tracing::debug!("Ignoring wallet event: {}", other.description()); + continue; + } + Err(err) => { + panic!("Wallet event receiver failed: {}", err); + } + } + } + } + } +} + /// Wait for a wallet event about `txid` whose `TransactionContext` matches `pred`. /// /// Returns the matching context. Matches both `TransactionDetected` (first-time diff --git a/dash-spv/tests/dashd_masternode/main.rs b/dash-spv/tests/dashd_masternode/main.rs index 214b32964..7bb5564f7 100644 --- a/dash-spv/tests/dashd_masternode/main.rs +++ b/dash-spv/tests/dashd_masternode/main.rs @@ -10,5 +10,6 @@ mod helpers; mod setup; +mod tests_chainlock; mod tests_instantsend; mod tests_sync; diff --git a/dash-spv/tests/dashd_masternode/tests_chainlock.rs b/dash-spv/tests/dashd_masternode/tests_chainlock.rs new file mode 100644 index 000000000..692848f08 --- /dev/null +++ b/dash-spv/tests/dashd_masternode/tests_chainlock.rs @@ -0,0 +1,104 @@ +//! ChainLock-driven wallet finalization tests using the masternode network harness. +//! +//! These tests exercise the chainlock fan-out from the SPV layer's +//! [`ChainLockManager`] into the wallet manager's chainlock-driven +//! record promotion. Each scenario lands on the deferred-sync / +//! live-arrival contract: during initial sync the wallet ignores +//! chainlocks and applies one at `SyncComplete { cycle: 0 }`, after +//! which every validated chainlock immediately promotes the relevant +//! transactions and fires +//! [`key_wallet_manager::WalletEvent::TransactionsChainlocked`]. + +use std::sync::Arc; + +use dash_spv::sync::SyncState; +use dashcore::Amount; + +use super::helpers::{ + mine_dkg_cycle_and_wait, wait_for_chainlock_height_at_least, wait_for_masternode_sync, + wait_for_wallet_tx_chainlocked, +}; +use super::setup::{ + create_and_start_client, create_mn_test_config, create_wallet_from_controller, receive_address, + TestContext, SYNC_TIMEOUT, +}; + +/// Live arrival: send a tx into a block, mine through to a chainlock, +/// and assert the wallet emits [`WalletEvent::TransactionsChainlocked`] +/// carrying the tx's txid. +/// +/// Drives the full live path: the tx lands as `InBlock` during normal +/// block processing, and a later chainlock promotes its context to +/// `InChainLockedBlock`. +/// Under the default `keep-finalized-transactions=false` feature the +/// full record is dropped at that moment, but the txid lives on in the +/// emitted event so the consumer can persist the finalization. +#[tokio::test] +async fn test_chainlock_promotes_in_block_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_path().to_path_buf(), ctx.mn_ctx.controller_addr); + let mut client_handle = create_and_start_client(&config, Arc::clone(&wallet)).await; + + 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(); + + ctx.mn_ctx + .controller + .try_rpc_call( + "sporkupdate", + &["SPORK_2_INSTANTSEND_ENABLED".into(), 4_070_908_800i64.into()], + ) + .expect("disable SPORK_2_INSTANTSEND_ENABLED"); + + // Form a live signing quorum so the network can sign chainlocks for + // the blocks we're about to mine. + let post_dkg_height = + mine_dkg_cycle_and_wait(&mut ctx, &mut client_handle.sync_event_receiver, initial_height) + .await; + tracing::info!("Live signing quorum ready at height {}", post_dkg_height); + + // Send a tx from the controller to the SPV wallet. With IS disabled, + // dashd includes it in the next block as a normal mempool tx. + 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 txid={} to {}", txid, addr); + + // Mine until a chainlock is produced. The chainlock will cover the + // block carrying our tx and trigger the wallet-side promotion. + let cl_height = ctx + .mn_ctx + .mine_blocks_and_wait_for_chainlock(3, 60) + .expect("ChainLock should be produced after DKG cycle completion"); + + // SPV catches up to the chainlock height. + 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); + + // Wallet event must surface chainlock-driven finality for our txid. + let promoted_at = wait_for_wallet_tx_chainlocked( + &mut client_handle.wallet_event_receiver, + txid, + SYNC_TIMEOUT, + ) + .await; + assert!( + promoted_at >= cl_height, + "wallet promotion height ({}) must reach the network chainlock height ({})", + promoted_at, + cl_height + ); + + client_handle.stop().await; +} diff --git a/dash-spv/tests/dashd_masternode/tests_instantsend.rs b/dash-spv/tests/dashd_masternode/tests_instantsend.rs index 315678131..b1d4fa96b 100644 --- a/dash-spv/tests/dashd_masternode/tests_instantsend.rs +++ b/dash-spv/tests/dashd_masternode/tests_instantsend.rs @@ -15,6 +15,7 @@ use super::helpers::{ mine_dkg_cycle_and_wait, 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_with_stored_cycle_above, wait_for_wallet_tx_status, + wait_for_wallet_txs_chainlocked, }; use super::setup::{ create_and_start_client, create_mn_test_config, create_wallet_from_controller, receive_address, @@ -129,6 +130,16 @@ async fn test_instantsend_full_lifecycle() { assert!(cl_sync_height >= cl_height); tracing::info!("SPV synced to ChainLocked height {}", cl_sync_height); + // Wallet-side assertion: every previously-IS-locked tx must now be + // surfaced as chainlock-finalized. A single `BlockProcessed + // { chain_lock: Some(..) }` event can cover all of them at once + // when they confirm in the same chainlocked block, so use the + // plural helper rather than a per-txid wait that would only + // consume the first event. + wait_for_wallet_txs_chainlocked(&mut client_handle.wallet_event_receiver, &txids, SYNC_TIMEOUT) + .await; + tracing::info!("All {} txs wallet-finalized via chainlock", NUM_TXS); + client_handle.stop().await; } diff --git a/key-wallet-manager/src/event_tests.rs b/key-wallet-manager/src/event_tests.rs index beb48fa95..c8c33fca6 100644 --- a/key-wallet-manager/src/event_tests.rs +++ b/key-wallet-manager/src/event_tests.rs @@ -6,6 +6,7 @@ use dashcore::blockdata::script::Builder; use dashcore::blockdata::transaction::special_transaction::asset_lock::AssetLockPayload; use dashcore::blockdata::transaction::special_transaction::TransactionPayload; use dashcore::bls_sig_utils::BLSSignature; +use dashcore::ephemerealdata::chain_lock::ChainLock; use dashcore::ephemerealdata::instant_lock::InstantLock; use dashcore::hash_types::CycleHash; use dashcore::hashes::Hash; @@ -17,6 +18,7 @@ use key_wallet::account::StandardAccountType; use key_wallet::managed_account::address_pool::AddressPoolType; use key_wallet::managed_account::managed_account_trait::ManagedAccountTrait; use key_wallet::managed_account::managed_account_type::ManagedAccountType; +use key_wallet::wallet::managed_wallet_info::transaction_building::AccountTypePreference; use key_wallet::AccountType; use std::collections::BTreeSet; @@ -344,6 +346,7 @@ async fn test_block_with_new_tx_emits_inserted_record() { balance, account_balances, addresses_derived: _, + chain_lock: _, } => { assert_eq!(*wid, wallet_id); assert_eq!(*height, 100); @@ -409,6 +412,7 @@ async fn test_block_confirming_known_mempool_tx_emits_updated_record() { balance, account_balances, addresses_derived: _, + chain_lock: _, } => { assert_eq!(*wid, wallet_id); assert_eq!(*height, 200); @@ -1042,3 +1046,163 @@ async fn test_instant_send_lock_event_does_not_carry_addresses_derived_field() { _ => unreachable!(), } } + +// --------------------------------------------------------------------------- +// ChainLock path +// --------------------------------------------------------------------------- + +#[tokio::test] +async fn test_apply_chain_lock_promotes_in_block_record_and_emits_event() { + let (mut manager, wallet_id, addr) = setup_manager_with_wallet(); + let tx = create_tx_paying_to(&addr, 0xa1); + let block = make_block(vec![tx.clone()], 0xa1, 1000); + let wallets = BTreeSet::from([wallet_id]); + manager.process_block_for_wallets(&block, 100, &wallets).await; + + let mut rx = manager.subscribe_events(); + manager.apply_chain_lock(ChainLock::dummy(100)); + + let events = drain_events(&mut rx); + assert_eq!(events.len(), 1, "exactly one TransactionsChainlocked event expected"); + match &events[0] { + WalletEvent::TransactionsChainlocked { + wallet_id: wid, + chain_lock, + per_account, + } => { + assert_eq!(*wid, wallet_id); + assert_eq!(chain_lock.block_height, 100); + let receiving = AccountType::Standard { + index: 0, + standard_account_type: StandardAccountType::BIP44Account, + }; + let txids = per_account + .get(&receiving) + .expect("the receiving account should have a promotion entry"); + assert_eq!(txids, &vec![tx.txid()]); + } + other => panic!("expected TransactionsChainlocked, got {:?}", other), + } +} + +#[tokio::test] +async fn test_apply_chain_lock_with_no_records_emits_no_event_but_advances_boundary() { + let (mut manager, wallet_id, _addr) = setup_manager_with_wallet(); + let mut rx = manager.subscribe_events(); + manager.apply_chain_lock(ChainLock::dummy(500)); + + assert_no_events(&mut rx); + + // Subsequent block below the new finality boundary must be born chainlocked. + let addr = manager + .next_receive_address(&wallet_id, 0, AccountTypePreference::BIP44, true) + .expect("address generation"); + let tx = create_tx_paying_to(&addr, 0xa2); + let block = make_block(vec![tx.clone()], 0xa2, 1100); + let wallets = BTreeSet::from([wallet_id]); + manager.process_block_for_wallets(&block, 100, &wallets).await; + + let events = drain_events(&mut rx); + let bp = events + .iter() + .find(|e| matches!(e, WalletEvent::BlockProcessed { .. })) + .expect("BlockProcessed expected after late block below finality boundary"); + match bp { + WalletEvent::BlockProcessed { + chain_lock, + inserted, + .. + } => { + assert!(chain_lock.is_some(), "block below finality boundary must carry the chainlock"); + assert!( + matches!(inserted[0].context, TransactionContext::InChainLockedBlock(_)), + "late-block path must record the tx as InChainLockedBlock, got {:?}", + inserted[0].context + ); + } + _ => unreachable!(), + } + let chainlock_event_count = + events.iter().filter(|e| matches!(e, WalletEvent::TransactionsChainlocked { .. })).count(); + assert_eq!( + chainlock_event_count, 0, + "late-block path must not double-emit TransactionsChainlocked for newly-born chainlocked txs" + ); +} + +#[tokio::test] +async fn test_apply_chain_lock_is_idempotent_on_already_finalized() { + let (mut manager, wallet_id, addr) = setup_manager_with_wallet(); + let tx = create_tx_paying_to(&addr, 0xa3); + let block = make_block(vec![tx.clone()], 0xa3, 1200); + let wallets = BTreeSet::from([wallet_id]); + manager.process_block_for_wallets(&block, 50, &wallets).await; + + let mut rx = manager.subscribe_events(); + manager.apply_chain_lock(ChainLock::dummy(50)); + let first = drain_events(&mut rx); + assert_eq!( + first.iter().filter(|e| matches!(e, WalletEvent::TransactionsChainlocked { .. })).count(), + 1, + "first chainlock must emit exactly one TransactionsChainlocked" + ); + + // Replaying the same chainlock, or applying a higher one with no + // outstanding InBlock records below it, must not re-emit. + manager.apply_chain_lock(ChainLock::dummy(50)); + manager.apply_chain_lock(ChainLock::dummy(80)); + + assert_no_events(&mut rx); +} + +#[tokio::test] +async fn test_block_processed_chainlocked_flag_matches_record_context() { + let (mut manager, wallet_id, addr) = setup_manager_with_wallet(); + + // Below the finality boundary: chain_lock=Some, records InChainLockedBlock. + manager.apply_chain_lock(ChainLock::dummy(1000)); + let tx_below = create_tx_paying_to(&addr, 0xa4); + let block_below = make_block(vec![tx_below.clone()], 0xa4, 1300); + let wallets = BTreeSet::from([wallet_id]); + let mut rx = manager.subscribe_events(); + manager.process_block_for_wallets(&block_below, 500, &wallets).await; + + let events_below = drain_events(&mut rx); + let bp_below = events_below + .iter() + .find(|e| matches!(e, WalletEvent::BlockProcessed { .. })) + .expect("BlockProcessed expected"); + if let WalletEvent::BlockProcessed { + chain_lock, + inserted, + .. + } = bp_below + { + let cl = chain_lock.as_ref().expect("block below finality boundary must carry chainlock"); + assert_eq!(cl.block_height, 1000); + assert!(matches!(inserted[0].context, TransactionContext::InChainLockedBlock(_))); + } + + // Above the finality boundary: chain_lock=None, records InBlock. + let addr2 = manager + .next_receive_address(&wallet_id, 0, AccountTypePreference::BIP44, true) + .expect("address generation"); + let tx_above = create_tx_paying_to(&addr2, 0xa5); + let block_above = make_block(vec![tx_above.clone()], 0xa5, 1400); + manager.process_block_for_wallets(&block_above, 2000, &wallets).await; + + let events_above = drain_events(&mut rx); + let bp_above = events_above + .iter() + .find(|e| matches!(e, WalletEvent::BlockProcessed { .. })) + .expect("BlockProcessed expected"); + if let WalletEvent::BlockProcessed { + chain_lock, + inserted, + .. + } = bp_above + { + assert!(chain_lock.is_none()); + assert!(matches!(inserted[0].context, TransactionContext::InBlock(_))); + } +} diff --git a/key-wallet-manager/src/events.rs b/key-wallet-manager/src/events.rs index 248caf45e..b1d394cb0 100644 --- a/key-wallet-manager/src/events.rs +++ b/key-wallet-manager/src/events.rs @@ -6,6 +6,7 @@ use std::collections::BTreeMap; +use dashcore::ephemerealdata::chain_lock::ChainLock; use dashcore::ephemerealdata::instant_lock::InstantLock; use dashcore::prelude::CoreBlockHeight; use dashcore::Txid; @@ -232,6 +233,16 @@ pub enum WalletEvent { wallet_id: WalletId, /// Height of the block that was processed. height: CoreBlockHeight, + /// `Some(chain_lock)` iff the wallet's finality boundary at + /// processing time covers `height`, meaning every record in + /// this event has a [`key_wallet::transaction_checking::TransactionContext::InChainLockedBlock`] + /// context and the transactions are already finalized. The + /// chainlock carried here is the proof that established the + /// boundary, retained so consumers can persist the signing + /// proof alongside the block. By construction + /// `chain_lock.block_height >= height` whenever `Some`. The + /// per-record `context` is the source of truth and will agree. + chain_lock: Option, /// Records first stored for this wallet in this block. inserted: Vec, /// Previously-known records confirmed by this block. @@ -264,6 +275,34 @@ pub enum WalletEvent { /// New scanned height for the wallet. height: CoreBlockHeight, }, + /// Previously-recorded `InBlock` transactions were promoted to + /// [`key_wallet::transaction_checking::TransactionContext::InChainLockedBlock`] because a chainlock now + /// covers their height. Emitted by the wallet manager after the + /// coordinator applies a chainlock. Carries only net-new + /// promotions, grouped per account. + /// + /// Transactions born directly in a chainlocked block (block at + /// height `<= last_applied_chain_lock.block_height` at processing + /// time) are surfaced via [`WalletEvent::BlockProcessed`] with + /// `chain_lock = Some(..)` and their records already in + /// `InChainLockedBlock` context. They do not appear here, since no + /// promotion took place. + TransactionsChainlocked { + /// ID of the affected wallet. + wallet_id: WalletId, + /// The chainlock that drove this batch of promotions. Carries + /// the signing proof (`block_height`, `block_hash`, + /// `signature`) so consumers can persist it alongside the + /// promotions. The wallet's `last_applied_chain_lock` is + /// advanced to this chainlock (clamped forward by height). + chain_lock: ChainLock, + /// Per-account net-new finalized txids: records that flipped + /// from `InBlock` to `InChainLockedBlock` in this promotion. + /// Accounts with no net-new promotions are omitted. No balance + /// is carried because a chainlock does not change UTXO state + /// (only the certainty of the parent transaction). + per_account: BTreeMap>, + }, } impl WalletEvent { @@ -285,6 +324,10 @@ impl WalletEvent { | WalletEvent::SyncHeightAdvanced { wallet_id, .. + } + | WalletEvent::TransactionsChainlocked { + wallet_id, + .. } => *wallet_id, } } @@ -323,6 +366,7 @@ impl WalletEvent { } WalletEvent::BlockProcessed { height, + chain_lock, inserted, updated, matured, @@ -332,8 +376,9 @@ impl WalletEvent { .. } => { format!( - "BlockProcessed(height={}, inserted={}, updated={}, matured={}, balance={}, account_balances={}, derived={})", + "BlockProcessed(height={}, chainlocked={}, inserted={}, updated={}, matured={}, balance={}, account_balances={}, derived={})", height, + chain_lock.is_some(), inserted.len(), updated.len(), matured.len(), @@ -348,6 +393,19 @@ impl WalletEvent { } => { format!("SyncHeightAdvanced(height={})", height) } + WalletEvent::TransactionsChainlocked { + chain_lock, + per_account, + .. + } => { + let total_txids: usize = per_account.values().map(|v| v.len()).sum(); + format!( + "TransactionsChainlocked(chainlock_height={}, accounts={}, finalized_txids={})", + chain_lock.block_height, + per_account.len(), + total_txids, + ) + } } } } diff --git a/key-wallet-manager/src/process_block.rs b/key-wallet-manager/src/process_block.rs index 901baba03..cc417300b 100644 --- a/key-wallet-manager/src/process_block.rs +++ b/key-wallet-manager/src/process_block.rs @@ -3,6 +3,7 @@ use crate::wallet_interface::{BlockProcessingResult, MempoolTransactionResult, W use crate::{WalletEvent, WalletId, WalletManager}; use async_trait::async_trait; use core::fmt::Write as _; +use dashcore::ephemerealdata::chain_lock::ChainLock; use dashcore::ephemerealdata::instant_lock::InstantLock; use dashcore::prelude::CoreBlockHeight; use dashcore::{Address, Block, Transaction}; @@ -28,14 +29,37 @@ impl WalletInterface for WalletM } let info = BlockInfo::new(height, block.block_hash(), block.header.time); + // Late-block: when every wallet in scope already has its + // finality boundary at or above this height, the block is + // born chainlocked from each wallet's perspective. Record the + // tx directly with `InChainLockedBlock` so the inserted + // `TransactionRecord` carries the right context, the + // `BlockProcessed` event then surfaces this with + // `chain_lock: Some(..)`, and no follow-up + // `TransactionsChainlocked` event is needed for these txs. + // + // Heterogeneous boundaries (e.g. a wallet added mid-flight at + // a stale boundary) fall back to `InBlock` for the whole + // call; the lagging wallet is brought current by the next + // chainlock that arrives. + let all_chainlocked = !wallets.is_empty() + && wallets.iter().filter_map(|wid| self.wallet_infos.get(wid)).all(|info| { + info.last_applied_chain_lock().is_some_and(|cl| height <= cl.block_height) + }); + let block_context = if all_chainlocked { + TransactionContext::InChainLockedBlock(info) + } else { + TransactionContext::InBlock(info) + }; + let mut per_wallet_inserted: BTreeMap> = BTreeMap::new(); let mut per_wallet_updated: BTreeMap> = BTreeMap::new(); let mut per_wallet_derived: BTreeMap> = BTreeMap::new(); for tx in &block.txdata { - let context = TransactionContext::InBlock(info); - let check_result = - self.check_transaction_in_wallets(tx, context, wallets, true, false).await; + let check_result = self + .check_transaction_in_wallets(tx, block_context.clone(), wallets, true, false) + .await; if !check_result.affected_wallets.is_empty() { if check_result.is_new_transaction { @@ -267,6 +291,21 @@ impl WalletInterface for WalletM self.event_sender.subscribe() } + fn apply_chain_lock(&mut self, chain_lock: ChainLock) { + for (wallet_id, info) in self.wallet_infos.iter_mut() { + let per_account = info.apply_chain_lock(chain_lock.clone()); + if per_account.is_empty() { + continue; + } + let event = WalletEvent::TransactionsChainlocked { + wallet_id: *wallet_id, + chain_lock: chain_lock.clone(), + per_account, + }; + let _ = self.event_sender.send(event); + } + } + fn process_instant_send_lock(&mut self, instant_lock: InstantLock) { let txid = instant_lock.txid; @@ -416,6 +455,8 @@ impl WalletManager { let derived_for_wallet = per_wallet_derived.remove(wallet_id).unwrap_or_default(); let addresses_derived: Vec = project_derived_addresses(derived_for_wallet); + let chain_lock = + info.last_applied_chain_lock().filter(|cl| height <= cl.block_height).cloned(); if !inserted.is_empty() || !updated.is_empty() @@ -426,6 +467,7 @@ impl WalletManager { let event = WalletEvent::BlockProcessed { wallet_id: *wallet_id, height, + chain_lock, inserted, updated, matured, diff --git a/key-wallet-manager/src/test_utils/mock_wallet.rs b/key-wallet-manager/src/test_utils/mock_wallet.rs index bd16d60d6..c8917f507 100644 --- a/key-wallet-manager/src/test_utils/mock_wallet.rs +++ b/key-wallet-manager/src/test_utils/mock_wallet.rs @@ -1,6 +1,7 @@ use crate::{ BlockProcessingResult, MempoolTransactionResult, WalletEvent, WalletId, WalletInterface, }; +use dashcore::ephemerealdata::chain_lock::ChainLock; use dashcore::ephemerealdata::instant_lock::InstantLock; use dashcore::prelude::CoreBlockHeight; use dashcore::{Address, Block, OutPoint, Transaction, Txid}; @@ -252,6 +253,10 @@ impl WalletInterface for MockWallet { self.status_changes.try_lock().expect("status_changes lock contention in test helper"); changes.push((txid, TransactionContext::InstantSend(instant_lock))); } + + fn apply_chain_lock(&mut self, _chain_lock: ChainLock) { + panic!("apply_chain_lock not supported for MockWallet"); + } } /// Mock wallet that returns false for filter checks @@ -358,6 +363,10 @@ impl WalletInterface for NonMatchingMockWallet { self.event_sender.subscribe() } + fn apply_chain_lock(&mut self, _chain_lock: ChainLock) { + panic!("apply_chain_lock not supported for NonMatchingMockWallet"); + } + async fn describe(&self) -> String { "NonMatchingWallet (test implementation)".to_string() } @@ -501,6 +510,10 @@ impl WalletInterface for MultiMockWallet { self.event_sender.subscribe() } + fn apply_chain_lock(&mut self, _chain_lock: ChainLock) { + panic!("apply_chain_lock not supported for MultiMockWallet"); + } + async fn describe(&self) -> String { "MultiMockWallet (test implementation)".to_string() } diff --git a/key-wallet-manager/src/wallet_interface.rs b/key-wallet-manager/src/wallet_interface.rs index e2e7a8f89..ab413d384 100644 --- a/key-wallet-manager/src/wallet_interface.rs +++ b/key-wallet-manager/src/wallet_interface.rs @@ -4,6 +4,7 @@ use crate::{WalletEvent, WalletId}; use async_trait::async_trait; +use dashcore::ephemerealdata::chain_lock::ChainLock; use dashcore::ephemerealdata::instant_lock::InstantLock; use dashcore::prelude::CoreBlockHeight; use dashcore::{Address, Block, OutPoint, Transaction, Txid}; @@ -147,6 +148,21 @@ pub trait WalletInterface: Send + Sync + 'static { /// Marks UTXOs as IS-locked, emits status change and balance update events. fn process_instant_send_lock(&mut self, _instant_lock: InstantLock) {} + /// Apply a validated `chain_lock` to every wallet, promoting any + /// `InBlock` records at height `<= chain_lock.block_height` to + /// `InChainLockedBlock` and advancing each wallet's + /// `last_applied_chain_lock`. + /// + /// Emits one [`WalletEvent::TransactionsChainlocked`] per wallet that + /// had at least one net-new promotion, carrying the full `ChainLock` + /// so consumers can persist the signing proof alongside the + /// promotions. + /// + /// Implementations must serialize calls relative to + /// `process_block_for_wallets` to avoid interleaving promotions with + /// in-flight block processing. + fn apply_chain_lock(&mut self, chain_lock: ChainLock); + /// Provide a human-readable description of the wallet implementation. /// /// Implementations are encouraged to include high-level state such as the diff --git a/key-wallet/src/managed_account/managed_core_funds_account.rs b/key-wallet/src/managed_account/managed_core_funds_account.rs index 2402e36d1..ee1fd1daf 100644 --- a/key-wallet/src/managed_account/managed_core_funds_account.rs +++ b/key-wallet/src/managed_account/managed_core_funds_account.rs @@ -28,6 +28,7 @@ use crate::utxo::Utxo; use crate::wallet::balance::WalletCoreBalance; use crate::{ExtendedPubKey, Network}; use dashcore::blockdata::transaction::OutPoint; +use dashcore::prelude::CoreBlockHeight; use dashcore::{Address, Transaction, Txid}; #[cfg(feature = "serde")] use serde::{Deserialize, Serialize}; @@ -278,10 +279,11 @@ impl ManagedCoreFundsAccount { if tx_record.context != context { let was_confirmed = tx_record.context.confirmed(); tx_record.update_context(context.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 - // wallet transaction events are properly handled + // Confirm-time upgrades within the confirmed state (e.g. + // InBlock → InChainLockedBlock) are not signaled here. + // Chainlock-driven promotions go through the dedicated + // `apply_chain_lock` path which emits a single batched + // TransactionsChainlocked event. changed = !was_confirmed; } } @@ -452,14 +454,29 @@ impl ManagedCoreFundsAccount { self.utxos.values().filter(|utxo| utxo.is_spendable(last_processed_height)).collect() } + /// Promote any `InBlock` records at height `<= cl_height` to + /// [`TransactionContext::InChainLockedBlock`] and return the + /// promoted txids. + /// + /// Delegates the per-record promotion to + /// [`ManagedCoreKeysAccount::apply_chain_lock`] (which under the + /// default `keep-finalized-transactions=OFF` feature drops the + /// full records and retains only txids). UTXO state and account + /// balance are unaffected: a chainlock does not change a UTXO's + /// spentness or maturity, only the certainty of its parent + /// transaction. + pub(crate) fn apply_chain_lock(&mut self, cl_height: CoreBlockHeight) -> Vec { + self.keys.apply_chain_lock(cl_height) + } + /// Update the account balance. /// /// Mature, non-locked UTXOs land in either the `confirmed` bucket /// (in a block, InstantSend-locked, or trusted mempool change) or /// the `unconfirmed` bucket (untrusted mempool only). Trusted /// mempool change is surfaced as confirmed because it is just our - /// previously-tracked funds returning — see [`Utxo::is_trusted`]. - /// Both buckets are spendable per [`Utxo::is_spendable`]; the split + /// previously-tracked funds returning, see [`Utxo::is_trusted`]. + /// Both buckets are spendable per [`Utxo::is_spendable`]. The split /// is only for display. pub fn update_balance(&mut self, last_processed_height: u32) { let mut confirmed = 0; diff --git a/key-wallet/src/managed_account/managed_core_keys_account.rs b/key-wallet/src/managed_account/managed_core_keys_account.rs index 114b1c56c..62fa7a977 100644 --- a/key-wallet/src/managed_account/managed_core_keys_account.rs +++ b/key-wallet/src/managed_account/managed_core_keys_account.rs @@ -17,8 +17,9 @@ use crate::managed_account::managed_account_type::ManagedAccountType; use crate::managed_account::transaction_record::TransactionDirection; use crate::transaction_checking::account_checker::AccountMatch; use crate::transaction_checking::transaction_router::TransactionType; -use crate::transaction_checking::TransactionContext; +use crate::transaction_checking::{BlockInfo, TransactionContext}; use crate::Network; +use dashcore::prelude::CoreBlockHeight; use dashcore::{Transaction, Txid}; #[cfg(feature = "serde")] use serde::{Deserialize, Serialize}; @@ -106,6 +107,48 @@ impl ManagedCoreKeysAccount { self.transactions.remove(txid); } + /// Promote any `InBlock` records at height `<= cl_height` to + /// [`TransactionContext::InChainLockedBlock`] and return their txids. + /// + /// Under the default `keep-finalized-transactions=OFF` feature + /// configuration the promoted records are immediately dropped via + /// [`Self::drop_finalized_transaction`], with their txids retained + /// only in `finalized_txids`. With the feature on the records stay + /// in `transactions` with the updated context. + /// + /// Idempotent: records already in `InChainLockedBlock` or already + /// dropped to `finalized_txids` are not revisited and do not appear + /// in the result. Records still in `Mempool` or `InstantSend` + /// context are intentionally skipped, since chainlock-driven + /// promotion only applies to records that have already been mined. + pub(crate) fn apply_chain_lock(&mut self, cl_height: CoreBlockHeight) -> Vec { + let candidates: Vec<(Txid, BlockInfo)> = self + .transactions + .iter() + .filter_map(|(txid, record)| match &record.context { + TransactionContext::InBlock(info) if info.height() <= cl_height => { + Some((*txid, *info)) + } + _ => None, + }) + .collect(); + + let mut promoted = Vec::with_capacity(candidates.len()); + for (txid, info) in candidates { + if let Some(record) = self.transactions.get_mut(&txid) { + record.update_context(TransactionContext::InChainLockedBlock(info)); + promoted.push(txid); + } + } + + #[cfg(not(feature = "keep-finalized-transactions"))] + for txid in &promoted { + self.drop_finalized_transaction(txid); + } + + promoted + } + /// Create a `ManagedCoreKeysAccount` from an [`Account`](super::super::Account). pub fn from_account(account: &super::super::Account) -> Self { let key_source = address_pool::KeySource::Public(account.account_xpub); diff --git a/key-wallet/src/tests/keep_finalized_transactions_tests.rs b/key-wallet/src/tests/keep_finalized_transactions_tests.rs index 602d9c937..2226b9a10 100644 --- a/key-wallet/src/tests/keep_finalized_transactions_tests.rs +++ b/key-wallet/src/tests/keep_finalized_transactions_tests.rs @@ -16,15 +16,25 @@ //! IS-lock alone is **not** finality. use crate::{ + account::{AccountType, StandardAccountType}, managed_account::managed_account_trait::ManagedAccountTrait, test_utils::TestWalletContext, transaction_checking::{BlockInfo, TransactionContext}, + wallet::managed_wallet_info::wallet_info_interface::WalletInfoInterface, }; +use dashcore::ephemerealdata::chain_lock::ChainLock; #[cfg(not(feature = "keep-finalized-transactions"))] use dashcore::ephemerealdata::instant_lock::InstantLock; use dashcore::hashes::Hash; use dashcore::{BlockHash, Transaction}; +fn bip44_account_type() -> AccountType { + AccountType::Standard { + index: 0, + standard_account_type: StandardAccountType::BIP44Account, + } +} + /// Walks a single transaction through Mempool → InBlock → InChainLockedBlock /// and asserts that the record survives the chainlock when the feature is ON. #[cfg(feature = "keep-finalized-transactions")] @@ -125,6 +135,75 @@ async fn test_islocked_record_kept_when_feature_off() { ); } +/// `apply_chain_lock` at a height covering an `InBlock` record +/// promotes its context. With the feature OFF the record is dropped +/// from the map. With the feature ON it stays with the new context. +#[tokio::test] +async fn test_apply_chain_lock_promotes_in_block_records() { + let mut ctx = TestWalletContext::new_random(); + let tx = Transaction::dummy(&ctx.receive_address, 0..1, &[150_000]); + let txid = tx.txid(); + let block_hash = BlockHash::from_slice(&[9u8; 32]).expect("hash"); + + let _ = ctx + .check_transaction( + &tx, + TransactionContext::InBlock(BlockInfo::new(50, block_hash, 1_700_000_000)), + ) + .await; + assert!(ctx.bip44_account().transactions().contains_key(&txid)); + + ctx.managed_wallet.update_last_processed_height(50); + let per_account = ctx.managed_wallet.apply_chain_lock(ChainLock::dummy(50)); + let promoted = per_account + .get(&bip44_account_type()) + .expect("BIP44 account should have a promotion entry"); + assert_eq!(promoted, &vec![txid]); + assert!(ctx.bip44_account().transaction_is_finalized(&txid)); + + #[cfg(feature = "keep-finalized-transactions")] + { + let record = ctx.bip44_account().transactions().get(&txid).expect("record kept"); + assert!(matches!(record.context, TransactionContext::InChainLockedBlock(_))); + } + #[cfg(not(feature = "keep-finalized-transactions"))] + { + assert!( + !ctx.bip44_account().transactions().contains_key(&txid), + "with the feature OFF the record must be dropped after promotion" + ); + } +} + +/// `apply_chain_lock` only promotes records at or below `cl_height` and +/// never touches `Mempool` / `InstantSend` records (those have not been +/// mined yet, and chainlock-finality requires a block). +#[tokio::test] +async fn test_apply_chain_lock_skips_unmined_and_above_height() { + let mut ctx = TestWalletContext::new_random(); + let mempool_tx = Transaction::dummy(&ctx.receive_address, 0..1, &[120_000]); + let block_tx = Transaction::dummy(&ctx.receive_address, 1..2, &[150_000]); + let mempool_txid = mempool_tx.txid(); + let block_txid = block_tx.txid(); + let block_hash = BlockHash::from_slice(&[1u8; 32]).expect("hash"); + + let _ = ctx.check_transaction(&mempool_tx, TransactionContext::Mempool).await; + let _ = ctx + .check_transaction( + &block_tx, + TransactionContext::InBlock(BlockInfo::new(200, block_hash, 1_700_000_000)), + ) + .await; + + // Chainlock at 100 sits below the InBlock-at-200 record and above + // the mempool record's (absent) height, so neither promotes. + ctx.managed_wallet.update_last_processed_height(200); + let per_account = ctx.managed_wallet.apply_chain_lock(ChainLock::dummy(100)); + assert!(per_account.is_empty()); + assert!(!ctx.bip44_account().transaction_is_finalized(&mempool_txid)); + assert!(!ctx.bip44_account().transaction_is_finalized(&block_txid)); +} + /// IS-lock first, then a chainlocked block: the record must drop only at /// the chainlock step. We also assert that the chainlock event still /// "lands" — `transaction_is_finalized` must report `true` when asked 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 b964edf28..4de8ab78e 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 @@ -7,11 +7,13 @@ use std::collections::{BTreeMap, BTreeSet}; use super::managed_account_operations::ManagedAccountOperations; use crate::account::{AccountType, ManagedAccountTrait}; use crate::managed_account::managed_account_collection::ManagedAccountCollection; +use crate::managed_account::managed_account_ref::ManagedAccountRefMut; use crate::transaction_checking::TransactionContext; use crate::transaction_checking::WalletTransactionChecker; use crate::wallet::managed_wallet_info::TransactionRecord; use crate::wallet::ManagedWalletInfo; use crate::{Network, Utxo, Wallet, WalletCoreBalance}; +use dashcore::ephemerealdata::chain_lock::ChainLock; use dashcore::ephemerealdata::instant_lock::InstantLock; use dashcore::prelude::CoreBlockHeight; use dashcore::{Address as DashAddress, Transaction, Txid}; @@ -102,6 +104,35 @@ pub trait WalletInfoInterface: Sized + WalletTransactionChecker + ManagedAccount /// Return the durable wallet sync checkpoint height. fn synced_height(&self) -> CoreBlockHeight; + /// Return the highest chainlock that has been applied to this + /// wallet, retaining the signing proof. Blocks at or below + /// `chain_lock.block_height` are considered chainlock-finalized + /// for this wallet. `None` until the first chainlock arrives. + fn last_applied_chain_lock(&self) -> Option<&ChainLock> { + None + } + + /// Promote every `InBlock` transaction record across this wallet's + /// accounts whose block height is `<= chain_lock.block_height` to + /// `TransactionContext::InChainLockedBlock`, advance the wallet's + /// `last_applied_chain_lock` to `chain_lock` (clamped forward by + /// height), and return the per-account promotion result. + /// + /// Accounts with no net-new promotions are omitted from the map. + /// Under the default `keep-finalized-transactions=OFF` feature the + /// promoted records are dropped and only their txids are retained — + /// the txids are still surfaced here so the caller can emit a single + /// `TransactionsChainlocked` event before the records disappear. + /// + /// `last_applied_chain_lock` advances even when no records were + /// promoted: a chainlock that lands above a wallet's currently + /// recorded history still establishes the finality boundary for + /// any future blocks that arrive in that range via the late-block + /// path in block processing. + fn apply_chain_lock(&mut self, _chain_lock: ChainLock) -> BTreeMap> { + BTreeMap::new() + } + /// Update chain state and process any matured transactions /// This should be called when the chain tip advances to a new height fn update_last_processed_height(&mut self, current_height: u32); @@ -179,6 +210,49 @@ impl WalletInfoInterface for ManagedWalletInfo { self.metadata.synced_height } + fn last_applied_chain_lock(&self) -> Option<&ChainLock> { + self.metadata.last_applied_chain_lock.as_ref() + } + + fn apply_chain_lock(&mut self, chain_lock: ChainLock) -> BTreeMap> { + let cl_height = chain_lock.block_height; + let mut per_account: BTreeMap> = BTreeMap::new(); + + // Promote across every account: funds-bearing (Standard, + // CoinJoin, DashPay) and keys-only (identity, asset-lock, + // provider, platform-payment). Keys-only accounts hold + // transactions such as identity registrations and asset locks + // that under the default `keep-finalized-transactions=false` + // feature must be dropped to bound memory once chainlocked, + // exactly like funds-account txs. + for account in self.accounts.all_accounts_mut() { + let (account_type, finalized_txids) = match account { + ManagedAccountRefMut::Funds(funds) => ( + funds.managed_account_type().to_account_type(), + funds.apply_chain_lock(cl_height), + ), + ManagedAccountRefMut::Keys(keys) => ( + keys.managed_account_type().to_account_type(), + keys.apply_chain_lock(cl_height), + ), + }; + if !finalized_txids.is_empty() { + per_account.insert(account_type, finalized_txids); + } + } + + let advance = self + .metadata + .last_applied_chain_lock + .as_ref() + .is_none_or(|existing| cl_height > existing.block_height); + if advance { + self.metadata.last_applied_chain_lock = Some(chain_lock); + } + + per_account + } + fn update_last_synced(&mut self, timestamp: u64) { self.metadata.last_synced = Some(timestamp); } diff --git a/key-wallet/src/wallet/metadata.rs b/key-wallet/src/wallet/metadata.rs index f0de35240..b09edf9f3 100644 --- a/key-wallet/src/wallet/metadata.rs +++ b/key-wallet/src/wallet/metadata.rs @@ -4,6 +4,7 @@ use std::collections::BTreeMap; +use dashcore::ephemerealdata::chain_lock::ChainLock; use dashcore::prelude::CoreBlockHeight; #[cfg(feature = "serde")] use serde::{Deserialize, Serialize}; @@ -18,6 +19,14 @@ pub struct WalletMetadata { pub last_processed_height: CoreBlockHeight, /// Sync checkpoint height pub synced_height: CoreBlockHeight, + /// Highest chainlock that has been applied to this wallet, + /// establishing the finality boundary: every block at or below + /// `chain_lock.block_height` is final for this wallet. `None` until + /// the first chainlock arrives. Persisted so consumers (e.g. + /// Platform) with external transaction persistence can reason about + /// which transactions have already been finalized, and retain the + /// signing proof. + pub last_applied_chain_lock: Option, /// Last sync timestamp pub last_synced: Option, /// Wallet version