Skip to content

Commit 70d4bf8

Browse files
feat(dash-spv): track network acceptance of broadcast transactions (#913)
* feat(dash-spv): track network acceptance of broadcast transactions Broadcasts are now sent to a subset of connected peers (default: half, per BroadcastHoldout::Half) while being withheld from the rest. Since peers never re-announce a transaction to whoever sent it to them, an inv for our txid from a withheld peer proves the transaction relayed through the network — the broadcast is then reported Accepted. InstantSend locks, block confirmations, and already-have rejects also count as acceptance; p2p reject messages (best-effort on modern dashd) report Rejected, distinguishing txn-mempool-conflict (rejection) from txn-already-in-mempool (acceptance) under the shared Duplicate code; a configurable timeout reports Uncertain, upgradeable by a late echo. Outcomes surface as SyncEvent::TransactionBroadcastResult, an awaitable DashSpvClient::broadcast_transaction_and_wait API, and matching FFI callbacks, config setters, and a blocking FFI wait function. Rebroadcast keeps respecting the holdout while pending, re-picks holdouts from never-sent peers on disconnect, skips rejected entries, and sends deferred broadcasts as soon as a peer connects. Broadcast state survives disconnects. Verified with a new two-node dashd integration test: the transaction is sent to one node and the withheld node's inv echo confirms acceptance; a double-spend leg documents that dashd 23 sends no BIP61 rejects and correctly resolves Uncertain. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * docs(dash-spv-ffi): regenerate FFI_API.md for broadcast acceptance functions Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * refactor(dash-spv): drop BIP61 reject handling from broadcast acceptance Modern Dash Core removed the BIP61 reject message entirely (no enablebip61, NetMsgType::REJECT, or reject codes remain in dashpay/dash), and the two-node integration test confirmed dashd 23.1.0 sends nothing for a refused transaction. The reject path was therefore dead code: remove the Reject message subscription and handler, the BroadcastResult::Rejected and BroadcastStatus::Rejected variants, and the FFI reject fields (FFIBroadcastStatus is now Accepted=0/Uncertain=1, the callback and FFIBroadcastResult lose reject_code/reject_reason, and the result struct no longer owns strings so its destroy function is gone). A transaction the network refuses now uniformly surfaces as Uncertain: no echo arrives within the acceptance timeout. The integration test's double-spend leg asserts exactly that. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(dash-spv): address review feedback on broadcast await and FFI tests - broadcast_transaction_and_wait no longer resolves on the manager's interim Uncertain event: a late echo can still upgrade the outcome to Accepted, so callers with a timeout longer than the configured acceptance timeout keep waiting until their deadline. Uncertain is now only returned on deadline expiry (or bus close). - Add FFI unit tests for the broadcast config setters: zero threshold/timeout are rejected with InvalidArgument without modifying the config; valid values map onto the ClientConfig fields; holdout half/count setters round-trip. - Drop the obsolete "or rejection signal" wording from the timeout setter doc (BIP61 reject handling was removed) and regenerate FFI_API.md. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent 19690d3 commit 70d4bf8

20 files changed

Lines changed: 1676 additions & 121 deletions

File tree

dash-spv-ffi/FFI_API.md

Lines changed: 88 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ This document provides a comprehensive reference for all FFI (Foreign Function I
44

55
**Auto-generated**: This documentation is automatically generated from the source code. Do not edit manually.
66

7-
**Total Functions**: 39
7+
**Total Functions**: 44
88

99
## Table of Contents
1010

@@ -31,7 +31,7 @@ Functions: 3
3131

3232
### Configuration
3333

34-
Functions: 15
34+
Functions: 19
3535

3636
| Function | Description | Module |
3737
|----------|-------------|--------|
@@ -41,6 +41,10 @@ Functions: 15
4141
| `dash_spv_ffi_config_get_network` | Gets the network type from the configuration # Safety - `config` must be a... | config |
4242
| `dash_spv_ffi_config_mainnet` | No description | config |
4343
| `dash_spv_ffi_config_new` | No description | config |
44+
| `dash_spv_ffi_config_set_broadcast_acceptance_threshold` | Sets how many distinct non-recipient peers must announce a broadcast txid... | config |
45+
| `dash_spv_ffi_config_set_broadcast_acceptance_timeout_secs` | Sets the timeout (in seconds) after which a pending broadcast with no... | config |
46+
| `dash_spv_ffi_config_set_broadcast_holdout_count` | Withholds broadcast transactions from a fixed number of peers (clamped so... | config |
47+
| `dash_spv_ffi_config_set_broadcast_holdout_half` | Withholds broadcast transactions from half of the connected peers (the... | config |
4448
| `dash_spv_ffi_config_set_data_dir` | Sets the data directory for storing blockchain data # Safety - `config`... | config |
4549
| `dash_spv_ffi_config_set_fetch_mempool_transactions` | Sets whether to fetch full mempool transaction data # Safety - `config`... | config |
4650
| `dash_spv_ffi_config_set_masternode_sync_enabled` | Enables or disables masternode synchronization # Safety - `config` must be... | config |
@@ -63,11 +67,12 @@ Functions: 3
6367

6468
### Transaction Management
6569

66-
Functions: 1
70+
Functions: 2
6771

6872
| Function | Description | Module |
6973
|----------|-------------|--------|
7074
| `dash_spv_ffi_client_broadcast_transaction` | Broadcasts a transaction to the Dash network via connected peers | client |
75+
| `dash_spv_ffi_client_broadcast_transaction_and_wait` | Broadcasts a transaction and waits for its network-level outcome | client |
7176

7277
### Mempool Operations
7378

@@ -252,6 +257,70 @@ dash_spv_ffi_config_new(network: FFINetwork) -> *mut FFIClientConfig
252257
253258
---
254259
260+
#### `dash_spv_ffi_config_set_broadcast_acceptance_threshold`
261+
262+
```c
263+
dash_spv_ffi_config_set_broadcast_acceptance_threshold(config: *mut FFIClientConfig, threshold: u32,) -> i32
264+
```
265+
266+
**Description:**
267+
Sets how many distinct non-recipient peers must announce a broadcast txid back before it is reported as accepted. Must be > 0. # Safety - `config` must be a valid pointer to an FFIClientConfig created by dash_spv_ffi_config_new/mainnet/testnet - The caller must ensure the config pointer remains valid for the duration of this call
268+
269+
**Safety:**
270+
- `config` must be a valid pointer to an FFIClientConfig created by dash_spv_ffi_config_new/mainnet/testnet - The caller must ensure the config pointer remains valid for the duration of this call
271+
272+
**Module:** `config`
273+
274+
---
275+
276+
#### `dash_spv_ffi_config_set_broadcast_acceptance_timeout_secs`
277+
278+
```c
279+
dash_spv_ffi_config_set_broadcast_acceptance_timeout_secs(config: *mut FFIClientConfig, seconds: u32,) -> i32
280+
```
281+
282+
**Description:**
283+
Sets the timeout (in seconds) after which a pending broadcast with no acceptance signal is reported as uncertain. Must be > 0. # Safety - `config` must be a valid pointer to an FFIClientConfig created by dash_spv_ffi_config_new/mainnet/testnet - The caller must ensure the config pointer remains valid for the duration of this call
284+
285+
**Safety:**
286+
- `config` must be a valid pointer to an FFIClientConfig created by dash_spv_ffi_config_new/mainnet/testnet - The caller must ensure the config pointer remains valid for the duration of this call
287+
288+
**Module:** `config`
289+
290+
---
291+
292+
#### `dash_spv_ffi_config_set_broadcast_holdout_count`
293+
294+
```c
295+
dash_spv_ffi_config_set_broadcast_holdout_count(config: *mut FFIClientConfig, count: u32,) -> i32
296+
```
297+
298+
**Description:**
299+
Withholds broadcast transactions from a fixed number of peers (clamped so that at least one peer always receives the transaction). # Safety - `config` must be a valid pointer to an FFIClientConfig created by dash_spv_ffi_config_new/mainnet/testnet - The caller must ensure the config pointer remains valid for the duration of this call
300+
301+
**Safety:**
302+
- `config` must be a valid pointer to an FFIClientConfig created by dash_spv_ffi_config_new/mainnet/testnet - The caller must ensure the config pointer remains valid for the duration of this call
303+
304+
**Module:** `config`
305+
306+
---
307+
308+
#### `dash_spv_ffi_config_set_broadcast_holdout_half`
309+
310+
```c
311+
dash_spv_ffi_config_set_broadcast_holdout_half(config: *mut FFIClientConfig,) -> i32
312+
```
313+
314+
**Description:**
315+
Withholds broadcast transactions from half of the connected peers (the default policy). The withheld peers are the source of the `inv` echo that proves a broadcast propagated through the network. # Safety - `config` must be a valid pointer to an FFIClientConfig created by dash_spv_ffi_config_new/mainnet/testnet - The caller must ensure the config pointer remains valid for the duration of this call
316+
317+
**Safety:**
318+
- `config` must be a valid pointer to an FFIClientConfig created by dash_spv_ffi_config_new/mainnet/testnet - The caller must ensure the config pointer remains valid for the duration of this call
319+
320+
**Module:** `config`
321+
322+
---
323+
255324
#### `dash_spv_ffi_config_set_data_dir`
256325
257326
```c
@@ -458,6 +527,22 @@ Broadcasts a transaction to the Dash network via connected peers. # Safety - `
458527

459528
---
460529

530+
#### `dash_spv_ffi_client_broadcast_transaction_and_wait`
531+
532+
```c
533+
dash_spv_ffi_client_broadcast_transaction_and_wait(client: *mut FFIDashSpvClient, tx_bytes: *const u8, length: usize, timeout_secs: u32, out_result: *mut FFIBroadcastResult,) -> i32
534+
```
535+
536+
**Description:**
537+
Broadcasts a transaction and waits for its network-level outcome. Blocks until the network accepts the transaction (non-recipient peers announce it back, it is InstantSend-locked, or confirmed) or the timeout elapses (outcome `Uncertain`). `timeout_secs == 0` uses the configured acceptance timeout plus a small grace period. Requires mempool tracking to be enabled in the client config. # Safety - `client` must be a valid, non-null pointer to an initialized FFIDashSpvClient - `tx_bytes` must be a valid, non-null pointer to the transaction data - `length` must be the length of the transaction data in bytes - `out_result` must be a valid, non-null pointer to an FFIBroadcastResult
538+
539+
**Safety:**
540+
- `client` must be a valid, non-null pointer to an initialized FFIDashSpvClient - `tx_bytes` must be a valid, non-null pointer to the transaction data - `length` must be the length of the transaction data in bytes - `out_result` must be a valid, non-null pointer to an FFIBroadcastResult
541+
542+
**Module:** `client`
543+
544+
---
545+
461546
### Mempool Operations - Detailed
462547
463548
#### `dash_spv_ffi_mempool_progress_destroy`

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

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -133,6 +133,27 @@ extern "C" fn on_sync_complete(header_tip: u32, cycle: u32, _user_data: *mut c_v
133133
println!("[Sync] Sync complete at height: {} (cycle {})", header_tip, cycle);
134134
}
135135

136+
extern "C" fn on_transaction_broadcast_result(
137+
txid: *const [u8; 32],
138+
status: FFIBroadcastStatus,
139+
relayed_by: u32,
140+
_user_data: *mut c_void,
141+
) {
142+
let txid_hex = unsafe { &*txid }.iter().rev().fold(String::new(), |mut acc, b| {
143+
use std::fmt::Write;
144+
let _ = write!(acc, "{:02x}", b);
145+
acc
146+
});
147+
match status {
148+
FFIBroadcastStatus::Accepted => {
149+
println!("[Broadcast] {} accepted ({} peer(s) relayed it back)", txid_hex, relayed_by)
150+
}
151+
FFIBroadcastStatus::Uncertain => {
152+
println!("[Broadcast] {} outcome uncertain (no network signal)", txid_hex)
153+
}
154+
}
155+
}
156+
136157
// ============================================================================
137158
// Network Event Callbacks
138159
// ============================================================================
@@ -511,6 +532,7 @@ fn main() {
511532
on_instantlock_received: Some(on_instantlock_received),
512533
on_manager_error: Some(on_manager_error),
513534
on_sync_complete: Some(on_sync_complete),
535+
on_transaction_broadcast_result: Some(on_transaction_broadcast_result),
514536
user_data: ptr::null_mut(),
515537
},
516538
network: FFINetworkEventCallbacks {

dash-spv-ffi/src/callbacks.rs

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -226,6 +226,35 @@ pub type OnManagerErrorCallback =
226226
pub type OnSyncCompleteCallback =
227227
Option<extern "C" fn(header_tip: u32, cycle: u32, user_data: *mut c_void)>;
228228

229+
/// Network-level outcome of a transaction broadcast.
230+
///
231+
/// There is no rejected state: modern Dash Core removed the BIP61 `reject`
232+
/// message, so the p2p network provides no negative signal — a transaction
233+
/// the network refuses surfaces as `Uncertain` (no echo within the timeout).
234+
#[repr(C)]
235+
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
236+
pub enum FFIBroadcastStatus {
237+
/// Non-recipient peers announced the txid back (or it was
238+
/// InstantSend-locked/confirmed) — the network accepted it.
239+
Accepted = 0,
240+
/// No acceptance signal arrived within the acceptance timeout.
241+
Uncertain = 1,
242+
}
243+
244+
/// Callback for SyncEvent::TransactionBroadcastResult
245+
///
246+
/// The `txid` pointer is borrowed and only valid for the duration of the
247+
/// callback. `relayed_by` is the number of distinct non-recipient peers that
248+
/// announced the txid back (meaningful for `Accepted`).
249+
pub type OnTransactionBroadcastResultCallback = Option<
250+
extern "C" fn(
251+
txid: *const [u8; 32],
252+
status: FFIBroadcastStatus,
253+
relayed_by: u32,
254+
user_data: *mut c_void,
255+
),
256+
>;
257+
229258
/// Sync event callbacks - one callback per SyncEvent variant.
230259
///
231260
/// Set only the callbacks you're interested in; unset callbacks will be ignored.
@@ -250,6 +279,7 @@ pub struct FFISyncEventCallbacks {
250279
pub on_instantlock_received: OnInstantLockReceivedCallback,
251280
pub on_manager_error: OnManagerErrorCallback,
252281
pub on_sync_complete: OnSyncCompleteCallback,
282+
pub on_transaction_broadcast_result: OnTransactionBroadcastResultCallback,
253283
pub user_data: *mut c_void,
254284
}
255285

@@ -282,6 +312,7 @@ impl Default for FFISyncEventCallbacks {
282312
on_instantlock_received: None,
283313
on_manager_error: None,
284314
on_sync_complete: None,
315+
on_transaction_broadcast_result: None,
285316
user_data: std::ptr::null_mut(),
286317
}
287318
}
@@ -438,6 +469,22 @@ impl FFISyncEventCallbacks {
438469
cb(*header_tip, *cycle, self.user_data);
439470
}
440471
}
472+
SyncEvent::TransactionBroadcastResult {
473+
txid,
474+
result,
475+
} => {
476+
if let Some(cb) = self.on_transaction_broadcast_result {
477+
use dash_spv::BroadcastResult;
478+
let txid_bytes = txid.as_byte_array();
479+
let (status, relayed_by) = match result {
480+
BroadcastResult::Accepted {
481+
relayed_by,
482+
} => (FFIBroadcastStatus::Accepted, *relayed_by as u32),
483+
BroadcastResult::Uncertain => (FFIBroadcastStatus::Uncertain, 0),
484+
};
485+
cb(txid_bytes as *const [u8; 32], status, relayed_by, self.user_data);
486+
}
487+
}
441488
}
442489
}
443490
}
@@ -1370,4 +1417,47 @@ mod tests {
13701417
});
13711418
assert_eq!(FIRED.load(Ordering::SeqCst), 0);
13721419
}
1420+
1421+
/// `TransactionBroadcastResult` dispatch must marshal the outcome fields
1422+
/// (status, relayed_by) for each variant.
1423+
#[test]
1424+
fn test_transaction_broadcast_result_dispatch() {
1425+
use dash_spv::BroadcastResult;
1426+
1427+
static STATUS: AtomicU32 = AtomicU32::new(u32::MAX);
1428+
static RELAYED: AtomicU32 = AtomicU32::new(u32::MAX);
1429+
1430+
extern "C" fn cb(
1431+
txid: *const [u8; 32],
1432+
status: FFIBroadcastStatus,
1433+
relayed_by: u32,
1434+
_user: *mut c_void,
1435+
) {
1436+
assert!(!txid.is_null());
1437+
STATUS.store(status as u32, Ordering::SeqCst);
1438+
RELAYED.store(relayed_by, Ordering::SeqCst);
1439+
}
1440+
1441+
let callbacks = FFISyncEventCallbacks {
1442+
on_transaction_broadcast_result: Some(cb),
1443+
..FFISyncEventCallbacks::default()
1444+
};
1445+
let txid = Txid::from_byte_array([7u8; 32]);
1446+
1447+
callbacks.dispatch(&SyncEvent::TransactionBroadcastResult {
1448+
txid,
1449+
result: BroadcastResult::Accepted {
1450+
relayed_by: 3,
1451+
},
1452+
});
1453+
assert_eq!(STATUS.load(Ordering::SeqCst), FFIBroadcastStatus::Accepted as u32);
1454+
assert_eq!(RELAYED.load(Ordering::SeqCst), 3);
1455+
1456+
callbacks.dispatch(&SyncEvent::TransactionBroadcastResult {
1457+
txid,
1458+
result: BroadcastResult::Uncertain,
1459+
});
1460+
assert_eq!(STATUS.load(Ordering::SeqCst), FFIBroadcastStatus::Uncertain as u32);
1461+
assert_eq!(RELAYED.load(Ordering::SeqCst), 0);
1462+
}
13731463
}

dash-spv-ffi/src/client.rs

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -348,6 +348,89 @@ pub unsafe extern "C" fn dash_spv_ffi_client_broadcast_transaction(
348348
}
349349
}
350350

351+
/// Network-level outcome of a broadcast, as returned by
352+
/// `dash_spv_ffi_client_broadcast_transaction_and_wait`.
353+
///
354+
/// Plain value type — nothing to free.
355+
#[repr(C)]
356+
pub struct FFIBroadcastResult {
357+
/// The determined outcome.
358+
pub status: crate::FFIBroadcastStatus,
359+
/// Distinct non-recipient peers that announced the txid back
360+
/// (meaningful for `Accepted`).
361+
pub relayed_by: u32,
362+
}
363+
364+
/// Broadcasts a transaction and waits for its network-level outcome.
365+
///
366+
/// Blocks until the network accepts the transaction (non-recipient peers
367+
/// announce it back, it is InstantSend-locked, or confirmed) or the timeout
368+
/// elapses (outcome `Uncertain`). `timeout_secs == 0` uses the configured
369+
/// acceptance timeout plus a small grace period.
370+
///
371+
/// Requires mempool tracking to be enabled in the client config.
372+
///
373+
/// # Safety
374+
///
375+
/// - `client` must be a valid, non-null pointer to an initialized FFIDashSpvClient
376+
/// - `tx_bytes` must be a valid, non-null pointer to the transaction data
377+
/// - `length` must be the length of the transaction data in bytes
378+
/// - `out_result` must be a valid, non-null pointer to an FFIBroadcastResult
379+
#[no_mangle]
380+
pub unsafe extern "C" fn dash_spv_ffi_client_broadcast_transaction_and_wait(
381+
client: *mut FFIDashSpvClient,
382+
tx_bytes: *const u8,
383+
length: usize,
384+
timeout_secs: u32,
385+
out_result: *mut FFIBroadcastResult,
386+
) -> i32 {
387+
null_check!(client);
388+
null_check!(tx_bytes);
389+
null_check!(out_result);
390+
391+
let tx_bytes = std::slice::from_raw_parts(tx_bytes, length);
392+
393+
let tx = match dashcore::consensus::deserialize::<dashcore::Transaction>(tx_bytes) {
394+
Ok(t) => t,
395+
Err(e) => {
396+
set_last_error(&format!("Invalid transaction: {}", e));
397+
return FFIErrorCode::InvalidArgument as i32;
398+
}
399+
};
400+
401+
let client = &(*client);
402+
let spv_client = client.inner.clone();
403+
let timeout = (timeout_secs > 0).then(|| std::time::Duration::from_secs(timeout_secs as u64));
404+
405+
let result = client
406+
.runtime
407+
.block_on(async { spv_client.broadcast_transaction_and_wait(&tx, timeout).await });
408+
409+
match result {
410+
Ok(outcome) => {
411+
use dash_spv::BroadcastResult;
412+
let ffi = match outcome {
413+
BroadcastResult::Accepted {
414+
relayed_by,
415+
} => FFIBroadcastResult {
416+
status: crate::FFIBroadcastStatus::Accepted,
417+
relayed_by: relayed_by as u32,
418+
},
419+
BroadcastResult::Uncertain => FFIBroadcastResult {
420+
status: crate::FFIBroadcastStatus::Uncertain,
421+
relayed_by: 0,
422+
},
423+
};
424+
std::ptr::write(out_result, ffi);
425+
FFIErrorCode::Success as i32
426+
}
427+
Err(e) => {
428+
set_last_error(&format!("Failed to broadcast transaction: {}", e));
429+
FFIErrorCode::from(e) as i32
430+
}
431+
}
432+
}
433+
351434
/// Destroy the client and free associated resources.
352435
///
353436
/// # Safety

0 commit comments

Comments
 (0)