From 48249b229788fd5bfbb3c023333028f818b5ac80 Mon Sep 17 00:00:00 2001 From: xdustinface Date: Tue, 31 Mar 2026 20:15:39 +1100 Subject: [PATCH 1/7] feat: include `InstantLock` in `TransactionContext::InstantSend` variant Change `TransactionContext::InstantSend` from a unit variant to `InstantSend(InstantLock)` so the full IS lock payload is available throughout the wallet and SPV layers. - `WalletInterface::process_mempool_transaction` now takes `Option` instead of `bool` - `WalletInterface::process_instant_send_lock` takes `InstantLock` instead of `Txid` - `MempoolManager::pending_is_locks` stores the full lock - `FFITransactionContext` struct gains `islock_data`/`islock_len` fields for consensus-serialized IS lock bytes across the FFI boundary - `transaction_context_from_ffi` accepts IS lock bytes and deserializes - `mark_instant_send_utxos` and `wallet_checker` update transaction record context with the IS lock payload --- dash-spv-ffi/src/callbacks.rs | 13 +- dash-spv/src/sync/mempool/manager.rs | 100 ++++++++++----- dash-spv/src/sync/mempool/sync_manager.rs | 2 +- key-wallet-ffi/FFI_API.md | 4 +- key-wallet-ffi/src/managed_account.rs | 11 +- key-wallet-ffi/src/transaction.rs | 11 +- key-wallet-ffi/src/transaction_checking.rs | 17 ++- key-wallet-ffi/src/types.rs | 116 ++++++++++++++++-- key-wallet-manager/src/event_tests.rs | 98 +++++++++++---- key-wallet-manager/src/lib.rs | 4 +- key-wallet-manager/src/process_block.rs | 33 ++--- key-wallet-manager/src/test_helpers.rs | 14 ++- .../src/test_utils/mock_wallet.rs | 28 ++++- key-wallet-manager/src/wallet_interface.rs | 7 +- key-wallet/src/managed_account/mod.rs | 6 +- .../transaction_context.rs | 14 ++- .../transaction_router/tests/coinbase.rs | 5 +- .../transaction_router/tests/routing.rs | 7 +- .../transaction_checking/wallet_checker.rs | 50 ++++++-- .../wallet_info_interface.rs | 13 +- 20 files changed, 414 insertions(+), 139 deletions(-) diff --git a/dash-spv-ffi/src/callbacks.rs b/dash-spv-ffi/src/callbacks.rs index 8aefa815f..0f22e0def 100644 --- a/dash-spv-ffi/src/callbacks.rs +++ b/dash-spv-ffi/src/callbacks.rs @@ -705,6 +705,8 @@ impl FFIWalletEventCallbacks { let wallet_id_hex = hex::encode(wallet_id); let c_wallet_id = CString::new(wallet_id_hex).unwrap_or_default(); + let mut ffi_ctx = FFITransactionContext::from(record.context.clone()); + let tx_bytes = dashcore::consensus::serialize(&record.transaction).into_boxed_slice(); @@ -736,7 +738,7 @@ impl FFIWalletEventCallbacks { let ffi_record = FFITransactionRecord { txid: record.txid.to_byte_array(), net_amount: record.net_amount, - context: FFITransactionContext::from(record.context), + context: ffi_ctx.clone(), transaction_type: FFITransactionType::from(record.transaction_type), direction: FFITransactionDirection::from(record.direction), fee: record.fee.unwrap_or(0), @@ -778,6 +780,9 @@ impl FFIWalletEventCallbacks { } } } + // SAFETY: `ffi_ctx` owns the heap-allocated IS lock bytes produced + // by `From`. Free them after the callback returns. + unsafe { ffi_ctx.free_islock_data() }; } } WalletEvent::TransactionStatusChanged { @@ -789,12 +794,16 @@ impl FFIWalletEventCallbacks { let wallet_id_hex = hex::encode(wallet_id); let c_wallet_id = CString::new(wallet_id_hex).unwrap_or_default(); let txid_bytes = txid.as_byte_array(); + let mut ffi_ctx = FFITransactionContext::from(status.clone()); cb( c_wallet_id.as_ptr(), txid_bytes as *const [u8; 32], - FFITransactionContext::from(*status), + ffi_ctx.clone(), self.user_data, ); + // SAFETY: `ffi_ctx` owns the heap-allocated IS lock bytes produced + // by `From`. Free them after the callback returns. + unsafe { ffi_ctx.free_islock_data() }; } } WalletEvent::BalanceUpdated { diff --git a/dash-spv/src/sync/mempool/manager.rs b/dash-spv/src/sync/mempool/manager.rs index b3608cc8c..de0604781 100644 --- a/dash-spv/src/sync/mempool/manager.rs +++ b/dash-spv/src/sync/mempool/manager.rs @@ -10,6 +10,7 @@ use std::net::SocketAddr; use std::sync::Arc; use std::time::{Duration, Instant}; +use dashcore::ephemerealdata::instant_lock::InstantLock; use dashcore::network::message_blockdata::Inventory; use dashcore::{Amount, Transaction, Txid}; use rand::seq::IteratorRandom; @@ -56,8 +57,8 @@ pub(crate) struct MempoolManager { pending_requests: HashMap, /// Connected peers and their activation state. pub(super) peers: HashMap>>, - /// IS lock txids that arrived before their corresponding transaction, with insertion time. - pending_is_locks: HashMap, + /// IS locks that arrived before their corresponding transaction, with insertion time. + pending_is_locks: HashMap, /// Txids already downloaded, with download timestamp. /// Prevents duplicate downloads when multiple peers announce the same transactions. /// Entries expire after `SEEN_TXID_EXPIRY`. @@ -302,11 +303,12 @@ impl MempoolManager { self.progress.add_received(1); // Check for a pre-arrived IS lock before wallet processing consumes it - let is_locked = self.pending_is_locks.remove(&txid).is_some(); + let pending_lock = self.pending_is_locks.remove(&txid).map(|(lock, _)| lock); + let is_locked = pending_lock.is_some(); let result = { let mut wallet = self.wallet.write().await; - wallet.process_mempool_transaction(&tx, is_locked).await + wallet.process_mempool_transaction(&tx, pending_lock).await }; if !result.is_relevant { @@ -356,30 +358,31 @@ impl MempoolManager { /// Mark a mempool transaction as InstantSend-locked and notify the wallet. /// - /// If the transaction hasn't arrived yet, remembers the txid so the lock + /// If the transaction hasn't arrived yet, remembers the lock so it /// can be applied when the transaction is later received via `handle_tx`. - pub(super) async fn mark_instant_send(&mut self, txid: &Txid) { + pub(super) async fn mark_instant_send(&mut self, instant_lock: InstantLock) { + let txid = instant_lock.txid; let mut state = self.mempool_state.write().await; - let marked = if let Some(tx) = state.transactions.get_mut(txid) { + let instant_lock_opt = if let Some(tx) = state.transactions.get_mut(&txid) { tx.is_instant_send = true; tracing::debug!("Marked mempool tx {} as InstantSend-locked", txid); - true + Some(instant_lock) } else if self.pending_is_locks.len() < MAX_PENDING_IS_LOCKS { - self.pending_is_locks.insert(*txid, Instant::now()); + self.pending_is_locks.insert(txid, (instant_lock, Instant::now())); tracing::debug!("IS lock arrived before tx {}, remembering for later", txid); - false + None } else { tracing::warn!( "Pending IS locks at capacity ({}), dropping IS lock for {}", MAX_PENDING_IS_LOCKS, txid ); - false + None }; drop(state); - if marked { + if let Some(lock) = instant_lock_opt { let mut wallet = self.wallet.write().await; - wallet.process_instant_send_lock(*txid); + wallet.process_instant_send_lock(lock); } } @@ -398,7 +401,7 @@ impl MempoolManager { // Prune pending IS locks whose transaction never arrived let before = self.pending_is_locks.len(); - self.pending_is_locks.retain(|_, inserted_at| inserted_at.elapsed() < timeout); + self.pending_is_locks.retain(|_, (_, inserted_at)| inserted_at.elapsed() < timeout); let expired = before - self.pending_is_locks.len(); if expired > 0 { tracing::debug!("Pruned {} expired pending IS locks", expired); @@ -504,6 +507,21 @@ mod tests { use crate::test_utils::test_socket_address; use tokio::sync::mpsc; + fn dummy_instant_lock(txid: Txid) -> InstantLock { + InstantLock { + txid, + ..InstantLock::default() + } + } + + fn rich_instant_lock(txid: Txid) -> InstantLock { + InstantLock { + txid, + cyclehash: BlockHash::from_byte_array([0xab; 32]), + ..InstantLock::default() + } + } + fn create_test_manager( ) -> (MempoolManager, RequestSender, mpsc::UnboundedReceiver) { let wallet = Arc::new(RwLock::new(MockWallet::new())); @@ -917,7 +935,7 @@ mod tests { )); } - manager.mark_instant_send(&txid).await; + manager.mark_instant_send(dummy_instant_lock(txid)).await; // Verify mempool state also reflects IS flag let state = manager.mempool_state.read().await; @@ -929,7 +947,7 @@ mod tests { let changes = status_changes.lock().await; assert_eq!(changes.len(), 1); assert_eq!(changes[0].0, txid); - assert_eq!(changes[0].1, TransactionContext::InstantSend); + assert!(matches!(changes[0].1, TransactionContext::InstantSend(_))); } #[tokio::test] @@ -937,7 +955,7 @@ mod tests { let (mut manager, _requests, _rx) = create_test_manager(); let unknown_txid = Txid::from_byte_array([0xbb; 32]); - manager.mark_instant_send(&unknown_txid).await; + manager.mark_instant_send(dummy_instant_lock(unknown_txid)).await; // No immediate wallet notification let wallet = manager.wallet.read().await; @@ -1055,7 +1073,8 @@ mod tests { manager .peers .insert(test_socket_address(1), Some(VecDeque::from([Txid::from_byte_array([2; 32])]))); - manager.pending_is_locks.insert(Txid::from_byte_array([3; 32]), Instant::now()); + let txid3 = Txid::from_byte_array([3; 32]); + manager.pending_is_locks.insert(txid3, (dummy_instant_lock(txid3), Instant::now())); manager.clear_pending(); @@ -1092,7 +1111,7 @@ mod tests { #[tokio::test] async fn test_instant_send_before_transaction() { - let (mut manager, _requests, _wallet) = create_relevant_manager(); + let (mut manager, _requests, wallet) = create_relevant_manager(); let tx = Transaction { version: 1, @@ -1103,8 +1122,8 @@ mod tests { }; let txid = tx.txid(); - // IS lock arrives before the transaction - manager.mark_instant_send(&txid).await; + // IS lock arrives before the transaction (with a distinct cyclehash) + manager.mark_instant_send(rich_instant_lock(txid)).await; assert!(manager.pending_is_locks.contains_key(&txid)); // Transaction arrives @@ -1116,6 +1135,18 @@ mod tests { // Transaction stored with IS flag set let state = manager.mempool_state.read().await; assert!(state.transactions.get(&txid).unwrap().is_instant_send); + drop(state); + + // Wallet received the IS lock payload with the correct cyclehash + let w = wallet.read().await; + let locks = w.processed_instant_locks.lock().await; + let received = locks.iter().find(|(id, lock)| { + *id == txid + && lock + .as_ref() + .is_some_and(|l| l.cyclehash == BlockHash::from_byte_array([0xab; 32])) + }); + assert!(received.is_some(), "wallet should have received rich IS lock with cyclehash 0xab"); } #[tokio::test] @@ -1132,7 +1163,7 @@ mod tests { let txid = tx.txid(); // IS lock arrives before the transaction - manager.mark_instant_send(&txid).await; + manager.mark_instant_send(dummy_instant_lock(txid)).await; assert!(manager.pending_is_locks.contains_key(&txid)); // Transaction arrives but wallet says it's not relevant @@ -1154,13 +1185,14 @@ mod tests { for i in 0..MAX_PENDING_IS_LOCKS { let mut bytes = [0u8; 32]; bytes[0..8].copy_from_slice(&(i as u64).to_le_bytes()); - manager.pending_is_locks.insert(Txid::from_byte_array(bytes), Instant::now()); + let txid = Txid::from_byte_array(bytes); + manager.pending_is_locks.insert(txid, (dummy_instant_lock(txid), Instant::now())); } assert_eq!(manager.pending_is_locks.len(), MAX_PENDING_IS_LOCKS); // Next IS lock should be dropped let overflow_txid = Txid::from_byte_array([0xff; 32]); - manager.mark_instant_send(&overflow_txid).await; + manager.mark_instant_send(dummy_instant_lock(overflow_txid)).await; assert!(!manager.pending_is_locks.contains_key(&overflow_txid)); assert_eq!(manager.pending_is_locks.len(), MAX_PENDING_IS_LOCKS); } @@ -1191,8 +1223,10 @@ mod tests { // Also store a pending IS lock for this txid and an unrelated one let unrelated_txid = Txid::from_byte_array([0xdd; 32]); - manager.pending_is_locks.insert(txid, Instant::now()); - manager.pending_is_locks.insert(unrelated_txid, Instant::now()); + manager.pending_is_locks.insert(txid, (dummy_instant_lock(txid), Instant::now())); + manager + .pending_is_locks + .insert(unrelated_txid, (dummy_instant_lock(unrelated_txid), Instant::now())); manager.prune_expired(test_timeout).await; @@ -1216,13 +1250,19 @@ mod tests { // Insert a pending IS lock that is older than the test timeout let stale_txid = Txid::from_byte_array([0xaa; 32]); - manager - .pending_is_locks - .insert(stale_txid, Instant::now() - test_timeout - Duration::from_secs(1)); + manager.pending_is_locks.insert( + stale_txid, + ( + dummy_instant_lock(stale_txid), + Instant::now() - test_timeout - Duration::from_secs(1), + ), + ); // Insert a fresh pending IS lock let fresh_txid = Txid::from_byte_array([0xbb; 32]); - manager.pending_is_locks.insert(fresh_txid, Instant::now()); + manager + .pending_is_locks + .insert(fresh_txid, (dummy_instant_lock(fresh_txid), Instant::now())); manager.prune_expired(test_timeout).await; diff --git a/dash-spv/src/sync/mempool/sync_manager.rs b/dash-spv/src/sync/mempool/sync_manager.rs index 3292ae770..99cce293f 100644 --- a/dash-spv/src/sync/mempool/sync_manager.rs +++ b/dash-spv/src/sync/mempool/sync_manager.rs @@ -96,7 +96,7 @@ impl SyncManager for MempoolManager { instant_lock, .. } => { - self.mark_instant_send(&instant_lock.txid).await; + self.mark_instant_send(instant_lock.clone()).await; Ok(vec![]) } _ => Ok(vec![]), diff --git a/key-wallet-ffi/FFI_API.md b/key-wallet-ffi/FFI_API.md index 043d29ede..754f27100 100644 --- a/key-wallet-ffi/FFI_API.md +++ b/key-wallet-ffi/FFI_API.md @@ -853,7 +853,7 @@ Get the parent wallet ID of a managed account Note: ManagedAccount doesn't stor #### `managed_wallet_check_transaction` ```c -managed_wallet_check_transaction(managed_wallet: *mut FFIManagedWalletInfo, wallet: *mut FFIWallet, tx_bytes: *const u8, tx_len: usize, context_type: FFITransactionContextType, block_info: FFIBlockInfo, update_state: bool, result_out: *mut FFITransactionCheckResult, error: *mut FFIError,) -> bool +managed_wallet_check_transaction(managed_wallet: *mut FFIManagedWalletInfo, wallet: *mut FFIWallet, tx_bytes: *const u8, tx_len: usize, context_type: FFITransactionContextType, block_info: FFIBlockInfo, islock_data: *const u8, islock_len: usize, update_state: bool, result_out: *mut FFITransactionCheckResult, error: *mut FFIError,) -> bool ``` **Description:** @@ -1317,7 +1317,7 @@ Build and sign a transaction using the wallet's managed info This is the recomm #### `wallet_check_transaction` ```c -wallet_check_transaction(wallet: *mut FFIWallet, tx_bytes: *const u8, tx_len: usize, context_type: FFITransactionContextType, block_info: FFIBlockInfo, update_state: bool, result_out: *mut FFITransactionCheckResult, error: *mut FFIError,) -> bool +wallet_check_transaction(wallet: *mut FFIWallet, tx_bytes: *const u8, tx_len: usize, context_type: FFITransactionContextType, block_info: FFIBlockInfo, islock_data: *const u8, islock_len: usize, update_state: bool, result_out: *mut FFITransactionCheckResult, error: *mut FFIError,) -> bool ``` **Description:** diff --git a/key-wallet-ffi/src/managed_account.rs b/key-wallet-ffi/src/managed_account.rs index 495430a4b..f1aae9aa9 100644 --- a/key-wallet-ffi/src/managed_account.rs +++ b/key-wallet-ffi/src/managed_account.rs @@ -742,7 +742,7 @@ pub unsafe extern "C" fn managed_core_account_get_transactions( ffi_record.txid = record.txid.to_byte_array(); ffi_record.net_amount = record.net_amount; - ffi_record.context = FFITransactionContext::from(record.context); + ffi_record.context = FFITransactionContext::from(record.context.clone()); ffi_record.transaction_type = FFITransactionType::from(record.transaction_type); ffi_record.direction = FFITransactionDirection::from(record.direction); ffi_record.fee = record.fee.unwrap_or(0); @@ -823,7 +823,10 @@ pub unsafe extern "C" fn managed_core_account_free_transactions( } for i in 0..count { - let record = &*transactions.add(i); + let record = &mut *transactions.add(i); + + // Free IS lock data + record.context.free_islock_data(); // Free input detail addresses first, then the array if !record.input_details.is_null() && record.input_details_count > 0 { @@ -2012,6 +2015,8 @@ mod tests { r0.context = FFITransactionContext { context_type: FFITransactionContextType::Mempool, block_info: FFIBlockInfo::empty(), + islock_data: std::ptr::null(), + islock_len: 0, }; r0.transaction_type = FFITransactionType::Standard; r0.direction = FFITransactionDirection::Incoming; @@ -2053,6 +2058,8 @@ mod tests { r1.context = FFITransactionContext { context_type: FFITransactionContextType::Mempool, block_info: FFIBlockInfo::empty(), + islock_data: std::ptr::null(), + islock_len: 0, }; r1.transaction_type = FFITransactionType::Standard; r1.direction = FFITransactionDirection::Outgoing; diff --git a/key-wallet-ffi/src/transaction.rs b/key-wallet-ffi/src/transaction.rs index f33667734..8c51b3636 100644 --- a/key-wallet-ffi/src/transaction.rs +++ b/key-wallet-ffi/src/transaction.rs @@ -394,6 +394,8 @@ pub unsafe extern "C" fn wallet_check_transaction( tx_len: usize, context_type: FFITransactionContextType, block_info: FFIBlockInfo, + islock_data: *const u8, + islock_len: usize, update_state: bool, result_out: *mut FFITransactionCheckResult, error: *mut FFIError, @@ -422,13 +424,18 @@ pub unsafe extern "C" fn wallet_check_transaction( }; // Build the transaction context - let context = match transaction_context_from_ffi(context_type, &block_info) { + let context = match transaction_context_from_ffi( + context_type, + &block_info, + islock_data, + islock_len, + ) { Some(ctx) => ctx, None => { FFIError::set_error( error, FFIErrorCode::InvalidInput, - "Block info must not be zeroed for confirmed contexts".to_string(), + "Invalid transaction context: block info is zeroed for a confirmed context, or IS lock data is missing/malformed for InstantSend".to_string(), ); return false; } diff --git a/key-wallet-ffi/src/transaction_checking.rs b/key-wallet-ffi/src/transaction_checking.rs index 77631721b..2eaf0dde3 100644 --- a/key-wallet-ffi/src/transaction_checking.rs +++ b/key-wallet-ffi/src/transaction_checking.rs @@ -115,6 +115,8 @@ pub unsafe extern "C" fn managed_wallet_check_transaction( tx_len: usize, context_type: FFITransactionContextType, block_info: FFIBlockInfo, + islock_data: *const u8, + islock_len: usize, update_state: bool, result_out: *mut FFITransactionCheckResult, error: *mut FFIError, @@ -141,14 +143,19 @@ pub unsafe extern "C" fn managed_wallet_check_transaction( }; // Build the transaction context - let context = match transaction_context_from_ffi(context_type, &block_info) { + let context = match transaction_context_from_ffi( + context_type, + &block_info, + islock_data, + islock_len, + ) { Some(ctx) => ctx, None => { FFIError::set_error( - error, - FFIErrorCode::InvalidInput, - "Block info must not be zeroed for confirmed contexts".to_string(), - ); + error, + FFIErrorCode::InvalidInput, + "Invalid transaction context: block info is zeroed for a confirmed context, or IS lock data is missing/malformed for InstantSend".to_string(), + ); return false; } }; diff --git a/key-wallet-ffi/src/types.rs b/key-wallet-ffi/src/types.rs index 53e65b37a..a81cf0172 100644 --- a/key-wallet-ffi/src/types.rs +++ b/key-wallet-ffi/src/types.rs @@ -1,5 +1,6 @@ //! Common types for FFI interface +use dashcore::ephemerealdata::instant_lock::InstantLock; use dashcore::hashes::Hash; use key_wallet::managed_account::transaction_record::{OutputRole, TransactionDirection}; use key_wallet::transaction_checking::transaction_router::TransactionType; @@ -49,15 +50,29 @@ impl From for FFIBlockInfo { /// Convert an `FFIBlockInfo` and context type to a native `TransactionContext`. /// -/// Returns `None` when block info is all-zeros for confirmed contexts (`InBlock`, -/// `InChainLockedBlock`), indicating invalid input from the FFI caller. +/// Returns `None` when: +/// - Block info is all-zeros for confirmed contexts (`InBlock`, `InChainLockedBlock`) +/// - IS lock data is null/empty for `InstantSend` contexts +/// - IS lock data fails deserialization pub(crate) fn transaction_context_from_ffi( context_type: FFITransactionContextType, block_info: &FFIBlockInfo, + islock_data: *const u8, + islock_len: usize, ) -> Option { match context_type { FFITransactionContextType::Mempool => Some(TransactionContext::Mempool), - FFITransactionContextType::InstantSend => Some(TransactionContext::InstantSend), + FFITransactionContextType::InstantSend => { + if islock_data.is_null() || islock_len == 0 { + return None; + } + let bytes = unsafe { std::slice::from_raw_parts(islock_data, islock_len) }; + let lock = match dashcore::consensus::deserialize::(bytes) { + Ok(lock) => lock, + Err(_) => return None, + }; + Some(TransactionContext::InstantSend(lock)) + } FFITransactionContextType::InBlock => { if block_info.block_hash == [0u8; 32] && block_info.timestamp == 0 { return None; @@ -717,7 +732,7 @@ impl From for FFITransactionContextType { fn from(ctx: TransactionContext) -> Self { match ctx { TransactionContext::Mempool => FFITransactionContextType::Mempool, - TransactionContext::InstantSend => FFITransactionContextType::InstantSend, + TransactionContext::InstantSend(_) => FFITransactionContextType::InstantSend, TransactionContext::InBlock(_) => FFITransactionContextType::InBlock, TransactionContext::InChainLockedBlock(_) => { FFITransactionContextType::InChainLockedBlock @@ -726,14 +741,18 @@ impl From for FFITransactionContextType { } } -/// FFI-compatible transaction context (type + optional block info) +/// FFI-compatible transaction context (type + optional block info + optional IS lock) #[repr(C)] -#[derive(Debug, Clone, Copy)] +#[derive(Debug, Clone)] pub struct FFITransactionContext { /// The context type pub context_type: FFITransactionContextType, /// Block info (zeroed for mempool/instant-send contexts) pub block_info: FFIBlockInfo, + /// Consensus-serialized `InstantLock` bytes (null for non-IS contexts) + pub islock_data: *const u8, + /// Length of the `islock_data` buffer + pub islock_len: usize, } impl FFITransactionContext { @@ -742,6 +761,8 @@ impl FFITransactionContext { Self { context_type: FFITransactionContextType::Mempool, block_info: FFIBlockInfo::empty(), + islock_data: std::ptr::null(), + islock_len: 0, } } @@ -750,6 +771,8 @@ impl FFITransactionContext { Self { context_type: FFITransactionContextType::InBlock, block_info, + islock_data: std::ptr::null(), + islock_len: 0, } } @@ -758,6 +781,8 @@ impl FFITransactionContext { Self { context_type: FFITransactionContextType::InChainLockedBlock, block_info, + islock_data: std::ptr::null(), + islock_len: 0, } } @@ -765,20 +790,54 @@ impl FFITransactionContext { /// /// Returns `None` when block info is all-zeros for confirmed contexts. pub fn to_transaction_context(&self) -> Option { - transaction_context_from_ffi(self.context_type, &self.block_info) + transaction_context_from_ffi( + self.context_type, + &self.block_info, + self.islock_data, + self.islock_len, + ) + } + + /// Free the heap-allocated `islock_data` buffer, if present. + /// + /// # Safety + /// + /// Must only be called once per instance. The pointer must have been + /// produced by `Box::into_raw` in the `From` impl. + pub unsafe fn free_islock_data(&mut self) { + if !self.islock_data.is_null() && self.islock_len > 0 { + drop(Box::from_raw(std::ptr::slice_from_raw_parts_mut( + self.islock_data as *mut u8, + self.islock_len, + ))); + self.islock_data = std::ptr::null(); + self.islock_len = 0; + } } } impl From for FFITransactionContext { fn from(ctx: TransactionContext) -> Self { - let context_type = FFITransactionContextType::from(ctx); let block_info = ctx .block_info() .map(|info| FFIBlockInfo::from(*info)) .unwrap_or_else(FFIBlockInfo::empty); + + let (islock_data, islock_len) = if let TransactionContext::InstantSend(ref lock) = ctx { + let bytes = dashcore::consensus::serialize(lock).into_boxed_slice(); + let len = bytes.len(); + let ptr = Box::into_raw(bytes) as *const u8; + (ptr, len) + } else { + (std::ptr::null(), 0) + }; + + let context_type = FFITransactionContextType::from(ctx); Self { context_type, block_info, + islock_data, + islock_len, } } } @@ -875,9 +934,14 @@ pub struct FFIOutputDetail { #[cfg(test)] mod tests { - use super::*; + use std::ptr; + + use dashcore::consensus::serialize; + use dashcore::ephemerealdata::instant_lock::InstantLock; use key_wallet::transaction_checking::BlockInfo; + use super::*; + fn valid_block_info() -> FFIBlockInfo { FFIBlockInfo { height: 1000, @@ -962,17 +1026,34 @@ mod tests { let result = transaction_context_from_ffi( FFITransactionContextType::Mempool, &FFIBlockInfo::empty(), + ptr::null(), + 0, ); assert!(matches!(result, Some(TransactionContext::Mempool))); } #[test] - fn transaction_context_from_ffi_instant_send_with_empty_block_info() { + fn transaction_context_from_ffi_instant_send_with_null_islock() { let result = transaction_context_from_ffi( FFITransactionContextType::InstantSend, &FFIBlockInfo::empty(), + ptr::null(), + 0, ); - assert!(matches!(result, Some(TransactionContext::InstantSend))); + assert!(result.is_none()); + } + + #[test] + fn transaction_context_from_ffi_instant_send_with_valid_islock() { + let islock = InstantLock::default(); + let bytes = serialize(&islock); + let result = transaction_context_from_ffi( + FFITransactionContextType::InstantSend, + &FFIBlockInfo::empty(), + bytes.as_ptr(), + bytes.len(), + ); + assert!(matches!(result, Some(TransactionContext::InstantSend(_)))); } #[test] @@ -980,6 +1061,8 @@ mod tests { let result = transaction_context_from_ffi( FFITransactionContextType::InBlock, &FFIBlockInfo::empty(), + ptr::null(), + 0, ); assert!(result.is_none()); } @@ -989,6 +1072,8 @@ mod tests { let result = transaction_context_from_ffi( FFITransactionContextType::InChainLockedBlock, &FFIBlockInfo::empty(), + ptr::null(), + 0, ); assert!(result.is_none()); } @@ -996,7 +1081,12 @@ mod tests { #[test] fn transaction_context_from_ffi_in_block_with_valid_block_info() { let block_info = valid_block_info(); - let result = transaction_context_from_ffi(FFITransactionContextType::InBlock, &block_info); + let result = transaction_context_from_ffi( + FFITransactionContextType::InBlock, + &block_info, + ptr::null(), + 0, + ); let ctx = result.expect("should return Some for InBlock with valid block info"); assert!(matches!(ctx, TransactionContext::InBlock(info) if info.height() == 1000)); } @@ -1007,6 +1097,8 @@ mod tests { let result = transaction_context_from_ffi( FFITransactionContextType::InChainLockedBlock, &block_info, + ptr::null(), + 0, ); let ctx = result.expect("should return Some for InChainLockedBlock with valid block info"); assert!( diff --git a/key-wallet-manager/src/event_tests.rs b/key-wallet-manager/src/event_tests.rs index 9b1a4308a..c16827bb0 100644 --- a/key-wallet-manager/src/event_tests.rs +++ b/key-wallet-manager/src/event_tests.rs @@ -1,6 +1,9 @@ use super::test_helpers::*; use super::*; use crate::wallet_interface::WalletInterface; +use dashcore::bls_sig_utils::BLSSignature; +use dashcore::ephemerealdata::instant_lock::InstantLock; +use dashcore::hash_types::CycleHash; use dashcore::hashes::Hash; use dashcore::BlockHash; use key_wallet::transaction_checking::BlockInfo; @@ -66,7 +69,7 @@ async fn test_mempool_to_instantsend_to_confirmed_event_flow() { assert_lifecycle_flow( &[ TransactionContext::Mempool, - TransactionContext::InstantSend, + TransactionContext::InstantSend(InstantLock::default()), TransactionContext::InBlock(BlockInfo::new( 200, BlockHash::from_byte_array([0xbb; 32]), @@ -109,8 +112,8 @@ async fn test_duplicate_mempool_emits_no_event() { #[tokio::test] async fn test_duplicate_instantsend_emits_no_event() { assert_context_suppressed( - &[TransactionContext::Mempool, TransactionContext::InstantSend], - TransactionContext::InstantSend, + &[TransactionContext::Mempool, TransactionContext::InstantSend(InstantLock::default())], + TransactionContext::InstantSend(InstantLock::default()), None, 0x22, ) @@ -124,7 +127,8 @@ async fn test_duplicate_confirmed_emits_no_event() { BlockHash::from_byte_array([0x33; 32]), 3000, )); - assert_context_suppressed(&[block_ctx], block_ctx, Some(300), 0x33).await; + let block_ctx2 = block_ctx.clone(); + assert_context_suppressed(&[block_ctx], block_ctx2, Some(300), 0x33).await; } // --------------------------------------------------------------------------- @@ -134,8 +138,8 @@ async fn test_duplicate_confirmed_emits_no_event() { #[tokio::test] async fn test_first_seen_as_instantsend_then_duplicate() { assert_context_suppressed( - &[TransactionContext::InstantSend], - TransactionContext::InstantSend, + &[TransactionContext::InstantSend(InstantLock::default())], + TransactionContext::InstantSend(InstantLock::default()), None, 0x55, ) @@ -153,7 +157,7 @@ async fn test_late_instantsend_after_confirmation_is_ignored() { 8000, )), ], - TransactionContext::InstantSend, + TransactionContext::InstantSend(InstantLock::default()), Some(800), 0x77, ) @@ -163,7 +167,7 @@ async fn test_late_instantsend_after_confirmation_is_ignored() { #[tokio::test] async fn test_mempool_after_instantsend_is_suppressed() { assert_context_suppressed( - &[TransactionContext::Mempool, TransactionContext::InstantSend], + &[TransactionContext::Mempool, TransactionContext::InstantSend(InstantLock::default())], TransactionContext::Mempool, None, 0xab, @@ -181,7 +185,7 @@ async fn test_mempool_tx_emits_balance_updated() { let mut rx = manager.subscribe_events(); let tx = create_tx_paying_to(&addr, 0xf1); - manager.process_mempool_transaction(&tx, false).await; + manager.process_mempool_transaction(&tx, None).await; let events = drain_events(&mut rx); let balance_events: Vec<_> = @@ -208,7 +212,7 @@ async fn test_instantsend_tx_emits_balance_updated_spendable() { let mut rx = manager.subscribe_events(); let tx = create_tx_paying_to(&addr, 0xf2); - manager.process_mempool_transaction(&tx, true).await; + manager.process_mempool_transaction(&tx, Some(InstantLock::default())).await; let events = drain_events(&mut rx); let balance_events: Vec<_> = @@ -236,7 +240,7 @@ async fn test_mempool_to_instantsend_transitions_balance() { let tx = create_tx_paying_to(&addr, 0xf3); // Mempool tx: balance should be unconfirmed - manager.process_mempool_transaction(&tx, false).await; + manager.process_mempool_transaction(&tx, None).await; let events = drain_events(&mut rx); assert!( events.iter().any(|e| matches!( @@ -253,7 +257,7 @@ async fn test_mempool_to_instantsend_transitions_balance() { ); // IS lock: balance should move from unconfirmed to spendable - manager.process_instant_send_lock(tx.txid()); + manager.process_instant_send_lock(dummy_instant_lock(tx.txid())); let events = drain_events(&mut rx); assert!( events.iter().any(|e| matches!( @@ -270,6 +274,39 @@ async fn test_mempool_to_instantsend_transitions_balance() { ); } +#[tokio::test] +async fn test_process_instant_send_lock_updates_transaction_record_context() { + let (mut manager, wallet_id, addr) = setup_manager_with_wallet(); + let tx = create_tx_paying_to(&addr, 0xf4); + + // Process as mempool transaction first + manager.process_mempool_transaction(&tx, None).await; + + // Verify record starts with Mempool context + let history = manager.wallet_transaction_history(&wallet_id).unwrap(); + let record = history.iter().find(|r| r.txid == tx.txid()).unwrap(); + assert_eq!(record.context, TransactionContext::Mempool); + + // Create a rich InstantLock with a non-default cyclehash + let lock = InstantLock { + txid: tx.txid(), + cyclehash: CycleHash::from_byte_array([0xab; 32]), + signature: BLSSignature::from([0xcd; 96]), + ..InstantLock::default() + }; + + manager.process_instant_send_lock(lock.clone()); + + // Verify the transaction record context was updated to InstantSend + let history = manager.wallet_transaction_history(&wallet_id).unwrap(); + let record = history.iter().find(|r| r.txid == tx.txid()).unwrap(); + assert_eq!( + record.context, + TransactionContext::InstantSend(lock), + "transaction record context should be updated to InstantSend with matching lock" + ); +} + // --------------------------------------------------------------------------- // Production API tests // --------------------------------------------------------------------------- @@ -282,7 +319,7 @@ async fn test_process_instant_send_lock_for_unknown_txid() { let unknown_txid = dashcore::Txid::from_byte_array([0xee; 32]); let balance_before = manager.wallet_infos.get(&wallet_id).unwrap().balance(); - manager.process_instant_send_lock(unknown_txid); + manager.process_instant_send_lock(dummy_instant_lock(unknown_txid)); assert_no_events(&mut rx); let balance_after = manager.wallet_infos.get(&wallet_id).unwrap().balance(); @@ -294,18 +331,18 @@ async fn test_process_instant_send_lock_dedup() { let (mut manager, wallet_id, addr) = setup_manager_with_wallet(); let tx = create_tx_paying_to(&addr, 0xe1); - manager.process_mempool_transaction(&tx, false).await; + manager.process_mempool_transaction(&tx, None).await; let mut rx = manager.subscribe_events(); // First IS lock should emit events - manager.process_instant_send_lock(tx.txid()); + manager.process_instant_send_lock(dummy_instant_lock(tx.txid())); let events = drain_events(&mut rx); assert!( events.iter().any(|e| matches!( e, WalletEvent::TransactionStatusChanged { wallet_id: wid, - status: TransactionContext::InstantSend, + status: TransactionContext::InstantSend(_), .. } if *wid == wallet_id )), @@ -321,7 +358,7 @@ async fn test_process_instant_send_lock_dedup() { ); // Second IS lock should be a no-op - manager.process_instant_send_lock(tx.txid()); + manager.process_instant_send_lock(dummy_instant_lock(tx.txid())); assert_no_events(&mut rx); } @@ -331,7 +368,7 @@ async fn test_process_instant_send_lock_after_block_confirmation() { let tx = create_tx_paying_to(&addr, 0xe2); // Process as IS mempool tx, then confirm in block - manager.process_mempool_transaction(&tx, true).await; + manager.process_mempool_transaction(&tx, Some(InstantLock::default())).await; let block_ctx = TransactionContext::InBlock(BlockInfo::new( 500, BlockHash::from_byte_array([0xe2; 32]), @@ -341,7 +378,7 @@ async fn test_process_instant_send_lock_after_block_confirmation() { // IS lock after block confirmation is a no-op (already tracked via mempool IS) let mut rx = manager.subscribe_events(); - manager.process_instant_send_lock(tx.txid()); + manager.process_instant_send_lock(dummy_instant_lock(tx.txid())); assert_no_events(&mut rx); // Confirm height preserved @@ -362,14 +399,14 @@ async fn test_mixed_instantsend_paths_no_duplicate_events() { drain_events(&mut rx); // IS lock via process_instant_send_lock (network IS lock message) - manager.process_instant_send_lock(tx.txid()); + manager.process_instant_send_lock(dummy_instant_lock(tx.txid())); let events = drain_events(&mut rx); assert!( events.iter().any(|e| matches!( e, WalletEvent::TransactionStatusChanged { wallet_id: wid, - status: TransactionContext::InstantSend, + status: TransactionContext::InstantSend(_), .. } if *wid == wallet_id )), @@ -379,8 +416,9 @@ async fn test_mixed_instantsend_paths_no_duplicate_events() { // Same IS lock via check_transaction_in_all_wallets (block/tx processing path) // should be suppressed — no duplicate event + let is_lock = dummy_instant_lock(tx.txid()); manager - .check_transaction_in_all_wallets(&tx, TransactionContext::InstantSend, true, true) + .check_transaction_in_all_wallets(&tx, TransactionContext::InstantSend(is_lock), true, true) .await; assert_no_events(&mut rx); } @@ -396,8 +434,14 @@ async fn test_mixed_instantsend_paths_reverse_no_duplicate_events() { drain_events(&mut rx); // IS lock via check_transaction_in_all_wallets first + let is_lock = dummy_instant_lock(tx.txid()); manager - .check_transaction_in_all_wallets(&tx, TransactionContext::InstantSend, true, true) + .check_transaction_in_all_wallets( + &tx, + TransactionContext::InstantSend(is_lock.clone()), + true, + true, + ) .await; let events = drain_events(&mut rx); assert!( @@ -405,7 +449,7 @@ async fn test_mixed_instantsend_paths_reverse_no_duplicate_events() { e, WalletEvent::TransactionStatusChanged { wallet_id: wid, - status: TransactionContext::InstantSend, + status: TransactionContext::InstantSend(_), .. } if *wid == wallet_id )), @@ -414,7 +458,7 @@ async fn test_mixed_instantsend_paths_reverse_no_duplicate_events() { ); // Same IS lock via process_instant_send_lock — should be suppressed - manager.process_instant_send_lock(tx.txid()); + manager.process_instant_send_lock(is_lock); assert_no_events(&mut rx); } @@ -511,7 +555,7 @@ async fn test_irrelevant_mempool_tx_emits_no_events() { special_transaction_payload: None, }; - let result = manager.process_mempool_transaction(&tx, false).await; + let result = manager.process_mempool_transaction(&tx, None).await; assert!(!result.is_relevant); assert_eq!(result.net_amount, 0); @@ -526,7 +570,7 @@ async fn test_irrelevant_mempool_tx_emits_no_events() { async fn test_instantsend_to_chainlocked_event_flow() { assert_lifecycle_flow( &[ - TransactionContext::InstantSend, + TransactionContext::InstantSend(InstantLock::default()), TransactionContext::InChainLockedBlock(BlockInfo::new( 1600, BlockHash::from_byte_array([0xc3; 32]), diff --git a/key-wallet-manager/src/lib.rs b/key-wallet-manager/src/lib.rs index f1412de89..62fcca997 100644 --- a/key-wallet-manager/src/lib.rs +++ b/key-wallet-manager/src/lib.rs @@ -480,7 +480,7 @@ impl WalletManager { let check_result = wallet_info .check_core_transaction( tx, - context, + context.clone(), wallet, update_state_if_found, update_balance, @@ -519,7 +519,7 @@ impl WalletManager { let event = WalletEvent::TransactionStatusChanged { wallet_id, txid: tx.txid(), - status: context, + status: context.clone(), }; let _ = self.event_sender.send(event); } diff --git a/key-wallet-manager/src/process_block.rs b/key-wallet-manager/src/process_block.rs index a62ee9d55..288c992a5 100644 --- a/key-wallet-manager/src/process_block.rs +++ b/key-wallet-manager/src/process_block.rs @@ -2,8 +2,9 @@ use crate::wallet_interface::{BlockProcessingResult, MempoolTransactionResult, W use crate::{WalletEvent, WalletManager}; use async_trait::async_trait; use core::fmt::Write as _; +use dashcore::ephemerealdata::instant_lock::InstantLock; use dashcore::prelude::CoreBlockHeight; -use dashcore::{Address, Block, Transaction, Txid}; +use dashcore::{Address, Block, Transaction}; use key_wallet::transaction_checking::{BlockInfo, TransactionContext}; use key_wallet::wallet::managed_wallet_info::wallet_info_interface::WalletInfoInterface; use tokio::sync::broadcast; @@ -44,12 +45,11 @@ impl WalletInterface for WalletM async fn process_mempool_transaction( &mut self, tx: &Transaction, - is_instant_send: bool, + instant_lock: Option, ) -> MempoolTransactionResult { - let context = if is_instant_send { - TransactionContext::InstantSend - } else { - TransactionContext::Mempool + let context = match instant_lock { + Some(lock) => TransactionContext::InstantSend(lock), + None => TransactionContext::Mempool, }; let snapshot = self.snapshot_balances(); let check_result = self.check_transaction_in_all_wallets(tx, context, true, false).await; @@ -125,12 +125,13 @@ impl WalletInterface for WalletM self.event_sender.subscribe() } - fn process_instant_send_lock(&mut self, txid: Txid) { + fn process_instant_send_lock(&mut self, instant_lock: InstantLock) { + let txid = instant_lock.txid; let snapshot = self.snapshot_balances(); let mut affected_wallets = Vec::new(); for (wallet_id, info) in self.wallet_infos.iter_mut() { - if info.mark_instant_send_utxos(&txid) { + if info.mark_instant_send_utxos(&txid, &instant_lock) { affected_wallets.push(*wallet_id); } } @@ -139,11 +140,11 @@ impl WalletInterface for WalletM return; } - for wallet_id in affected_wallets { + for wallet_id in &affected_wallets { let event = WalletEvent::TransactionStatusChanged { - wallet_id, + wallet_id: *wallet_id, txid, - status: TransactionContext::InstantSend, + status: TransactionContext::InstantSend(instant_lock.clone()), }; let _ = self.event_sender().send(event); } @@ -234,7 +235,7 @@ mod tests { // Relevant tx should emit BalanceUpdated let tx = create_tx_paying_to(&addr, 0xaa); - manager.process_mempool_transaction(&tx, false).await; + manager.process_mempool_transaction(&tx, None).await; let mut found = false; while let Ok(event) = rx.try_recv() { @@ -271,7 +272,7 @@ mod tests { }], special_transaction_payload: None, }; - manager.process_mempool_transaction(&unrelated_tx, false).await; + manager.process_mempool_transaction(&unrelated_tx, None).await; assert!(rx.try_recv().is_err(), "should not emit events for irrelevant transaction"); } @@ -304,7 +305,7 @@ mod tests { let (mut manager, _wallet_id, addr) = setup_manager_with_wallet(); let tx = create_tx_paying_to(&addr, 0xaa); - let result = manager.process_mempool_transaction(&tx, false).await; + let result = manager.process_mempool_transaction(&tx, None).await; assert!(result.is_relevant); assert_eq!(result.net_amount, TX_AMOUNT as i64); @@ -391,7 +392,7 @@ mod tests { let rev_before_mempool = manager.monitor_revision(); let addr = manager.monitored_addresses()[0].clone(); let tx = create_tx_paying_to(&addr, 0xd0); - let _result = manager.process_mempool_transaction(&tx, false).await; + let _result = manager.process_mempool_transaction(&tx, None).await; assert!( manager.monitor_revision() > rev_before_mempool, "mempool tx paying to our address should bump revision (UTXO added)" @@ -399,7 +400,7 @@ mod tests { let rev_after_mempool = manager.monitor_revision(); // process_instant_send_lock does NOT bump (no outpoint set change) - manager.process_instant_send_lock(tx.txid()); + manager.process_instant_send_lock(dummy_instant_lock(tx.txid())); assert_eq!( manager.monitor_revision(), rev_after_mempool, diff --git a/key-wallet-manager/src/test_helpers.rs b/key-wallet-manager/src/test_helpers.rs index 6b3a8dcae..f70cef633 100644 --- a/key-wallet-manager/src/test_helpers.rs +++ b/key-wallet-manager/src/test_helpers.rs @@ -1,4 +1,5 @@ use super::*; +use dashcore::ephemerealdata::instant_lock::InstantLock; use dashcore::hashes::Hash; use dashcore::{OutPoint, ScriptBuf, TxIn, TxOut, Txid, Witness}; use key_wallet::wallet::initialization::WalletAccountCreationOptions; @@ -11,6 +12,13 @@ pub(crate) const TEST_MNEMONIC: &str = pub(crate) const TX_AMOUNT: u64 = 100_000; +pub(crate) fn dummy_instant_lock(txid: Txid) -> InstantLock { + InstantLock { + txid, + ..InstantLock::default() + } +} + pub(crate) fn setup_manager_with_wallet() -> (WalletManager, WalletId, Address) { let mut manager = WalletManager::new(Network::Testnet); let wallet_id = manager @@ -76,7 +84,7 @@ pub(crate) async fn assert_lifecycle_flow(contexts: &[TransactionContext], input let tx = create_tx_paying_to(&addr, input_seed); for (i, ctx) in contexts.iter().enumerate() { - manager.check_transaction_in_all_wallets(&tx, *ctx, true, true).await; + manager.check_transaction_in_all_wallets(&tx, ctx.clone(), true, true).await; let event = assert_single_event(&mut rx); if i == 0 { @@ -89,7 +97,7 @@ pub(crate) async fn assert_lifecycle_flow(contexts: &[TransactionContext], input ); } else { assert!( - matches!(event, WalletEvent::TransactionStatusChanged { wallet_id: wid, status, .. } if wid == wallet_id && status == *ctx), + matches!(&event, WalletEvent::TransactionStatusChanged { wallet_id: wid, status, .. } if *wid == wallet_id && status == ctx), "context[{}]: expected TransactionStatusChanged with wallet_id and status {:?}, got {:?}", i, ctx, @@ -113,7 +121,7 @@ pub(crate) async fn assert_context_suppressed( let tx = create_tx_paying_to(&addr, input_seed); for ctx in setup_contexts { - manager.check_transaction_in_all_wallets(&tx, *ctx, true, true).await; + manager.check_transaction_in_all_wallets(&tx, ctx.clone(), true, true).await; drain_events(&mut rx); } diff --git a/key-wallet-manager/src/test_utils/mock_wallet.rs b/key-wallet-manager/src/test_utils/mock_wallet.rs index 5d69a344b..d17cb1ce3 100644 --- a/key-wallet-manager/src/test_utils/mock_wallet.rs +++ b/key-wallet-manager/src/test_utils/mock_wallet.rs @@ -1,10 +1,14 @@ use crate::{BlockProcessingResult, MempoolTransactionResult, WalletEvent, WalletInterface}; +use dashcore::ephemerealdata::instant_lock::InstantLock; use dashcore::prelude::CoreBlockHeight; use dashcore::{Address, Block, OutPoint, Transaction, Txid}; use key_wallet::transaction_checking::TransactionContext; use std::sync::Arc; use tokio::sync::{broadcast, Mutex}; +// Type alias for captured IS lock payloads +type InstantLockCaptures = Arc)>>>; + pub struct MockWallet { processed_blocks: Arc>>, processed_transactions: Arc>>, @@ -24,6 +28,9 @@ pub struct MockWallet { mempool_new_addresses: Vec
, /// Recorded status change notifications for test assertions. status_changes: Arc>>, + /// Captured `InstantLock` payloads from both `process_mempool_transaction` and + /// `process_instant_send_lock`, for test assertions. + pub processed_instant_locks: InstantLockCaptures, /// Monitor revision counter for staleness detection. monitor_revision: u64, } @@ -49,6 +56,7 @@ impl MockWallet { mempool_addresses: Vec::new(), mempool_new_addresses: Vec::new(), status_changes: Arc::new(Mutex::new(Vec::new())), + processed_instant_locks: Arc::new(Mutex::new(Vec::new())), monitor_revision: 0, } } @@ -114,11 +122,15 @@ impl WalletInterface for MockWallet { async fn process_mempool_transaction( &mut self, tx: &Transaction, - _is_instant_send: bool, + instant_lock: Option, ) -> MempoolTransactionResult { let mut processed = self.processed_transactions.lock().await; processed.push(tx.txid()); + let mut locks = self.processed_instant_locks.lock().await; + locks.push((tx.txid(), instant_lock)); + drop(locks); + if !self.mempool_relevant { return MempoolTransactionResult::default(); } @@ -160,10 +172,18 @@ impl WalletInterface for MockWallet { self.event_sender.subscribe() } - fn process_instant_send_lock(&mut self, txid: Txid) { + fn process_instant_send_lock(&mut self, instant_lock: InstantLock) { + let txid = instant_lock.txid; + let mut locks = self + .processed_instant_locks + .try_lock() + .expect("processed_instant_locks lock contention in test helper"); + locks.push((txid, Some(instant_lock.clone()))); + drop(locks); + let mut changes = self.status_changes.try_lock().expect("status_changes lock contention in test helper"); - changes.push((txid, TransactionContext::InstantSend)); + changes.push((txid, TransactionContext::InstantSend(instant_lock))); } } @@ -198,7 +218,7 @@ impl WalletInterface for NonMatchingMockWallet { async fn process_mempool_transaction( &mut self, _tx: &Transaction, - _is_instant_send: bool, + _instant_lock: Option, ) -> MempoolTransactionResult { MempoolTransactionResult::default() } diff --git a/key-wallet-manager/src/wallet_interface.rs b/key-wallet-manager/src/wallet_interface.rs index aad1d86ea..85a066368 100644 --- a/key-wallet-manager/src/wallet_interface.rs +++ b/key-wallet-manager/src/wallet_interface.rs @@ -4,6 +4,7 @@ use crate::WalletEvent; use async_trait::async_trait; +use dashcore::ephemerealdata::instant_lock::InstantLock; use dashcore::prelude::CoreBlockHeight; use dashcore::{Address, Block, OutPoint, Transaction, Txid}; use tokio::sync::broadcast; @@ -60,11 +61,11 @@ pub trait WalletInterface: Send + Sync + 'static { /// Called when a transaction is seen in the mempool. /// Returns whether the transaction was relevant and any new addresses generated. - /// When `is_instant_send` is true, the transaction already has an IS lock. + /// When `instant_lock` is `Some`, the transaction already has an IS lock. async fn process_mempool_transaction( &mut self, tx: &Transaction, - is_instant_send: bool, + instant_lock: Option, ) -> MempoolTransactionResult; /// Get all addresses the wallet is monitoring for incoming transactions @@ -117,7 +118,7 @@ pub trait WalletInterface: Send + Sync + 'static { /// Process an InstantSend lock for a transaction already in the wallet. /// Marks UTXOs as IS-locked, emits status change and balance update events. - fn process_instant_send_lock(&mut self, _txid: Txid) {} + fn process_instant_send_lock(&mut self, _instant_lock: InstantLock) {} /// Provide a human-readable description of the wallet implementation. /// diff --git a/key-wallet/src/managed_account/mod.rs b/key-wallet/src/managed_account/mod.rs index 529f81736..16a1c6539 100644 --- a/key-wallet/src/managed_account/mod.rs +++ b/key-wallet/src/managed_account/mod.rs @@ -369,7 +369,7 @@ impl ManagedCoreAccount { Utxo::new(outpoint, txout, addr, block_height, tx.is_coin_base()); utxo.is_confirmed = context.confirmed(); utxo.is_instantlocked = - matches!(context, TransactionContext::InstantSend); + matches!(context, TransactionContext::InstantSend(_)); self.utxos.insert(outpoint, utxo); utxos_changed = true; } @@ -422,7 +422,7 @@ impl ManagedCoreAccount { ); if tx_record.context != context { let was_confirmed = tx_record.context.confirmed(); - tx_record.update_context(context); + tx_record.update_context(context.clone()); // Only signal a change when confirmation status actually changes, // not for upgrades within the confirmed state (e.g. InBlock → InChainLockedBlock). // TODO: emit a change event for InBlock → InChainLockedBlock once chainlock @@ -524,7 +524,7 @@ impl ManagedCoreAccount { let tx_record = TransactionRecord::new( tx.clone(), - context, + context.clone(), transaction_type, direction, input_details, diff --git a/key-wallet/src/transaction_checking/transaction_context.rs b/key-wallet/src/transaction_checking/transaction_context.rs index ba673d1ff..910f69763 100644 --- a/key-wallet/src/transaction_checking/transaction_context.rs +++ b/key-wallet/src/transaction_checking/transaction_context.rs @@ -1,5 +1,6 @@ //! Block metadata and transaction context types. +use dashcore::ephemerealdata::instant_lock::InstantLock; use dashcore::prelude::CoreBlockHeight; use dashcore::BlockHash; @@ -35,13 +36,13 @@ impl BlockInfo { } /// Context for transaction processing -#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[derive(Debug, Clone, PartialEq, Eq)] #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] pub enum TransactionContext { /// Transaction is in the mempool (unconfirmed) Mempool, /// Transaction is in the mempool with an InstantSend lock - InstantSend, + InstantSend(InstantLock), /// Transaction is in a block at the given height InBlock(BlockInfo), /// Transaction is in a chain-locked block at the given height @@ -52,7 +53,7 @@ impl std::fmt::Display for TransactionContext { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { TransactionContext::Mempool => write!(f, "mempool"), - TransactionContext::InstantSend => write!(f, "instant send"), + TransactionContext::InstantSend(_) => write!(f, "instant send"), TransactionContext::InBlock(info) => write!(f, "block {}", info.height), TransactionContext::InChainLockedBlock(info) => { write!(f, "chainlocked block {}", info.height) @@ -67,10 +68,15 @@ impl TransactionContext { matches!(self, TransactionContext::InChainLockedBlock(_) | TransactionContext::InBlock(_)) } + /// Returns whether this context is an InstantSend lock. + pub(crate) fn is_instant_send(&self) -> bool { + matches!(self, TransactionContext::InstantSend(_)) + } + /// Returns the block info if confirmed. pub fn block_info(&self) -> Option<&BlockInfo> { match self { - TransactionContext::Mempool | TransactionContext::InstantSend => None, + TransactionContext::Mempool | TransactionContext::InstantSend(_) => None, TransactionContext::InBlock(info) | TransactionContext::InChainLockedBlock(info) => { Some(info) } diff --git a/key-wallet/src/transaction_checking/transaction_router/tests/coinbase.rs b/key-wallet/src/transaction_checking/transaction_router/tests/coinbase.rs index 57a69fcd4..d918d8865 100644 --- a/key-wallet/src/transaction_checking/transaction_router/tests/coinbase.rs +++ b/key-wallet/src/transaction_checking/transaction_router/tests/coinbase.rs @@ -188,8 +188,9 @@ async fn test_update_state_flag_behavior() { )); // First check with update_state = false - let result1 = - managed_wallet_info.check_core_transaction(&tx, context, &mut wallet, false, true).await; + let result1 = managed_wallet_info + .check_core_transaction(&tx, context.clone(), &mut wallet, false, true) + .await; assert!(result1.is_relevant); diff --git a/key-wallet/src/transaction_checking/transaction_router/tests/routing.rs b/key-wallet/src/transaction_checking/transaction_router/tests/routing.rs index ac9aabda9..85a9ce6df 100644 --- a/key-wallet/src/transaction_checking/transaction_router/tests/routing.rs +++ b/key-wallet/src/transaction_checking/transaction_router/tests/routing.rs @@ -111,8 +111,9 @@ async fn test_transaction_routing_to_bip32_account() { let context = TransactionContext::InBlock(test_block_info(100000)); // Check with update_state = false - let result = - managed_wallet_info.check_core_transaction(&tx, context, &mut wallet, false, true).await; + let result = managed_wallet_info + .check_core_transaction(&tx, context.clone(), &mut wallet, false, true) + .await; // The transaction should be recognized as relevant assert!(result.is_relevant, "Transaction should be relevant to the BIP32 account"); @@ -327,7 +328,7 @@ async fn test_transaction_affects_multiple_accounts() { let result = managed_wallet_info .check_core_transaction( &tx, - context, + context.clone(), &mut wallet, true, // update state true, // update balance diff --git a/key-wallet/src/transaction_checking/wallet_checker.rs b/key-wallet/src/transaction_checking/wallet_checker.rs index e00c8f647..020f796b4 100644 --- a/key-wallet/src/transaction_checking/wallet_checker.rs +++ b/key-wallet/src/transaction_checking/wallet_checker.rs @@ -80,7 +80,7 @@ impl WalletTransactionChecker for ManagedWalletInfo { if !is_new { // IS lock on a transaction that is already confirmed is stale — ignore - if context == TransactionContext::InstantSend { + if context.is_instant_send() { if !self.instant_send_locks.insert(txid) { return result; } @@ -94,13 +94,16 @@ impl WalletTransactionChecker for ManagedWalletInfo { if already_confirmed { return result; } - // Mark UTXOs as IS-locked in affected accounts + // Mark UTXOs as IS-locked and update the transaction context for account_match in &result.affected_accounts { if let Some(account) = self .accounts .get_by_account_type_match_mut(&account_match.account_type_match) { account.mark_utxos_instant_send(&txid); + if let Some(record) = account.transactions.get_mut(&txid) { + record.update_context(context.clone()); + } } } if update_balance { @@ -124,12 +127,13 @@ impl WalletTransactionChecker for ManagedWalletInfo { }; if is_new { - let record = account.record_transaction(tx, &account_match, context, tx_type); + let record = + account.record_transaction(tx, &account_match, context.clone(), tx_type); if let Some(account_index) = account_match.account_type_match.account_index() { result.new_records.push((account_index, record)); } result.state_modified = true; - } else if account.confirm_transaction(tx, &account_match, context, tx_type) { + } else if account.confirm_transaction(tx, &account_match, context.clone(), tx_type) { result.state_modified = true; } @@ -166,7 +170,7 @@ impl WalletTransactionChecker for ManagedWalletInfo { if is_new { // Populate dedup sets when a tx arrives with an initial IS status - if context == TransactionContext::InstantSend { + if context.is_instant_send() { self.instant_send_locks.insert(txid); } self.increment_transactions(); @@ -203,6 +207,7 @@ mod tests { use crate::Network; use dashcore::blockdata::script::ScriptBuf; use dashcore::blockdata::transaction::Transaction; + use dashcore::ephemerealdata::instant_lock::InstantLock; use dashcore::OutPoint; use dashcore::TxOut; use dashcore::{Address, BlockHash, TxIn, Txid}; @@ -664,8 +669,9 @@ mod tests { )); // First processing - should be marked as new - let result1 = - managed_wallet.check_core_transaction(&tx, context, &mut wallet, true, true).await; + let result1 = managed_wallet + .check_core_transaction(&tx, context.clone(), &mut wallet, true, true) + .await; assert!(result1.is_relevant, "Transaction should be relevant"); assert!( @@ -876,7 +882,11 @@ mod tests { assert_eq!(ctx.managed_wallet.metadata.total_transactions, 1); // Stage 2: IS lock - let result = ctx.check_transaction(&tx, TransactionContext::InstantSend).await; + let is_lock = InstantLock { + txid, + ..InstantLock::default() + }; + let result = ctx.check_transaction(&tx, TransactionContext::InstantSend(is_lock)).await; assert!(result.is_relevant); assert!(!result.is_new_transaction); assert_eq!(ctx.managed_wallet.balance().spendable(), 200_000); @@ -886,8 +896,18 @@ mod tests { assert_eq!(ctx.managed_wallet.metadata.total_transactions, 1); assert!(ctx.managed_wallet.instant_send_locks.contains(&txid)); + // Verify the TransactionRecord stores the IS lock payload + let record = ctx.transaction(&txid); + if let TransactionContext::InstantSend(ref lock) = record.context { + assert_eq!(lock.txid, txid); + } else { + panic!("expected InstantSend context, got {:?}", record.context); + } + // Duplicate IS lock should be a no-op - let result_dup = ctx.check_transaction(&tx, TransactionContext::InstantSend).await; + let result_dup = ctx + .check_transaction(&tx, TransactionContext::InstantSend(InstantLock::default())) + .await; assert!(result_dup.is_relevant); assert!(!result_dup.is_new_transaction); assert_eq!(ctx.managed_wallet.balance().spendable(), 200_000); @@ -913,7 +933,9 @@ mod tests { // Stage 5: late IS lock on already-confirmed tx should be ignored let balance_before = ctx.managed_wallet.balance(); - let result = ctx.check_transaction(&tx, TransactionContext::InstantSend).await; + let result = ctx + .check_transaction(&tx, TransactionContext::InstantSend(InstantLock::default())) + .await; assert!(result.is_relevant); assert!(!result.is_new_transaction); assert_eq!(ctx.managed_wallet.balance().spendable(), balance_before.spendable()); @@ -927,7 +949,9 @@ mod tests { let txid = tx.txid(); // Arrive directly as IS (skipping plain mempool) - let result = ctx.check_transaction(&tx, TransactionContext::InstantSend).await; + let result = ctx + .check_transaction(&tx, TransactionContext::InstantSend(InstantLock::default())) + .await; assert!(result.is_relevant); assert!(result.is_new_transaction); assert_eq!(result.total_received, 150_000); @@ -938,7 +962,9 @@ mod tests { assert!(ctx.managed_wallet.instant_send_locks.contains(&txid)); // A follow-up IS lock should be a no-op - let result2 = ctx.check_transaction(&tx, TransactionContext::InstantSend).await; + let result2 = ctx + .check_transaction(&tx, TransactionContext::InstantSend(InstantLock::default())) + .await; assert!(!result2.is_new_transaction); assert_eq!(ctx.managed_wallet.balance().spendable(), 150_000); assert_eq!(ctx.managed_wallet.metadata.total_transactions, 1); 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 0eb0b2132..1c9fa13e7 100644 --- a/key-wallet/src/wallet/managed_wallet_info/wallet_info_interface.rs +++ b/key-wallet/src/wallet/managed_wallet_info/wallet_info_interface.rs @@ -7,11 +7,12 @@ use std::collections::BTreeSet; use super::managed_account_operations::ManagedAccountOperations; use crate::account::ManagedAccountTrait; use crate::managed_account::managed_account_collection::ManagedAccountCollection; +use crate::transaction_checking::TransactionContext; use crate::transaction_checking::WalletTransactionChecker; use crate::wallet::managed_wallet_info::TransactionRecord; use crate::wallet::ManagedWalletInfo; use crate::{Network, Utxo, Wallet, WalletCoreBalance}; - +use dashcore::ephemerealdata::instant_lock::InstantLock; use dashcore::prelude::CoreBlockHeight; use dashcore::{Address as DashAddress, Transaction, Txid}; @@ -92,9 +93,10 @@ pub trait WalletInfoInterface: Sized + WalletTransactionChecker + ManagedAccount /// This should be called when the chain tip advances to a new height fn update_synced_height(&mut self, current_height: u32); - /// Mark UTXOs for a transaction as InstantSend-locked across all accounts. + /// Mark UTXOs for a transaction as InstantSend-locked across all accounts + /// and update the corresponding transaction record context. /// Returns `true` if any UTXO was newly marked. - fn mark_instant_send_utxos(&mut self, txid: &Txid) -> bool; + fn mark_instant_send_utxos(&mut self, txid: &Txid, lock: &InstantLock) -> bool; /// Return the aggregated monitor revision across all accounts. /// Increments whenever the monitored address set changes. @@ -240,7 +242,7 @@ impl WalletInfoInterface for ManagedWalletInfo { self.update_balance(); } - fn mark_instant_send_utxos(&mut self, txid: &Txid) -> bool { + fn mark_instant_send_utxos(&mut self, txid: &Txid, lock: &InstantLock) -> bool { if !self.instant_send_locks.insert(*txid) { return false; } @@ -249,6 +251,9 @@ impl WalletInfoInterface for ManagedWalletInfo { if account.mark_utxos_instant_send(txid) { any_changed = true; } + if let Some(record) = account.transactions_mut().get_mut(txid) { + record.update_context(TransactionContext::InstantSend(lock.clone())); + } } if any_changed { self.update_balance(); From 4ffc95c482e08204a780683d3def6f2af158d9e0 Mon Sep 17 00:00:00 2001 From: xdustinface Date: Tue, 31 Mar 2026 20:47:25 +1100 Subject: [PATCH 2/7] refactor: rename `mark_instant_send` to `process_instant_send` --- dash-spv/src/sync/mempool/manager.rs | 12 ++++++------ dash-spv/src/sync/mempool/sync_manager.rs | 2 +- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/dash-spv/src/sync/mempool/manager.rs b/dash-spv/src/sync/mempool/manager.rs index de0604781..cee25f68c 100644 --- a/dash-spv/src/sync/mempool/manager.rs +++ b/dash-spv/src/sync/mempool/manager.rs @@ -360,7 +360,7 @@ impl MempoolManager { /// /// If the transaction hasn't arrived yet, remembers the lock so it /// can be applied when the transaction is later received via `handle_tx`. - pub(super) async fn mark_instant_send(&mut self, instant_lock: InstantLock) { + pub(super) async fn process_instant_send(&mut self, instant_lock: InstantLock) { let txid = instant_lock.txid; let mut state = self.mempool_state.write().await; let instant_lock_opt = if let Some(tx) = state.transactions.get_mut(&txid) { @@ -935,7 +935,7 @@ mod tests { )); } - manager.mark_instant_send(dummy_instant_lock(txid)).await; + manager.process_instant_send(dummy_instant_lock(txid)).await; // Verify mempool state also reflects IS flag let state = manager.mempool_state.read().await; @@ -955,7 +955,7 @@ mod tests { let (mut manager, _requests, _rx) = create_test_manager(); let unknown_txid = Txid::from_byte_array([0xbb; 32]); - manager.mark_instant_send(dummy_instant_lock(unknown_txid)).await; + manager.process_instant_send(dummy_instant_lock(unknown_txid)).await; // No immediate wallet notification let wallet = manager.wallet.read().await; @@ -1123,7 +1123,7 @@ mod tests { let txid = tx.txid(); // IS lock arrives before the transaction (with a distinct cyclehash) - manager.mark_instant_send(rich_instant_lock(txid)).await; + manager.process_instant_send(rich_instant_lock(txid)).await; assert!(manager.pending_is_locks.contains_key(&txid)); // Transaction arrives @@ -1163,7 +1163,7 @@ mod tests { let txid = tx.txid(); // IS lock arrives before the transaction - manager.mark_instant_send(dummy_instant_lock(txid)).await; + manager.process_instant_send(dummy_instant_lock(txid)).await; assert!(manager.pending_is_locks.contains_key(&txid)); // Transaction arrives but wallet says it's not relevant @@ -1192,7 +1192,7 @@ mod tests { // Next IS lock should be dropped let overflow_txid = Txid::from_byte_array([0xff; 32]); - manager.mark_instant_send(dummy_instant_lock(overflow_txid)).await; + manager.process_instant_send(dummy_instant_lock(overflow_txid)).await; assert!(!manager.pending_is_locks.contains_key(&overflow_txid)); assert_eq!(manager.pending_is_locks.len(), MAX_PENDING_IS_LOCKS); } diff --git a/dash-spv/src/sync/mempool/sync_manager.rs b/dash-spv/src/sync/mempool/sync_manager.rs index 99cce293f..fea5e6091 100644 --- a/dash-spv/src/sync/mempool/sync_manager.rs +++ b/dash-spv/src/sync/mempool/sync_manager.rs @@ -96,7 +96,7 @@ impl SyncManager for MempoolManager { instant_lock, .. } => { - self.mark_instant_send(instant_lock.clone()).await; + self.process_instant_send(instant_lock.clone()).await; Ok(vec![]) } _ => Ok(vec![]), From 890264d1962d3384fc81f9e357ea8b5fd415ab7b Mon Sep 17 00:00:00 2001 From: xdustinface Date: Thu, 2 Apr 2026 00:15:23 +1100 Subject: [PATCH 3/7] fix: validate `InstantLock` txid matches transaction in FFI boundary Addresses CodeRabbit review comment on PR #615 https://github.com/dashpay/rust-dashcore/pull/615#discussion_r3021757856 --- key-wallet-ffi/src/transaction_checking.rs | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/key-wallet-ffi/src/transaction_checking.rs b/key-wallet-ffi/src/transaction_checking.rs index 2eaf0dde3..6bf7c38a5 100644 --- a/key-wallet-ffi/src/transaction_checking.rs +++ b/key-wallet-ffi/src/transaction_checking.rs @@ -16,7 +16,7 @@ use crate::types::{ use dashcore::consensus::Decodable; use dashcore::Transaction; use key_wallet::transaction_checking::{ - account_checker::CoreAccountTypeMatch, WalletTransactionChecker, + account_checker::CoreAccountTypeMatch, TransactionContext, WalletTransactionChecker, }; use key_wallet::wallet::managed_wallet_info::ManagedWalletInfo; @@ -160,6 +160,17 @@ pub unsafe extern "C" fn managed_wallet_check_transaction( } }; + if let TransactionContext::InstantSend(ref lock) = context { + if lock.txid != tx.txid() { + FFIError::set_error( + error, + FFIErrorCode::InvalidInput, + "InstantLock txid does not match transaction".to_string(), + ); + return false; + } + } + // Check the transaction - wallet is now required if wallet.is_null() { FFIError::set_error( From 2005f854b650c082722d05520ee8dbf4578b4ebd Mon Sep 17 00:00:00 2001 From: xdustinface Date: Thu, 2 Apr 2026 00:16:49 +1100 Subject: [PATCH 4/7] refactor: remove `Clone` derive from `FFITransactionContext` to prevent shallow pointer copies Addresses CodeRabbit review comment on PR #615 https://github.com/dashpay/rust-dashcore/pull/615#discussion_r3021757873 --- key-wallet-ffi/src/types.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/key-wallet-ffi/src/types.rs b/key-wallet-ffi/src/types.rs index a81cf0172..12495596b 100644 --- a/key-wallet-ffi/src/types.rs +++ b/key-wallet-ffi/src/types.rs @@ -743,7 +743,7 @@ impl From for FFITransactionContextType { /// FFI-compatible transaction context (type + optional block info + optional IS lock) #[repr(C)] -#[derive(Debug, Clone)] +#[derive(Debug)] pub struct FFITransactionContext { /// The context type pub context_type: FFITransactionContextType, From 8dfa4afabe86b265c6e6b49eced4c8f8b4d13c78 Mon Sep 17 00:00:00 2001 From: xdustinface Date: Thu, 2 Apr 2026 00:16:53 +1100 Subject: [PATCH 5/7] fix: avoid dangling `islock_data` pointer in FFI callback by saving pointer before move Addresses CodeRabbit review comment on PR #615 https://github.com/dashpay/rust-dashcore/pull/615#discussion_r3021757845 --- dash-spv-ffi/src/callbacks.rs | 38 ++++++++++++++++++++++++++--------- 1 file changed, 28 insertions(+), 10 deletions(-) diff --git a/dash-spv-ffi/src/callbacks.rs b/dash-spv-ffi/src/callbacks.rs index 0f22e0def..893789b97 100644 --- a/dash-spv-ffi/src/callbacks.rs +++ b/dash-spv-ffi/src/callbacks.rs @@ -705,7 +705,9 @@ impl FFIWalletEventCallbacks { let wallet_id_hex = hex::encode(wallet_id); let c_wallet_id = CString::new(wallet_id_hex).unwrap_or_default(); - let mut ffi_ctx = FFITransactionContext::from(record.context.clone()); + let ffi_ctx = FFITransactionContext::from(record.context.clone()); + let islock_data = ffi_ctx.islock_data; + let islock_len = ffi_ctx.islock_len; let tx_bytes = dashcore::consensus::serialize(&record.transaction).into_boxed_slice(); @@ -738,7 +740,7 @@ impl FFIWalletEventCallbacks { let ffi_record = FFITransactionRecord { txid: record.txid.to_byte_array(), net_amount: record.net_amount, - context: ffi_ctx.clone(), + context: ffi_ctx, transaction_type: FFITransactionType::from(record.transaction_type), direction: FFITransactionDirection::from(record.direction), fee: record.fee.unwrap_or(0), @@ -780,9 +782,16 @@ impl FFIWalletEventCallbacks { } } } - // SAFETY: `ffi_ctx` owns the heap-allocated IS lock bytes produced - // by `From`. Free them after the callback returns. - unsafe { ffi_ctx.free_islock_data() }; + // SAFETY: Free the heap-allocated IS lock bytes produced by + // `From` after the callback returns. + if !islock_data.is_null() && islock_len > 0 { + unsafe { + drop(Box::from_raw(std::ptr::slice_from_raw_parts_mut( + islock_data as *mut u8, + islock_len, + ))); + } + } } } WalletEvent::TransactionStatusChanged { @@ -794,16 +803,25 @@ impl FFIWalletEventCallbacks { let wallet_id_hex = hex::encode(wallet_id); let c_wallet_id = CString::new(wallet_id_hex).unwrap_or_default(); let txid_bytes = txid.as_byte_array(); - let mut ffi_ctx = FFITransactionContext::from(status.clone()); + let ffi_ctx = FFITransactionContext::from(status.clone()); + let islock_data = ffi_ctx.islock_data; + let islock_len = ffi_ctx.islock_len; cb( c_wallet_id.as_ptr(), txid_bytes as *const [u8; 32], - ffi_ctx.clone(), + ffi_ctx, self.user_data, ); - // SAFETY: `ffi_ctx` owns the heap-allocated IS lock bytes produced - // by `From`. Free them after the callback returns. - unsafe { ffi_ctx.free_islock_data() }; + // SAFETY: Free the heap-allocated IS lock bytes produced by + // `From` after the callback returns. + if !islock_data.is_null() && islock_len > 0 { + unsafe { + drop(Box::from_raw(std::ptr::slice_from_raw_parts_mut( + islock_data as *mut u8, + islock_len, + ))); + } + } } } WalletEvent::BalanceUpdated { From 01ffbe10dd53a4acb8aa87dba3472a719dc848d0 Mon Sep 17 00:00:00 2001 From: xdustinface Date: Thu, 2 Apr 2026 08:37:50 +1100 Subject: [PATCH 6/7] fix: add `debug_assert` for `InstantLock` txid match in `process_mempool_transaction` Addresses CodeRabbit review comment on PR #615 https://github.com/dashpay/rust-dashcore/pull/615#discussion_r3021757882 --- key-wallet-manager/src/process_block.rs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/key-wallet-manager/src/process_block.rs b/key-wallet-manager/src/process_block.rs index 288c992a5..98fb14298 100644 --- a/key-wallet-manager/src/process_block.rs +++ b/key-wallet-manager/src/process_block.rs @@ -48,7 +48,10 @@ impl WalletInterface for WalletM instant_lock: Option, ) -> MempoolTransactionResult { let context = match instant_lock { - Some(lock) => TransactionContext::InstantSend(lock), + Some(lock) => { + debug_assert_eq!(lock.txid, tx.txid(), "InstantLock txid must match transaction"); + TransactionContext::InstantSend(lock) + } None => TransactionContext::Mempool, }; let snapshot = self.snapshot_balances(); From b1aaa08e83ae67a8219050ad65dcab9c50cbe83a Mon Sep 17 00:00:00 2001 From: xdustinface Date: Thu, 2 Apr 2026 09:29:04 +1100 Subject: [PATCH 7/7] test: fix is lock passed to `process_mempool_transaction` --- key-wallet-manager/src/event_tests.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/key-wallet-manager/src/event_tests.rs b/key-wallet-manager/src/event_tests.rs index c16827bb0..a97cc93aa 100644 --- a/key-wallet-manager/src/event_tests.rs +++ b/key-wallet-manager/src/event_tests.rs @@ -212,7 +212,7 @@ async fn test_instantsend_tx_emits_balance_updated_spendable() { let mut rx = manager.subscribe_events(); let tx = create_tx_paying_to(&addr, 0xf2); - manager.process_mempool_transaction(&tx, Some(InstantLock::default())).await; + manager.process_mempool_transaction(&tx, Some(dummy_instant_lock(tx.txid()))).await; let events = drain_events(&mut rx); let balance_events: Vec<_> = @@ -368,7 +368,7 @@ async fn test_process_instant_send_lock_after_block_confirmation() { let tx = create_tx_paying_to(&addr, 0xe2); // Process as IS mempool tx, then confirm in block - manager.process_mempool_transaction(&tx, Some(InstantLock::default())).await; + manager.process_mempool_transaction(&tx, Some(dummy_instant_lock(tx.txid()))).await; let block_ctx = TransactionContext::InBlock(BlockInfo::new( 500, BlockHash::from_byte_array([0xe2; 32]),