Skip to content

fix(platform-wallet): release UTXO reservation when broadcast fails#3985

Open
Claudius-Maginificent wants to merge 24 commits into
v4.1-devfrom
fix/core-wallet-release-reservation-on-broadcast-failure
Open

fix(platform-wallet): release UTXO reservation when broadcast fails#3985
Claudius-Maginificent wants to merge 24 commits into
v4.1-devfrom
fix/core-wallet-release-reservation-on-broadcast-failure

Conversation

@Claudius-Maginificent

@Claudius-Maginificent Claudius-Maginificent commented Jul 2, 2026

Copy link
Copy Markdown
Collaborator

Issue being fixed or feature implemented

TransactionBuilder::build_signed reserves the selected UTXOs in the funding account's ReservationSet and leaves the reservation held on success, expecting the broadcast to follow. When the broadcast failed, nothing released the reservation — the inputs stayed locked until the 24-block (~1h) TTL backstop reclaimed them. A user whose send failed and who immediately retried hit a spurious "insufficient funds".

This affected every build-then-broadcast path in the crate: CoreWallet::send_to_addresses, send_dashpay_payment, and create_funded_asset_lock_proof (identity funding).

Not a funds-loss bug (self-heals via TTL), but a real UX rough edge, reachable in practice via dash-evo-tool's WalletBackend::send_payment ("Send Dash" screen) and the SwiftExampleApp send flows.

Depends on the reservation system introduced by the rust-dashcore bump to afcff156 in #3976 (merged).

What was done?

  • Typed broadcast errors. TransactionBroadcaster::broadcast now returns BroadcastError with two variants: Rejected (the transaction provably never reached the network — safe to release its inputs and retry) and MaybeSent (outcome unknown — the reservation must be kept for the TTL backstop, since releasing and retrying could double-spend). The classification is made inside each broadcaster implementation, where the transport knowledge lives, rather than by matching catch-all error variants at the wallet layer:
    • DapiBroadcaster classifies every failure MaybeSent: its internal multi-node retries (RequestSettings::default()) mean the surfaced error is only the last attempt's — an earlier attempt may have delivered the transaction (e.g. a node accepts the tx but its gRPC response times out).
    • SpvRuntime classifies "client not started" and dash-spv's zero-peer NetworkError::NotConnected as Rejected — both fire before any bytes leave the process — and everything else MaybeSent (a later failure may follow a partial peer send).
    • MaybeSent surfaces to callers as the new PlatformWalletError::TransactionBroadcastUnconfirmed, whose message tells the user not to immediately re-spend.
  • One shared release seam. The release logic lives in wallet::reservations::broadcast_releasing_on_rejection, used by all three build-then-broadcast paths (send_to_addresses, send_dashpay_payment, create_funded_asset_lock_proof), so a rejected broadcast releases the reserved inputs immediately on every path instead of only core sends. The asset-lock recovery rebroadcast intentionally does not release — it re-drives a previously built transaction and is not paired with a fresh reserve. The release path is read-lock only (release_reservation takes &self), so it doesn't serialize concurrent sends, and the wallet-manager lock is never held across the broadcast await.
  • Account lookup uses key-wallet's existing bip44_managed_account_at_index / bip32_managed_account_at_index accessors instead of hand-rolled map dispatch; a missing wallet/account on the release path is tracing::warn!-logged (with wallet_id) without altering the returned error.

How Has This Been Tested?

  • Table-driven send_to_addresses_releases_reservation_on_broadcast_failure covers both BIP44Account and BIP32Account: funds a wallet, fails the first broadcast via a Rejected-returning mock broadcaster, asserts an immediate retry succeeds for each account type.
  • send_to_addresses_keeps_reservation_on_ambiguous_broadcast_failure (both account types) proves the conservative side: a MaybeSent broadcaster error keeps the reservation, and an immediate retry fails at UTXO selection rather than silently double-spending.
  • cargo fmt --all
  • cargo clippy -p platform-wallet --all-targets -- -D warnings — clean
  • cargo test -p platform-wallet — 204 passed, 0 failed
  • cargo check --all-features --tests for platform-wallet, plus platform-wallet-ffi, platform-wallet-storage, and rs-unified-sdk-ffi (all dependents of the changed trait)

Breaking Changes

None (not consensus-affecting). TransactionBroadcaster::broadcast's error type changed from PlatformWalletError to BroadcastError — all in-workspace implementations and callers are updated in this PR; a From<BroadcastError> for PlatformWalletError impl preserves the existing ? ergonomics.

Checklist:

  • I have performed a self-review of my own code
  • I have commented my code, particularly in hard-to-understand areas
  • I have added or updated relevant unit/integration/functional/e2e tests
  • I have added "!" to the title and described breaking changes in the corresponding section if my code contains any
  • I have made corresponding changes to the documentation if needed

For repository code-owners and collaborators only

  • I have assigned this pull request to a milestone

🤖 Generated with Claude Code

Summary by CodeRabbit

  • Bug Fixes
    • Improved transaction broadcast error handling: definitive rejections now release reserved inputs for safe retries.
    • Ambiguous outcomes now keep reservations and return a “broadcast unconfirmed” error to prevent unsafe auto-retries.
    • Updated payment and asset-lock broadcasting flows to use the new reservation-aware retry behavior.
  • New Features
    • Added a dedicated “broadcast unconfirmed” error code and typed error across platform interfaces (FFI/Swift).
  • Tests
    • Added retry-behavior coverage for both BIP44 and BIP32 account types (rejected-first vs maybe-sent).
  • Documentation
    • Clarified the distinction between safe retry and ambiguous “possibly sent” cases.

lklimek and others added 16 commits July 1, 2026 10:32
Bump all 8 rust-dashcore workspace dependencies (dashcore, dash-spv,
key-wallet, key-wallet-ffi, key-wallet-manager, dash-network,
dash-network-seeds, dashcore-rpc) from 5c0113e7 to 78d10022
(rust-dashcore `dev` head, 15 commits, 0.43.0 -> 0.45.0).

key-wallet's `Signer` trait gained a required `extended_public_key`
method. Implement it for MnemonicResolverCoreSigner (real BIP-32 xpub
derivation with private-scalar zeroization on every return path) and
add stubs to the two rs-dpp test signers. Resolver + derive boilerplate
extracted into a shared `resolve_and_derive` helper (DRY); the master
scalar is wiped on both the success and derive-error paths.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MoY6vhmqZuHzNsMfJ8wakQ

<sub>🤖 Co-authored by [Claudius the Magnificent](https://github.com/lklimek/claudius) AI Agent</sub>
…st stub

The rust-dashcore bump added `Signer::extended_public_key` as a required
trait method. The shielded `shield_from_asset_lock_transition` signing
test's `FixedKeySigner` stub was missed: it is feature-gated behind
`all(test, state-transition-signing, core_key_wallet, shielded-client)`
and is not compiled under default features, so the local workspace check
passed while CI (which enables those features) failed with E0046.

Add the stub matching the two sibling test signers (returns Err — the
fixed-key stub carries no chain code). Verified with
`cargo check -p dpp --tests --features
state-transition-signing,core_key_wallet,shielded-client`.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MoY6vhmqZuHzNsMfJ8wakQ

<sub>🤖 Co-authored by [Claudius the Magnificent](https://github.com/lklimek/claudius) AI Agent</sub>
Move the 8 rust-dashcore workspace deps (dashcore, dash-spv, key-wallet,
key-wallet-ffi, key-wallet-manager, dash-network, dash-network-seeds,
dashcore-rpc) from rev 78d10022 to f42498e0 — the head of rust-dashcore
PR #833, which adds `impl zeroize::Zeroize for ExtendedPrivKey` in
key-wallet/src/bip32.rs. Package versions stay at 0.45.0; Cargo.lock
change is rev-only.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…erase

Now that key-wallet's ExtendedPrivKey implements Zeroize (rust-dashcore
PR #833), hold the master and derived extended keys in Zeroizing<_> and
let RAII wipe them on every exit path — success, ?-early-return, and
panic-unwind. This removes the 4 manual `.private_key.non_secure_erase()`
calls in resolve_and_derive / derive_priv / extended_public_key that only
covered the paths they were hand-placed on.

resolve_and_derive now returns Zeroizing<ExtendedPrivKey>; its two callers
are updated. The two `secret.non_secure_erase()` calls in sign_ecdsa /
public_key stay: secp256k1::SecretKey has no Zeroize impl, so those raw
SecretKey copies still need an explicit wipe.

Module and per-fn zeroization docs rewritten to describe the current
Zeroizing approach.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…ia extract closure

resolve_and_derive now takes `extract: impl FnOnce(&ExtendedPrivKey) -> T`
and returns T, instead of handing back Zeroizing<ExtendedPrivKey>. The derived
key is wrapped in Zeroizing the instant derive_priv returns and is only ever
borrowed by the closure, so no raw or wrapped ExtendedPrivKey crosses the
function boundary — closing the Copy<ExtendedPrivKey> stack-residue gap flagged
in review. The one remaining by-value copy is derive_priv's own return slot,
which is upstream's (key-wallet) API to own.

derive_priv and extended_public_key become thin closure callers.

Also add a #[tokio::test] driving extended_public_key against an independently
derived ExtendedPubKey from the same BIP-39 vector / network / path, asserting
full-struct equality plus explicit chain_code / depth / network spot-checks so
metadata loss is caught, not just the public point (fixes codecov/patch gap).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…ing; doc irreducible xpriv transient

Reorder the extended_public_key test so each field-level assert (public_key,
chain_code, depth, network) runs before the full-struct assert_eq!, which
otherwise short-circuits and leaves the per-field metadata checks unreachable
on a regression (i.e. vacuous). Proven non-vacuous: temporarily corrupting
only `depth` in the impl fails the test at "depth must match", where a
pubkey-only check would have passed.

Also document the one irreducible transient in resolve_and_derive: key-wallet's
ExtendedPrivKey::derive_priv returns by value (Copy), so its return slot holds
an un-wiped copy until the same-line Zeroizing::new takes ownership — noted so
the zeroization contract doesn't over-promise "zero copies ever".

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…edPrivKey on Drop)

Advances the 8 rust-dashcore workspace deps from rev
f42498e0d04257e28b4e457c16629904a872ab61 to
a8c57fe863c96ac9c7e33833549e7a4f75ac9b5e (PR #833 head). The new
commit drops `Copy` from `ExtendedPrivKey` and gives it a `Drop` impl
that zeroizes its secret material on scope exit — so the key wipes
itself automatically instead of relying on caller-side wrappers, and
non-`Copy` moves leave no stray bitwise duplicate behind.

No workspace call sites relied on `ExtendedPrivKey: Copy`: the
Copy-removal impact is absorbed upstream in `bip32::derive_priv`
(clones `self` instead of `*self`), so `cargo check --workspace
--all-targets` is clean with no source changes.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…roizing wrap

`ExtendedPrivKey` now zeroizes on `Drop` (rust-dashcore rev
a8c57fe863c96ac9c7e33833549e7a4f75ac9b5e), so wrapping the master and
derived keys in `Zeroizing` inside `resolve_and_derive` only bought a
harmless double-wipe. Unwrap both and let the type's own `Drop` do the
work; `Zeroizing` stays on the plain byte buffers that have no `Drop`
of their own (mnemonic buffer, BIP-39 seed, final 32-byte scalar).

Rewrites the module `# Zeroization` block and the `resolve_and_derive`
contract to the present three-mechanism reality (self-wiping key +
Zeroizing buffers + non_secure_erase on SecretKey). The previously
documented "irreducible transient" caveat is fully closed: a non-`Copy`
move leaves no bitwise duplicate, so there is no un-wiped return slot.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…ashcore-dev

# Conflicts:
#	Cargo.lock
#	Cargo.toml
The prior merge commit updated Cargo.toml's rust-dashcore rev pins to
the current dev HEAD but left Cargo.lock staged at the pre-cargo-update
state (still pinned to v0.44.0/991c6ebe), an oversight caught during PR
comment verification. Stage the already-regenerated lockfile so both
files agree on the a8a096838b829cf5bec3c2374a23511640a0c35c pin.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
The committed lockfile still pinned all 12 rust-dashcore workspace
crates at the stale rev 991c6eb (v0.44.0), even though root Cargo.toml
was already re-pinned to a8a096838b829cf5bec3c2374a23511640a0c35c
(v0.45.0). Cargo silently re-resolves the lock on the next build, so
--locked builds (CI) would fail against the mismatch.

Regenerate the lock to match: dashcore, dashcore-private,
dashcore_hashes, dashcore-rpc, dashcore-rpc-json, dash-network,
dash-network-seeds, dash-spv, git-state, key-wallet, key-wallet-ffi,
key-wallet-manager all move 991c6eb -> a8a0968. No other crate churn.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…a0968

Two accuracy fixes forced by the rust-dashcore pin move to a8a0968
(squashed PR #833 form), verified against key-wallet/src/bip32.rs at
that rev — ExtendedPrivKey now derives Clone (not Copy) and has an
explicit `impl Drop` that calls `self.zeroize()`:

- signer_simple.rs: the `dash_sdk_sign_with_mnemonic_and_path` comment
  still claimed "the ExtendedPrivKey itself doesn't zeroize — derived
  falls out of scope intact". That was true when written (PR #3541,
  pre-Zeroize), but is now false: `derived` self-wipes on Drop. Reword
  to state present reality and clarify the Zeroizing wrapper is there
  because `secret_bytes()` copies the scalar into a fresh array with no
  Drop of its own.
- mnemonic_resolver_core_signer.rs: re-point two rev citations from
  a8c57fe (rebased away upstream, no longer reachable from dev) to the
  current reachable pin a8a0968. The behavioral claims are unchanged
  and still hold.

Comment-only; no functional change.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Move all eight rust-dashcore workspace deps (dashcore, dash-spv,
key-wallet, key-wallet-ffi, key-wallet-manager, dash-network,
dash-network-seeds, dashcore-rpc) from a8a09683 to dev HEAD afcff156,
and regenerate Cargo.lock so every rust-dashcore-sourced crate
(including the transitive dashcore_hashes, dashcore-private,
dashcore-rpc-json, git-state) resolves to the new rev.

Upstream refactors the signing API: `extended_public_key` moves out of
the `Signer` trait into a dedicated `ExtendedPubKeySigner: Signer`
trait, so implementing an extended-pubkey export is now opt-in rather
than a required `Signer` method. `ExtendedPrivKey` keeps its
zeroize-on-`Drop`, non-`Copy` shape.

The three rs-dpp test-signer stubs added earlier in this branch
(`FixedKeySigner` in `state_transition/mod.rs` and its two
`signing_tests.rs` call sites) only existed to satisfy the previously
required `Signer::extended_public_key` method. With that method moved
to the opt-in trait, the stubs no longer compile and nothing needs
them as `ExtendedPubKeySigner` -- revert those three files to their
v4.1-dev content.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

<sub>🤖 Co-authored by [Claudius the Magnificent](https://github.com/lklimek/claudius) AI Agent</sub>
Implement `ExtendedPubKeySigner::extended_public_key` on
`MnemonicResolverCoreSigner`, tracking the upstream split of that
method out of the base `Signer` trait. Callers that need offline
non-hardened descendant derivation (e.g. DashPay contact-payment
addresses) can now obtain the full BIP-32 extended public key --
public point plus chain code -- from the mnemonic-resolver signer in
one call.

Funnel all private-key material through a single `resolve_and_derive`
helper that owns the mnemonic -> seed -> master -> child
`ExtendedPrivKey` stack and hands the derived key to a caller-supplied
closure by reference. The extended key never crosses the call
boundary and self-wipes on `Drop` (`ExtendedPrivKey` zeroizes and is
not `Copy`); the mnemonic and seed byte buffers ride `Zeroizing`
wrappers. Both `derive_priv` (raw scalar for signing) and
`extended_public_key` (xpub) share this one entry point.

Add a unit test asserting the exported xpub matches an independent
BIP-39 derivation field-by-field (public key, chain code, depth,
network) before the full-struct equality, so a dropped BIP-32
metadatum fails with a precise message rather than a vacuous assert.

Also correct the `signer_simple.rs` zeroization comment, now that
`ExtendedPrivKey` self-wipes on `Drop` -- the `Zeroizing` wrap there
guards only the separate `secret_bytes()` array copy.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

<sub>🤖 Co-authored by [Claudius the Magnificent](https://github.com/lklimek/claudius) AI Agent</sub>
`CoreWallet::send_to_addresses` builds+signs a transaction, which reserves
the selected UTXOs in the funds account's `ReservationSet`, then broadcasts.
On broadcast failure the `?` propagated the error without releasing the
reservation, so the inputs stayed reserved until the 24-block (~1h) TTL
backstop reclaimed them. A user whose send failed on broadcast and retried
immediately could see those coins as unavailable and hit a spurious
"insufficient funds".

Now a failed broadcast re-acquires the wallet-manager lock and calls
`ManagedCoreFundsAccount::release_reservation(&tx)` before propagating the
original error, so the inputs are selectable again right away. The account
re-lookup is factored into a small `funds_account_mut` helper; a missing
wallet/account on the release path is logged via `tracing::warn!` and does
not alter the returned broadcast error.

Adds a TDD test (`key-wallet/test-utils` dev-feature) that funds a wallet
with a single UTXO, fails the first broadcast, and asserts an immediate
retry succeeds — it fails against the old code with "No UTXOs available for
selection".

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@thepastaclaw

thepastaclaw commented Jul 2, 2026

Copy link
Copy Markdown
Collaborator

✅ Review complete (commit 79caf9b)

@coderabbitai

coderabbitai Bot commented Jul 2, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Warning

Review limit reached

@QuantumExplorer, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 52 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: f623da52-8647-4952-9f2e-7fa0e85f486b

📥 Commits

Reviewing files that changed from the base of the PR and between 04ab5cd and 79caf9b.

📒 Files selected for processing (1)
  • packages/rs-platform-wallet/src/spv/runtime.rs
📝 Walkthrough

Walkthrough

This PR classifies broadcast outcomes as rejected or maybe-sent, releases wallet reservations only on rejection, routes wallet broadcast paths through a shared helper, and updates Rust, FFI, Swift, and test coverage for the new behavior.

Changes

Broadcast failure handling

Layer / File(s) Summary
Error contract and mappings
packages/rs-platform-wallet/src/broadcaster.rs, packages/rs-platform-wallet/src/error.rs, packages/rs-platform-wallet-ffi/src/error.rs, packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletResult.swift
Adds BroadcastError, updates TransactionBroadcaster::broadcast, introduces TransactionBroadcastUnconfirmed(String), and maps the new unconfirmed broadcast state through FFI and Swift result/error types.
Broadcaster runtime classification
packages/rs-platform-wallet/src/broadcaster.rs, packages/rs-platform-wallet/src/spv/runtime.rs
Updates DAPI and SPV broadcaster implementations to return BroadcastError, with SPV runtime distinguishing rejected and maybe-sent outcomes from transaction broadcast failures.
Reservation cleanup helper and call sites
packages/rs-platform-wallet/src/wallet/mod.rs, packages/rs-platform-wallet/src/wallet/reservations.rs, packages/rs-platform-wallet/src/wallet/core/broadcast.rs, packages/rs-platform-wallet/src/wallet/identity/network/payments.rs, packages/rs-platform-wallet/src/wallet/asset_lock/build.rs, packages/rs-platform-wallet/src/wallet/asset_lock/sync/tracking.rs
Adds broadcast_releasing_on_rejection, exports the wallet reservations module, routes core send/payment/asset-lock broadcast paths through it, and adds asset-lock untracking for rejected broadcasts.
Test support and retry coverage
packages/rs-platform-wallet/Cargo.toml, packages/rs-platform-wallet/src/lib.rs, packages/rs-platform-wallet/src/test_support.rs, packages/rs-platform-wallet/src/wallet/core/broadcast.rs, packages/rs-platform-wallet/src/wallet/asset_lock/build.rs
Adds test-only key-wallet support plus broadcaster test doubles, funded-wallet setup, and retry tests for rejected versus maybe-sent broadcast outcomes across BIP44 and BIP32.

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

Sequence Diagram(s)

sequenceDiagram
  participant WalletFlow
  participant BroadcastHelper
  participant Broadcaster
  participant WalletManager
  participant ReservationSet

  WalletFlow->>BroadcastHelper: broadcast_releasing_on_rejection(...)
  BroadcastHelper->>Broadcaster: broadcast(tx)
  alt rejected
    BroadcastHelper->>WalletManager: read lock, locate account
    WalletManager-->>BroadcastHelper: managed account
    BroadcastHelper->>ReservationSet: release_reservation(tx)
  else maybe sent or success
    BroadcastHelper-->>WalletFlow: txid or error
  end
Loading

Possibly related PRs

  • dashpay/platform#3730: Both PRs modify packages/rs-platform-wallet/src/spv/runtime.rs and the SPV broadcast path.

Suggested labels: ready for final review

Suggested reviewers: QuantumExplorer, shumkov, llbartekll, ZocoLini, thepastaclaw

🚥 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: releasing UTXO reservations when platform-wallet broadcasts fail.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
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.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/core-wallet-release-reservation-on-broadcast-failure

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.

lklimek
lklimek previously approved these changes Jul 2, 2026

@thepastaclaw thepastaclaw left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Code Review

Small, well-scoped fix that releases the transaction builder's UTXO reservation when broadcast fails, preventing a spurious ~1h stranding of coins after a failed send. No blocking issues; a few suggestions and nitpicks around release-path locking, error semantics, and test coverage.

🟡 2 suggestion(s) | 💬 2 nitpick(s)

Source: reviewers opus general/rust-quality; claude-sonnet-5 general/rust-quality (ACP wrappers terminated after parseable output); gpt-5.5[high] general/rust-quality failed at initialize; verifier opus.

🤖 Prompt for all review comments with AI agents
These findings are from an automated code review. Verify each finding against the current code and only fix it if needed.

In `packages/rs-platform-wallet/src/wallet/core/broadcast.rs`:
- [SUGGESTION] packages/rs-platform-wallet/src/wallet/core/broadcast.rs:168-189: Failure-path release takes an exclusive write lock it doesn't need
  `ManagedCoreFundsAccount::release_reservation` takes `&self` (the `ReservationSet` uses an internal `Arc<Mutex<_>>`), so no mutable borrow of the account is required. The current code takes `self.wallet_manager.write().await` and looks the account up via `get_wallet_and_info_mut` + `get_mut` purely to satisfy `funds_account_mut`'s `&mut` signature. `WalletManager` stores all wallets in shared maps, so this write lock serializes against every other in-flight `send_to_addresses` / balance-read across every wallet managed by this instance for the duration of the cleanup — on the exact path meant to make retries fast. Switch this branch to `wallet_manager.read().await` + immutable `get_wallet_and_info` + `BTreeMap::get`, and either change `funds_account_mut` to return `&ManagedCoreFundsAccount` or introduce an immutable variant. Not a bug today (the section is short), but it's an avoidable point of contention on the exact code path meant to unblock retries.
- [SUGGESTION] packages/rs-platform-wallet/src/wallet/core/broadcast.rs:168-189: Releasing on every broadcaster Err assumes the tx never reached the network
  Any `Err` from `broadcast_transaction` is treated as "tx definitely did not reach the network", which triggers an immediate release so a retry can reselect the same UTXOs. In practice a broadcaster can fail *after* the network has accepted the transaction — e.g. a dropped SPV/gRPC connection after send, a timeout waiting for ack, or a node error that still relayed the tx. In those cases the release + retry produces a second signed spend over the same inputs, turning a transient network hiccup into a self-inflicted double-spend attempt (the second broadcast usually then fails on the node, but the UX is confusing). This is defensible as a UX call — the pre-PR TTL-backstop behavior had the opposite failure mode — but it should be a deliberate contract. Either narrow the release to error variants that are unambiguously pre-send (transport/serialization/local rejection), or add a comment on `TransactionBroadcaster::broadcast` documenting that `Err` MUST mean "the network did not accept this transaction" so other broadcaster impls are held to the same contract.
- [NITPICK] packages/rs-platform-wallet/src/wallet/core/broadcast.rs:287-348: Regression test only covers the BIP44 arm of `funds_account_mut`
  The test exercises `StandardAccountType::BIP44Account` only. The helper and the release path also have a `BIP32Account` arm that the fix depends on to resolve the same account the builder reserved on. The two arms are structurally identical today, but a table-driven parameterization over `account_type` (or a second minimal case) would prevent silent divergence if a new `StandardAccountType` variant is added without updating the helper.
- [NITPICK] packages/rs-platform-wallet/src/wallet/core/broadcast.rs:177-186: Warn logs on release-path fallback lack `wallet_id` for correlation
  If the wallet or funds account has been removed between build and the failed broadcast, the release path logs `tracing::warn!` and returns the original broadcast error. The reservation is dropped along with the account, so state is fine, but neither warn arm includes `wallet_id`, which makes it hard to correlate an operator-visible warning with a specific send. Add `wallet_id=%self.wallet_id` to both arms, and for the outer arm also `?account_type, account_index`, so orphaned-reservation warnings are greppable.

Comment thread packages/rs-platform-wallet/src/wallet/core/broadcast.rs Outdated
Comment thread packages/rs-platform-wallet/src/wallet/core/broadcast.rs Outdated
Comment thread packages/rs-platform-wallet/src/wallet/core/broadcast.rs
Comment thread packages/rs-platform-wallet/src/wallet/core/broadcast.rs Outdated
Address reviewer findings on the send-to-addresses failure cleanup:

- Take a read lock (not write) on the release path: release_reservation is
  &self and the manager map is untouched, so the cleanup no longer serializes
  concurrent sends and balance reads. Add an immutable funds_account helper and
  drop the now-unused funds_account_mut.
- Only release the reservation for unambiguously pre-send broadcast failures
  (TransactionBroadcast / SpvError); ambiguous "possibly accepted" results are
  kept for the TTL backstop to avoid a double-spend on retry. Document the
  "Err means not accepted" contract on TransactionBroadcaster::broadcast.
- Cover both BIP44 and BIP32 funds-account arms with a table-driven test plus a
  new test proving an ambiguous failure keeps the reservation, sharing a common
  funded-wallet helper.
- Add wallet_id to both release-failure warn logs for correlation.

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

@thepastaclaw thepastaclaw left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Code Review

Incremental review of eb54b37 vs prior f9c49ed. All four prior findings are FIXED at HEAD: release path now takes a read lock via immutable funds_account helper; release is gated by broadcast_failure_is_pre_send with the documented TransactionBroadcaster::broadcast Err-contract; regression tests iterate BIP44/BIP32 via funded_core_wallet and add an ambiguous-failure keep-reservation counterpart; both warn arms include wallet_id. One new in-scope suggestion: the documented Err = 'not accepted by the network' contract that broadcast_failure_is_pre_send relies on is not actually upheld by DapiBroadcaster (the default non-SPV path), because it flattens all gRPC errors — including DeadlineExceeded/Unavailable after Core may have accepted the tx — into TransactionBroadcast, which the predicate treats as safe to release. Verified against dash-spv afcff15: SpvError genuinely does mean pre-send (peer success counter). No blocking issues.

🟡 1 suggestion(s)

1 additional finding(s) omitted (not in diff).

🤖 Prompt for all review comments with AI agents
These findings are from an automated code review. Verify each finding against the current code and only fix it if needed.

In `packages/rs-platform-wallet/src/broadcaster.rs`:
- [SUGGESTION] packages/rs-platform-wallet/src/broadcaster.rs:41-67: `DapiBroadcaster` does not uphold the new `Err` = not-accepted contract, narrowing but not closing the double-spend-on-retry window
  The new `broadcast_failure_is_pre_send` (broadcast.rs:44-49) classifies `PlatformWalletError::TransactionBroadcast(_)` as unambiguously pre-send and safe to release-and-retry, backed only by the doc comment on `TransactionBroadcaster::broadcast` (broadcaster.rs:23-24). `SpvBroadcaster` genuinely honors that contract — dash-spv (pinned rev `afcff15`) counts per-peer send results and only surfaces `Err` when zero peers received the tx. `DapiBroadcaster::broadcast` (broadcaster.rs:41-67) does not: it wraps *any* `DapiClientError` returned by `self.sdk.execute(...).into_inner()` into `TransactionBroadcast(String)`, with no discrimination by cause. The underlying `DapiClientError` includes `Transport(TransportError::Grpc(tonic::Status))` covering codes like `DeadlineExceeded` and `Unavailable` — the classic 'client gave up waiting for an ack, but the server-side call may have already put the tx in Core's mempool' case. A user hitting a gRPC deadline/connection drop after DAPI already relayed the tx will see `TransactionBroadcast`, get classified as pre-send, have the reservation released, and immediately retry — signing a second tx over the same inputs while the first is propagating. That is exactly the release-and-retry double-spend the PR's granular condition is meant to prevent, narrowed but not closed on the default broadcast path. Options: (a) in `DapiBroadcaster::broadcast`, inspect the `DapiClientError` and only return `TransactionBroadcast` for definite pre-send rejections (e.g. `Server(_)` decode/reject responses), routing ambiguous transport failures into a distinct 'possibly accepted' variant that `broadcast_failure_is_pre_send` treats as keep-reservation; or (b) drop `TransactionBroadcast` from the pre-send allowlist and rely on the TTL backstop for DAPI failures until (a) lands. Contract-by-doc-comment alone doesn't make it safe.

Source: reviewers: opus (general), claude-sonnet-5 (general), gpt-5.5[high] (general, failed_or_unparseable), opus (rust-quality), claude-sonnet-5 (rust-quality), gpt-5.5[high] (rust-quality, failed_or_unparseable), opus (ffi-engineer), claude-sonnet-5 (ffi-engineer), gpt-5.5[high] (ffi-engineer, failed_or_unparseable); verifier: opus; specialists: rust-quality, ffi-engineer

Base automatically changed from chore/bump-rust-dashcore-dev to v4.1-dev July 3, 2026 13:43
@QuantumExplorer QuantumExplorer dismissed lklimek’s stale review July 3, 2026 13:43

The base branch was changed.

@github-actions github-actions Bot added this to the v4.1.0 milestone Jul 3, 2026

@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

🧹 Nitpick comments (1)
packages/rs-platform-wallet/src/wallet/core/broadcast.rs (1)

409-444: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Cover the SpvError pre-send arm in the retry test.

broadcast_failure_is_pre_send releases for both TransactionBroadcast and SpvError, but this test only exercises TransactionBroadcast. Parameterize the first failure so the SpvError release path cannot regress unnoticed.

🤖 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 `@packages/rs-platform-wallet/src/wallet/core/broadcast.rs` around lines 409 -
444, The retry test only covers the TransactionBroadcast pre-send failure path,
so the SpvError release branch in the broadcast flow is still untested. Update
send_to_addresses_releases_reservation_on_broadcast_failure to parameterize the
first failure source using FailFirstBroadcaster or a similar test helper so it
can trigger both TransactionBroadcast and SpvError cases, and keep the retry
assertion in the send_to_addresses path to verify the reservation is released in
both scenarios.
🤖 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 `@packages/rs-platform-wallet/src/broadcaster.rs`:
- Around line 23-24: Update the trait-level docs in the broadcaster contract so
they no longer imply every Err is safe to retry; instead, explicitly distinguish
between pre-send failures where the transaction was not accepted and callers may
release reservations, and ambiguous or post-submit failures where reservations
must be kept. Use the send_to_addresses semantics as the reference point, and
make sure broadcaster implementers understand not to collapse uncertain errors
into the safe-retry class.

---

Nitpick comments:
In `@packages/rs-platform-wallet/src/wallet/core/broadcast.rs`:
- Around line 409-444: The retry test only covers the TransactionBroadcast
pre-send failure path, so the SpvError release branch in the broadcast flow is
still untested. Update
send_to_addresses_releases_reservation_on_broadcast_failure to parameterize the
first failure source using FailFirstBroadcaster or a similar test helper so it
can trigger both TransactionBroadcast and SpvError cases, and keep the retry
assertion in the send_to_addresses path to verify the reservation is released in
both scenarios.
🪄 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: defaults

Review profile: CHILL

Plan: Pro

Run ID: b2b03dbc-be99-480b-950e-ce454975459b

📥 Commits

Reviewing files that changed from the base of the PR and between b9e57c0 and c81f9d2.

📒 Files selected for processing (3)
  • packages/rs-platform-wallet/Cargo.toml
  • packages/rs-platform-wallet/src/broadcaster.rs
  • packages/rs-platform-wallet/src/wallet/core/broadcast.rs

Comment thread packages/rs-platform-wallet/src/broadcaster.rs Outdated

@thepastaclaw thepastaclaw left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Code Review

HEAD c81f9d2 is a no-op merge of v4.1-dev into the fix branch — git diff eb54b37001..c81f9d2c81 -- packages/rs-platform-wallet/ is empty. No new delta findings. The one prior in-scope finding (DapiBroadcaster does not uphold the Err=not-accepted contract that broadcast_failure_is_pre_send depends on) remains STILL_VALID at HEAD and is carried forward as a suggestion. All 6 agents converged on this single finding.

🟡 1 suggestion(s)

🤖 Prompt for all review comments with AI agents
These findings are from an automated code review. Verify each finding against the current code and only fix it if needed.

In `packages/rs-platform-wallet/src/broadcaster.rs`:
- [SUGGESTION] packages/rs-platform-wallet/src/broadcaster.rs:41-67: `DapiBroadcaster` does not uphold the new `Err` = not-accepted contract, narrowing but not closing the double-spend-on-retry window
  The new `broadcast_failure_is_pre_send` (packages/rs-platform-wallet/src/wallet/core/broadcast.rs:44-49) classifies `PlatformWalletError::TransactionBroadcast(_)` as unambiguously pre-send and therefore safe to release the UTXO reservation and let the caller retry. That predicate is backed only by the doc comment added on `TransactionBroadcaster::broadcast` (broadcaster.rs:23-24): "Err must mean the transaction was not accepted by the network".

  `SpvBroadcaster` genuinely upholds that contract — dash-spv (pinned rev afcff156) counts per-peer send results and only surfaces `Err` when zero peers received the tx. `DapiBroadcaster::broadcast` does not: at broadcaster.rs:56-63 it wraps *any* error returned by `self.sdk.execute(request, RequestSettings::default()).await.into_inner()` into `PlatformWalletError::TransactionBroadcast(String)` with no discrimination by cause. The underlying `DapiClientError` includes `Transport(TransportError::Grpc(tonic::Status))`, which covers codes such as `DeadlineExceeded`, `Unavailable`, `Cancelled`, and mid-response resets — the classic "client gave up waiting for an ack, but the server-side call may have already put the tx in Core's mempool" case.

  A user hitting a gRPC deadline or connection drop after DAPI already relayed the tx will see `TransactionBroadcast`, be classified pre-send by the predicate, have the reservation released, and immediately retry — signing a second tx over the same inputs while the first may already be propagating. That is exactly the release-and-retry double-spend the granular condition is meant to prevent, narrowed but not closed on the default (non-SPV) broadcast path. On the security side, the input-conflict property means at most one tx confirms (no funds theft), but a hostile or degraded DAPI relay can deliberately drop the response after forwarding to Core to coerce the wallet into duplicate broadcasts (mempool spam using the user's key, retry-policy fingerprinting).

  The contract is also enforced only by convention (a doc comment), not the type system, so any future `TransactionBroadcaster` impl that raises `TransactionBroadcast` for an ambiguous submission will silently violate it.

  Options: (a) in `DapiBroadcaster::broadcast`, match on `DapiClientError` and only return `TransactionBroadcast` for definite pre-send rejections (e.g. `Server(_)` decode/reject responses, connection-refused before the request was written), routing ambiguous transport failures (`Transport(Grpc(_))` with `DeadlineExceeded`/`Unavailable`/`Cancelled`/`Unknown`, or any post-write-side error) into a distinct "possibly accepted" `PlatformWalletError` variant that `broadcast_failure_is_pre_send` treats as keep-reservation; or (b) drop `TransactionBroadcast` from the pre-send allowlist and rely on the TTL backstop for DAPI failures until (a) lands — SPV keeps its fast-path.

Source: reviewers: opus (general), claude-sonnet-5 (general), gpt-5.5[high] (general, failed_or_unparseable), opus (security-auditor), claude-sonnet-5 (security-auditor), gpt-5.5[high] (security-auditor, failed_or_unparseable), opus (rust-quality), claude-sonnet-5 (rust-quality), gpt-5.5[high] (rust-quality, failed_or_unparseable); verifier: opus; specialists: security-auditor, rust-quality

@thepastaclaw

thepastaclaw commented Jul 3, 2026

Copy link
Copy Markdown
Collaborator

Handled the CodeRabbit follow-up in helper PR #3996: #3996

Current status: #3996 is docs-only per maintainer request. It clarifies the broadcaster error contract in packages/rs-platform-wallet/src/broadcaster.rs; the wallet broadcast code/test changes were removed, so this helper no longer extends the retry test coverage.

Latest checks are green: PR title, action-semantic-pull-request, and CodeRabbit manual review. Local validation before the docs-only push included git diff --check.

…-release seam

Review follow-ups on the release-on-broadcast-failure fix:

- TransactionBroadcaster::broadcast now returns a typed BroadcastError
  (Rejected vs MaybeSent) so the release decision is made where the
  transport knowledge lives, instead of variant-matching catch-all
  PlatformWalletError strings. DapiBroadcaster classifies every failure
  MaybeSent (its internal multi-node retries make any failure ambiguous);
  SpvRuntime classifies "client not started" and dash-spv's zero-peer
  NotConnected as Rejected (both fire before any bytes leave the process)
  and everything else MaybeSent. MaybeSent surfaces as the new
  PlatformWalletError::TransactionBroadcastUnconfirmed.

- The release logic moves from a send_to_addresses-only block into
  wallet::reservations::broadcast_releasing_on_rejection, and the two
  other build-then-broadcast paths that leaked reservations the same way
  now use it too: send_dashpay_payment and create_funded_asset_lock_proof.
  The asset-lock recovery rebroadcast intentionally does not release (it
  re-drives a previously built transaction, not a fresh reserve).

- funds_account dispatch replaced with key-wallet's existing
  bip{44,32}_managed_account_at_index accessors; the nested release
  match flattened to one lookup chain with a single warn.

- Test fixture reuses ctx.receive_address / ctx.check_transaction from
  TestWalletContext instead of re-deriving the BIP44 address and
  hand-rolling the mempool check; mocks updated to the typed error.

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

@thepastaclaw thepastaclaw left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Code Review

Prior finding prior-1 is resolved at HEAD 3145402: the doc-comment-only pre-send contract in DapiBroadcaster is replaced by a typed BroadcastError { Rejected, MaybeSent } enum, DapiBroadcaster conservatively classifies every DAPI failure as MaybeSent, and the release logic flows through a shared broadcast_releasing_on_rejection seam consumed by all three build-then-broadcast callers. Two new latest-delta findings surface: (1) the new PlatformWalletError::TransactionBroadcastUnconfirmed variant flattens to ErrorUnknown at the FFI boundary because the analogous arms added for ShieldedBroadcastUnconfirmed/ShieldedSpendUnconfirmed were not added for the new variant; (2) on BroadcastError::Rejected the asset-lock path releases the funding UTXO reservation while leaving a persisted Built tracked lock that resume_asset_lock will re-broadcast if later invoked with that outpoint. Both are suggestions, not blockers — the DAPI path structurally never returns Rejected today, so the second is currently only reachable via the SPV path.

🟡 2 suggestion(s)

The FFI-boundary suggestion is included body-only in the details below because packages/rs-platform-wallet-ffi/src/error.rs is outside the PR diff.

Source: reviewers opus/opus, sonnet5/claude-sonnet-5, codex/gpt-5.5 for general/security-auditor/rust-quality/ffi-engineer; verifier opus/opus.

🤖 Prompt for all review comments with AI agents
These findings are from an automated code review. Verify each finding against the current code and only fix it if needed.

In `packages/rs-platform-wallet-ffi/src/error.rs`:
- [SUGGESTION] packages/rs-platform-wallet-ffi/src/error.rs:231-240: New `TransactionBroadcastUnconfirmed` variant flattens to `ErrorUnknown` at the FFI boundary, erasing the PR's retry-safety signal
  This PR's entire point is a typed distinction between broadcast outcomes that are safe to retry immediately (`BroadcastError::Rejected` → `PlatformWalletError::TransactionBroadcast`) and outcomes that must NOT be retried because the transaction may already be propagating (`BroadcastError::MaybeSent` → the new `PlatformWalletError::TransactionBroadcastUnconfirmed`, per broadcaster.rs:45-56 and error.rs:60-77). The distinction is enforced by the type system in Rust, but is lost crossing the C ABI.

  `From<PlatformWalletError> for PlatformWalletFFIResult` at rs-platform-wallet-ffi/src/error.rs:205-244 has dedicated arms for the structurally identical shielded siblings — `ShieldedBroadcastFailed`, `ShieldedBroadcastUnconfirmed`, `ShieldedSpendUnconfirmed` — precisely because the in-code comment (lines 221-230) documents that flattening these to `ErrorUnknown` would 'defeat the slot-holding contract.' The new `TransactionBroadcastUnconfirmed` variant carries the same slot-holding contract (its own doc at error.rs:63-77 names `ShieldedSpendUnconfirmed` as its direct sibling) but has no arm here, so every ambiguous broadcast on the Core-wallet, DashPay, and asset-lock send paths crosses the boundary as generic code 99. Since every DAPI broadcast failure now maps to `MaybeSent` unconditionally, this is the primary error class the DAPI-backed send paths (`core_wallet_send_to_addresses`, `platform_wallet_send_dashpay_payment`, `asset_lock_manager_create_funded_proof`) will emit on failure.

  Swift's `PlatformWalletError.init(result:)` is a pure code switch with no message-string fallback, and `SendViewModel` already special-cases `shieldedSpendUnconfirmed` as non-retryable — that special-casing cannot be replicated for the Core-wallet path until the FFI code exists. The safety property is still preserved by the TTL-backstopped reservation, but the UX degrades from a specific 'tx may already be on network — do not retry' to a generic error, followed by a misleading `NoSpendableInputs` on retry.

  Add a dedicated `ErrorTransactionBroadcastUnconfirmed` variant to `PlatformWalletFFIResultCode`, add the matching arm here mirroring the shielded siblings, and add the corresponding case to Swift's `PlatformWalletError`. The PR diff is currently confined to `packages/rs-platform-wallet/`, but this variant is only useful once foreign callers can act on it.

In `packages/rs-platform-wallet/src/wallet/asset_lock/build.rs`:
- [SUGGESTION] packages/rs-platform-wallet/src/wallet/asset_lock/build.rs:329-361: Rejected asset-lock broadcast releases the funding reservation while leaving a resumable `Built` lock behind
  `create_funded_asset_lock_proof` tracks and persists the transaction as `AssetLockStatus::Built` (lines 329-341) before calling `broadcast_releasing_on_rejection`. On `BroadcastError::Rejected`, that helper releases the BIP44 funding reservation and returns via `?` without advancing or removing the tracked lock, leaving the persisted `Built` row live in `tracked_asset_locks`. The inline comment at lines 348-352 documents this as intentional ('the tracked lock stays `Built`, so a later resume can still re-drive the same transaction while its inputs remain unspent') — but with the reservation now released, the 'inputs remain unspent' precondition can no longer be relied on. If the caller retries `create_funded_asset_lock_proof` successfully with a fresh transaction spending the same UTXOs, `resume_asset_lock(&old_out_point)` (recovery.rs:251-261, driven by `FundingResolution` orchestration or a `list_tracked_locks`-based recovery UI) will re-broadcast the stale `Built` transaction whose inputs have already been consumed.

  The worst-case fallout is not a double-spend — the second broadcast will fail mempool acceptance because the inputs are already spent — but it does re-broadcast a superseded transaction, produce confusing failures, and leave clutter in the tracked-lock persistence. This is currently only reachable via the SPV path (`DapiBroadcaster` never returns `Rejected`; only `SpvRuntime::broadcast_transaction`'s zero-peer / client-not-started arms do).

  On `BroadcastError::Rejected`, either remove/tombstone the tracked lock (so it isn't offered up by `list_tracked_locks` for resume), or keep the reservation held for the `Built` row and only release it once the lock is definitively abandoned. Either shape restores the invariant the doc comment relies on.

Comment thread packages/rs-platform-wallet/src/wallet/asset_lock/build.rs Outdated
…nfirmed-broadcast code across FFI

Review follow-ups:

- On BroadcastError::Rejected, create_funded_asset_lock_proof now
  untracks the Built lock (in-memory + persisted row via the
  changeset's removed set). With the funding reservation released for
  re-selection, leaving the row resumable would let a later
  resume_asset_lock re-broadcast a transaction whose inputs may
  already be re-spent. MaybeSent failures keep both the reservation
  and the resumable row. broadcast_releasing_on_rejection returns the
  typed BroadcastError so call sites with rejection-specific cleanup
  can branch on it; `?` still converts via From.

- PlatformWalletError::TransactionBroadcastUnconfirmed now crosses the
  FFI boundary as a dedicated ErrorTransactionBroadcastUnconfirmed
  code (19) instead of flattening to ErrorUnknown, mirroring the
  shielded unconfirmed siblings, with the matching Swift
  PlatformWalletResultCode / PlatformWalletError cases so hosts can
  special-case the do-not-retry outcome.

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

@thepastaclaw thepastaclaw left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Code Review

Both prior findings (prior-1 FFI flattening of TransactionBroadcastUnconfirmed and prior-2 rejected asset-lock leaving a resumable Built row) are fully resolved at head 12ab17f. Carried-forward findings: none. New in-scope findings from the latest delta: two suggestion-level items — (a) the new asset-lock untrack-on-Rejected branch in create_funded_asset_lock_proof has no direct regression test, unlike the core-send sibling that got table-driven tests; (b) a narrow window between broadcast_releasing_on_rejection releasing the UTXO reservation and untrack_asset_lock removing the Built row lets a concurrent resume_asset_lock re-broadcast the just-rejected transaction using inputs now eligible for re-selection. Neither rises to blocking.

🟡 2 suggestion(s)

🤖 Prompt for all review comments with AI agents
These findings are from an automated code review. Verify each finding against the current code and only fix it if needed.

In `packages/rs-platform-wallet/src/wallet/asset_lock/build.rs`:
- [SUGGESTION] packages/rs-platform-wallet/src/wallet/asset_lock/build.rs:356-371: New untrack-on-Rejected branch in create_funded_asset_lock_proof has no direct regression test
  The PR added table-driven tests (`send_to_addresses_releases_reservation_on_broadcast_failure` / `..._keeps_reservation_on_ambiguous_broadcast_failure`) for the parallel core-send path, but the newly added asset-lock branch here — matching `BroadcastError::Rejected` and calling `untrack_asset_lock`, while `MaybeSent` deliberately leaves the reservation and Built row intact — has no equivalent coverage. `build.rs` has no `#[cfg(test)]` module, and no test elsewhere in the crate exercises `AssetLockManager` together with `BroadcastError::Rejected`. Since this is the exact invariant that closes prior-2 (a `resume_asset_lock` on a rejected outpoint must not re-broadcast a stale tx whose inputs may already be re-spent), a future refactor could silently regress the branch or the `matches!` guard without breaking any existing test. A direct test that asserts a `Rejected` outcome (i) queues a changeset with the outpoint in `removed`, (ii) empties the in-memory `tracked_asset_locks` entry, and that a `MaybeSent` outcome does neither, would pin the invariant.
- [SUGGESTION] packages/rs-platform-wallet/src/wallet/asset_lock/build.rs:356-371: Reservation release and Built-row untrack are not atomic, leaving a window where a concurrent resume_asset_lock can re-broadcast the rejected tx
  `broadcast_releasing_on_rejection` releases the funding UTXO reservation and returns `Err(BroadcastError::Rejected)`; only afterward does this call site acquire the write lock via `untrack_asset_lock` to remove the `Built` row. Between those two lock acquisitions, the `Built` entry for `out_point` is still visible via `list_tracked_locks()` / `tracked_asset_locks.values()`. `resume_asset_lock` (sync/recovery.rs:209-261) for `Built` status calls `self.broadcaster.broadcast(&tx).await?` directly — it does not go through `broadcast_releasing_on_rejection` and does not re-check reservation state. If a host recovery/resume flow interleaves precisely with the rejection cleanup, it can re-drive the just-rejected transaction using inputs whose reservation is already released and therefore concurrently eligible for re-selection by a new build. `untrack_asset_lock` also has no `AssetLockStatus::Built` guard, so if `resume_asset_lock` advanced the row to `Broadcast` in that window, the untrack still removes it — clobbering genuine progress. Blast radius is bounded: Core's mempool resolves double-spend at the network layer, so this is not a fund-loss bug, but it can produce a confusing state where the wallet believes a new build succeeded while the confirmed tx is actually the resumed one. Consider combining the reservation release and row untrack under a single write-lock section, or gating `untrack_asset_lock` on the status still being `Built`.

Source: reviewers: opus (general), claude-sonnet-5 (general), gpt-5.5 (general), opus (security-auditor), claude-sonnet-5 (security-auditor), gpt-5.5 (security-auditor), opus (rust-quality), claude-sonnet-5 (rust-quality), gpt-5.5 (rust-quality), opus (ffi-engineer), claude-sonnet-5 (ffi-engineer), gpt-5.5 (ffi-engineer); verifier: opus; specialists: security-auditor, rust-quality, ffi-engineer

Comment thread packages/rs-platform-wallet/src/wallet/asset_lock/build.rs Outdated
Comment thread packages/rs-platform-wallet/src/wallet/asset_lock/build.rs Outdated
… branches with tests

Review follow-ups:

- Untrack-before-release ordering: on a Rejected asset-lock broadcast
  the Built row is now removed BEFORE the funding reservation is
  released. While the reservation is held the inputs cannot be
  re-selected by a new build, and once the row is gone
  resume_asset_lock cannot re-drive the rejected transaction — so at
  no point is the row resumable while its inputs are re-spendable.
  untrack_asset_lock is additionally guarded on the row still being
  Built, so a concurrent resume that advanced it to Broadcast is kept
  rather than clobbered. The release block moved into
  release_reservation_after_rejected_broadcast, which
  broadcast_releasing_on_rejection now delegates to.

- Regression tests for the asset-lock branch: a Rejected broadcast
  empties tracked_asset_locks, queues the outpoint in the changeset's
  removed set, and a fresh build reselects the released inputs; a
  MaybeSent failure keeps the Built row, queues no deletion, and a
  fresh build fails at input selection while the reservation is held.
  The mocks/signer/funded-wallet fixture moved to a shared
  cfg(test) test_support module used by both the core-send and
  asset-lock test suites.

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

@thepastaclaw thepastaclaw left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Code Review

Both prior findings from 12ab17f are FIXED at head 25ea9d2: dedicated Rejected/MaybeSent regression tests were added to build.rs, and the cleanup order was inverted (untrack before release) with untrack_asset_lock newly guarded on status == Built. However, the same reorder introduced one new in-scope logic gap flagged by 5 of 12 reviewers: the reservation release in create_funded_asset_lock_proof runs unconditionally even when the untrack guard fires (i.e. a concurrent resume_asset_lock already advanced the row past Built), freeing UTXOs of a transaction the wallet is still tracking as Broadcast. That is a variant of the exact race this commit's title claims to close, and the fix is a one-line conditional on cs_untrack.removed.contains(&out_point).

🔴 1 blocking

Source: reviewers: opus (general), claude-sonnet-5 (general), gpt-5.5 (general), opus (security-auditor), claude-sonnet-5 (security-auditor), gpt-5.5 (security-auditor), opus (rust-quality), claude-sonnet-5 (rust-quality), gpt-5.5 (rust-quality), opus (ffi-engineer), claude-sonnet-5 (ffi-engineer), gpt-5.5 (ffi-engineer); verifier: opus; specialists: security-auditor, rust-quality, ffi-engineer.

🤖 Prompt for all review comments with AI agents
These findings are from an automated code review. Verify each finding against the current code and only fix it if needed.

In `packages/rs-platform-wallet/src/wallet/asset_lock/build.rs`:
- [BLOCKING] packages/rs-platform-wallet/src/wallet/asset_lock/build.rs:357-371: Release the funding reservation only when untrack_asset_lock actually removed the Built row
  The reorder at build.rs:357-371 correctly untracks the `Built` asset-lock row before releasing the funding reservation, and `untrack_asset_lock` (sync/tracking.rs:46-64) is now status-guarded so a concurrent `resume_asset_lock` that advanced the row to `Broadcast` in the window between this call's rejected broadcast and its cleanup is preserved (logged, not clobbered) — returning an empty changeset in that case.

  But `release_reservation_after_rejected_broadcast` on line 361 runs unconditionally inside the `Rejected` branch, regardless of whether `untrack_asset_lock` actually removed the entry. When the guard fires, the row is left tracked as `Broadcast` (positive evidence the transaction reached the network via the concurrent resume), yet the funding reservation is still released, making its inputs immediately selectable by a fresh build. This is strictly worse than the `MaybeSent` case this PR is designed to protect (there the outcome is merely ambiguous; here the status transition is direct evidence of a successful send), and it reopens a variant of the exact double-spend race the commit message ("close the rejected-asset-lock race") claims to eliminate.

  The invariant the doc comment at build.rs:348-356 claims — "at no point is the row resumable while its inputs are re-spendable" — is violated in this interleaving: after cleanup the row is resumable/tracked *and* the inputs are re-spendable. `reservations.rs:70-75` explicitly states the ordering precondition, and that precondition is only satisfied when the untrack actually succeeded. Gate the release on `cs_untrack.removed.contains(&out_point)`.

Comment thread packages/rs-platform-wallet/src/wallet/asset_lock/build.rs
…lt row actually being removed

If the untrack guard fires — a concurrent resume_asset_lock advanced
the row past Built in the rejection window, positive evidence the
transaction reached the network — the funding reservation must stay
held exactly like a MaybeSent outcome, or the still-tracked row would
be resumable while its inputs are re-spendable. Release now runs only
when untrack_asset_lock actually removed the row
(cs_untrack.removed.contains(&out_point)).

Pinned by rejected_broadcast_racing_concurrent_resume_keeps_row_and_reservation:
a mock broadcaster advances the row to Broadcast mid-broadcast and then
returns Rejected; the test asserts the row survives as Broadcast, no
persisted-row deletion is queued, and a fresh build still fails at
input selection because the reservation was kept.

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

@thepastaclaw thepastaclaw left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Code Review

Incremental review from 25ea9d2 to 5df222b. The sole prior blocking finding (prior-1) is verified FIXED: create_funded_asset_lock_proof now captures removed_built_row = cs_untrack.removed.contains(&out_point) before queuing the changeset and gates the reservation release on that flag, exactly matching the suggested fix. untrack_asset_lock only populates cs.removed when the row was actually removed (guarded on status == Built), so the flag is a correct proxy for the race outcome. A new regression test rejected_broadcast_racing_concurrent_resume_keeps_row_and_reservation pins the concurrent-resume interleaving. No new in-scope findings in the latest delta or cumulative PR.

Source: reviewers: opus (general), claude-sonnet-5 (general), gpt-5.5 (general), opus (security-auditor), claude-sonnet-5 (security-auditor), gpt-5.5 (security-auditor), opus (rust-quality), claude-sonnet-5 (rust-quality), gpt-5.5 (rust-quality), opus (ffi-engineer), claude-sonnet-5 (ffi-engineer), gpt-5.5 (ffi-engineer); verifier: opus; specialists: security-auditor, rust-quality, ffi-engineer.

@QuantumExplorer QuantumExplorer changed the base branch from v4.1-dev to v4.0-dev July 4, 2026 08:29
@github-actions github-actions Bot modified the milestones: v4.1.0, v4.0.0 Jul 4, 2026
@codecov

codecov Bot commented Jul 4, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 87.18%. Comparing base (b9e57c0) to head (79caf9b).

Additional details and impacted files
@@            Coverage Diff            @@
##           v4.1-dev    #3985   +/-   ##
=========================================
  Coverage     87.18%   87.18%           
=========================================
  Files          2632     2632           
  Lines        327563   327563           
=========================================
  Hits         285592   285592           
  Misses        41971    41971           
Components Coverage Δ
dpp 87.70% <ø> (ø)
drive 86.14% <ø> (ø)
drive-abci 89.45% <ø> (ø)
sdk ∅ <ø> (∅)
dapi-client ∅ <ø> (∅)
platform-version ∅ <ø> (∅)
platform-value 92.20% <ø> (ø)
platform-wallet ∅ <ø> (∅)
drive-proof-verifier 49.55% <ø> (ø)
🚀 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.

@QuantumExplorer QuantumExplorer changed the base branch from v4.0-dev to v4.1-dev July 4, 2026 08:47
@github-actions github-actions Bot modified the milestones: v4.0.0, v4.1.0 Jul 4, 2026
…nchor error

v4.0-dev already ships ErrorShieldedNoRecordedAnchor = 19; move
ErrorTransactionBroadcastUnconfirmed to 20 (Rust enum + Swift mirror)
so the FFI codes stay numerically identical across both release lines
when the 4.0 -> 4.1 sync lands, following the ErrorArithmeticOverflow
reserved-slot precedent.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@QuantumExplorer QuantumExplorer force-pushed the fix/core-wallet-release-reservation-on-broadcast-failure branch from aef46b9 to 04ab5cd Compare July 4, 2026 08:51

@thepastaclaw thepastaclaw left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Code Review

Carried-forward prior findings: none — the prior review at 5df222b had zero unresolved findings and nothing in the current head aef46b9 regresses it. New findings in the latest delta: one suggestion, independently raised by two sonnet5 reviewers (security + rust-quality), that the safety-critical SPV Rejected/MaybeSent classification in SpvRuntime::broadcast_transaction has no direct unit test coverage against dash-spv's error semantics. Verified in-tree: the core reservation-release seam (BroadcastError split, broadcast_releasing_on_rejection, asset-lock untrack-before-release with the removed_built_row guard) is correct, read-lock discipline is sound, and the FFI code renumbering to 20 (to accommodate ErrorShieldedNoRecordedAnchor=19 from the v4.0-dev merge) is mirrored consistently in the Swift PlatformWalletResult. The DapiBroadcaster blanket-MaybeSent behavior flagged by opus-rust-quality is an author-acknowledged, code-commented tradeoff (safe, conservative, documented as a follow-up), not a defect — dropped.

🟡 1 suggestion(s)

🤖 Prompt for all review comments with AI agents
These findings are from an automated code review. Verify each finding against the current code and only fix it if needed.

In `packages/rs-platform-wallet/src/spv/runtime.rs`:
- [SUGGESTION] packages/rs-platform-wallet/src/spv/runtime.rs:104-128: SpvRuntime::broadcast_transaction's Rejected/MaybeSent classification is untested against dash-spv error semantics
  This method is the concrete classification point that decides whether an SPV broadcast failure is safe to treat as `Rejected` (release the UTXO reservation and let a later resume re-spend the inputs) versus `MaybeSent` (must keep the reservation to prevent double-spend). The safety of releasing on `SpvError::Network(NetworkError::NotConnected)` depends entirely on an external dash-spv invariant: that `NotConnected` is only raised when zero peers are connected *before* any send attempt, never after the client has already handed the tx to at least one peer. dash-spv is pinned by git rev and its error contract is not covered by any test in this crate — `mod tests` is absent from `spv/runtime.rs`, and every test in `wallet/core/broadcast.rs` and `wallet/asset_lock/build.rs` uses mock broadcasters that never construct a real `SpvRuntime`. A future dash-spv change that raises `NotConnected` post-partial-send would compile cleanly and silently reintroduce the double-spend window this PR is designed to prevent, with no test signal. At minimum, add a targeted unit test that stubs the internal SPV client and pins both match arms (unstarted client → `Rejected`, `NetworkError::NotConnected` → `Rejected`, any other `SpvError` → `MaybeSent`), so a dash-spv semantic change is caught at the crate boundary rather than the trust boundary.

Source: reviewers: opus (general), claude-sonnet-5 (general), gpt-5.5 (general), opus (security-auditor), claude-sonnet-5 (security-auditor), gpt-5.5 (security-auditor), opus (rust-quality), claude-sonnet-5 (rust-quality), gpt-5.5 (rust-quality), opus (ffi-engineer), claude-sonnet-5 (ffi-engineer, failed_or_unparseable), gpt-5.5 (ffi-engineer); verifier: opus; specialists: security-auditor, rust-quality, ffi-engineer

Comment thread packages/rs-platform-wallet/src/spv/runtime.rs
…ification

Extract the dash-spv error mapping into classify_spv_broadcast_error
and pin all three arms at the crate boundary: unstarted client ->
Rejected, NetworkError::NotConnected -> Rejected (dash-spv raises it
from the zero-peers check before any bytes leave the process), and
every other SpvError -> MaybeSent. A dash-spv semantic change now
trips a test here instead of silently reopening the
release-and-retry double-spend window.

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

@thepastaclaw thepastaclaw left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Code Review

Prior finding on untested SPV Rejected/MaybeSent classification is FIXED at 79caf9b by the extracted classify_spv_broadcast_error and its three-arm test module (unstarted-client, NotConnected→Rejected, all other SpvError→MaybeSent). Reservation-release seam, DAPI conservative MaybeSent, asset-lock untrack-before-release ordering, and FFI code-20 mapping (with Swift mirror) all verify clean. No in-scope blocking or suggestion findings remain.

Source: reviewers: opus (general), claude-sonnet-5 (general), gpt-5.5 (general), opus (security-auditor), claude-sonnet-5 (security-auditor, failed_or_unparseable), gpt-5.5 (security-auditor), opus (rust-quality), claude-sonnet-5 (rust-quality), gpt-5.5 (rust-quality), opus (ffi-engineer), claude-sonnet-5 (ffi-engineer), gpt-5.5 (ffi-engineer); verifier: opus; specialists: security-auditor, rust-quality, ffi-engineer.

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.

4 participants