Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions dash-spv-ffi/src/bin/ffi_cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
48 changes: 48 additions & 0 deletions dash-spv-ffi/src/callbacks.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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,
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
pub user_data: *mut c_void,
}

Expand All @@ -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(),
}
}
Expand Down Expand Up @@ -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,
);
}
}
}
}
}
Expand Down
64 changes: 56 additions & 8 deletions key-wallet-manager/src/event_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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
Expand Down Expand Up @@ -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]
Expand Down
56 changes: 56 additions & 0 deletions key-wallet-manager/src/events.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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,
Expand Down Expand Up @@ -325,6 +371,10 @@ impl WalletEvent {
wallet_id,
..
}
| WalletEvent::ChainLockApplied {
wallet_id,
..
}
| WalletEvent::TransactionsChainlocked {
wallet_id,
..
Expand Down Expand Up @@ -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,
Expand Down
28 changes: 19 additions & 9 deletions key-wallet-manager/src/process_block.rs
Original file line number Diff line number Diff line change
Expand Up @@ -293,16 +293,26 @@ impl<T: WalletInfoInterface + Send + Sync + 'static> 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);
}
}

Expand Down
18 changes: 14 additions & 4 deletions key-wallet-manager/src/wallet_interface.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
17 changes: 13 additions & 4 deletions key-wallet/src/tests/keep_finalized_transactions_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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]);
Expand Down Expand Up @@ -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));
}
Expand Down
Loading
Loading