Skip to content

feat(dash-spv): track network acceptance of broadcast transactions#913

Merged
QuantumExplorer merged 4 commits into
devfrom
claude/dash-transaction-acceptance-f6d425
Jul 22, 2026
Merged

feat(dash-spv): track network acceptance of broadcast transactions#913
QuantumExplorer merged 4 commits into
devfrom
claude/dash-transaction-acceptance-f6d425

Conversation

@QuantumExplorer

@QuantumExplorer QuantumExplorer commented Jul 22, 2026

Copy link
Copy Markdown
Member

Problem

broadcast_transaction sent the transaction to all connected peers and immediately reported it as detected via the local re-inject — no network signal was ever consulted. inv echoes of our own txid were discarded by the dedup gate in handle_inv, p2p reject messages had no subscriber, and there was no accepted/rejected/uncertain state. Until an InstantSend lock or block confirmation arrived, the client genuinely did not know whether the network accepted a broadcast.

Approach

Implements the classic SPV acceptance heuristic. Key protocol fact: a peer never re-announces a transaction to the peer it received it from, so broadcasting to everyone means no echo can ever arrive. Instead the mempool manager now:

  • sends the tx to half the connected peers (configurable via BroadcastHoldout::{Half, Count(n)}), withholding it from the rest;
  • treats an inv for the txid from a non-recipient peer as proof the transaction relayed through the network → Accepted { relayed_by };
  • also promotes to Accepted on InstantSend lock, block confirmation, or an "already-have" reject;
  • handles p2p reject as a best-effort negative signal → Rejected { code, reason }, distinguishing txn-mempool-conflict (a real rejection, e.g. double-spend) from txn-already-in-mempool (acceptance) under the shared Duplicate code;
  • reports Uncertain after a configurable timeout (default 60s), upgradeable by a late echo.

State transitions (Pending → Accepted | Rejected | Uncertain, Uncertain → Accepted) are the single duplicate-event guard; each transition emits exactly one SyncEvent::TransactionBroadcastResult.

Rebroadcast keeps respecting the holdout while pending, re-picks holdouts from never-sent peers when they disconnect, never resends rejected transactions, and sends deferred (zero-peer) broadcasts as soon as a peer connects. Broadcast state survives disconnects.

API

  • SyncEvent::TransactionBroadcastResult { txid, result: BroadcastResult }
  • DashSpvClient::broadcast_transaction_and_wait(&tx, timeout) -> Result<BroadcastResult> (subscribes before sending so the outcome cannot be missed)
  • ClientConfig: broadcast_holdout, broadcast_acceptance_threshold, broadcast_acceptance_timeout (+ builders, validation)
  • FFI: on_transaction_broadcast_result callback on FFISyncEventCallbacks, dash_spv_ffi_client_broadcast_transaction_and_wait returning FFIBroadcastResult, and four config setters
  • With enable_mempool_tracking = false the legacy broadcast-to-all behavior is retained (documented as untracked)

Testing

  • 16 new unit tests: holdout split (4→2, single-peer→no holdout), echo acceptance + dedup, echo-from-recipient ignored, echo detected while mempool full, both Duplicate reject cases, non-tx rejects ignored, timeout→Uncertain→late-echo upgrade, sticky/re-picked holdouts, threshold=2, zero-peer deferral, clear_pending survival, broadcast pruning; FFI dispatch test for all three outcome variants
  • New two-node dashd integration test (test_broadcast_transaction_network_acceptance): SPV connects to two connected dashd nodes, sends to exactly one, and the withheld node's echo resolves Accepted(relayed_by=1) (~119ms on regtest). The double-spend leg resolves Uncertain, documenting that dashd 23.1.0 sends no BIP61 rejects — which is why the echo, not the reject, is the primary signal.
  • Full suites green: 497 dash-spv lib tests, all 30 dashd_sync integration tests, all 12 dash-spv-ffi targets incl. FFI dashd tests; clippy -D warnings, fmt clean.

Related: dashpay/platform#4181 solves the same problem for the platform wallet by treating Core/DAPI as authoritative; this PR is the complementary pure-SPV path for when no trusted Core is available.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features
    • Added broadcast-transaction API that waits for a network-level result (accepted/uncertain) and reports relay/echo details.
    • Added broadcast policy controls: holdout (half/count), acceptance threshold, and acceptance timeout.
    • Added sync callback support to notify broadcast outcomes.
  • Bug Fixes
    • Added stricter validation for broadcast acceptance threshold/timeout to reject zero values.
  • Tests
    • Added network acceptance/timeout coverage and updated callback/config dispatch tests to include the new broadcast result flow.

Fixes #664
Fixes #734

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>
@coderabbitai

coderabbitai Bot commented Jul 22, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 6ee67d2f-b721-40f6-a8af-b133893b5198

📥 Commits

Reviewing files that changed from the base of the PR and between 19690d3 and 05f9c9c.

📒 Files selected for processing (20)
  • dash-spv-ffi/FFI_API.md
  • dash-spv-ffi/src/bin/ffi_cli.rs
  • dash-spv-ffi/src/callbacks.rs
  • dash-spv-ffi/src/client.rs
  • dash-spv-ffi/src/config.rs
  • dash-spv-ffi/tests/dashd_sync/callbacks.rs
  • dash-spv-ffi/tests/test_config.rs
  • dash-spv-ffi/tests/unit/test_async_operations.rs
  • dash-spv/src/client/config.rs
  • dash-spv/src/client/lifecycle.rs
  • dash-spv/src/client/transactions.rs
  • dash-spv/src/lib.rs
  • dash-spv/src/network/mod.rs
  • dash-spv/src/sync/events.rs
  • dash-spv/src/sync/mempool/broadcast.rs
  • dash-spv/src/sync/mempool/manager.rs
  • dash-spv/src/sync/mempool/mod.rs
  • dash-spv/src/sync/mempool/sync_manager.rs
  • dash-spv/src/sync/mod.rs
  • dash-spv/tests/dashd_sync/tests_mempool.rs

📝 Walkthrough

Walkthrough

The PR adds configurable transaction broadcast acceptance tracking with holdout and timeout policies, accepted and uncertain outcomes, sync events, client waiting APIs, integration coverage, and FFI configuration, result, and callback support.

Changes

Transaction broadcast acceptance

Layer / File(s) Summary
Broadcast contracts and configuration
dash-spv/src/sync/mempool/broadcast.rs, dash-spv/src/client/config.rs, dash-spv/src/sync/events.rs, dash-spv/src/lib.rs, dash-spv/src/sync/mod.rs
Adds broadcast policies, result types, lifecycle state, configuration validation/builders, public re-exports, and TransactionBroadcastResult events.
Mempool broadcast state machine
dash-spv/src/sync/mempool/manager.rs, dash-spv/src/sync/mempool/sync_manager.rs, dash-spv/src/network/mod.rs
Tracks local broadcasts, processes echoes, emits outcomes, promotes confirmed transactions, prunes state, and performs status-specific rebroadcasts.
Client broadcast-and-wait API
dash-spv/src/client/transactions.rs, dash-spv/src/client/lifecycle.rs, dash-spv/tests/dashd_sync/tests_mempool.rs
Routes tracked broadcasts through local dispatch, waits for matching outcomes, handles uncertain timeouts, and verifies accepted and double-spend outcomes in an integration test.
FFI broadcast surface
dash-spv-ffi/src/callbacks.rs, dash-spv-ffi/src/client.rs, dash-spv-ffi/src/config.rs, dash-spv-ffi/src/bin/ffi_cli.rs, dash-spv-ffi/tests/*, dash-spv-ffi/FFI_API.md
Exposes broadcast configuration, result-returning broadcast calls, callback dispatch, CLI logging, tests, and API documentation.

Estimated code review effort: 4 (Complex) | ~60 minutes

Sequence Diagram(s)

sequenceDiagram
  participant Client
  participant MempoolManager
  participant Peers
  participant SyncEvents
  Client->>MempoolManager: broadcast_transaction_and_wait
  MempoolManager->>Peers: send transaction using holdout policy
  Peers-->>MempoolManager: inventory echo
  MempoolManager->>SyncEvents: emit TransactionBroadcastResult
  SyncEvents-->>Client: return Accepted or Uncertain
Loading

Suggested reviewers: xdustinface, zocolini

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the main change: tracking network acceptance for broadcast transactions.
Linked Issues check ✅ Passed The PR implements subset broadcasting plus multi-peer corroboration, threshold, timeout, and acceptance events required by #664 and #734.
Out of Scope Changes check ✅ Passed The changes stay focused on broadcast acceptance tracking, FFI exposure, tests, and docs, with no clearly unrelated scope introduced.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch claude/dash-transaction-acceptance-f6d425

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@dash-spv-ffi/src/config.rs`:
- Around line 364-406: Add unit coverage for
dash_spv_ffi_config_set_broadcast_acceptance_threshold and
dash_spv_ffi_config_set_broadcast_acceptance_timeout_secs: verify zero values
return InvalidArgument and set the expected error, while nonzero values return
Success and map to the corresponding ClientConfig fields. Use the existing FFI
config test helpers and assertion patterns.

In `@dash-spv/src/client/transactions.rs`:
- Around line 85-99: Update the transaction broadcast result handling around the
SyncEvent::TransactionBroadcastResult match so an Uncertain result does not
return immediately; continue waiting for a later Accepted or Rejected result.
Return Uncertain only when the caller’s deadline expires, while preserving the
existing handling for unrelated events, lagged streams, and closed channels.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 89bed8d6-5666-4fd7-a9db-f79937a716c2

📥 Commits

Reviewing files that changed from the base of the PR and between 19690d3 and 58f207e.

📒 Files selected for processing (18)
  • dash-spv-ffi/src/bin/ffi_cli.rs
  • dash-spv-ffi/src/callbacks.rs
  • dash-spv-ffi/src/client.rs
  • dash-spv-ffi/src/config.rs
  • dash-spv-ffi/tests/dashd_sync/callbacks.rs
  • dash-spv-ffi/tests/unit/test_async_operations.rs
  • dash-spv/src/client/config.rs
  • dash-spv/src/client/lifecycle.rs
  • dash-spv/src/client/transactions.rs
  • dash-spv/src/lib.rs
  • dash-spv/src/network/mod.rs
  • dash-spv/src/sync/events.rs
  • dash-spv/src/sync/mempool/broadcast.rs
  • dash-spv/src/sync/mempool/manager.rs
  • dash-spv/src/sync/mempool/mod.rs
  • dash-spv/src/sync/mempool/sync_manager.rs
  • dash-spv/src/sync/mod.rs
  • dash-spv/tests/dashd_sync/tests_mempool.rs

Comment thread dash-spv-ffi/src/config.rs
Comment thread dash-spv/src/client/transactions.rs
@xdustinface

xdustinface commented Jul 22, 2026

Copy link
Copy Markdown
Collaborator

Fixes #664 / #734. (to link them)

QuantumExplorer and others added 2 commits July 22, 2026 18:40
…nctions

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
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>

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Actionable comments posted: 1

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
dash-spv/src/sync/mempool/sync_manager.rs (1)

105-113: 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Do not rebroadcast broadcasts that are already Accepted.

This now invokes rebroadcast bookkeeping before the sync-state guard, while dash-spv/src/sync/mempool/manager.rs:625-682 sends transactions for every non-Pending status. Consequently, terminal Accepted broadcasts are repeatedly resent instead of being skipped, violating the stated “avoid resending transactions that have reached a terminal state” behavior. Restrict rebroadcasting to Pending and Uncertain, or explicitly skip BroadcastStatus::Accepted in the helper.

Proposed fix
-            } else {
+            } else if state.status == BroadcastStatus::Uncertain {
                 let _ = requests.broadcast(NetworkMessage::Tx(state.transaction.clone()));
             }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@dash-spv/src/sync/mempool/sync_manager.rs` around lines 105 - 113, The
rebroadcast flow in tick must not resend broadcasts with terminal Accepted
status. Update rebroadcast_if_due and its related status handling to process
only Pending and Uncertain broadcasts, or explicitly skip
BroadcastStatus::Accepted, while preserving expiry bookkeeping and existing
sync-state behavior.
dash-spv-ffi/src/callbacks.rs (1)

230-257: 🩺 Stability & Availability | 🟠 Major | 🏗️ Heavy lift

Version the broadcast FFI ABI OnTransactionBroadcastResultCallback, FFISyncEventCallbacks, and FFIBroadcastResult changed incompatibly. Regenerated headers don’t protect already-built binaries, so add versioned *_v2 symbols/types or make this a documented major ABI bump.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@dash-spv-ffi/src/callbacks.rs` around lines 230 - 257, The broadcast FFI
changes require explicit ABI versioning: add compatible *_v2 symbols/types for
OnTransactionBroadcastResultCallback, FFISyncEventCallbacks, and
FFIBroadcastResult, preserving the existing ABI for already-built clients.
Update dash-spv-ffi/src/callbacks.rs lines 230-257 and 282, plus
dash-spv-ffi/src/client.rs lines 351-362, so the new callback and result paths
use the versioned definitions; alternatively, document and enforce this as a
major ABI bump if versioned symbols are not added.

Source: Coding guidelines

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@dash-spv-ffi/FFI_API.md`:
- Around line 276-283: Update the description for
dash_spv_ffi_config_set_broadcast_acceptance_timeout_secs to remove references
to rejection signals and state that broadcasts without an acceptance signal are
reported as Uncertain. Keep the timeout and safety requirements unchanged.

---

Outside diff comments:
In `@dash-spv-ffi/src/callbacks.rs`:
- Around line 230-257: The broadcast FFI changes require explicit ABI
versioning: add compatible *_v2 symbols/types for
OnTransactionBroadcastResultCallback, FFISyncEventCallbacks, and
FFIBroadcastResult, preserving the existing ABI for already-built clients.
Update dash-spv-ffi/src/callbacks.rs lines 230-257 and 282, plus
dash-spv-ffi/src/client.rs lines 351-362, so the new callback and result paths
use the versioned definitions; alternatively, document and enforce this as a
major ABI bump if versioned symbols are not added.

In `@dash-spv/src/sync/mempool/sync_manager.rs`:
- Around line 105-113: The rebroadcast flow in tick must not resend broadcasts
with terminal Accepted status. Update rebroadcast_if_due and its related status
handling to process only Pending and Uncertain broadcasts, or explicitly skip
BroadcastStatus::Accepted, while preserving expiry bookkeeping and existing
sync-state behavior.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 4b56568a-101f-4745-bb61-58128ac67330

📥 Commits

Reviewing files that changed from the base of the PR and between 58f207e and c4625d1.

📒 Files selected for processing (10)
  • dash-spv-ffi/FFI_API.md
  • dash-spv-ffi/src/bin/ffi_cli.rs
  • dash-spv-ffi/src/callbacks.rs
  • dash-spv-ffi/src/client.rs
  • dash-spv/src/client/transactions.rs
  • dash-spv/src/sync/events.rs
  • dash-spv/src/sync/mempool/broadcast.rs
  • dash-spv/src/sync/mempool/manager.rs
  • dash-spv/src/sync/mempool/sync_manager.rs
  • dash-spv/tests/dashd_sync/tests_mempool.rs
💤 Files with no reviewable changes (1)
  • dash-spv-ffi/src/bin/ffi_cli.rs
🚧 Files skipped from review as they are similar to previous changes (4)
  • dash-spv/src/sync/events.rs
  • dash-spv/tests/dashd_sync/tests_mempool.rs
  • dash-spv/src/client/transactions.rs
  • dash-spv/src/sync/mempool/manager.rs

Comment thread dash-spv-ffi/FFI_API.md Outdated
- 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>
@QuantumExplorer

Copy link
Copy Markdown
Member Author

@coderabbitai full review

@coderabbitai

coderabbitai Bot commented Jul 22, 2026

Copy link
Copy Markdown
Contributor
✅ Action performed

Full review finished.

@github-actions github-actions Bot added the ready-for-review CodeRabbit has approved this PR label Jul 22, 2026
@QuantumExplorer

Copy link
Copy Markdown
Member Author

Tested in iOS simulator.

@QuantumExplorer
QuantumExplorer merged commit 70d4bf8 into dev Jul 22, 2026
37 checks passed
@QuantumExplorer
QuantumExplorer deleted the claude/dash-transaction-acceptance-f6d425 branch July 22, 2026 15:11
QuantumExplorer added a commit to dashpay/platform that referenced this pull request Jul 22, 2026
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
bfoss765 pushed a commit to bfoss765/platform that referenced this pull request Jul 23, 2026
…allback

Network acceptance, not a successful peer socket write, now decides what a
broadcast reports. Two authorities establish it:

- DAPI/Core (primary): single-attempt submission through DAPI's Core
  sendrawtransaction bridge, classified accepted / already-known / rejected /
  uncertain from the gRPC code, with uncertain submissions reconciled via
  getTransaction.
- SPV peer echo (trustless fallback): when DAPI is ambiguous or unreachable,
  the wallet falls back to dash-spv's broadcast acceptance detection
  (dashpay/rust-dashcore#913) — the transaction is sent to a subset of SPV
  peers and a withheld peer announcing the txid back proves it propagated
  into network mempools. A p2p reject resolves Rejected; no signal within
  30s resolves MaybeSent.

Rejected releases the transaction's UTXO reservation for immediate retry;
MaybeSent keeps it (double-spend guard). Accepted transactions are relayed
through SPV best-effort so the local mempool pipeline sees them immediately;
a relay failure never downgrades an authoritative accept.

The outcome surfaces through FFI as a dedicated rejection code (26) with the
txid preserved on all network outcomes, and in Swift as
CoreTransactionBroadcastOutcome (accepted/rejected/unknown) via
broadcastTransactionWithOutcome, so the example app can no longer report
payment success without network acceptance.

Bumps rust-dashcore to 58f207e5 (dashpay/rust-dashcore#913).

Supersedes dashpay#4181; carries over its DAPI classification, getTransaction
reconciliation, FFI/Swift outcome plumbing, and reservation reconciliation.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ready-for-review CodeRabbit has approved this PR

Projects

None yet

Development

Successfully merging this pull request may close these issues.

dash-spv: require multi-peer corroboration before accepting mempool transactions feat: improve transaction broadcasting to get network a confirmation

2 participants