Skip to content

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

Open
Claudius-Maginificent wants to merge 2 commits into
chore/bump-rust-dashcore-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 2 commits into
chore/bump-rust-dashcore-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

CoreWallet::send_to_addresses (packages/rs-platform-wallet/src/wallet/core/broadcast.rs) builds and signs a transaction via TransactionBuilder::build_signed, which reserves the selected UTXOs in the funds account's ReservationSet, then broadcasts. On broadcast failure the ? propagated the error without releasing the reservation — the inputs stayed locked until the 24-block (~1h) TTL backstop reclaimed them.

Not a funds-loss bug (self-heals via TTL), but a real UX rough edge: a user whose send fails on broadcast (network hiccup, node rejection, etc.) and immediately retries can see those specific UTXOs as unavailable and hit a spurious "insufficient funds", reachable in practice via dash-evo-tool's WalletBackend::send_payment ("Send Dash" screen).

Depends on the reservation system introduced by the rust-dashcore bump to afcff156 in #3976 — this PR is based on that branch and should merge after (or alongside) it.

Fix

On broadcast failure, send_to_addresses releases the reservation before propagating the original broadcast error, so the inputs are selectable again immediately instead of waiting on the TTL backstop:

  • Read-only cleanup path. The release only needs wallet_manager.read().await + an immutable funds_account lookup (ManagedCoreFundsAccount::release_reservation takes &self) — it no longer takes the manager's write lock, so it doesn't serialize against other in-flight sends/balance reads.
  • Granular release condition. Only errors that unambiguously mean "not accepted by the network" (TransactionBroadcast, SpvError) trigger a release; other variants (e.g. Shielded*Unconfirmed, where the tx may have partially propagated) keep the reservation and fall back to the TTL backstop, avoiding a release-and-retry double-spend attempt. TransactionBroadcaster::broadcast's doc comment now states this Err contract explicitly.
  • A missing wallet/account on the release path is tracing::warn!-logged (with wallet_id for correlation) and does not alter the returned error. The wallet-manager lock is never held across the broadcast await.

Test plan

  • TDD: table-driven send_to_addresses_releases_reservation_on_broadcast_failure covers both BIP44Account and BIP32Account — funds a wallet, fails the first broadcast via a fail-once mock TransactionBroadcaster, asserts an immediate retry succeeds for each account type.
  • send_to_addresses_keeps_reservation_on_ambiguous_broadcast_failure (both account types) proves the granular-release condition: an ambiguous broadcaster error keeps the reservation, and an immediate retry fails at UTXO selection rather than silently double-spending.
  • cargo fmt -p platform-wallet
  • cargo clippy -p platform-wallet --all-targets -- -D warnings — clean
  • cargo test -p platform-wallet — 0 failures

🤖 Generated with Claude Code

Co-authored by Claudius the Magnificent AI Agent

`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 eb54b37)

@coderabbitai

coderabbitai Bot commented Jul 2, 2026

Copy link
Copy Markdown
Contributor

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: d8a81f9f-e0cb-40c2-b97f-7727de1e6ed5

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
✨ 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.

@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
Comment thread packages/rs-platform-wallet/src/wallet/core/broadcast.rs
Comment on lines +287 to +348
#[tokio::test]
async fn send_to_addresses_releases_reservation_on_broadcast_failure() {
// Testnet wallet funded with a single spendable UTXO — the whole
// balance rides on that one input, so a leaked reservation strands it.
let (ctx, _funding_tx) = TestWalletContext::new_random()
.with_mempool_funding(10_000_000)
.await;
let signer = WalletSigner {
wallet: ctx.wallet.clone(),
};

let balance = Arc::new(WalletBalance::new());
let info = PlatformWalletInfo {
core_wallet: ctx.managed_wallet,
balance: Arc::clone(&balance),
identity_manager: IdentityManager::new(),
tracked_asset_locks: BTreeMap::new(),
};

let mut wm = WalletManager::<PlatformWalletInfo>::new(Network::Testnet);
let wallet_id = wm.insert_wallet(ctx.wallet, info).expect("insert wallet");
let wallet_manager = Arc::new(RwLock::new(wm));

let sdk = Arc::new(dash_sdk::SdkBuilder::new_mock().build().expect("mock sdk"));
let broadcaster = Arc::new(FailFirstBroadcaster::new());
let core = CoreWallet::new(
sdk,
Arc::clone(&wallet_manager),
wallet_id,
broadcaster,
balance,
);

let recipient = DashAddress::dummy(Network::Testnet, 42);
let outputs = vec![(recipient, 1_000_000u64)];

// First attempt: build + sign succeed, broadcast fails.
let first = core
.send_to_addresses(
StandardAccountType::BIP44Account,
0,
outputs.clone(),
&signer,
)
.await;
assert!(
matches!(first, Err(PlatformWalletError::TransactionBroadcast(_))),
"first send should surface the broadcast failure, got {first:?}"
);

// Immediate retry: only succeeds if the failed broadcast released the
// reservation. With the leak, coin selection sees no spendable UTXO and
// this fails with a build error instead.
let second = core
.send_to_addresses(StandardAccountType::BIP44Account, 0, outputs, &signer)
.await;
assert!(
second.is_ok(),
"retry after a failed broadcast should succeed once the reservation \
is released, got {second:?}"
);
}

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.

💬 Nitpick: 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.

source: ['claude']

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Confirmed — the only regression test (send_to_addresses_releases_reservation_on_broadcast_failure) covers BIP44Account only; the BIP32Account arm in funds_account_mut is untested. Valid gap, not fixed in this PR yet — tracked as a follow-up.

🤖 Co-authored by Claudius the Magnificent AI Agent

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.

Resolved in eb54b37Regression test only covers the BIP44 arm of funds_account_mut no longer present.

Auto-resolved by the review system based on the latest commit diff. If you believe this was closed in error, reopen the thread.

Comment on lines +177 to +186
None => tracing::warn!(
?account_type,
account_index,
"could not release UTXO reservation after failed broadcast: \
funds account not found"
),
},
None => tracing::warn!(
"could not release UTXO reservation after failed broadcast: wallet not found"
),

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.

💬 Nitpick: 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.

source: ['claude']

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Confirmed — both tracing::warn! calls on the release-failure fallback paths omit wallet_id (and the outer one omits account_type/account_index too). Valid correlation gap, not fixed in this PR yet — tracked as a follow-up.

🤖 Co-authored by Claudius the Magnificent AI Agent

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.

Resolved in eb54b37Warn logs on release-path fallback lack wallet_id for correlation no longer present.

Auto-resolved by the review system based on the latest commit diff. If you believe this was closed in error, reopen the thread.

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

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