Skip to content

fix(wallet): trustless SPV broadcast acceptance with authoritative outcomes#4198

Merged
QuantumExplorer merged 6 commits into
v4.1-devfrom
feat/spv-broadcast-acceptance
Jul 22, 2026
Merged

fix(wallet): trustless SPV broadcast acceptance with authoritative outcomes#4198
QuantumExplorer merged 6 commits into
v4.1-devfrom
feat/spv-broadcast-acceptance

Conversation

@QuantumExplorer

@QuantumExplorer QuantumExplorer commented Jul 22, 2026

Copy link
Copy Markdown
Member

Note

Supersedes #4181. Built on dashpay/rust-dashcore#913 (merged); rust-dashcore is pinned to its merge commit 70d4bf8e.

Problem

The wallet reported a broadcast as successful once bytes were written to an SPV peer socket. No network signal was consulted, so an invalid or dropped transaction still surfaced as "Payment sent", and UTXO reservation handling had only a coarse rejected/maybe-sent split derived from transport errors.

Approach

Network acceptance, not a socket write, decides the outcome. The authority depends on how the wallet runs:

  • SPV wallets (production): pure P2P — built on feat(dash-spv): track network acceptance of broadcast transactions rust-dashcore#913. The wallet broadcasts exclusively through dash-spv: the transaction is sent to a subset of SPV peers while withheld from the rest, and a withheld peer announcing the txid back via inv, an InstantSend lock, or a confirmation proves the network accepted it → Accepted. Trustless — no DAPI involvement in the L1 send path, and no separate relay step (dash-spv injects the transaction into its local mempool pipeline as part of the broadcast). No signal within 30s → MaybeSent (dash-spv keeps rebroadcasting; a later echo/IS-lock/confirmation or the reservation-TTL backstop reconciles). Failures that provably precede any send (client not started, zero connected peers — pinned against dash-spv semantics by boundary tests) → definitive Rejected.
  • Wallets without an SPV runtime: DAPI/Core — unchanged from v4.1-dev: submission via DAPI's gRPC endpoint with every failure conservatively classified MaybeSent. (This path has no production call sites today, so this PR deliberately leaves it untouched.)

Outcome semantics through the stack:

  • Rejected releases the transaction's UTXO reservation for immediate retry; MaybeSent keeps it (double-spend guard) — unchanged reconcile point in wallet/reservations.rs. Failures that provably never left the process (SPV client down, zero connected peers) release rather than lock the reservation.
  • FFI: dedicated rejection code 26 (ErrorTransactionBroadcastRejected); the txid out-param is populated on all network outcomes (accepted, rejected, unknown), null only on operational errors.
  • Swift: CoreTransactionBroadcastOutcome (accepted/rejected/unknown) returned by broadcastTransactionWithOutcome; the old throwing broadcastTransaction remains as a deprecated wrapper. SendViewModel applies the outcome as a pure state transition — success is only shown on acceptance, and unknown outcomes warn against retrying.

Testing

  • cargo test -p platform-wallet: 493 passed, 0 failed — the SPV broadcaster maps every dash-spv verdict (accepted / uncertain / never-sent / transport error) with the reservation released only on never-sent; boundary pins catch dash-spv semantic drift on the never-sent classification.
  • cargo test -p platform-wallet-ffi: 236 passed, 0 failed — outcome classification carries the txid on all network outcomes; code-26 mapping round-trips.
  • Swift-side view-model and error-mapping tests carried over from fix(wallet): report authoritative L1 broadcast outcomes #4181; the full SwiftExampleApp suite (156 tests) passes on an iOS 18.6 simulator against the real FFI binary.
  • Upstream, rust-dashcore#913's two-node dashd integration test exercises the echo mechanism end-to-end against dashd 23.1.0 (accepted in ~119ms via withheld-peer echo; double-spends resolve uncertain since dashd 23 sends no BIP61 rejects).

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • Added clear transaction broadcast outcomes: accepted, rejected, or unknown.
    • Added typed errors for transactions definitively rejected by Core.
    • Swift wallet APIs now expose broadcast outcomes and transaction IDs.
    • Send flow messaging now distinguishes successful, rejected, and uncertain broadcasts.
  • Bug Fixes

    • Improved handling of ambiguous broadcasts to avoid unsafe retries.
    • Corrected transaction ID reporting for rejected and uncertain outcomes.
    • Enhanced fallback behavior when Core or SPV connectivity is unavailable.
  • Tests

    • Expanded coverage for broadcast classification, error mapping, and user-facing send results.

…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 #4181; carries over its DAPI classification, getTransaction
reconciliation, FFI/Swift outcome plumbing, and reservation reconciliation.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Jul 22, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

📝 Walkthrough

Walkthrough

Broadcasting now distinguishes authoritative acceptance, definitive rejection, and uncertain delivery across DAPI, SPV, FFI, and Swift. FFI txid ownership and result codes are updated, Swift exposes typed outcomes, and the example app presents outcome-specific states.

Changes

Broadcast outcome pipeline

Layer / File(s) Summary
DAPI authority and SPV fallback
packages/rs-platform-wallet/src/broadcaster.rs
DAPI submissions are classified and reconciled through transaction lookup; ambiguous outcomes use SPV peer echo.
SPV error classification
packages/rs-platform-wallet/src/spv/runtime.rs
Relay and acceptance-check failures use separate Rejected and MaybeSent mappings.
Broadcaster integration
packages/rs-platform-wallet/src/manager/..., packages/rs-platform-wallet/src/wallet/..., packages/rs-platform-wallet/src/test_support.rs
Broadcaster construction passes the SDK dependency through production and test wiring, with related documentation updates.
FFI outcome contract
packages/rs-platform-wallet-ffi/src/core_wallet/broadcast.rs, packages/rs-platform-wallet-ffi/src/error.rs
Broadcast entrypoints conditionally return txids and expose a dedicated definitive-rejection result code.
Swift outcome API and presentation
packages/swift-sdk/Sources/..., packages/swift-sdk/SwiftExampleApp/..., packages/swift-sdk/SwiftTests/...
Swift maps FFI results to typed outcomes, preserves compatibility wrappers, and displays accepted, rejected, and unknown states.

Dash Core dependency revision

Layer / File(s) Summary
Workspace dependency pin
Cargo.toml
Dash Core-related workspace dependencies now use the shared 70d4bf... git revision.

Estimated code review effort: 5 (Critical) | ~120 minutes

Sequence Diagram(s)

sequenceDiagram
  participant Wallet
  participant DapiBroadcaster
  participant DashCore
  participant SpvRuntime
  Wallet->>DapiBroadcaster: submit transaction
  DapiBroadcaster->>DashCore: broadcast transaction
  DashCore-->>DapiBroadcaster: accepted, rejected, or uncertain
  DapiBroadcaster->>DashCore: reconcile uncertain transaction
  DapiBroadcaster-->>Wallet: authoritative outcome
  Wallet->>SpvRuntime: request peer-echo fallback
  SpvRuntime-->>Wallet: acceptance verdict
Loading

Possibly related PRs

Suggested reviewers: llbartekll, zocolini, bfoss765

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the main wallet broadcast change: authoritative network outcomes with trustless SPV fallback.
✨ 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 feat/spv-broadcast-acceptance

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.

rust-dashcore#913 dropped its dead BIP61 reject path (modern Dash Core
never sends reject messages), so dash-spv's BroadcastResult is now
Accepted/Uncertain only. The SPV acceptance fallback loses its Rejected
arm: a transaction the p2p network refuses produces no echo and resolves
MaybeSent via the timeout. DAPI/Core remains the only source of
definitive rejections.

Bumps rust-dashcore to c4625d18.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@llbartekll

llbartekll commented Jul 22, 2026

Copy link
Copy Markdown
Contributor

Small heads-up: while testing #4181, I found that DapiClientError::NoAvailableAddresses was classified as unknown, which kept the UTXO reservation and caused the next retry to fail with an artificial Insufficient funds. Worth checking whether the same can still happen here when DAPI has no addresses and the SPV fallback doesn’t produce an acceptance signal.

QuantumExplorer and others added 2 commits July 22, 2026 20:45
…t the process

Testing on the predecessor PR surfaced a stuck-reservation bug:
DapiClientError::NoAvailableAddresses was classified as unknown, keeping
the UTXO reservation, and the retry then failed with artificial
insufficient funds even though nothing had ever been submitted.

DAPI failures that provably precede any dispatch (empty address list,
address-list errors) now classify as never-sent instead of uncertain.
The SPV acceptance path gains a matching contract: an unstarted client
or dash-spv's zero-connected-peers check (which fires before any local
dispatch or deferred rebroadcast) surfaces as never-sent, pinned by
boundary tests. When BOTH channels provably never sent, the combined
outcome is a definitive rejection that releases the reservation for an
immediate retry. Any path where bytes may have left the process —
DAPI-uncertain, SPV echo timeout, banned-address retries — still keeps
the reservation (double-spend guard).

Never-sent DAPI submissions also skip the pointless getTransaction
reconciliation, and the plain DapiBroadcaster reports them as definitive
rejections directly.

Thanks to @llbartekll for the report.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@QuantumExplorer
QuantumExplorer marked this pull request as ready for review July 22, 2026 15:17
@thepastaclaw

thepastaclaw commented Jul 22, 2026

Copy link
Copy Markdown
Collaborator

🕓 Ready for review — 58 ahead in queue (commit c82fe1d)
Queue position: 59/72 · 2 reviews active
ETA: start ~09:36 UTC · complete ~10:05 UTC (median 28m across 30 recent reviews; 2 slots)
Queued 3h 46m ago · Last checked: 2026-07-22 19:40 UTC

…'s send

SPV wallets now broadcast exclusively over P2P through dash-spv's
acceptance detection (rust-dashcore#913): the transaction goes to a
subset of peers, and a withheld peer announcing it back, an InstantSend
lock, or a confirmation proves network acceptance. Trustless, no DAPI
round-trip, and no separate relay step (dash-spv injects the
transaction into its local mempool pipeline as part of the broadcast).

Outcome mapping is unchanged for callers: accepted resolves the send,
no signal within 30s stays MaybeSent (reservation kept; dash-spv keeps
rebroadcasting and a later echo/IS-lock/confirmation or the TTL
backstop reconciles), and provably-never-sent failures (client not
started, zero connected peers) release the reservation.

DapiBroadcaster remains for standalone wallets without an SPV runtime,
keeping its classification, getTransaction reconciliation, and
never-sent handling. SpvRuntime's now-unused relay path is removed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@QuantumExplorer QuantumExplorer changed the title fix(wallet): authoritative L1 broadcast outcomes with SPV peer-echo fallback fix(wallet): trustless SPV broadcast acceptance with authoritative outcomes Jul 22, 2026
DapiBroadcaster has no call sites — the manager always constructs
SpvBroadcaster — so the classification/reconciliation machinery built up
for it (DapiSubmission, gRPC code mapping, single-attempt settings,
DapiCoreClient seam, getTransaction reconciliation, never-sent handling,
and its test suite) serviced a dead path. Revert it to its pre-PR form
and revert the incidental comment/test churn in asset-lock recovery,
test_support, and the shielded bind tests.

The live path is untouched: SpvBroadcaster's pure-SPV acceptance
mapping, the SPV-side never-sent classification, and the FFI/Swift
outcome surface all remain.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@QuantumExplorer
QuantumExplorer merged commit 16a63c8 into v4.1-dev Jul 22, 2026
17 of 21 checks passed
@QuantumExplorer
QuantumExplorer deleted the feat/spv-broadcast-acceptance branch July 22, 2026 16:08
@codecov

codecov Bot commented Jul 22, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 87.51%. Comparing base (4559b6f) to head (c82fe1d).
⚠️ Report is 298 commits behind head on v4.1-dev.

Additional details and impacted files
@@            Coverage Diff             @@
##           v4.1-dev    #4198    +/-   ##
==========================================
  Coverage     87.50%   87.51%            
==========================================
  Files          2666     2666            
  Lines        336721   336866   +145     
==========================================
+ Hits         294659   294800   +141     
- Misses        42062    42066     +4     
Components Coverage Δ
dpp 88.47% <ø> (ø)
drive 86.24% <ø> (ø)
drive-abci 89.58% <ø> (+0.02%) ⬆️
sdk ∅ <ø> (∅)
dapi-client ∅ <ø> (∅)
platform-version ∅ <ø> (∅)
platform-value 92.90% <ø> (ø)
platform-wallet ∅ <ø> (∅)
drive-proof-verifier 49.79% <ø> (ø)
🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

thepastaclaw pushed a commit to thepastaclaw/platform that referenced this pull request Jul 23, 2026
The Swift SDK CI build compiles SwiftTests with warnings escalated to
errors, and the integration-test support wallet still called the
broadcastTransaction compatibility wrapper that dashpay#4198 deprecated, so any
PR triggering the Swift workflow now fails to build. Switch TestWallet
to broadcastTransactionWithOutcome and surface rejected/unknown outcomes
as the same errors the wrapper raised. The base branch never notices
because the Swift job is path-filtered and rs-only pushes skip it.

Also make the coverage-artifact cleanup retry loop break only when the
removal succeeded and target/llvm-cov-target is actually absent, so a
concurrently recreated directory is retried instead of silently kept.

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

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants