Skip to content

Commit 734b35f

Browse files
authored
feat: expose full TransactionRecord through FFI and wallet events (#614)
* feat: expose full `TransactionRecord` through FFI and wallet events - Add FFI types for transaction direction, type, output role, and input/output details - Enrich `FFITransactionRecord` with classification, details, label and serialized tx bytes - Simplify `WalletEvent::TransactionReceived` to carry `Box<TransactionRecord>` - Return `TransactionRecord` from `record_transaction` and propagate via `TransactionCheckResult` - Refactor `OnTransactionReceivedCallback` to pass `*const FFITransactionRecord` * fix: prevent `label` CString memory leak in transaction callback Addresses CodeRabbit review comment on PR #614 #614 (comment) * refactor: combine received txid/amount into single mutex in test tracker Addresses CodeRabbit review comment on PR #614 #614 (comment) * fix: correct `net_amount` assertion in FFI callback integration test The test sends 1 DASH from the same wallet (same mnemonic) that the SPV client tracks, making it an internal transfer where both inputs and outputs belong to the wallet. The `net_amount` therefore equals approximately `-fee`, not +100_000_000. Replace the strict amount pairing assertion with a txid presence check and non-zero net_amount verification.
1 parent c6a4ed6 commit 734b35f

File tree

15 files changed

+689
-290
lines changed

15 files changed

+689
-290
lines changed

dash-spv-ffi/src/bin/ffi_cli.rs

Lines changed: 13 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ use std::ptr;
55
use clap::{Arg, ArgAction, Command};
66

77
use dash_spv_ffi::*;
8+
use key_wallet_ffi::managed_account::FFITransactionRecord;
89
use key_wallet_ffi::types::FFITransactionContext;
910
use key_wallet_ffi::wallet_manager::wallet_manager_add_wallet_from_mnemonic;
1011
use key_wallet_ffi::{FFIError, FFINetwork};
@@ -157,24 +158,28 @@ extern "C" fn on_peers_updated(connected_count: u32, best_height: u32, _user_dat
157158

158159
extern "C" fn on_transaction_received(
159160
wallet_id: *const c_char,
160-
status: FFITransactionContext,
161161
account_index: u32,
162-
txid: *const [u8; 32],
163-
amount: i64,
164-
addresses: *const c_char,
162+
record: *const FFITransactionRecord,
165163
_user_data: *mut c_void,
166164
) {
167165
let wallet_str = ffi_string_to_rust(wallet_id);
168-
let addr_str = ffi_string_to_rust(addresses);
169166
let wallet_short = if wallet_str.len() > 8 {
170167
&wallet_str[..8]
171168
} else {
172169
&wallet_str
173170
};
174-
let txid_hex = unsafe { hex::encode(*txid) };
171+
if record.is_null() {
172+
println!(
173+
"[Wallet] TX received: wallet={}..., account={}, record=null",
174+
wallet_short, account_index
175+
);
176+
return;
177+
}
178+
let r = unsafe { &*record };
179+
let txid_hex = hex::encode(r.txid);
175180
println!(
176-
"[Wallet] TX received: wallet={}..., txid={}, account={}, amount={} duffs, status={:?}, addresses={}",
177-
wallet_short, txid_hex, account_index, amount, status, addr_str
181+
"[Wallet] TX received: wallet={}..., txid={}, account={}, amount={} duffs, tx_size={}",
182+
wallet_short, txid_hex, account_index, r.net_amount, r.tx_len
178183
);
179184
}
180185

dash-spv-ffi/src/callbacks.rs

Lines changed: 80 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,11 @@ use dash_spv::network::NetworkEvent;
1111
use dash_spv::sync::{SyncEvent, SyncProgress};
1212
use dash_spv::EventHandler;
1313
use dashcore::hashes::Hash;
14-
use key_wallet_ffi::types::FFITransactionContext;
14+
use key_wallet_ffi::managed_account::FFITransactionRecord;
15+
use key_wallet_ffi::types::{
16+
FFIInputDetail, FFIOutputDetail, FFIOutputRole, FFITransactionContext, FFITransactionDirection,
17+
FFITransactionType,
18+
};
1519
use key_wallet_manager::WalletEvent;
1620
use std::ffi::CString;
1721
use std::os::raw::{c_char, c_void};
@@ -530,17 +534,15 @@ impl FFINetworkEventCallbacks {
530534

531535
/// Callback for WalletEvent::TransactionReceived
532536
///
533-
/// The `wallet_id`, `addresses` string pointers and the `txid` hash pointer
534-
/// are borrowed and only valid for the duration of the callback. Callers must
535-
/// copy any data they need to retain after the callback returns.
537+
/// The `record` pointer is borrowed and only valid for the duration of the
538+
/// callback. Callers must copy any data they need to retain after the callback
539+
/// returns. The record contains all transaction details including serialized
540+
/// transaction bytes, input/output details, and classification metadata.
536541
pub type OnTransactionReceivedCallback = Option<
537542
extern "C" fn(
538543
wallet_id: *const c_char,
539-
status: FFITransactionContext,
540544
account_index: u32,
541-
txid: *const [u8; 32],
542-
amount: i64,
543-
addresses: *const c_char,
545+
record: *const FFITransactionRecord,
544546
user_data: *mut c_void,
545547
),
546548
>;
@@ -696,28 +698,86 @@ impl FFIWalletEventCallbacks {
696698
match event {
697699
WalletEvent::TransactionReceived {
698700
wallet_id,
699-
status,
700701
account_index,
701-
txid,
702-
amount,
703-
addresses,
702+
record,
704703
} => {
705704
if let Some(cb) = self.on_transaction_received {
706705
let wallet_id_hex = hex::encode(wallet_id);
707706
let c_wallet_id = CString::new(wallet_id_hex).unwrap_or_default();
708-
let txid_bytes = txid.as_byte_array();
709-
let addresses_str: Vec<String> =
710-
addresses.iter().map(|a| a.to_string()).collect();
711-
let c_addresses = CString::new(addresses_str.join(",")).unwrap_or_default();
707+
708+
let tx_bytes =
709+
dashcore::consensus::serialize(&record.transaction).into_boxed_slice();
710+
711+
let input_details: Vec<FFIInputDetail> = record
712+
.input_details
713+
.iter()
714+
.map(|d| {
715+
let addr = CString::new(d.address.to_string()).unwrap_or_default();
716+
FFIInputDetail {
717+
index: d.index,
718+
value: d.value,
719+
address: addr.into_raw(),
720+
}
721+
})
722+
.collect();
723+
724+
let output_details: Vec<FFIOutputDetail> = record
725+
.output_details
726+
.iter()
727+
.map(|d| FFIOutputDetail {
728+
index: d.index,
729+
role: FFIOutputRole::from(d.role),
730+
})
731+
.collect();
732+
733+
let c_label =
734+
record.label.as_ref().map(|l| CString::new(l.as_str()).unwrap_or_default());
735+
736+
let ffi_record = FFITransactionRecord {
737+
txid: record.txid.to_byte_array(),
738+
net_amount: record.net_amount,
739+
context: FFITransactionContext::from(record.context),
740+
transaction_type: FFITransactionType::from(record.transaction_type),
741+
direction: FFITransactionDirection::from(record.direction),
742+
fee: record.fee.unwrap_or(0),
743+
input_details: if input_details.is_empty() {
744+
std::ptr::null_mut()
745+
} else {
746+
input_details.as_ptr() as *mut _
747+
},
748+
input_details_count: input_details.len(),
749+
output_details: if output_details.is_empty() {
750+
std::ptr::null_mut()
751+
} else {
752+
output_details.as_ptr() as *mut _
753+
},
754+
output_details_count: output_details.len(),
755+
tx_data: if tx_bytes.is_empty() {
756+
std::ptr::null_mut()
757+
} else {
758+
tx_bytes.as_ptr() as *mut _
759+
},
760+
tx_len: tx_bytes.len(),
761+
label: c_label
762+
.as_ref()
763+
.map_or(std::ptr::null_mut(), |l| l.as_ptr() as *mut _),
764+
};
765+
712766
cb(
713767
c_wallet_id.as_ptr(),
714-
FFITransactionContext::from(*status),
715768
*account_index,
716-
txid_bytes as *const [u8; 32],
717-
*amount,
718-
c_addresses.as_ptr(),
769+
&ffi_record as *const FFITransactionRecord,
719770
self.user_data,
720771
);
772+
773+
// Free the CString addresses from input details
774+
for detail in input_details {
775+
if !detail.address.is_null() {
776+
unsafe {
777+
drop(CString::from_raw(detail.address));
778+
}
779+
}
780+
}
721781
}
722782
}
723783
WalletEvent::TransactionStatusChanged {

dash-spv-ffi/tests/dashd_sync/callbacks.rs

Lines changed: 12 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ use std::sync::{Arc, Mutex};
77
use std::time::Duration;
88

99
use dash_spv_ffi::*;
10+
use key_wallet_ffi::managed_account::FFITransactionRecord;
1011
use key_wallet_ffi::types::FFITransactionContext;
1112

1213
/// Tracks callback invocations for verification.
@@ -48,9 +49,8 @@ pub(super) struct CallbackTracker {
4849
pub(super) connected_peers: Mutex<Vec<String>>,
4950
pub(super) errors: Mutex<Vec<String>>,
5051

51-
// Transaction data from on_transaction_received
52-
pub(super) received_txids: Mutex<Vec<[u8; 32]>>,
53-
pub(super) received_amounts: Mutex<Vec<i64>>,
52+
// Transaction data from on_transaction_received (txid, net_amount)
53+
pub(super) received_transactions: Mutex<Vec<([u8; 32], i64)>>,
5454

5555
// Balance data from on_balance_updated
5656
pub(super) last_spendable: AtomicU64,
@@ -343,29 +343,24 @@ extern "C" fn on_peers_updated(connected_count: u32, best_height: u32, user_data
343343

344344
extern "C" fn on_transaction_received(
345345
wallet_id: *const c_char,
346-
_status: FFITransactionContext,
347346
account_index: u32,
348-
txid: *const [u8; 32],
349-
amount: i64,
350-
_addresses: *const c_char,
347+
record: *const FFITransactionRecord,
351348
user_data: *mut c_void,
352349
) {
353350
let Some(tracker) = (unsafe { tracker_from(user_data) }) else {
354351
return;
355352
};
356-
if !txid.is_null() {
357-
let txid_bytes = unsafe { *txid };
358-
tracker.received_txids.lock().unwrap_or_else(|e| e.into_inner()).push(txid_bytes);
353+
if !record.is_null() {
354+
let r = unsafe { &*record };
355+
tracker
356+
.received_transactions
357+
.lock()
358+
.unwrap_or_else(|e| e.into_inner())
359+
.push((r.txid, r.net_amount));
359360
}
360-
tracker.received_amounts.lock().unwrap_or_else(|e| e.into_inner()).push(amount);
361361
tracker.transaction_received_count.fetch_add(1, Ordering::SeqCst);
362362
let wallet_str = unsafe { cstr_or_unknown(wallet_id) };
363-
tracing::info!(
364-
"on_transaction_received: wallet={}, account={}, amount={}",
365-
wallet_str,
366-
account_index,
367-
amount
368-
);
363+
tracing::info!("on_transaction_received: wallet={}, account={}", wallet_str, account_index,);
369364
}
370365

371366
extern "C" fn on_transaction_status_changed(

dash-spv-ffi/tests/dashd_sync/tests_callback.rs

Lines changed: 20 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -212,20 +212,13 @@ fn test_all_callbacks_during_sync() {
212212
);
213213

214214
// Validate transaction data from initial sync
215-
let received_txids = tracker.received_txids.lock().unwrap();
216-
assert!(!received_txids.is_empty(), "should have received transaction txids during sync");
217-
drop(received_txids);
218-
219-
let received_amounts = tracker.received_amounts.lock().unwrap();
220-
assert!(
221-
!received_amounts.is_empty(),
222-
"should have received transaction amounts during sync"
223-
);
215+
let received_txs = tracker.received_transactions.lock().unwrap();
216+
assert!(!received_txs.is_empty(), "should have received transactions during sync");
224217
assert!(
225-
received_amounts.iter().any(|&a| a != 0),
218+
received_txs.iter().any(|&(_, amount)| amount != 0),
226219
"at least one received transaction amount should be non-zero"
227220
);
228-
drop(received_amounts);
221+
drop(received_txs);
229222

230223
// Masternodes are disabled in test config, so these should not fire
231224
let masternode_updated = tracker.masternode_state_updated_count.load(Ordering::SeqCst);
@@ -308,23 +301,27 @@ fn test_callbacks_post_sync_transactions_and_disconnect() {
308301
tx_received_after
309302
);
310303

311-
// Verify the sent txid appears in the callback data
304+
// Verify the sent txid appears in the callback data with a non-zero
305+
// net_amount. The SPV wallet and dashd share the same mnemonic so the
306+
// transaction is an internal transfer (wallet owns both inputs and
307+
// outputs); net_amount therefore equals approximately -fee, not the
308+
// nominal send amount.
312309
let sent_txid_bytes = *txid.as_byte_array();
313-
let received_txids = tracker.received_txids.lock().unwrap();
310+
let received_txs = tracker.received_transactions.lock().unwrap();
311+
let sent_entry = received_txs.iter().find(|&&(id, _)| id == sent_txid_bytes);
314312
assert!(
315-
received_txids.contains(&sent_txid_bytes),
316-
"sent txid should appear in received_txids callback data"
313+
sent_entry.is_some(),
314+
"sent txid should appear in received transaction callback data"
317315
);
318-
drop(received_txids);
319-
320-
// Verify 1 DASH (100_000_000 satoshis) appears in received amounts
321-
let received_amounts = tracker.received_amounts.lock().unwrap();
316+
let &(_, net_amount) = sent_entry.unwrap();
317+
// Internal transfer: net_amount = received - sent = (send_amount + change) - input = -fee.
318+
// The fee must be negative, non-zero, and small (< 0.001 DASH).
322319
assert!(
323-
received_amounts.contains(&100_000_000),
324-
"1 DASH (100_000_000 sat) should appear in received_amounts: {:?}",
325-
*received_amounts
320+
net_amount < 0 && net_amount > -100_000,
321+
"internal transfer net_amount should equal -fee (small negative), got: {}",
322+
net_amount
326323
);
327-
drop(received_amounts);
324+
drop(received_txs);
328325

329326
let balance_updated_after = tracker.balance_updated_count.load(Ordering::SeqCst);
330327
tracing::info!(

dash-spv-ffi/tests/dashd_sync/tests_transaction.rs

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -74,14 +74,14 @@ fn test_ffi_sync_then_generate_blocks() {
7474
);
7575

7676
// Verify the transaction was received via wallet callback
77-
let received_txids = ctx.tracker().received_txids.lock().unwrap();
77+
let received_txs = ctx.tracker().received_transactions.lock().unwrap();
7878
let txid_bytes = *txid.as_byte_array();
7979
assert!(
80-
received_txids.contains(&txid_bytes),
80+
received_txs.iter().any(|&(txid, _)| txid == txid_bytes),
8181
"Wallet callback should have received txid {}",
8282
txid
8383
);
84-
drop(received_txids);
84+
drop(received_txs);
8585

8686
// Verify via wallet query as well
8787
assert!(

dash-spv/tests/dashd_sync/helpers.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -140,7 +140,7 @@ pub(super) async fn wait_for_mempool_tx(
140140
_ = &mut timeout => return None,
141141
result = receiver.recv() => {
142142
match result {
143-
Ok(WalletEvent::TransactionReceived { txid, status: TransactionContext::Mempool, .. }) => return Some(txid),
143+
Ok(WalletEvent::TransactionReceived { ref record, .. }) if record.context == TransactionContext::Mempool => return Some(record.txid),
144144
Ok(_) => continue,
145145
Err(_) => return None,
146146
}

0 commit comments

Comments
 (0)