From 3a6e452fa44518e6eface9164694982308d86f1d Mon Sep 17 00:00:00 2001 From: Ivan Shumkov Date: Fri, 15 May 2026 18:43:59 +0700 Subject: [PATCH 1/6] feat(key-wallet): ApplyChainLockOutcome surfaces metadata advance flag MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the BTreeMap return of `WalletInfoInterface::apply_chain_lock` with an explicit `ApplyChainLockOutcome { per_account, metadata_advanced }` so the wallet-manager-level emitter can fire one event per effect: `TransactionsChainlocked` when records were promoted, and a separate event whenever `last_applied_chain_lock` advanced (added in the next commit). The two effects fire independently — a quiescent wallet that sees a chainlock above its history still advances the finality boundary even though no record is promoted. Durable consumers that persist `last_applied_chain_lock` (e.g. the platform-wallet bridge that uses it to build a `ChainAssetLockProof` for `InBlock` asset-lock TXs on restart) need a signal on every boundary advance, not just on the promotion path. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../keep_finalized_transactions_tests.rs | 17 +++-- .../wallet_info_interface.rs | 70 +++++++++++++++---- 2 files changed, 69 insertions(+), 18 deletions(-) diff --git a/key-wallet/src/tests/keep_finalized_transactions_tests.rs b/key-wallet/src/tests/keep_finalized_transactions_tests.rs index 2226b9a10..4ee504a81 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 + .per_account .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.per_account.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..3766e8b43 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,31 @@ use dashcore::ephemerealdata::instant_lock::InstantLock; use dashcore::prelude::CoreBlockHeight; use dashcore::{Address as DashAddress, Transaction, Txid}; +/// Outcome of [`WalletInfoInterface::apply_chain_lock`]. +/// +/// Separates the two independent effects of applying a chainlock so the +/// manager-level emitter (in `key-wallet-manager`) can fire one event +/// per effect: a `WalletEvent::TransactionsChainlocked` event when +/// records were promoted (carries `per_account`), and a +/// `WalletEvent::ChainLockApplied` event whenever the wallet's +/// `last_applied_chain_lock` metadata advanced (independent of whether +/// any record was promoted — a quiescent wallet's metadata still +/// advances forward). +#[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 per_account: 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 +141,35 @@ 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 independently in an + /// [`ApplyChainLockOutcome`]. + /// + /// The two effects fire independently and either, both, or neither + /// may be observed for a given call: + /// + /// - `per_account` 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`. The manager + /// (in `key-wallet-manager`) emits one + /// `WalletEvent::TransactionsChainlocked` when this is non-empty. + /// - `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 emits + /// one `WalletEvent::ChainLockApplied` when this is `true`. This + /// fires INDEPENDENTLY of promotion: 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 `per_account` so the caller can + /// emit the `TransactionsChainlocked` 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,7 +253,7 @@ 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(); @@ -250,7 +289,10 @@ impl WalletInfoInterface for ManagedWalletInfo { self.metadata.last_applied_chain_lock = Some(chain_lock); } - per_account + ApplyChainLockOutcome { + per_account, + metadata_advanced: advance, + } } fn update_last_synced(&mut self, timestamp: u64) { From de28922f97854743b2a420aa9aae65091ca40af8 Mon Sep 17 00:00:00 2001 From: Ivan Shumkov Date: Fri, 15 May 2026 18:52:23 +0700 Subject: [PATCH 2/6] feat(key-wallet-manager): emit ChainLockApplied event on metadata advance Add a new `WalletEvent::ChainLockApplied { wallet_id, chain_lock }` variant that fires whenever a wallet's `last_applied_chain_lock` metadata advances forward (or moves from `None` to `Some`), and route it through the FFI dispatcher. `apply_chain_lock` now reads `ApplyChainLockOutcome` from the wallet info and emits up to two events per wallet, in this order: 1. `ChainLockApplied` when the metadata advanced, so persisters that need to mirror `last_applied_chain_lock` to durable storage have a single hook regardless of whether any record was promoted. This is the gap the platform-wallet bridge had to live with: a chainlock that advanced the boundary without promoting anything was completely invisible, and the persisted `last_applied_chain_lock` went stale, which broke restart-time `ChainAssetLockProof` construction for `InBlock` asset-lock TXs. 2. `TransactionsChainlocked` when at least one record was promoted from `InBlock` to `InChainLockedBlock` (unchanged contract). Ordering matters: `ChainLockApplied` fires FIRST so a persister listening to both events can write durable metadata before the promotion record. FFI: adds `OnWalletChainLockAppliedCallback` and an `on_chain_lock_applied` field appended to `FFIWalletEventCallbacks` (before `user_data`) so existing offsets stay stable for C consumers. Event-tests updated to reflect the new two-event contract on chainlock promotion, the metadata-only advance path, and the higher-replay path. Co-Authored-By: Claude Opus 4.7 (1M context) --- dash-spv-ffi/src/bin/ffi_cli.rs | 1 + dash-spv-ffi/src/callbacks.rs | 48 ++++++++++++++++ key-wallet-manager/src/event_tests.rs | 64 +++++++++++++++++++--- key-wallet-manager/src/events.rs | 56 +++++++++++++++++++ key-wallet-manager/src/process_block.rs | 28 +++++++--- key-wallet-manager/src/wallet_interface.rs | 18 ++++-- 6 files changed, 194 insertions(+), 21 deletions(-) diff --git a/dash-spv-ffi/src/bin/ffi_cli.rs b/dash-spv-ffi/src/bin/ffi_cli.rs index c80b23bde..2db47d735 100644 --- a/dash-spv-ffi/src/bin/ffi_cli.rs +++ b/dash-spv-ffi/src/bin/ffi_cli.rs @@ -529,6 +529,7 @@ fn main() { 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_applied: None, 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..da438198f 100644 --- a/dash-spv-ffi/src/callbacks.rs +++ b/dash-spv-ffi/src/callbacks.rs @@ -841,6 +841,33 @@ pub type OnWalletTransactionsChainlockedCallback = Option< ), >; +/// Callback for `WalletEvent::ChainLockApplied`. +/// +/// Fires once per wallet every time the wallet's +/// `last_applied_chain_lock` advances forward by height (or moves from +/// `None` to `Some`), independently of whether any record was +/// promoted. 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). +/// +/// When the same chainlock also promoted records, this callback fires +/// BEFORE `on_transactions_chainlocked` so persisters can write the +/// durable metadata before the promotion record. +/// +/// All pointers are borrowed and only valid for the duration of the +/// callback. +pub type OnWalletChainLockAppliedCallback = Option< + extern "C" fn( + wallet_id: *const c_char, + cl_height: u32, + cl_hash: *const [u8; 32], + cl_signature: *const [u8; 96], + 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. @@ -856,6 +883,10 @@ pub struct FFIWalletEventCallbacks { pub on_block_processed: OnWalletBlockProcessedCallback, pub on_sync_height_advanced: OnSyncHeightAdvancedCallback, pub on_transactions_chainlocked: OnWalletTransactionsChainlockedCallback, + /// Appended after `on_transactions_chainlocked` (before `user_data`) + /// so existing field offsets stay stable for any C-side consumers + /// that allocated this struct from older headers. + pub on_chain_lock_applied: OnWalletChainLockAppliedCallback, pub user_data: *mut c_void, } @@ -871,6 +902,7 @@ impl Default for FFIWalletEventCallbacks { on_block_processed: None, on_sync_height_advanced: None, on_transactions_chainlocked: None, + on_chain_lock_applied: None, user_data: std::ptr::null_mut(), } } @@ -1168,6 +1200,22 @@ impl FFIWalletEventCallbacks { drop(ffi_finalized); } } + WalletEvent::ChainLockApplied { + wallet_id, + chain_lock, + } => { + if let Some(cb) = self.on_chain_lock_applied { + let wallet_id_hex = hex::encode(wallet_id); + let c_wallet_id = CString::new(wallet_id_hex).unwrap_or_default(); + 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], + self.user_data, + ); + } + } } } } diff --git a/key-wallet-manager/src/event_tests.rs b/key-wallet-manager/src/event_tests.rs index 6bd2f3d9e..72ad9f4e4 100644 --- a/key-wallet-manager/src/event_tests.rs +++ b/key-wallet-manager/src/event_tests.rs @@ -1060,8 +1060,22 @@ 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 both events fire — `ChainLockApplied` first (so + // persisters write the durable metadata before the promotion), + // then `TransactionsChainlocked`. + assert_eq!(events.len(), 2, "ChainLockApplied + TransactionsChainlocked expected, got {events:?}"); match &events[0] { + WalletEvent::ChainLockApplied { + wallet_id: wid, + chain_lock, + } => { + assert_eq!(*wid, wallet_id); + assert_eq!(chain_lock.block_height, 100); + } + other => panic!("expected ChainLockApplied first, got {:?}", other), + } + match &events[1] { WalletEvent::TransactionsChainlocked { wallet_id: wid, chain_lock, @@ -1078,17 +1092,32 @@ async fn test_apply_chain_lock_promotes_in_block_record_and_emits_event() { .expect("the receiving account should have a promotion entry"); assert_eq!(txids, &vec![tx.txid()]); } - other => panic!("expected TransactionsChainlocked, got {:?}", other), + other => panic!("expected TransactionsChainlocked second, 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_applied_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 `ChainLockApplied` to know the metadata moved. + let advance_events = drain_events(&mut rx); + assert_eq!(advance_events.len(), 1, "exactly one ChainLockApplied expected, got {advance_events:?}"); + match &advance_events[0] { + WalletEvent::ChainLockApplied { + wallet_id: wid, + chain_lock, + } => { + assert_eq!(*wid, wallet_id); + assert_eq!(chain_lock.block_height, 500); + } + other => panic!("expected ChainLockApplied, got {:?}", other), + } // Subsequent block below the new finality boundary must be born chainlocked. let addr = manager @@ -1143,13 +1172,32 @@ async fn test_apply_chain_lock_is_idempotent_on_already_finalized() { 1, "first chainlock must emit exactly one TransactionsChainlocked" ); + assert_eq!( + first.iter().filter(|e| matches!(e, WalletEvent::ChainLockApplied { .. })).count(), + 1, + "first chainlock must also emit ChainLockApplied (None -> Some(50))" + ); - // 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 + // `ChainLockApplied` (no `TransactionsChainlocked`). + manager.apply_chain_lock(ChainLock::dummy(80)); + let advance = drain_events(&mut rx); + assert_eq!( + advance.iter().filter(|e| matches!(e, WalletEvent::TransactionsChainlocked { .. })).count(), + 0, + "no records to promote => no TransactionsChainlocked" + ); + assert_eq!( + advance.iter().filter(|e| matches!(e, WalletEvent::ChainLockApplied { .. })).count(), + 1, + "metadata advance from 50 -> 80 must emit exactly one ChainLockApplied" + ); } #[tokio::test] diff --git a/key-wallet-manager/src/events.rs b/key-wallet-manager/src/events.rs index b8844b294..d16afbe32 100644 --- a/key-wallet-manager/src/events.rs +++ b/key-wallet-manager/src/events.rs @@ -275,6 +275,46 @@ pub enum WalletEvent { /// New scanned height for the wallet. height: CoreBlockHeight, }, + /// The wallet's `last_applied_chain_lock` metadata advanced because + /// the wallet manager applied a chainlock whose height strictly + /// exceeded the previously-stored chainlock (or moved it from + /// `None` to `Some`). + /// + /// Fires once per wallet, every time the finality boundary + /// advances forward, INDEPENDENTLY of whether any records were + /// promoted in the same call. It is paired with — and emitted + /// immediately before — a [`WalletEvent::TransactionsChainlocked`] + /// event when the same chainlock also promoted records; consumers + /// that listen to both will see this event first so the durable + /// `last_applied_chain_lock` is written before the promotion is + /// persisted. + /// + /// The two events have distinct audiences: + /// + /// - Consumers 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) + /// listen here. Listening only to `TransactionsChainlocked` + /// misses every chainlock whose height advanced the wallet's + /// 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 but emits no promotion. + /// - Consumers that only care about per-tx promotions keep + /// subscribing to `TransactionsChainlocked` and can ignore this + /// event. + /// + /// Carries the full `ChainLock` (signing proof: `block_height`, + /// `block_hash`, `signature`) so consumers can persist the proof + /// alongside the height. + ChainLockApplied { + /// ID of the affected wallet. + wallet_id: WalletId, + /// The chainlock whose application advanced the wallet's + /// `last_applied_chain_lock`. Carries the signing proof. + chain_lock: ChainLock, + }, /// 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 @@ -287,6 +327,12 @@ pub enum WalletEvent { /// `chain_lock = Some(..)` and their records already in /// `InChainLockedBlock` context. They do not appear here, since no /// promotion took place. + /// + /// When this event fires for a chainlock that also advanced the + /// wallet's `last_applied_chain_lock`, it is preceded by a + /// [`WalletEvent::ChainLockApplied`] event for the same chainlock. + /// Consumers that need both metadata persistence and the promotion + /// list should subscribe to both events. TransactionsChainlocked { /// ID of the affected wallet. wallet_id: WalletId, @@ -325,6 +371,10 @@ impl WalletEvent { wallet_id, .. } + | WalletEvent::ChainLockApplied { + wallet_id, + .. + } | WalletEvent::TransactionsChainlocked { wallet_id, .. @@ -391,6 +441,12 @@ impl fmt::Display for WalletEvent { } => { write!(f, "SyncHeightAdvanced(height={})", height) } + WalletEvent::ChainLockApplied { + chain_lock, + .. + } => { + write!(f, "ChainLockApplied(chainlock_height={})", chain_lock.block_height) + } WalletEvent::TransactionsChainlocked { chain_lock, per_account, diff --git a/key-wallet-manager/src/process_block.rs b/key-wallet-manager/src/process_block.rs index cc417300b..a5c8dd3c7 100644 --- a/key-wallet-manager/src/process_block.rs +++ b/key-wallet-manager/src/process_block.rs @@ -293,16 +293,26 @@ 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 `ChainLockApplied` BEFORE `TransactionsChainlocked` so + // persisters that listen to both can write the durable + // `last_applied_chain_lock` first, then persist any promotions + // atomically with the metadata they imply. The ordering is a + // contract relied on by downstream consumers. + if outcome.metadata_advanced { + let _ = self.event_sender.send(WalletEvent::ChainLockApplied { + wallet_id: *wallet_id, + chain_lock: chain_lock.clone(), + }); + } + if !outcome.per_account.is_empty() { + let _ = self.event_sender.send(WalletEvent::TransactionsChainlocked { + wallet_id: *wallet_id, + chain_lock: chain_lock.clone(), + per_account: outcome.per_account, + }); } - 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..f8f49b6b8 100644 --- a/key-wallet-manager/src/wallet_interface.rs +++ b/key-wallet-manager/src/wallet_interface.rs @@ -153,10 +153,20 @@ 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. + /// May emit up to two events per wallet, in this order: + /// + /// 1. [`WalletEvent::ChainLockApplied`] when the wallet's + /// `last_applied_chain_lock` advanced (strictly forward by + /// height, or `None` → `Some`). Fires even when no record was + /// promoted — durable consumers that persist the chainlock + /// metadata must listen here, not only on the promotion event. + /// 2. [`WalletEvent::TransactionsChainlocked`] when the chainlock + /// promoted at least one previously-`InBlock` record, carrying + /// the per-account net-new finalized txids. + /// + /// Both events carry the same full `ChainLock` so consumers can + /// persist the signing proof. A given call may emit only #1, only + /// #2, both, or neither — they fire independently. /// /// Implementations must serialize calls relative to /// `process_block_for_wallets` to avoid interleaving promotions with From 0caad20a58a03cf8404beb6e895b9065261bf407 Mon Sep 17 00:00:00 2001 From: Ivan Shumkov Date: Fri, 15 May 2026 19:10:11 +0700 Subject: [PATCH 3/6] fix(dash-spv-ffi): preserve user_data ABI offset + test ChainLockApplied dispatch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CodeRabbit caught that the new `on_chain_lock_applied` field was inserted before `user_data` in `FFIWalletEventCallbacks`, which shifted `user_data`'s byte offset and contradicted the layout-stability claim in the doc comment — C-side consumers hand-allocating this struct from older headers (i.e. without regenerating via cbindgen) rely on `user_data` staying where it was. Move the new field strictly to the end so every prior field — including `user_data` — keeps its previous offset, and rewrite the field's doc comment to reflect the new placement and the actual ABI guarantee. Also adds the `ChainLockApplied` dispatch unit test CodeRabbit suggested as a nice-to-have, sitting alongside the existing dispatch tests at the bottom of `callbacks.rs`. The test registers an `extern "C"` callback, fires a `WalletEvent::ChainLockApplied` carrying a synthetic `ChainLock::dummy(777)`, and asserts the callback received `cl_height == 777` — pinning the dispatch path against silent regressions. Refs: https://github.com/dashpay/rust-dashcore/pull/769#discussion_r3248023799 Co-Authored-By: Claude Opus 4.7 (1M context) --- dash-spv-ffi/src/callbacks.rs | 42 ++++++++++++++++++++++++++++++----- 1 file changed, 36 insertions(+), 6 deletions(-) diff --git a/dash-spv-ffi/src/callbacks.rs b/dash-spv-ffi/src/callbacks.rs index da438198f..d4a61b220 100644 --- a/dash-spv-ffi/src/callbacks.rs +++ b/dash-spv-ffi/src/callbacks.rs @@ -883,11 +883,13 @@ pub struct FFIWalletEventCallbacks { pub on_block_processed: OnWalletBlockProcessedCallback, pub on_sync_height_advanced: OnSyncHeightAdvancedCallback, pub on_transactions_chainlocked: OnWalletTransactionsChainlockedCallback, - /// Appended after `on_transactions_chainlocked` (before `user_data`) - /// so existing field offsets stay stable for any C-side consumers - /// that allocated this struct from older headers. - pub on_chain_lock_applied: OnWalletChainLockAppliedCallback, pub user_data: *mut c_void, + /// Appended strictly after `user_data` so every prior field — most + /// importantly `user_data` itself, whose offset C-side consumers + /// hand-allocating this struct from older headers may depend on — + /// keeps its previous byte offset. New consumers regenerated via + /// cbindgen pick this field up automatically. + pub on_chain_lock_applied: OnWalletChainLockAppliedCallback, } // SAFETY: Same rationale as FFISyncEventCallbacks @@ -902,8 +904,8 @@ impl Default for FFIWalletEventCallbacks { on_block_processed: None, on_sync_height_advanced: None, on_transactions_chainlocked: None, - on_chain_lock_applied: None, user_data: std::ptr::null_mut(), + on_chain_lock_applied: None, } } } @@ -1224,7 +1226,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}; @@ -1300,4 +1302,32 @@ mod tests { }); assert_eq!(NEW_ADDR_COUNT.load(Ordering::SeqCst), 3); } + + /// `ChainLockApplied` dispatch must invoke the registered callback + /// with the chainlock's height — the durable signal SDKs use to + /// know the wallet's `last_applied_chain_lock` advanced. + #[test] + fn test_chain_lock_applied_dispatch_passes_height() { + static CL_HEIGHT: 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], + _user: *mut c_void, + ) { + CL_HEIGHT.store(cl_height, Ordering::SeqCst); + } + + let callbacks = FFIWalletEventCallbacks { + on_chain_lock_applied: Some(cb), + ..FFIWalletEventCallbacks::default() + }; + + callbacks.dispatch(&WalletEvent::ChainLockApplied { + wallet_id: [3u8; 32], + chain_lock: ChainLock::dummy(777), + }); + assert_eq!(CL_HEIGHT.load(Ordering::SeqCst), 777); + } } From 359894232b95c3910942ef668f158b0934bffa4e Mon Sep 17 00:00:00 2001 From: Ivan Shumkov Date: Fri, 15 May 2026 19:15:23 +0700 Subject: [PATCH 4/6] fix(dash-spv-ffi): wire on_chain_lock_applied into dashd_sync test fixture MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Appending `on_chain_lock_applied` to `FFIWalletEventCallbacks` broke the integration-test fixture in `dashd_sync/callbacks.rs`, which constructs the struct with all fields named explicitly (no `..Default::default()` spread). CI under `cargo test -p dash-spv-ffi --all-features` failed with `error[E0063]: missing field on_chain_lock_applied`, which cascaded into every job that builds the FFI test suite (ffi matrix, pre-commit, address-sanitizer). The new wallet-event tests don't need this callback, so wire it as `None` — same shape as the freshly-added `on_transactions_chainlocked` slot right above it. Co-Authored-By: Claude Opus 4.7 (1M context) --- dash-spv-ffi/tests/dashd_sync/callbacks.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/dash-spv-ffi/tests/dashd_sync/callbacks.rs b/dash-spv-ffi/tests/dashd_sync/callbacks.rs index 2311278f0..7f8536ca3 100644 --- a/dash-spv-ffi/tests/dashd_sync/callbacks.rs +++ b/dash-spv-ffi/tests/dashd_sync/callbacks.rs @@ -621,6 +621,7 @@ pub(super) fn create_wallet_callbacks(tracker: &Arc) -> FFIWall on_block_processed: Some(on_wallet_block_processed), on_sync_height_advanced: Some(on_sync_height_advanced), on_transactions_chainlocked: None, + on_chain_lock_applied: None, user_data: Arc::as_ptr(tracker) as *mut c_void, } } From 56deed4c1458cb7ea7eabde1ba51b01a6ac909d5 Mon Sep 17 00:00:00 2001 From: Ivan Shumkov Date: Fri, 15 May 2026 19:33:07 +0700 Subject: [PATCH 5/6] style: rustfmt over-long assert_eq! lines in event_tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two `assert_eq!` calls added earlier on this branch (commits 3a6e452f / de28922f) exceed the workspace rustfmt width and now fail the `cargo fmt` pre-commit hook on macOS/Ubuntu/Windows. Straight `cargo fmt --all` output — no behaviour change. Co-Authored-By: Claude Opus 4.7 (1M context) --- key-wallet-manager/src/event_tests.rs | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/key-wallet-manager/src/event_tests.rs b/key-wallet-manager/src/event_tests.rs index 72ad9f4e4..96deed91d 100644 --- a/key-wallet-manager/src/event_tests.rs +++ b/key-wallet-manager/src/event_tests.rs @@ -1064,7 +1064,11 @@ async fn test_apply_chain_lock_promotes_in_block_record_and_emits_event() { // record, so both events fire — `ChainLockApplied` first (so // persisters write the durable metadata before the promotion), // then `TransactionsChainlocked`. - assert_eq!(events.len(), 2, "ChainLockApplied + TransactionsChainlocked expected, got {events:?}"); + assert_eq!( + events.len(), + 2, + "ChainLockApplied + TransactionsChainlocked expected, got {events:?}" + ); match &events[0] { WalletEvent::ChainLockApplied { wallet_id: wid, @@ -1107,7 +1111,11 @@ async fn test_apply_chain_lock_with_no_records_emits_chain_lock_applied_and_adva // durable consumers (e.g. asset-lock persisters) must observe a // single `ChainLockApplied` to know the metadata moved. let advance_events = drain_events(&mut rx); - assert_eq!(advance_events.len(), 1, "exactly one ChainLockApplied expected, got {advance_events:?}"); + assert_eq!( + advance_events.len(), + 1, + "exactly one ChainLockApplied expected, got {advance_events:?}" + ); match &advance_events[0] { WalletEvent::ChainLockApplied { wallet_id: wid, From 73fb21cd424eea195e9b60b4565646ef573a7b3e Mon Sep 17 00:00:00 2001 From: Ivan Shumkov Date: Fri, 15 May 2026 20:19:58 +0700 Subject: [PATCH 6/6] refactor(key-wallet-manager): fold ChainLockApplied into ChainLockProcessed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per @xdustinface's review on #769: keep chainlock fan-out atomic by collapsing the two-event split (`ChainLockApplied` + `TransactionsChainlocked`) into a single `WalletEvent::ChainLockProcessed`, fired whenever the wallet's `last_applied_chain_lock` advances. Net-new per-account promotions ride along in `locked_transactions` — possibly empty when the chainlock advanced the metadata boundary without promoting any record (durable consumers that persist the chainlock proof still observe those empty-promotion events). Surface changes: * `WalletEvent::TransactionsChainlocked` → `WalletEvent::ChainLockProcessed` * `ApplyChainLockOutcome.per_account` → `locked_transactions` (matching the event field name; outcome and event speak the same vocabulary) * `WalletEvent::ChainLockApplied` removed entirely * `OnWalletTransactionsChainlockedCallback` → `OnWalletChainLockProcessedCallback`, signature unchanged * `FFIWalletEventCallbacks.on_transactions_chainlocked` → `on_chain_lock_processed`; the separate `on_chain_lock_applied` slot is gone, so `FFIWalletEventCallbacks` shrinks by one callback pointer (a hard ABI break for the unreleased addition, fine since v0.42 hasn't shipped) * `ffi_cli`'s `on_wallet_transactions_chainlocked` printer renamed `on_wallet_chain_lock_processed`; emits "[Wallet] ChainLock processed" Emission semantics: `process_block.rs` now sends one event per wallet per chainlock, gated on `outcome.metadata_advanced`. Replays of the same chainlock height are silent. Tests in `key-wallet-manager/src/event_tests.rs` and `key-wallet/src/tests/keep_finalized_transactions_tests.rs` updated to the merged shape, including the empty-`locked_transactions` case for chainlocks that advance the boundary without promoting any record. Dispatch test coverage strengthened per CodeRabbit's nitpick on #769#discussion (1203-1218): replaces the height-only assertion with two tests at the bottom of `dash-spv-ffi/src/callbacks.rs`: * `test_chain_lock_processed_dispatch_round_trips_every_field` — registers an `extern "C"` callback, captures every wired argument into a typed `Captured` struct, fires a `ChainLockProcessed` with two distinct accounts (one txid each), and asserts wallet_id hex-encoding, height, 32-byte block hash, 96-byte signature, and `finalized_count == 2` (counts (account, txid) pairs, not accounts). * `test_chain_lock_processed_dispatch_fires_with_empty_promotions` — empty `locked_transactions` must still fire the callback with `finalized_count == 0`; pins the contract for durable consumers that persist the chainlock proof even when no record was promoted. Closes the design pushback on #769; addresses the dispatch-test nitpick in the same review. Co-Authored-By: Claude Opus 4.7 (1M context) --- dash-spv-ffi/src/bin/ffi_cli.rs | 7 +- dash-spv-ffi/src/callbacks.rs | 192 +++++++++++------- dash-spv-ffi/tests/dashd_sync/callbacks.rs | 3 +- dash-spv/tests/dashd_masternode/helpers.rs | 22 +- .../tests/dashd_masternode/tests_chainlock.rs | 4 +- key-wallet-manager/src/event_tests.rs | 96 +++++---- key-wallet-manager/src/events.rs | 117 ++++------- key-wallet-manager/src/process_block.rs | 20 +- key-wallet-manager/src/wallet_interface.rs | 23 +-- .../managed_core_funds_account.rs | 2 +- .../keep_finalized_transactions_tests.rs | 4 +- .../wallet_info_interface.rs | 59 +++--- 12 files changed, 274 insertions(+), 275 deletions(-) diff --git a/dash-spv-ffi/src/bin/ffi_cli.rs b/dash-spv-ffi/src/bin/ffi_cli.rs index 2db47d735..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,8 +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_applied: None, + 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 d4a61b220..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,51 +819,33 @@ 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. -/// -/// 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, - ), ->; - -/// Callback for `WalletEvent::ChainLockApplied`. -/// -/// Fires once per wallet every time the wallet's +/// Fires once per wallet whenever the wallet's /// `last_applied_chain_lock` advances forward by height (or moves from -/// `None` to `Some`), independently of whether any record was -/// promoted. 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). +/// `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). /// -/// When the same chainlock also promoted records, this callback fires -/// BEFORE `on_transactions_chainlocked` so persisters can write the -/// durable metadata before the promotion record. +/// `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 OnWalletChainLockAppliedCallback = Option< +pub type OnWalletChainLockProcessedCallback = 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, ), >; @@ -882,14 +864,8 @@ 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, - /// Appended strictly after `user_data` so every prior field — most - /// importantly `user_data` itself, whose offset C-side consumers - /// hand-allocating this struct from older headers may depend on — - /// keeps its previous byte offset. New consumers regenerated via - /// cbindgen pick this field up automatically. - pub on_chain_lock_applied: OnWalletChainLockAppliedCallback, } // SAFETY: Same rationale as FFISyncEventCallbacks @@ -903,9 +879,8 @@ 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(), - on_chain_lock_applied: None, } } } @@ -1174,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 { @@ -1202,22 +1177,6 @@ impl FFIWalletEventCallbacks { drop(ffi_finalized); } } - WalletEvent::ChainLockApplied { - wallet_id, - chain_lock, - } => { - if let Some(cb) = self.on_chain_lock_applied { - let wallet_id_hex = hex::encode(wallet_id); - let c_wallet_id = CString::new(wallet_id_hex).unwrap_or_default(); - 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], - self.user_data, - ); - } - } } } } @@ -1303,31 +1262,112 @@ mod tests { assert_eq!(NEW_ADDR_COUNT.load(Ordering::SeqCst), 3); } - /// `ChainLockApplied` dispatch must invoke the registered callback - /// with the chainlock's height — the durable signal SDKs use to - /// know the wallet's `last_applied_chain_lock` advanced. + /// `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_applied_dispatch_passes_height() { - static CL_HEIGHT: AtomicU32 = AtomicU32::new(u32::MAX); + 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, + 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, ) { - CL_HEIGHT.store(cl_height, Ordering::SeqCst); + FIRED.store(finalized_count, Ordering::SeqCst); } let callbacks = FFIWalletEventCallbacks { - on_chain_lock_applied: Some(cb), + on_chain_lock_processed: Some(cb), ..FFIWalletEventCallbacks::default() }; - callbacks.dispatch(&WalletEvent::ChainLockApplied { - wallet_id: [3u8; 32], - chain_lock: ChainLock::dummy(777), + callbacks.dispatch(&WalletEvent::ChainLockProcessed { + wallet_id: [4u8; 32], + chain_lock: ChainLock::dummy(900), + locked_transactions: BTreeMap::new(), }); - assert_eq!(CL_HEIGHT.load(Ordering::SeqCst), 777); + 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 7f8536ca3..a36b944ef 100644 --- a/dash-spv-ffi/tests/dashd_sync/callbacks.rs +++ b/dash-spv-ffi/tests/dashd_sync/callbacks.rs @@ -620,8 +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_applied: 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 96deed91d..516c5356f 100644 --- a/key-wallet-manager/src/event_tests.rs +++ b/key-wallet-manager/src/event_tests.rs @@ -1061,29 +1061,14 @@ async fn test_apply_chain_lock_promotes_in_block_record_and_emits_event() { let events = drain_events(&mut rx); // First chainlock advances the wallet's metadata AND promotes a - // record, so both events fire — `ChainLockApplied` first (so - // persisters write the durable metadata before the promotion), - // then `TransactionsChainlocked`. - assert_eq!( - events.len(), - 2, - "ChainLockApplied + TransactionsChainlocked expected, got {events:?}" - ); + // 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::ChainLockApplied { - wallet_id: wid, - chain_lock, - } => { - assert_eq!(*wid, wallet_id); - assert_eq!(chain_lock.block_height, 100); - } - other => panic!("expected ChainLockApplied first, got {:?}", other), - } - match &events[1] { - 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); @@ -1091,17 +1076,17 @@ 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 second, got {:?}", other), + other => panic!("expected ChainLockProcessed, got {:?}", other), } } #[tokio::test] -async fn test_apply_chain_lock_with_no_records_emits_chain_lock_applied_and_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)); @@ -1109,22 +1094,28 @@ async fn test_apply_chain_lock_with_no_records_emits_chain_lock_applied_and_adva // 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 `ChainLockApplied` to know the metadata moved. + // 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 ChainLockApplied expected, got {advance_events:?}" + "exactly one ChainLockProcessed expected, got {advance_events:?}" ); match &advance_events[0] { - WalletEvent::ChainLockApplied { + 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 ChainLockApplied, got {:?}", other), + other => panic!("expected ChainLockProcessed, got {:?}", other), } // Subsequent block below the new finality boundary must be born chainlocked. @@ -1157,10 +1148,10 @@ async fn test_apply_chain_lock_with_no_records_emits_chain_lock_applied_and_adva _ => 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" ); } @@ -1175,15 +1166,24 @@ 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_eq!( - first.iter().filter(|e| matches!(e, WalletEvent::ChainLockApplied { .. })).count(), - 1, - "first chainlock must also emit ChainLockApplied (None -> Some(50))" + assert!( + !chainlock_events[0].is_empty(), + "first chainlock at height 50 must promote the InBlock record" ); // Replaying the same chainlock must not re-emit anything: no @@ -1193,18 +1193,28 @@ async fn test_apply_chain_lock_is_idempotent_on_already_finalized() { // A higher chainlock with no outstanding InBlock records below it // still advances the metadata boundary, so emits exactly one - // `ChainLockApplied` (no `TransactionsChainlocked`). + // `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.iter().filter(|e| matches!(e, WalletEvent::TransactionsChainlocked { .. })).count(), - 0, - "no records to promote => no TransactionsChainlocked" - ); - assert_eq!( - advance.iter().filter(|e| matches!(e, WalletEvent::ChainLockApplied { .. })).count(), + advance_events.len(), 1, - "metadata advance from 50 -> 80 must emit exactly one ChainLockApplied" + "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] ); } diff --git a/key-wallet-manager/src/events.rs b/key-wallet-manager/src/events.rs index d16afbe32..d5d597cba 100644 --- a/key-wallet-manager/src/events.rs +++ b/key-wallet-manager/src/events.rs @@ -275,51 +275,24 @@ pub enum WalletEvent { /// New scanned height for the wallet. height: CoreBlockHeight, }, - /// The wallet's `last_applied_chain_lock` metadata advanced because - /// the wallet manager applied a chainlock whose height strictly - /// exceeded the previously-stored chainlock (or moved it from - /// `None` to `Some`). + /// 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, every time the finality boundary - /// advances forward, INDEPENDENTLY of whether any records were - /// promoted in the same call. It is paired with — and emitted - /// immediately before — a [`WalletEvent::TransactionsChainlocked`] - /// event when the same chainlock also promoted records; consumers - /// that listen to both will see this event first so the durable - /// `last_applied_chain_lock` is written before the promotion is - /// persisted. - /// - /// The two events have distinct audiences: - /// - /// - Consumers 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) - /// listen here. Listening only to `TransactionsChainlocked` - /// misses every chainlock whose height advanced the wallet's - /// 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 but emits no promotion. - /// - Consumers that only care about per-tx promotions keep - /// subscribing to `TransactionsChainlocked` and can ignore this - /// event. - /// - /// Carries the full `ChainLock` (signing proof: `block_height`, - /// `block_hash`, `signature`) so consumers can persist the proof - /// alongside the height. - ChainLockApplied { - /// ID of the affected wallet. - wallet_id: WalletId, - /// The chainlock whose application advanced the wallet's - /// `last_applied_chain_lock`. Carries the signing proof. - chain_lock: ChainLock, - }, - /// 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. + /// 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 @@ -328,26 +301,26 @@ pub enum WalletEvent { /// `InChainLockedBlock` context. They do not appear here, since no /// promotion took place. /// - /// When this event fires for a chainlock that also advanced the - /// wallet's `last_applied_chain_lock`, it is preceded by a - /// [`WalletEvent::ChainLockApplied`] event for the same chainlock. - /// Consumers that need both metadata persistence and the promotion - /// list should subscribe to both events. - 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>, }, } @@ -371,11 +344,7 @@ impl WalletEvent { wallet_id, .. } - | WalletEvent::ChainLockApplied { - wallet_id, - .. - } - | WalletEvent::TransactionsChainlocked { + | WalletEvent::ChainLockProcessed { wallet_id, .. } => *wallet_id, @@ -441,22 +410,18 @@ impl fmt::Display for WalletEvent { } => { write!(f, "SyncHeightAdvanced(height={})", height) } - WalletEvent::ChainLockApplied { - chain_lock, - .. - } => { - write!(f, "ChainLockApplied(chainlock_height={})", chain_lock.block_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 a5c8dd3c7..043b94021 100644 --- a/key-wallet-manager/src/process_block.rs +++ b/key-wallet-manager/src/process_block.rs @@ -295,22 +295,16 @@ impl WalletInterface for WalletM for (wallet_id, info) in self.wallet_infos.iter_mut() { let outcome = info.apply_chain_lock(chain_lock.clone()); - // Emit `ChainLockApplied` BEFORE `TransactionsChainlocked` so - // persisters that listen to both can write the durable - // `last_applied_chain_lock` first, then persist any promotions - // atomically with the metadata they imply. The ordering is a - // contract relied on by downstream consumers. + // 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::ChainLockApplied { + let _ = self.event_sender.send(WalletEvent::ChainLockProcessed { wallet_id: *wallet_id, chain_lock: chain_lock.clone(), - }); - } - if !outcome.per_account.is_empty() { - let _ = self.event_sender.send(WalletEvent::TransactionsChainlocked { - wallet_id: *wallet_id, - chain_lock: chain_lock.clone(), - per_account: outcome.per_account, + locked_transactions: outcome.locked_transactions, }); } } diff --git a/key-wallet-manager/src/wallet_interface.rs b/key-wallet-manager/src/wallet_interface.rs index f8f49b6b8..9b2b5fde0 100644 --- a/key-wallet-manager/src/wallet_interface.rs +++ b/key-wallet-manager/src/wallet_interface.rs @@ -153,20 +153,15 @@ pub trait WalletInterface: Send + Sync + 'static { /// `InChainLockedBlock` and advancing each wallet's /// `last_applied_chain_lock`. /// - /// May emit up to two events per wallet, in this order: - /// - /// 1. [`WalletEvent::ChainLockApplied`] when the wallet's - /// `last_applied_chain_lock` advanced (strictly forward by - /// height, or `None` → `Some`). Fires even when no record was - /// promoted — durable consumers that persist the chainlock - /// metadata must listen here, not only on the promotion event. - /// 2. [`WalletEvent::TransactionsChainlocked`] when the chainlock - /// promoted at least one previously-`InBlock` record, carrying - /// the per-account net-new finalized txids. - /// - /// Both events carry the same full `ChainLock` so consumers can - /// persist the signing proof. A given call may emit only #1, only - /// #2, both, or neither — they fire independently. + /// 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 4ee504a81..dcfef377a 100644 --- a/key-wallet/src/tests/keep_finalized_transactions_tests.rs +++ b/key-wallet/src/tests/keep_finalized_transactions_tests.rs @@ -160,7 +160,7 @@ async fn test_apply_chain_lock_promotes_in_block_records() { "first chainlock must advance metadata from None to Some(50)" ); let promoted = outcome - .per_account + .locked_transactions .get(&bip44_account_type()) .expect("BIP44 account should have a promotion entry"); assert_eq!(promoted, &vec![txid]); @@ -204,7 +204,7 @@ async fn test_apply_chain_lock_skips_unmined_and_above_height() { // the mempool record's (absent) height, so neither promotes. ctx.managed_wallet.update_last_processed_height(200); let outcome = ctx.managed_wallet.apply_chain_lock(ChainLock::dummy(100)); - assert!(outcome.per_account.is_empty()); + assert!(outcome.locked_transactions.is_empty()); assert!( outcome.metadata_advanced, "metadata must still advance to the new finality boundary even when no record promotes" 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 3766e8b43..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 @@ -20,14 +20,12 @@ use dashcore::{Address as DashAddress, Transaction, Txid}; /// Outcome of [`WalletInfoInterface::apply_chain_lock`]. /// -/// Separates the two independent effects of applying a chainlock so the -/// manager-level emitter (in `key-wallet-manager`) can fire one event -/// per effect: a `WalletEvent::TransactionsChainlocked` event when -/// records were promoted (carries `per_account`), and a -/// `WalletEvent::ChainLockApplied` event whenever the wallet's -/// `last_applied_chain_lock` metadata advanced (independent of whether -/// any record was promoted — a quiescent wallet's metadata still -/// advances forward). +/// 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 @@ -35,7 +33,7 @@ pub struct ApplyChainLockOutcome { /// 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 per_account: BTreeMap>, + 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 @@ -141,33 +139,32 @@ 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 both effects independently in an + /// height), and return both effects in a single /// [`ApplyChainLockOutcome`]. /// - /// The two effects fire independently and either, both, or neither - /// may be observed for a given call: + /// Field semantics: /// - /// - `per_account` 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`. The manager - /// (in `key-wallet-manager`) emits one - /// `WalletEvent::TransactionsChainlocked` when this is non-empty. + /// - `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 emits - /// one `WalletEvent::ChainLockApplied` when this is `true`. This - /// fires INDEPENDENTLY of promotion: 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. + /// `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. /// /// Under the default `keep-finalized-transactions=OFF` feature the /// promoted records are dropped and only their txids are retained — - /// the txids are still surfaced in `per_account` so the caller can - /// emit the `TransactionsChainlocked` event before the records - /// disappear. + /// 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() } @@ -255,7 +252,7 @@ impl WalletInfoInterface for ManagedWalletInfo { 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, @@ -276,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); } } @@ -290,7 +287,7 @@ impl WalletInfoInterface for ManagedWalletInfo { } ApplyChainLockOutcome { - per_account, + locked_transactions, metadata_advanced: advance, } }