Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
6 changes: 3 additions & 3 deletions dash-spv-ffi/src/bin/ffi_cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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],
Expand All @@ -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,
);
}
Expand Down Expand Up @@ -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 {
Expand Down
148 changes: 133 additions & 15 deletions dash-spv-ffi/src/callbacks.rs
Original file line number Diff line number Diff line change
Expand Up @@ -787,7 +787,7 @@ pub type OnSyncHeightAdvancedCallback =
Option<extern "C" fn(wallet_id: *const c_char, height: u32, user_data: *mut c_void)>;

/// 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.
///
Expand Down Expand Up @@ -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,
Expand All @@ -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,
}

Expand All @@ -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(),
}
}
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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};
Expand Down Expand Up @@ -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<Option<Captured>> = 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<AccountType, Vec<Txid>> = 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() {
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This test doesn't really make sense i think? Its testing the dispatch function?

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);
}
}
2 changes: 1 addition & 1 deletion dash-spv-ffi/tests/dashd_sync/callbacks.rs
Original file line number Diff line number Diff line change
Expand Up @@ -620,7 +620,7 @@ pub(super) fn create_wallet_callbacks(tracker: &Arc<CallbackTracker>) -> 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,
}
}
22 changes: 11 additions & 11 deletions dash-spv/tests/dashd_masternode/helpers.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
///
Expand Down Expand Up @@ -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,
);
}
Expand Down Expand Up @@ -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;
Expand Down
4 changes: 2 additions & 2 deletions dash-spv/tests/dashd_masternode/tests_chainlock.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -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
Expand Down
Loading
Loading