diff --git a/dash-spv-ffi/src/bin/ffi_cli.rs b/dash-spv-ffi/src/bin/ffi_cli.rs index c80b23bde..fa32ddb87 100644 --- a/dash-spv-ffi/src/bin/ffi_cli.rs +++ b/dash-spv-ffi/src/bin/ffi_cli.rs @@ -278,7 +278,7 @@ extern "C" fn on_wallet_block_processed( ); } -extern "C" fn on_wallet_transactions_chainlocked( +extern "C" fn on_wallet_chain_lock_processed( wallet_id: *const c_char, cl_height: u32, _cl_hash: *const [u8; 32], @@ -289,7 +289,7 @@ extern "C" fn on_wallet_transactions_chainlocked( ) { let wallet_short = short_wallet(wallet_id); println!( - "[Wallet] Transactions chainlocked: wallet={}..., cl_height={}, finalized={}", + "[Wallet] ChainLock processed: wallet={}..., cl_height={}, finalized={}", wallet_short, cl_height, finalized_count, ); } @@ -528,7 +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), + on_chain_lock_processed: Some(on_wallet_chain_lock_processed), user_data: ptr::null_mut(), }, error: FFIClientErrorCallback { diff --git a/dash-spv-ffi/src/callbacks.rs b/dash-spv-ffi/src/callbacks.rs index 69e7d1d45..62e25f8e0 100644 --- a/dash-spv-ffi/src/callbacks.rs +++ b/dash-spv-ffi/src/callbacks.rs @@ -787,7 +787,7 @@ pub type OnSyncHeightAdvancedCallback = Option; /// One net-new chainlock-finalized txid, scoped to the account it was -/// promoted on. `WalletEvent::TransactionsChainlocked` delivers an +/// promoted on. `WalletEvent::ChainLockProcessed` delivers an /// array of these — one entry per (account, txid) pair promoted by /// the chainlock. /// @@ -819,17 +819,26 @@ impl FFIChainlockedTxid { } } -/// Callback for `WalletEvent::TransactionsChainlocked`. +/// Callback for `WalletEvent::ChainLockProcessed`. /// -/// 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. +/// Fires once per wallet whenever the wallet's +/// `last_applied_chain_lock` advances forward by height (or moves from +/// `None` to `Some`). Carries the full signing proof so durable +/// consumers can persist the chainlock alongside the height — important +/// for SDKs that need to reconstruct chainlock-derived state across +/// restarts (e.g. building a `ChainAssetLockProof` for an `InBlock` +/// asset-lock TX from the persisted chainlock). +/// +/// `finalized` carries the per-(account, txid) promotions when the +/// same chainlock also flipped one or more `InBlock` records to +/// `InChainLockedBlock`. `finalized_count == 0` (and `finalized == +/// NULL`) when the chainlock advanced the wallet's metadata without +/// promoting any record — consumers that persist the chainlock proof +/// must still observe these empty-promotion events. /// /// All pointers are borrowed and only valid for the duration of the /// callback. -pub type OnWalletTransactionsChainlockedCallback = Option< +pub type OnWalletChainLockProcessedCallback = Option< extern "C" fn( wallet_id: *const c_char, cl_height: u32, @@ -855,7 +864,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 on_chain_lock_processed: OnWalletChainLockProcessedCallback, pub user_data: *mut c_void, } @@ -870,7 +879,7 @@ impl Default for FFIWalletEventCallbacks { on_transaction_instant_locked: None, on_block_processed: None, on_sync_height_advanced: None, - on_transactions_chainlocked: None, + on_chain_lock_processed: None, user_data: std::ptr::null_mut(), } } @@ -1140,15 +1149,15 @@ impl FFIWalletEventCallbacks { cb(c_wallet_id.as_ptr(), *height, self.user_data); } } - WalletEvent::TransactionsChainlocked { + WalletEvent::ChainLockProcessed { wallet_id, chain_lock, - per_account, + locked_transactions, } => { - if let Some(cb) = self.on_transactions_chainlocked { + if let Some(cb) = self.on_chain_lock_processed { 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 ffi_finalized = FFIChainlockedTxid::from_map(locked_transactions); let finalized_ptr = if ffi_finalized.is_empty() { ptr::null() } else { @@ -1176,7 +1185,7 @@ impl FFIWalletEventCallbacks { mod tests { use super::*; use dashcore::hashes::Hash; - use dashcore::{Address, BlockHash, Network, Txid}; + use dashcore::{Address, BlockHash, ChainLock, Network, Txid}; use key_wallet_manager::{FilterMatchKey, WalletId}; use std::collections::{BTreeMap, BTreeSet}; use std::sync::atomic::{AtomicU32, Ordering}; @@ -1252,4 +1261,113 @@ mod tests { }); assert_eq!(NEW_ADDR_COUNT.load(Ordering::SeqCst), 3); } + + /// `ChainLockProcessed` dispatch must hand every wired field + /// through to the FFI callback unchanged: hex-encoded wallet_id, + /// height, 32-byte block hash, 96-byte signature, and the count of + /// per-(account, txid) promotions. A regression that miswires any + /// of these (e.g. height/hash swap, signature truncation, empty vs. + /// non-empty promotion handling) shows up as a single assertion + /// failure here. + #[test] + fn test_chain_lock_processed_dispatch_round_trips_every_field() { + struct Captured { + wallet_id_hex: String, + cl_height: u32, + cl_hash: [u8; 32], + cl_signature: [u8; 96], + finalized_count: u32, + } + static CAPTURED: std::sync::Mutex> = std::sync::Mutex::new(None); + + extern "C" fn cb( + 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: *mut c_void, + ) { + let wid = unsafe { std::ffi::CStr::from_ptr(wallet_id) } + .to_str() + .expect("wallet_id must be valid UTF-8 hex") + .to_string(); + *CAPTURED.lock().unwrap() = Some(Captured { + wallet_id_hex: wid, + cl_height, + cl_hash: unsafe { *cl_hash }, + cl_signature: unsafe { *cl_signature }, + finalized_count, + }); + } + + let callbacks = FFIWalletEventCallbacks { + on_chain_lock_processed: Some(cb), + ..FFIWalletEventCallbacks::default() + }; + + let chain_lock = ChainLock::dummy(777); + let expected_hash = *chain_lock.block_hash.as_byte_array(); + let expected_sig = *chain_lock.signature.as_bytes(); + let wallet_id: WalletId = [3u8; 32]; + + // Two promotions to verify `finalized_count` reflects total + // (account, txid) pairs, not the number of accounts. + let account_a = AccountType::Standard { + index: 0, + standard_account_type: key_wallet::account::StandardAccountType::BIP44Account, + }; + let account_b = AccountType::Standard { + index: 1, + standard_account_type: key_wallet::account::StandardAccountType::BIP44Account, + }; + let mut locked: BTreeMap> = BTreeMap::new(); + locked.insert(account_a, vec![Txid::from_byte_array([0xaa; 32])]); + locked.insert(account_b, vec![Txid::from_byte_array([0xbb; 32])]); + + callbacks.dispatch(&WalletEvent::ChainLockProcessed { + wallet_id, + chain_lock, + locked_transactions: locked, + }); + + let captured = CAPTURED.lock().unwrap().take().expect("callback fired"); + assert_eq!(captured.wallet_id_hex, hex::encode(wallet_id), "wallet_id hex-encoding"); + assert_eq!(captured.cl_height, 777, "cl_height"); + assert_eq!(captured.cl_hash, expected_hash, "cl_hash round-trip"); + assert_eq!(captured.cl_signature, expected_sig, "cl_signature round-trip"); + assert_eq!(captured.finalized_count, 2, "finalized_count counts (account, txid) pairs"); + } + + /// `ChainLockProcessed` with empty `locked_transactions` must still + /// fire the callback (durable consumers persist the chainlock proof + /// even when no record was promoted) with `finalized_count == 0`. + #[test] + fn test_chain_lock_processed_dispatch_fires_with_empty_promotions() { + static FIRED: AtomicU32 = AtomicU32::new(u32::MAX); + extern "C" fn cb( + _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: *mut c_void, + ) { + FIRED.store(finalized_count, Ordering::SeqCst); + } + + let callbacks = FFIWalletEventCallbacks { + on_chain_lock_processed: Some(cb), + ..FFIWalletEventCallbacks::default() + }; + + callbacks.dispatch(&WalletEvent::ChainLockProcessed { + wallet_id: [4u8; 32], + chain_lock: ChainLock::dummy(900), + locked_transactions: BTreeMap::new(), + }); + assert_eq!(FIRED.load(Ordering::SeqCst), 0); + } } diff --git a/dash-spv-ffi/tests/dashd_sync/callbacks.rs b/dash-spv-ffi/tests/dashd_sync/callbacks.rs index 2311278f0..a36b944ef 100644 --- a/dash-spv-ffi/tests/dashd_sync/callbacks.rs +++ b/dash-spv-ffi/tests/dashd_sync/callbacks.rs @@ -620,7 +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, + on_chain_lock_processed: None, user_data: Arc::as_ptr(tracker) as *mut c_void, } } diff --git a/dash-spv/tests/dashd_masternode/helpers.rs b/dash-spv/tests/dashd_masternode/helpers.rs index 80ad5d342..83f6b446a 100644 --- a/dash-spv/tests/dashd_masternode/helpers.rs +++ b/dash-spv/tests/dashd_masternode/helpers.rs @@ -254,8 +254,8 @@ 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::ChainLockProcessed`] event whose +/// `locked_transactions` includes the txid (across any account) or a /// [`WalletEvent::BlockProcessed`] event with `chain_lock = Some(..)` /// whose `inserted` / `updated` list includes the txid. /// @@ -283,15 +283,15 @@ pub(super) async fn wait_for_wallet_txs_chainlocked( } result = event_receiver.recv() => { match result { - Ok(WalletEvent::TransactionsChainlocked { + Ok(WalletEvent::ChainLockProcessed { chain_lock, - per_account, + locked_transactions, .. }) => { - for finalized in per_account.values().flatten() { + for finalized in locked_transactions.values().flatten() { if pending.remove(finalized) { tracing::info!( - "Wallet TransactionsChainlocked(chainlock_height={}, txid={})", + "Wallet ChainLockProcessed(chainlock_height={}, txid={})", chain_lock.block_height, finalized, ); } @@ -339,20 +339,20 @@ pub(super) async fn wait_for_wallet_tx_chainlocked( loop { tokio::select! { _ = &mut timeout => { - panic!("Timeout waiting for TransactionsChainlocked carrying txid {}", txid); + panic!("Timeout waiting for ChainLockProcessed carrying txid {}", txid); } result = event_receiver.recv() => { match result { - Ok(WalletEvent::TransactionsChainlocked { + Ok(WalletEvent::ChainLockProcessed { chain_lock, - per_account, + locked_transactions, .. - }) if per_account + }) if locked_transactions .values() .any(|txids| txids.contains(&txid)) => { tracing::info!( - "Wallet TransactionsChainlocked(chainlock_height={}, txid={})", + "Wallet ChainLockProcessed(chainlock_height={}, txid={})", chain_lock.block_height, txid ); return chain_lock.block_height; diff --git a/dash-spv/tests/dashd_masternode/tests_chainlock.rs b/dash-spv/tests/dashd_masternode/tests_chainlock.rs index 692848f08..b41f2b58f 100644 --- a/dash-spv/tests/dashd_masternode/tests_chainlock.rs +++ b/dash-spv/tests/dashd_masternode/tests_chainlock.rs @@ -7,7 +7,7 @@ //! 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`]. +//! [`key_wallet_manager::WalletEvent::ChainLockProcessed`]. use std::sync::Arc; @@ -24,7 +24,7 @@ use super::setup::{ }; /// Live arrival: send a tx into a block, mine through to a chainlock, -/// and assert the wallet emits [`WalletEvent::TransactionsChainlocked`] +/// and assert the wallet emits [`WalletEvent::ChainLockProcessed`] /// carrying the tx's txid. /// /// Drives the full live path: the tx lands as `InBlock` during normal diff --git a/key-wallet-manager/src/event_tests.rs b/key-wallet-manager/src/event_tests.rs index 6bd2f3d9e..516c5356f 100644 --- a/key-wallet-manager/src/event_tests.rs +++ b/key-wallet-manager/src/event_tests.rs @@ -1060,12 +1060,15 @@ async fn test_apply_chain_lock_promotes_in_block_record_and_emits_event() { manager.apply_chain_lock(ChainLock::dummy(100)); let events = drain_events(&mut rx); - assert_eq!(events.len(), 1, "exactly one TransactionsChainlocked event expected"); + // First chainlock advances the wallet's metadata AND promotes a + // record, so a single atomic `ChainLockProcessed` fires carrying + // both the chainlock proof and the per-account promotions. + assert_eq!(events.len(), 1, "ChainLockProcessed expected, got {events:?}"); match &events[0] { - WalletEvent::TransactionsChainlocked { + WalletEvent::ChainLockProcessed { wallet_id: wid, chain_lock, - per_account, + locked_transactions, } => { assert_eq!(*wid, wallet_id); assert_eq!(chain_lock.block_height, 100); @@ -1073,22 +1076,47 @@ async fn test_apply_chain_lock_promotes_in_block_record_and_emits_event() { index: 0, standard_account_type: StandardAccountType::BIP44Account, }; - let txids = per_account + let txids = locked_transactions .get(&receiving) .expect("the receiving account should have a promotion entry"); assert_eq!(txids, &vec![tx.txid()]); } - other => panic!("expected TransactionsChainlocked, got {:?}", other), + other => panic!("expected ChainLockProcessed, got {:?}", other), } } #[tokio::test] -async fn test_apply_chain_lock_with_no_records_emits_no_event_but_advances_boundary() { +async fn test_apply_chain_lock_with_no_records_emits_chain_lock_processed_and_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); + // Even though no record was promoted, the wallet's + // `last_applied_chain_lock` advanced from `None` to `Some(500)` — + // durable consumers (e.g. asset-lock persisters) must observe a + // single `ChainLockProcessed` (with empty `locked_transactions`) + // to know the metadata moved. + let advance_events = drain_events(&mut rx); + assert_eq!( + advance_events.len(), + 1, + "exactly one ChainLockProcessed expected, got {advance_events:?}" + ); + match &advance_events[0] { + WalletEvent::ChainLockProcessed { + wallet_id: wid, + chain_lock, + locked_transactions, + } => { + assert_eq!(*wid, wallet_id); + assert_eq!(chain_lock.block_height, 500); + assert!( + locked_transactions.is_empty(), + "metadata advance without records must carry empty locked_transactions, got {locked_transactions:?}" + ); + } + other => panic!("expected ChainLockProcessed, got {:?}", other), + } // Subsequent block below the new finality boundary must be born chainlocked. let addr = manager @@ -1120,10 +1148,10 @@ async fn test_apply_chain_lock_with_no_records_emits_no_event_but_advances_bound _ => unreachable!(), } let chainlock_event_count = - events.iter().filter(|e| matches!(e, WalletEvent::TransactionsChainlocked { .. })).count(); + events.iter().filter(|e| matches!(e, WalletEvent::ChainLockProcessed { .. })).count(); assert_eq!( chainlock_event_count, 0, - "late-block path must not double-emit TransactionsChainlocked for newly-born chainlocked txs" + "late-block path must not double-emit ChainLockProcessed for newly-born chainlocked txs" ); } @@ -1138,18 +1166,56 @@ async fn test_apply_chain_lock_is_idempotent_on_already_finalized() { let mut rx = manager.subscribe_events(); manager.apply_chain_lock(ChainLock::dummy(50)); let first = drain_events(&mut rx); + let chainlock_events: Vec<_> = first + .iter() + .filter_map(|e| match e { + WalletEvent::ChainLockProcessed { + locked_transactions, + .. + } => Some(locked_transactions), + _ => None, + }) + .collect(); assert_eq!( - first.iter().filter(|e| matches!(e, WalletEvent::TransactionsChainlocked { .. })).count(), + chainlock_events.len(), 1, - "first chainlock must emit exactly one TransactionsChainlocked" + "first chainlock must emit exactly one ChainLockProcessed, got {first:?}" + ); + assert!( + !chainlock_events[0].is_empty(), + "first chainlock at height 50 must promote the InBlock record" ); - // Replaying the same chainlock, or applying a higher one with no - // outstanding InBlock records below it, must not re-emit. + // Replaying the same chainlock must not re-emit anything: no + // promotions and no metadata advance. manager.apply_chain_lock(ChainLock::dummy(50)); - manager.apply_chain_lock(ChainLock::dummy(80)); - assert_no_events(&mut rx); + + // A higher chainlock with no outstanding InBlock records below it + // still advances the metadata boundary, so emits exactly one + // `ChainLockProcessed` with empty `locked_transactions`. + manager.apply_chain_lock(ChainLock::dummy(80)); + let advance = drain_events(&mut rx); + let advance_events: Vec<_> = advance + .iter() + .filter_map(|e| match e { + WalletEvent::ChainLockProcessed { + locked_transactions, + .. + } => Some(locked_transactions), + _ => None, + }) + .collect(); + assert_eq!( + advance_events.len(), + 1, + "metadata advance from 50 -> 80 must emit exactly one ChainLockProcessed, got {advance:?}" + ); + assert!( + advance_events[0].is_empty(), + "no records to promote => empty locked_transactions, got {:?}", + advance_events[0] + ); } #[tokio::test] diff --git a/key-wallet-manager/src/events.rs b/key-wallet-manager/src/events.rs index b8844b294..d5d597cba 100644 --- a/key-wallet-manager/src/events.rs +++ b/key-wallet-manager/src/events.rs @@ -275,11 +275,24 @@ 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. + /// A chainlock was applied to the wallet: its + /// `last_applied_chain_lock` metadata advanced (strictly forward by + /// height, or `None` → `Some`), and any previously-`InBlock` + /// records at heights `<= chain_lock.block_height` were promoted + /// to [`key_wallet::transaction_checking::TransactionContext::InChainLockedBlock`]. + /// + /// Fires once per wallet whenever the wallet's + /// `last_applied_chain_lock` advances. `locked_transactions` carries + /// the promotions when there were any, and is empty when the + /// chainlock advanced the metadata without promoting any record — + /// a chainlock at a height ahead of the wallet's recorded history + /// still establishes the finality boundary for future late-arriving + /// blocks. Subscribers that persist `last_applied_chain_lock` (so + /// they can reconstruct chainlock-derived state across restarts — + /// e.g. a platform-wallet bridge that builds a + /// `ChainAssetLockProof` for an `InBlock` asset-lock TX from the + /// persisted chainlock) must therefore listen to this event even + /// when `locked_transactions` is empty. /// /// Transactions born directly in a chainlocked block (block at /// height `<= last_applied_chain_lock.block_height` at processing @@ -287,21 +300,27 @@ pub enum WalletEvent { /// `chain_lock = Some(..)` and their records already in /// `InChainLockedBlock` context. They do not appear here, since no /// promotion took place. - TransactionsChainlocked { + /// + /// Carries the full `ChainLock` (signing proof: `block_height`, + /// `block_hash`, `signature`) so consumers can persist the proof + /// alongside the height. + ChainLockProcessed { /// 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). + /// The chainlock that drove this advance. Carries the signing + /// proof (`block_height`, `block_hash`, `signature`) so + /// consumers can persist it alongside any promotions. The + /// wallet's `last_applied_chain_lock` has been 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>, + /// Accounts with no net-new promotions are omitted; the map is + /// empty when the chainlock advanced the metadata without + /// promoting any record. No balance is carried because a + /// chainlock does not change UTXO state (only the certainty of + /// the parent transaction). + locked_transactions: BTreeMap>, }, } @@ -325,7 +344,7 @@ impl WalletEvent { wallet_id, .. } - | WalletEvent::TransactionsChainlocked { + | WalletEvent::ChainLockProcessed { wallet_id, .. } => *wallet_id, @@ -391,16 +410,18 @@ impl fmt::Display for WalletEvent { } => { write!(f, "SyncHeightAdvanced(height={})", height) } - WalletEvent::TransactionsChainlocked { + WalletEvent::ChainLockProcessed { chain_lock, - per_account, + locked_transactions, .. } => { - let total_txids: usize = per_account.values().map(|v| v.len()).sum(); - write!(f, - "TransactionsChainlocked(chainlock_height={}, accounts={}, finalized_txids={})", + let total_txids: usize = + locked_transactions.values().map(|v| v.len()).sum(); + write!( + f, + "ChainLockProcessed(chainlock_height={}, accounts={}, finalized_txids={})", chain_lock.block_height, - per_account.len(), + locked_transactions.len(), total_txids, ) } diff --git a/key-wallet-manager/src/process_block.rs b/key-wallet-manager/src/process_block.rs index cc417300b..043b94021 100644 --- a/key-wallet-manager/src/process_block.rs +++ b/key-wallet-manager/src/process_block.rs @@ -293,16 +293,20 @@ impl WalletInterface for WalletM 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 outcome = info.apply_chain_lock(chain_lock.clone()); + + // Emit a single atomic `ChainLockProcessed` whenever the + // wallet's `last_applied_chain_lock` advanced — carrying any + // net-new promotions (possibly empty when the advance + // promoted nothing). Replays of the same chainlock (no + // metadata advance) are silent. + if outcome.metadata_advanced { + let _ = self.event_sender.send(WalletEvent::ChainLockProcessed { + wallet_id: *wallet_id, + chain_lock: chain_lock.clone(), + locked_transactions: outcome.locked_transactions, + }); } - let event = WalletEvent::TransactionsChainlocked { - wallet_id: *wallet_id, - chain_lock: chain_lock.clone(), - per_account, - }; - let _ = self.event_sender.send(event); } } diff --git a/key-wallet-manager/src/wallet_interface.rs b/key-wallet-manager/src/wallet_interface.rs index ab413d384..9b2b5fde0 100644 --- a/key-wallet-manager/src/wallet_interface.rs +++ b/key-wallet-manager/src/wallet_interface.rs @@ -153,10 +153,15 @@ pub trait WalletInterface: Send + Sync + 'static { /// `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. + /// Emits at most one [`WalletEvent::ChainLockProcessed`] per + /// wallet, fired whenever the wallet's `last_applied_chain_lock` + /// advanced (strictly forward by height, or `None` → `Some`). The + /// event carries the full `ChainLock` plus any per-account net-new + /// promotions in `locked_transactions` — empty when the chainlock + /// advanced the metadata without promoting any record (durable + /// consumers that persist the chainlock metadata must still listen + /// for these empty-promotion events). Replays of the same chainlock + /// (no metadata advance) are silent. /// /// Implementations must serialize calls relative to /// `process_block_for_wallets` to avoid interleaving promotions with 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 ee1fd1daf..cf95d78d1 100644 --- a/key-wallet/src/managed_account/managed_core_funds_account.rs +++ b/key-wallet/src/managed_account/managed_core_funds_account.rs @@ -283,7 +283,7 @@ impl ManagedCoreFundsAccount { // InBlock → InChainLockedBlock) are not signaled here. // Chainlock-driven promotions go through the dedicated // `apply_chain_lock` path which emits a single batched - // TransactionsChainlocked event. + // ChainLockProcessed event. changed = !was_confirmed; } } diff --git a/key-wallet/src/tests/keep_finalized_transactions_tests.rs b/key-wallet/src/tests/keep_finalized_transactions_tests.rs index 2226b9a10..dcfef377a 100644 --- a/key-wallet/src/tests/keep_finalized_transactions_tests.rs +++ b/key-wallet/src/tests/keep_finalized_transactions_tests.rs @@ -154,8 +154,13 @@ async fn test_apply_chain_lock_promotes_in_block_records() { 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 + let outcome = ctx.managed_wallet.apply_chain_lock(ChainLock::dummy(50)); + assert!( + outcome.metadata_advanced, + "first chainlock must advance metadata from None to Some(50)" + ); + let promoted = outcome + .locked_transactions .get(&bip44_account_type()) .expect("BIP44 account should have a promotion entry"); assert_eq!(promoted, &vec![txid]); @@ -198,8 +203,12 @@ async fn test_apply_chain_lock_skips_unmined_and_above_height() { // 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()); + let outcome = ctx.managed_wallet.apply_chain_lock(ChainLock::dummy(100)); + assert!(outcome.locked_transactions.is_empty()); + assert!( + outcome.metadata_advanced, + "metadata must still advance to the new finality boundary even when no record promotes" + ); assert!(!ctx.bip44_account().transaction_is_finalized(&mempool_txid)); assert!(!ctx.bip44_account().transaction_is_finalized(&block_txid)); } 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 4de8ab78e..12f6bb934 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 @@ -18,6 +18,29 @@ use dashcore::ephemerealdata::instant_lock::InstantLock; use dashcore::prelude::CoreBlockHeight; use dashcore::{Address as DashAddress, Transaction, Txid}; +/// Outcome of [`WalletInfoInterface::apply_chain_lock`]. +/// +/// Captures both effects of applying a chainlock so the manager-level +/// emitter (in `key-wallet-manager`) can fire a single atomic +/// `WalletEvent::ChainLockProcessed` whenever the wallet's +/// `last_applied_chain_lock` metadata advanced — carrying any net-new +/// promotions in `locked_transactions` (empty when the metadata +/// advance promoted nothing). +#[derive(Debug, Clone, Default)] +pub struct ApplyChainLockOutcome { + /// Per-account net-new finalized txids: records that flipped from + /// `InBlock` to `InChainLockedBlock` in this promotion. Accounts + /// with no net-new promotions are omitted. Empty when the chainlock + /// landed on a wallet that has no `InBlock` records at heights + /// `<= chain_lock.block_height`. + pub locked_transactions: BTreeMap>, + /// `true` iff the wallet's `last_applied_chain_lock` strictly + /// advanced (or moved from `None` to `Some`) as a result of this + /// call. `false` when the incoming chainlock's height did not + /// exceed the already-stored chainlock's height. + pub metadata_advanced: bool, +} + /// Trait that wallet info types must implement to work with WalletManager pub trait WalletInfoInterface: Sized + WalletTransactionChecker + ManagedAccountOperations { /// Create a wallet info from an existing wallet, seeding the sync checkpoint at @@ -116,21 +139,34 @@ pub trait WalletInfoInterface: Sized + WalletTransactionChecker + ManagedAccount /// 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. + /// height), and return both effects in a single + /// [`ApplyChainLockOutcome`]. + /// + /// Field semantics: + /// + /// - `locked_transactions` is populated when records were promoted. + /// Accounts with no net-new promotions are omitted. Empty when no + /// record was `InBlock` at a height `<= chain_lock.block_height`. + /// - `metadata_advanced` is `true` when the wallet's + /// `last_applied_chain_lock` strictly advanced (or moved from + /// `None` to `Some`) as a result of this call. The manager (in + /// `key-wallet-manager`) emits one + /// `WalletEvent::ChainLockProcessed` per wallet when this is + /// `true`, regardless of whether `locked_transactions` is empty — + /// a chainlock that lands above a wallet's currently recorded + /// history still establishes the finality boundary for future + /// blocks that arrive in that range via the late-block path in + /// block processing, and durable consumers must persist the new + /// `last_applied_chain_lock` to benefit from that boundary across + /// restarts. /// - /// 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() + /// the txids are still surfaced in `locked_transactions` so the + /// caller can emit the `ChainLockProcessed` event before the + /// records disappear. + fn apply_chain_lock(&mut self, _chain_lock: ChainLock) -> ApplyChainLockOutcome { + ApplyChainLockOutcome::default() } /// Update chain state and process any matured transactions @@ -214,9 +250,9 @@ impl WalletInfoInterface for ManagedWalletInfo { self.metadata.last_applied_chain_lock.as_ref() } - fn apply_chain_lock(&mut self, chain_lock: ChainLock) -> BTreeMap> { + fn apply_chain_lock(&mut self, chain_lock: ChainLock) -> ApplyChainLockOutcome { let cl_height = chain_lock.block_height; - let mut per_account: BTreeMap> = BTreeMap::new(); + let mut locked_transactions: BTreeMap> = BTreeMap::new(); // Promote across every account: funds-bearing (Standard, // CoinJoin, DashPay) and keys-only (identity, asset-lock, @@ -237,7 +273,7 @@ impl WalletInfoInterface for ManagedWalletInfo { ), }; if !finalized_txids.is_empty() { - per_account.insert(account_type, finalized_txids); + locked_transactions.insert(account_type, finalized_txids); } } @@ -250,7 +286,10 @@ impl WalletInfoInterface for ManagedWalletInfo { self.metadata.last_applied_chain_lock = Some(chain_lock); } - per_account + ApplyChainLockOutcome { + locked_transactions, + metadata_advanced: advance, + } } fn update_last_synced(&mut self, timestamp: u64) {