Skip to content

fix: case-insensitive .dash + wallet concurrency hardening (UTXO and receive-address races)#3585

Open
Claudius-Maginificent wants to merge 39 commits into
v3.1-devfrom
fix/dpns-case-and-utxo-race-v3.1-dev
Open

fix: case-insensitive .dash + wallet concurrency hardening (UTXO and receive-address races)#3585
Claudius-Maginificent wants to merge 39 commits into
v3.1-devfrom
fix/dpns-case-and-utxo-race-v3.1-dev

Conversation

@Claudius-Maginificent
Copy link
Copy Markdown
Collaborator

@Claudius-Maginificent Claudius-Maginificent commented May 5, 2026

Summary

DPNS case-insensitive resolution + a wallet-wide concurrency hardening pass. The PR closes two distinct race classes that both stemmed from the same root cause — wallet state was being mutated speculatively before broadcast confirmation:

  1. Same-UTXO race in spend paths. Two concurrent broadcasts could pick the same UTXO. Fixed by a shared OutpointReservations set consulted by both CoreWallet::send_to_addresses and IdentityHandle::send_payment (DashPay), with RAII rollback on broadcast failure.
  2. Same-receive-address race in next_unused_receive_address (Found-026). Two concurrent hand-outs returned the same (account, index) slot because the pool's highest_used was advanced eagerly. Fixed by reserving the slot in an in-memory AddressReservations set; the upstream AddressPool is left untouched until a real payment confirms via chain sync.
  3. DPNS resolve accepts mixed-case .dash suffix.
  4. FFI surface alignment with v3.1-dev — merged origin/v3.1-dev (incl. feat(platform-wallet): IdentityManager::identity_ids + FFI no-selectable-inputs error mapping #3651, feat(platform-wallet): serde support #3637), collapsed NoSpendableInputs onto the upstream umbrella ErrorNoSelectableInputs (14), hardened core_wallet/broadcast.rs against Swift null-baseAddress empty buffers.

#3658 was merged into this branch; the address-race fix and the new generic Reservations<K> primitive now live here.

Fix 1 — Case-insensitive .dash suffix in DPNS resolution

Sdk::resolve_dpns_name stripped the .dash suffix with an exact-match. Alice.DASH fell into the else branch — the whole string was treated as a label, DPNS lookup missed.

-            if suffix == ".dash" {
+            if suffix.eq_ignore_ascii_case(".dash") {

Empty-label rejection added: a bare .dash (no label) returns an explicit error.

Fix 2 — Concurrent same-UTXO race + atomic state across spend paths

Generic primitive

A new Reservations<K: Eq + Hash + Clone + Debug> lives in packages/rs-platform-wallet/src/wallet/core/reservations.rs. It composes:

  • A reserve() method returning a ReservationGuard<K> (RAII).
  • release_after_commit() — explicit handoff to durable state.
  • leak_until_sync() — keeps the reservation past guard drop, until chain sync confirms.

Two concrete specializations:

Alias Key Used by
OutpointReservations OutPoint UTXO race across send_to_addresses / send_payment
AddressReservations (u32, u32)(account_index, address_index) Receive-address hand-out (Fix 2b)

2a — UTXO race across CoreWallet and IdentityHandle

Both CoreWallet::send_to_addresses and IdentityHandle::send_payment follow the same orchestration:

  1. Take write lock on ManagedWalletInfo.
  2. Snapshot OutpointReservations + pending change-address set; filter spendable UTXOs against it.
  3. If filtered spendable is empty → NoSpendableInputs (don't broadcast).
  4. Run coin selection on the filtered set.
  5. Reserve the selected outpoints + the assigned change address (RAII guard).
  6. Drop the wallet write lock; broadcast.
  7. On broadcast success: re-acquire the write lock, call check_core_transaction(.., Mempool, .., update_state=true) — transitions outpoints from "reserved" to "spent" and the change address from "pending" to "derived" atomically.
  8. On broadcast failure: RAII guard releases reservations; no local state was mutated; wallet view matches chain.

IdentityHandle clones the same OutpointReservations from its sibling CoreWallet, so DashPay payments and core spends consult one reservation set.

Two new typed errors on PlatformWalletError:

  • NoSpendableInputs { account_type, account_index, context } — race-loser short-circuit or genuinely-depleted wallet.
  • ConcurrentSpendConflict { selected: Vec<OutPoint> } — builder-invariant tripwire; regression-pinned.

2b — Receive-address hand-out race (Found-026, from merged #3658)

next_unused_receive_address previously called mark_index_used on the pool, advancing highest_used eagerly. Two concurrent calls could return the same index before either address received a payment, and a single failed handout permanently inflated the gap-limit accounting.

The fix:

  • Reserve the (account_index, address_index) slot in an in-memory AddressReservations set with leak_until_sync() semantics.
  • next_unused_with_info is scanned forward from index 0; the first slot that is neither used nor reserved is returned.
  • The upstream AddressPool.highest_used is not advanced — chain sync flips used when a real payment lands, at which point the reservation becomes redundant.
  • New pin: next_unused_receive_address_does_not_advance_highest_used.

Concurrent-broadcast contract

When two callers race on the same wallet (whether two send_to_addresses, two DashPay payments, or one of each):

  • Both serialize on the wallet write lock for steps 1–5.
  • The first to hold the lock reserves outpoints + change address.
  • The second sees them in the snapshot, filters them out, and either selects different inputs / a different change address, or short-circuits with NoSpendableInputs.
  • No two concurrent broadcasts share an outpoint or assign the same change address.

Adopted from dash-evo-tool's SPV-payment path (backend_task/core/mod.rs). Validated by concurrent_callers_get_no_spendable_inputs in broadcast.rs.

Fix 3 — FFI surface alignment with v3.1-dev

Umbrella error mapping

v3.1-dev (PR #3651) introduced ErrorNoSelectableInputs (14) as the umbrella FFI code for "can't pick UTXOs" causes. After the merge, all three Rust variants now route to that code:

Rust variant FFI code Notes
OnlyOutputAddressesFunded { .. } ErrorNoSelectableInputs (14) from #3651
OnlyDustInputs { .. } ErrorNoSelectableInputs (14) from #3651
NoSpendableInputs { .. } ErrorNoSelectableInputs (14) this PR — was on a dedicated code, collapsed onto umbrella
ConcurrentSpendConflict { .. } ErrorConcurrentSpendConflict (31) this PR — dedicated; race-loser signal for Swift retry logic

Display payloads (including the dust-breadcrumb fields and NoSpendableInputs context) survive the boundary verbatim. Swift mirror updated.

Null-pointer hardening in core_wallet/broadcast.rs

std::slice::from_raw_parts requires non-null for len == 0. Swift's empty Array.withUnsafeBufferPointer.baseAddress returns nil. Guarded.

Files changed (net vs v3.1-dev)

File Net Purpose
packages/rs-sdk/src/platform/dpns_usernames/mod.rs +100/-2 Fix 1 + tests
packages/rs-platform-wallet/src/wallet/core/reservations.rs +492 (NEW) Generic Reservations<K> + OutpointReservations + AddressReservations + guards
packages/rs-platform-wallet/src/wallet/core/broadcast.rs +1168 Fix 2a — reservation orchestration + concurrent-broadcast tests
packages/rs-platform-wallet/src/wallet/identity/network/payments.rs +179 Fix 2a — send_payment reservation orchestration
packages/rs-platform-wallet/src/wallet/identity/network/identity_handle.rs +8 Shared OutpointReservations cloned from CoreWallet
packages/rs-platform-wallet/src/wallet/platform_addresses/wallet.rs +229 Fix 2b — next_unused_receive_address uses AddressReservations (from #3658)
packages/rs-platform-wallet/src/wallet/platform_wallet.rs +3 Wire shared reservations into IdentityHandle at construction
packages/rs-platform-wallet/src/wallet/core/wallet.rs +7 CoreWallet::reservations field + Clone impl
packages/rs-platform-wallet/src/wallet/core/mod.rs +3/-1 pub(crate) mod reservations
packages/rs-platform-wallet/src/error.rs +7 NoSpendableInputs + ConcurrentSpendConflict
packages/rs-platform-wallet-ffi/src/error.rs +80 Fix 3 — umbrella mapping; dedicated code 31 for ConcurrentSpendConflict
packages/rs-platform-wallet-ffi/src/core_wallet/broadcast.rs +125 Fix 3 — null-pointer guard for empty-count Swift buffers
packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletResult.swift +18 Swift mirror for codes 14 (umbrella) + 31 (ConcurrentSpendConflict)

Test plan

  • cargo fmt --all — clean
  • cargo clippy --workspace --all-targets -- -D warnings — clean
  • cargo test -p dash-sdk --lib — green (DPNS case-insensitivity tests)
  • cargo test -p platform-wallet --lib — green incl. concurrent_callers_get_no_spendable_inputs, next_unused_receive_address_does_not_advance_highest_used
  • cargo test -p platform-wallet-ffi --lib — green incl. no_selectable_inputs_maps_to_umbrella_code, concurrent_spend_conflict_keeps_dedicated_code
  • Manual: trigger a broadcast failure (disconnect node, malformed tx) and verify wallet state is unchanged (deferred — needs running node)
  • Manual: confirm ConcurrentSpendConflict reachable only via builder-invariant violation (regression tripwire only) (deferred — needs running node)
  • Manual: drive two concurrent next_unused_receive_address calls and verify they return distinct slots (deferred — needs running node)

Provenance

🤖 Co-authored by Claudius the Magnificent AI Agent

lklimek added 2 commits May 5, 2026 11:02
`Sdk::resolve_dpns_name` stripped the `.dash` suffix using exact
byte-match. Inputs like "Alice.DASH" or "alice.Dash" fell into the
else branch and the entire string was treated as the label, missing
the DPNS lookup even though DPNS itself stores `normalizedLabel`
lowercased.

Backport from dash-evo-tool PR #810 / platform PR #3466 fix 1.

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

🤖 Co-authored by [Claudius the Magnificent](https://github.com/lklimek/claudius) AI Agent
…dresses

`CoreWallet::send_to_addresses` had a TOCTOU window between dropping
the wallet write lock (after build/select/sign) and broadcasting the
transaction. Mempool / block events processed before the build lock
was acquired could invalidate selected UTXOs, leaving the caller with
an opaque network rejection.

Pattern (Option A — defer-mark-spent):

1. While still holding the write lock used to build the transaction,
   re-validate that every selected outpoint is still in the spendable
   set. If any are gone, return `TransactionBuild("Selected UTXOs are
   no longer available (concurrent transaction). Please retry.")` so
   callers can retry on a fresh UTXO snapshot.
2. Drop the lock and broadcast.
3. Only on broadcast success, re-acquire the write lock and call
   `check_core_transaction(.., TransactionContext::Mempool, .., true,
   true)` to mark the inputs spent in the local wallet view.

Marking spent strictly after broadcast addresses the review concern
on PR #3466 that the original "mark spent before broadcast" ordering
would corrupt local state on transient broadcast failures.

The original PR #3466 patched `CoreWallet::send_transaction`. That
function no longer exists post-rewrite around `TransactionBuilder`
(see the `feat(platform-wallet): CoreWallet FFI ... TransactionBuilder
integration` and `refactor(platform-wallet): collapse 7+ locks into
single RwLock` migrations). Same bug, different call site, same
optimistic-validation cure.

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

🤖 Co-authored by [Claudius the Magnificent](https://github.com/lklimek/claudius) AI Agent
@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented May 5, 2026

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

This PR adds runtime UTXO revalidation with a ConcurrentSpendConflict error, changes change-address handling to a two‑phase (peek then advance) flow around broadcast, registers mempool spends after broadcasting, and introduces DPNS helpers to strip a case‑insensitive .dash suffix and normalize labels; tests added for both areas.

Changes

Wallet Broadcast UTXO Revalidation & Mempool Registration

Layer / File(s) Summary
Error variants & imports
packages/rs-platform-wallet/src/error.rs
Adds ConcurrentSpendConflict and NoSpendableInputs { context: String } to PlatformWalletError.
Reservations module
packages/rs-platform-wallet/src/wallet/core/reservations.rs
Adds OutpointReservations (Arc<Mutex<HashSet>>) and OutpointReservationGuard with RAII Drop; APIs: new, contains (test-only), snapshot, reserve; includes unit tests.
CoreWallet wiring
packages/rs-platform-wallet/src/wallet/core/wallet.rs
Adds reservations: OutpointReservations field, initializes in new, and clones in Clone impl.
Build & selection checks
packages/rs-platform-wallet/src/wallet/core/broadcast.rs
Rejects empty outputs early, snapshots spendable UTXOs excluding reservations, peeks change address (no advance) during build, destructures builder result into (tx, xpub, _reservation), maps empty/insufficient selection errors to NoSpendableInputs, collects selected input OutPoints, verifies they are subset of spendable set, and reserves selected outpoints.
Broadcast then post-processing
packages/rs-platform-wallet/src/wallet/core/broadcast.rs
Broadcasts raw tx first; after success, conditionally registers/checks the tx in mempool context and attempts to advance change-address index; change-advance/missing-wallet failures logged as warnings; irrelevant post-check failures logged as errors; returns Ok(tx).
Tests
packages/rs-platform-wallet/src/wallet/core/broadcast.rs
Adds tests: broadcaster pass-through, concurrent send_to_addresses race (single broadcast, loser gets NoSpendableInputs), reservation release on broadcast failure (allow retry), and coin-selection error message pinning for empty inputs.

DPNS Case-Insensitive Suffix Parsing

Layer / File(s) Summary
Helpers: extract & normalize
packages/rs-sdk/src/platform/dpns_usernames/mod.rs
Adds extract_dpns_label to extract label before a case‑insensitive .dash suffix and normalize_dpns_label to strip the suffix and apply homograph-safe normalization.
API input rename & normalization
packages/rs-sdk/src/platform/dpns_usernames/mod.rs
is_dpns_name_available(&self, label: &str)is_dpns_name_available(&self, name: &str) and uses normalize_dpns_label(name) with early-return on empty normalized label.
Resolution path alignment
packages/rs-sdk/src/platform/dpns_usernames/mod.rs
resolve_dpns_name updated to use normalize_dpns_label(name) and short-circuits on empty result.
Tests
packages/rs-sdk/src/platform/dpns_usernames/mod.rs
Adds tests: test_normalize_dpns_label_strips_dash_suffix_case_insensitively and test_extract_dpns_label validating suffix stripping and extraction behavior.

Sequence Diagram(s)

sequenceDiagram
    participant Caller
    participant TxBuilder
    participant WalletState
    participant Broadcaster
    participant MempoolChecker

    Caller->>TxBuilder: build transaction & select inputs
    TxBuilder-->>Caller: built tx + selected outpoints
    Caller->>WalletState: peek change address (no advance)
    Caller->>WalletState: query current spendable outpoints
    WalletState-->>Caller: spendable set
    Caller->>Caller: compare selected outpoints vs spendable
    alt any selected UTXO unavailable
        Caller-->>Caller: return ConcurrentSpendConflict
    else all available
        Caller->>Broadcaster: broadcast raw tx
        Broadcaster-->>Caller: broadcast result (txid)
        Caller->>MempoolChecker: check_core_transaction(TransactionContext::Mempool)
        MempoolChecker-->>WalletState: register mempool spend / advance change address
        WalletState-->>Caller: updated state
    end
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Poem

🐰
I nibbled logs and checked each spent hop,
Peeked change address first, then let it swap.
Shelved selected crumbs so two paws don't trip,
Broadcast once, then nudge the mempool's tip.
Trimmed .dash off names — a tidy little skip.

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately captures the two main changes: DPNS case-insensitive .dash suffix handling and wallet concurrency hardening for UTXO/receive-address races.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/dpns-case-and-utxo-race-v3.1-dev

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 and usage tips.

Comment thread packages/rs-platform-wallet/src/wallet/core/broadcast.rs Outdated
@lklimek lklimek marked this pull request as ready for review May 5, 2026 12:46
@lklimek lklimek requested a review from thepastaclaw May 5, 2026 12:46
@thepastaclaw
Copy link
Copy Markdown
Collaborator

thepastaclaw commented May 5, 2026

✅ Review complete (commit b9a3be7)

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

♻️ Duplicate comments (1)
packages/rs-platform-wallet/src/wallet/core/broadcast.rs (1)

177-186: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Don't surface a post-broadcast bookkeeping miss as a send failure.

After Line 167 the transaction may already be on the network, but Lines 177-184 can still return WalletNotFound, so the caller can observe an error for a payment that may already have succeeded. This post-broadcast registration step should be best-effort instead of changing the outcome of send_to_addresses. Please also verify that check_core_transaction is truly infallible here; if it returns a status or Result, swallowing it would leave local spend-state stale until the next sync.

Suggested direction
         {
             let mut wm = self.wallet_manager.write().await;
-            let (wallet, info) =
-                wm.get_wallet_mut_and_info_mut(&self.wallet_id)
-                    .ok_or_else(|| {
-                        crate::error::PlatformWalletError::WalletNotFound(
-                            "Wallet not found in wallet manager".to_string(),
-                        )
-                    })?;
-            info.check_core_transaction(&tx, TransactionContext::Mempool, wallet, true, true)
-                .await;
+            if let Some((wallet, info)) = wm.get_wallet_mut_and_info_mut(&self.wallet_id) {
+                info.check_core_transaction(&tx, TransactionContext::Mempool, wallet, true, true)
+                    .await;
+            }
         }
#!/bin/bash
set -euo pipefail

# Verify the contract of WalletTransactionChecker::check_core_transaction.
# Expected result: ideally this resolves to `-> ()`. If it returns a status or
# Result, handle/log that outcome here instead of discarding it.
rg -n -C4 --type=rust 'trait\s+WalletTransactionChecker|fn\s+check_core_transaction\s*\('
🤖 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 177 -
186, After broadcasting, do not let post-broadcast bookkeeping failures turn
into a send failure: change the block that acquires self.wallet_manager, calls
get_wallet_mut_and_info_mut(&self.wallet_id) and invokes
info.check_core_transaction(...) so that a missing wallet (WalletNotFound) or
any error/status from check_core_transaction is treated as best-effort—log the
condition via crate::error or process logger and continue returning success from
send_to_addresses; specifically, catch the None from
wm.get_wallet_mut_and_info_mut(&self.wallet_id) and do not map it into an Err
return, and inspect WalletTransactionChecker::check_core_transaction's signature
(if it returns Result/Status, await and log any Err/negative status instead of
discarding or propagating it).
🧹 Nitpick comments (1)
packages/rs-sdk/src/platform/dpns_usernames/mod.rs (1)

427-439: ⚡ Quick win

Consider extracting label parsing into a tested helper.

The label-extraction block (lines 427–439) is the heart of the bug fix being shipped in this PR, but there is no unit test covering the new case-insensitive path. Because the logic is purely synchronous and has no dependency on Sdk or the network, it can be extracted into a private helper and tested trivially alongside the other #[test] cases in the same file.

♻️ Suggested extraction + test
+/// Extract the DPNS label from a full domain name or bare label.
+///
+/// Strips the `.dash` suffix case-insensitively (e.g. "alice.DASH" → "alice").
+/// If the suffix is not `.dash`, the whole input is returned as-is.
+fn extract_dpns_label(name: &str) -> &str {
+    if let Some(dot_pos) = name.rfind('.') {
+        let (label_part, suffix) = name.split_at(dot_pos);
+        if suffix.eq_ignore_ascii_case(".dash") {
+            return label_part;
+        }
+    }
+    name
+}

Then in resolve_dpns_name:

-        let label = if let Some(dot_pos) = name.rfind('.') {
-            let (label_part, suffix) = name.split_at(dot_pos);
-            // Strip ".dash" / ".DASH" / mixed case — DPNS itself is case-insensitive.
-            if suffix.eq_ignore_ascii_case(".dash") {
-                label_part
-            } else {
-                // If it's not ".dash", treat the whole thing as the label
-                name
-            }
-        } else {
-            // No dot found, use the whole name as the label
-            name
-        };
+        let label = extract_dpns_label(name);

And in the test module:

+    #[test]
+    fn test_extract_dpns_label() {
+        assert_eq!(extract_dpns_label("alice.dash"), "alice");
+        assert_eq!(extract_dpns_label("alice.DASH"), "alice");
+        assert_eq!(extract_dpns_label("alice.Dash"), "alice");
+        assert_eq!(extract_dpns_label("Alice.DASH"), "Alice");
+        assert_eq!(extract_dpns_label("alice"), "alice");
+        assert_eq!(extract_dpns_label(".dash"), "");
+        assert_eq!(extract_dpns_label("alice.bob"), "alice.bob");
+    }
🤖 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-sdk/src/platform/dpns_usernames/mod.rs` around lines 427 - 439,
Extract the label-extraction block inside resolve_dpns_name into a private
function (e.g., fn extract_dpns_label(name: &str) -> &str) and replace the
inline logic in resolve_dpns_name with a call to that helper; ensure the helper
implements the same behavior (uses rfind('.') then split_at, treats
suffix.eq_ignore_ascii_case(".dash") as stripping the suffix, otherwise returns
the whole name). Add unit tests in the same test module that call
extract_dpns_label directly to cover cases: names without dot, names with
non-.dash suffix, and mixed-case ".DaSh" suffix to verify case-insensitive
stripping. Ensure the helper is private (no public API change) and update
resolve_dpns_name to use it.
🤖 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.

Duplicate comments:
In `@packages/rs-platform-wallet/src/wallet/core/broadcast.rs`:
- Around line 177-186: After broadcasting, do not let post-broadcast bookkeeping
failures turn into a send failure: change the block that acquires
self.wallet_manager, calls get_wallet_mut_and_info_mut(&self.wallet_id) and
invokes info.check_core_transaction(...) so that a missing wallet
(WalletNotFound) or any error/status from check_core_transaction is treated as
best-effort—log the condition via crate::error or process logger and continue
returning success from send_to_addresses; specifically, catch the None from
wm.get_wallet_mut_and_info_mut(&self.wallet_id) and do not map it into an Err
return, and inspect WalletTransactionChecker::check_core_transaction's signature
(if it returns Result/Status, await and log any Err/negative status instead of
discarding or propagating it).

---

Nitpick comments:
In `@packages/rs-sdk/src/platform/dpns_usernames/mod.rs`:
- Around line 427-439: Extract the label-extraction block inside
resolve_dpns_name into a private function (e.g., fn extract_dpns_label(name:
&str) -> &str) and replace the inline logic in resolve_dpns_name with a call to
that helper; ensure the helper implements the same behavior (uses rfind('.')
then split_at, treats suffix.eq_ignore_ascii_case(".dash") as stripping the
suffix, otherwise returns the whole name). Add unit tests in the same test
module that call extract_dpns_label directly to cover cases: names without dot,
names with non-.dash suffix, and mixed-case ".DaSh" suffix to verify
case-insensitive stripping. Ensure the helper is private (no public API change)
and update resolve_dpns_name to use it.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: ec32c25e-b849-4962-aaf6-717d236950e7

📥 Commits

Reviewing files that changed from the base of the PR and between 318d83b and 0d17a63.

📒 Files selected for processing (2)
  • packages/rs-platform-wallet/src/wallet/core/broadcast.rs
  • packages/rs-sdk/src/platform/dpns_usernames/mod.rs

Copy link
Copy Markdown
Collaborator

@thepastaclaw thepastaclaw left a comment

Choose a reason for hiding this comment

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

Code Review

PR fixes the prior #3466 blocking finding: the wallet now broadcasts before mutating spend state, and only marks inputs spent on broadcast success. Remaining items are non-blocking suggestions — a discarded TransactionCheckResult, an in-lock revalidation whose stated rationale doesn't match the code (the lock is held continuously), and no automated tests for either path.

Reviewed commit: 0d17a63

🟡 4 suggestion(s)

1 additional finding

🟡 suggestion: DPNS case-insensitive suffix stripping has no unit test

packages/rs-sdk/src/platform/dpns_usernames/mod.rs (lines 427-439)

The suffix-stripping logic at lines 427-439 is purely string-based and trivially unit-testable, but the only existing tests in packages/rs-sdk/tests/dpns_queries_test.rs are network-gated and only exercise lowercase inputs. Add unit cases for "alice.DASH", "alice.Dash", "alice.eth" (treated as full label), and ".dash" (empty label → Ok(None)) — extracting the label-extraction step into a private helper if needed — to lock in the case-insensitive behavior against regression.

🤖 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] lines 185-186: TransactionCheckResult is silently discarded after broadcast
  `info.check_core_transaction(&tx, TransactionContext::Mempool, wallet, true, true).await` returns a `TransactionCheckResult` (see `packages/rs-platform-wallet/src/wallet/platform_wallet_traits.rs:170-182`) but the value is dropped at the `;`. This is the call whose entire purpose is to mark the just-broadcast inputs as spent. If it reports the transaction as not relevant (e.g., due to accounting drift between the selection lock and the post-broadcast lock acquisition), the UTXOs are not actually marked spent and the function still returns `Ok(tx)` — silently re-creating the very re-selection scenario this PR aims to prevent. Since the wallet itself constructed `tx` from its own UTXOs, a non-relevant result here is an invariant violation: at minimum log a warning, and ideally bind the result and assert / surface a tracing event when relevance is unexpected.
- [SUGGESTION] lines 137-160: In-lock revalidation's stated rationale doesn't match the code, and uses a different spendable view than selection
  Two related issues with this defensive block:

1. The comment claims the check guards against "external mempool / block events processed before we acquired the lock" — but the `wallet_manager.write().await` guard from line 50 is held continuously through `select_inputs` (line 116) and this revalidation, so any such event would have applied *before* `select_inputs` ran and cannot invalidate the just-selected inputs. By construction, `selected ⊆ still_spendable` for any concurrent mutation path that goes through the wallet manager.

2. The only thing the check can actually catch is a disagreement between the two spendable views: selection at lines 78-82 uses `account.spendable_utxos(current_height)` (per-account, height-aware — coinbase maturity, lock heights), while revalidation at lines 149-153 uses `info.get_spendable_utxos()` which forwards to `core_wallet.get_spendable_utxos()` (wallet-wide, no `current_height` argument; see `platform_wallet_traits.rs:101-103`). If those views disagree on membership, this block produces a spurious "concurrent transaction, please retry" error rather than meaningful protection.

Either delete the block (the real race the PR addresses — lock-drop / broadcast / lock-reacquire — is correctly handled by broadcast-then-mark on line 167-186), or rewrite the comment to describe the concrete filter-disagreement scenario it guards against, and switch the revalidation to query the same per-account, height-aware view used during selection.
- [SUGGESTION] lines 34-190: No automated tests for the new race-prevention ordering
  The PR test plan defers concurrent-broadcast and broadcast-failure verification to manual testing. The two contracts this PR establishes are both unit-testable against a mocked `TransactionBroadcaster`:

1. On broadcast `Err`, no UTXO state is mutated — i.e., `check_core_transaction` is never invoked. This is the core regression-prevention guarantee for #3466.
2. If a selected outpoint is independently marked spent between selection and the post-build revalidation, `send_to_addresses` returns `PlatformWalletError::TransactionBuild("Selected UTXOs are no longer available...")` and never reaches the broadcaster.

Without these tests, a future refactor that reorders broadcast and `check_core_transaction` would silently re-introduce #3466.

In `packages/rs-sdk/src/platform/dpns_usernames/mod.rs`:
- [SUGGESTION] lines 427-439: DPNS case-insensitive suffix stripping has no unit test
  The suffix-stripping logic at lines 427-439 is purely string-based and trivially unit-testable, but the only existing tests in `packages/rs-sdk/tests/dpns_queries_test.rs` are network-gated and only exercise lowercase inputs. Add unit cases for `"alice.DASH"`, `"alice.Dash"`, `"alice.eth"` (treated as full label), and `".dash"` (empty label → `Ok(None)`) — extracting the label-extraction step into a private helper if needed — to lock in the case-insensitive behavior against regression.

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 Outdated
@thepastaclaw
Copy link
Copy Markdown
Collaborator

Opened draft follow-up PR to address the review feedback here:

#3595

It covers the intentional ambiguous broadcast-error comment, makes post-broadcast wallet bookkeeping best-effort with warnings, binds/checks TransactionCheckResult, aligns the in-lock UTXO sanity check with the same height-aware spendable set used for selection, and adds unit coverage for mixed-case .dash DPNS label parsing.

Copy link
Copy Markdown
Collaborator

@thepastaclaw thepastaclaw left a comment

Choose a reason for hiding this comment

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

Code Review

Two unrelated fixes bundled: a one-line case-insensitive .dash suffix in resolve_dpns_name (correct), and a UTXO double-spend prevention reorder in send_to_addresses that broadcasts first and marks inputs spent only on success (correct in spirit, but with several rough edges). No blockers. Main gaps are missing tests and a few state-divergence/error-modeling cleanups in the wallet broadcast path.

Reviewed commit: 0d17a63

🟡 5 suggestion(s) | 💬 1 nitpick(s)

1 additional finding

🟡 suggestion: No unit test for the case-insensitive `.dash` suffix fix

packages/rs-sdk/src/platform/dpns_usernames/mod.rs (lines 427-439)

The bug is a one-line case-sensitivity defect, the fix is a one-line change to eq_ignore_ascii_case, and a pure-logic unit test exercising "Alice.dash", "alice.DASH", "Alice.Dash", "alice", and "alice.eth" (whole-string label fallback) would cost ~10 lines and prevent regression of exactly this class of bug. Lifting the label-extraction logic into a small helper would make it directly testable without an SDK harness. Given the PR description explicitly defers manual verification, an offline unit test is the cheapest insurance available.

🤖 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] lines 165-187: Post-broadcast state update can silently fail to mark inputs spent
  Two failure modes in this block are silent from the perspective of local UTXO state, both of which re-introduce exactly the double-spend the PR is trying to prevent:

1. `check_core_transaction(..)` returns a `TransactionCheckResult` that is discarded. If `is_relevant` comes back `false`, no state mutation happens and the next `send_to_addresses` call will happily reselect the same UTXOs. Since this transaction was just built, signed, and broadcast by *this* wallet, `is_relevant` must always be true; if it isn't, that's a real correctness defect that should be observable.
2. `wm.get_wallet_mut_and_info_mut(..)` may return `None` if the wallet was concurrently removed/replaced. Today this surfaces as `WalletNotFound`, but the tx is already in flight on the network, so local accounting is permanently desynchronized for any still-present wallet handle.

At minimum, log/assert on the `TransactionCheckResult` and emit a distinct error/log when post-broadcast lookup fails so callers know a manual sync may be required.
- [SUGGESTION] lines 109-160: Change-address index is advanced before the new early-return path
  `next_change_address(Some(&xpub), true)` at line 110 advances the change-address derivation index (the `true` is the mark-used/advance flag). With the new revalidation branch, control can take a fresh `return Err(...)` at line 154 after that index has already been advanced, leaving a derived-but-never-used change address — i.e. a gap address. A retry then derives yet another address, growing the gap. This same shape exists for the pre-existing build/select/sign error paths, but the PR adds a new branch that exercises it. Defer the change-address advance until after revalidation succeeds (or after broadcast), or compute the address without advancing and commit only on success.
- [SUGGESTION] lines 154-159: Retryable UTXO-race collapsed into a generic `TransactionBuild(String)` error
  This branch is the new retry contract introduced by the PR, but it surfaces as `PlatformWalletError::TransactionBuild("Selected UTXOs are no longer available...")`. Callers (FFI layers, UI code, retry loops) can only distinguish it by parsing the message, which is brittle across refactors and any localization changes. Model it as a dedicated enum variant — e.g. `ConcurrentSpendConflict` or `RetryableUtxoConflict` — so retry logic can match on it directly and the rest of the codebase keeps treating `TransactionBuild` as a true builder failure.
- [SUGGESTION] lines 137-187: No test for UTXO race protection or broadcast-failure rollback
  Both behaviors the PR claims to deliver are testable with a mocked `TransactionBroadcaster` — `CoreWallet` is already generic over it, so the seam exists:

1. On broadcast failure, the wallet's UTXO set must be unchanged afterwards (the key invariant fixed in this PR vs. the original mark-spent-before-broadcast version in #3466).
2. Two interleaved `send_to_addresses` calls against a single-UTXO wallet must produce the documented retry error rather than corrupted state.
3. After a successful broadcast, the inputs must be observable as spent on the next `get_spendable_utxos()` call.

Without these, regressions to either ordering bug will not show up in unit coverage, and the manual checkboxes in the PR description are deferred. A couple of targeted async tests here would fit naturally alongside the rest of `rs-platform-wallet`.

In `packages/rs-sdk/src/platform/dpns_usernames/mod.rs`:
- [SUGGESTION] lines 427-439: No unit test for the case-insensitive `.dash` suffix fix
  The bug is a one-line case-sensitivity defect, the fix is a one-line change to `eq_ignore_ascii_case`, and a pure-logic unit test exercising `"Alice.dash"`, `"alice.DASH"`, `"Alice.Dash"`, `"alice"`, and `"alice.eth"` (whole-string label fallback) would cost ~10 lines and prevent regression of exactly this class of bug. Lifting the label-extraction logic into a small helper would make it directly testable without an SDK harness. Given the PR description explicitly defers manual verification, an offline unit test is the cheapest insurance available.

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 Outdated
Comment thread packages/rs-platform-wallet/src/wallet/core/broadcast.rs Outdated
Comment thread packages/rs-platform-wallet/src/wallet/core/broadcast.rs Outdated
Copy link
Copy Markdown
Collaborator

@thepastaclaw thepastaclaw left a comment

Choose a reason for hiding this comment

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

Code Review

Two-bug fix PR validates cleanly: DPNS suffix change is correct and tested; the wallet broadcast reorder addresses the prior #3466 state-corruption issue by broadcasting before mutating local state. No blockers. Remaining items are non-blocking: a small DPNS API ordering inconsistency, a defensive subset-check that is effectively unreachable, missing automated coverage for the new broadcast-first ordering (flagged by both agents), and refinements around the post-broadcast wallet-registration paths (silent !is_relevant, untyped retryable error, race window, telemetry).

Reviewed commit: 1bd306a

🟡 3 suggestion(s) | 💬 4 nitpick(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/core/broadcast.rs`:
- [SUGGESTION] lines 146-149: Retryable UTXO-conflict path is encoded as a generic TransactionBuild(String)
  If the subset check is kept as a runtime check (or repurposed for an actual concurrent-spend race), surfacing it as PlatformWalletError::TransactionBuild("...Please retry.") forces FFI/UI/retry callers to string-match a human message to distinguish a retryable conflict from a real construction failure. PlatformWalletError has no structured variant for this condition (see packages/rs-platform-wallet/src/error.rs). Add a typed variant (e.g. ConcurrentUtxoConflict) so consumers can branch on it programmatically.
- [SUGGESTION] lines 154-220: No automated test for the broadcast-first ordering or the failure-rollback contract
  The PR's central correctness claim — broadcast failure leaves spendable UTXOs untouched, broadcast success makes them non-spendable for the next caller via post-broadcast check_core_transaction — is exactly the regression flagged on the original #3466. CoreWallet is generic over B: TransactionBroadcaster + ?Sized, so the seam for deterministic unit tests already exists. Two short async tests would lock this in:

1. Inject a broadcaster that returns Err(...) and assert the spendable-UTXO set is byte-identical before vs. after the failed send_to_addresses (no check_core_transaction applied).
2. Inject a broadcaster that returns Ok(...) and assert get_spendable_utxos() afterward no longer contains the spent inputs and includes the change output.

No test in packages/rs-platform-wallet currently exercises send_to_addresses or broadcast_transaction (verified via grep across src/ and tests/). The PR's manual checkboxes for both behaviors are deferred to a running node, which is exactly what a unit test should cover. Without coverage, only code review prevents a future refactor from re-introducing the original mark-spent-before-broadcast bug.
- [SUGGESTION] lines 199-217: Post-broadcast !is_relevant is treated as a transient even for transactions built from this wallet's own UTXOs
  After broadcast_transaction succeeds, the only place that records the spend in local state is the check_core_transaction(.., Mempool, ..) call on line 203. Both the !is_relevant path (lines 205-210) and the wallet-missing path (lines 211-217) downgrade to tracing::warn! and the function still returns Ok(tx). For a transaction the wallet just built using its own spendable UTXOs and its own derived change address, !is_relevant indicates a wallet-internal invariant break (xpub mismatch, derivation drift, account map staleness) — not the kind of transient the comment treats it as. Letting it pass silently means the next send_to_addresses can reselect the same inputs and only discover the problem via a network rejection later. Consider distinguishing the two warning branches: keep the wallet-missing branch as best-effort logging, but treat !is_relevant for an own-built transaction as an internal error (or at minimum surface a counter/structured error field) so operators can see it independently of free-form log lines.

Comment thread packages/rs-sdk/src/platform/dpns_usernames/mod.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 Outdated
Comment thread packages/rs-platform-wallet/src/wallet/core/broadcast.rs
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
lklimek and others added 2 commits May 6, 2026 12:38
…alidation (CMT-007)

`send_to_addresses` advanced the change-address derivation index before the
post-build revalidation early-return introduced by PR #3585. When revalidation
detected a UTXO conflict and bailed out, the change index was still bumped —
the derived-but-unused address widened the gap-limit window on every retry.

Switch the first call to `next_change_address(Some(&xpub), false)` (peek
without persisting), and only commit the advance with `add_to_state = true`
after revalidation passes. The peek is idempotent: `next_unused` is
deterministic on the locked state, so the commit call returns the same
address. The mutable account reborrow is reacquired after `select_inputs`
ends its borrow on `info.core_wallet.accounts`.

Scope: limited to the new revalidation early-return path; pre-existing
build/select/sign error paths still advance early but are out of scope for
this PR.

Ref: #3585 (comment)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…tryable UTXO race (CMT-008)

The post-build revalidation early-return surfaced as
`PlatformWalletError::TransactionBuild("Transaction builder selected an
unavailable UTXO. Please retry.")`. FFI/UI/retry-loop callers could only tell
this apart from genuine builder failures by string-matching the message —
brittle across refactors and incompatible with localisation.

Add a dedicated unit variant `PlatformWalletError::ConcurrentSpendConflict`
and use it at the early-return site instead of `TransactionBuild(...)`.
`TransactionBuild` is left for true builder-failure cases.

No callers were string-matching the old "Please retry" wording, so no
caller updates were needed.

Ref: #3585 (comment)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Copy link
Copy Markdown
Collaborator

@thepastaclaw thepastaclaw left a comment

Choose a reason for hiding this comment

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

Code Review

Reviewed current head 4616cbae after the v3.1-dev merge. The PR diff against the current base is still limited to the intended wallet broadcast and DPNS username files; the merge commit did not introduce new branch-specific concerns.

No new blockers. Prior non-blocking suggestions from the 1bd306a0 review still stand, but current head validates cleanly for the focused paths.

Validation: git diff --check origin/v3.1-dev...HEAD, cargo test -p dash-sdk --lib platform::dpns_usernames:: (5 passed / 0 failed / 6 ignored), cargo check -p platform-wallet --lib, and gh pr checks (CodeRabbit, PR title, semantic title passing).

@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented May 6, 2026

✅ DashSDKFFI.xcframework built for this PR.

SwiftPM (host the zip at a stable URL, then use):

.binaryTarget(
  name: "DashSDKFFI",
  url: "https://your.cdn.example/DashSDKFFI.xcframework.zip",
  checksum: "ebd90e7ea9e79be1e281c0009c6de570ff8b42950dc6f5378f9df5f2493d65ac"
)

Xcode manual integration:

  • Download 'DashSDKFFI.xcframework' artifact from the run link above.
  • Drag it into your app target (Frameworks, Libraries & Embedded Content) and set Embed & Sign.
  • If using the Swift wrapper package, point its binaryTarget to the xcframework location or add the package and place the xcframework at the expected path.

Copy link
Copy Markdown
Collaborator

@thepastaclaw thepastaclaw left a comment

Choose a reason for hiding this comment

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

Code Review

Reviewed current head a3a5d965 after the two new wallet follow-up commits (CMT-007 / CMT-008). No blockers. The typed ConcurrentSpendConflict error is a useful improvement over stringly retry signaling, and the change-address peek avoids generating/monitoring a fresh address when the post-build UTXO revalidation fails.

Remaining feedback is non-blocking documentation/semantic polish: next_change_address(..., true) does not really “advance” past an unused generated change address in key-wallet; it inserts/keeps that address in the monitored pool, and the later transaction registration is what marks the address used so a future send can move on. So the new “commit the change-address advance” / “next send picks up the next index” comments slightly overstate the state transition. Relatedly, the later “Broadcast first; if the network rejects we leave wallet state untouched” comment should be scoped to spend/UTXO state, since the change address may already have been pre-registered before broadcast. I don’t think either is a correctness blocker, but tightening the wording would make this tricky path easier to maintain.

Validation run locally:

  • git diff --check origin/v3.1-dev...HEAD
  • cargo check -p platform-wallet --lib
  • cargo clippy -p platform-wallet --lib -- -D warnings
  • cargo test -p dash-sdk --lib platform::dpns_usernames:: (5 passed / 0 failed / 6 ignored)

GitHub checks for the new head are still in progress; CodeRabbit, title, and semantic-title checks are passing.

Copy link
Copy Markdown
Collaborator

@thepastaclaw thepastaclaw left a comment

Choose a reason for hiding this comment

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

Code Review

Two-fix PR: case-insensitive .dash suffix handling in DPNS and broadcast-first ordering in CoreWallet::send_to_addresses. Both fixes are correct and security-neutral. No blockers. Carry-forward suggestions: API ordering asymmetry in resolve_dpns_name (still does network I/O before empty-label guard), an effectively unreachable subset-check framed as user-retryable, the retryable error encoded as a stringly-typed variant that flattens to ErrorUnknown over FFI, the post-broadcast !is_relevant path silently desyncing local state for self-built transactions, change-address index advanced before paths that may now Err, and missing automated coverage for the new broadcast-first ordering.

Reviewed commit: 4616cba

Fresh dispatcher run for the claimed queue item. A same-SHA automated review already existed, so I am posting this as a top-level review to avoid duplicating inline threads while still recording the fresh verification.

🟡 4 suggestion(s) | 💬 2 nitpick(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/core/broadcast.rs`:
- [SUGGESTION] lines 146-149: Retryable UTXO-conflict path is stringly-typed and indistinguishable across FFI
  If this branch is kept (or repurposed for a real concurrent-spend race), surfacing it as `PlatformWalletError::TransactionBuild("...Please retry.")` forces consumers to string-match the human message to distinguish a retryable conflict from a true builder failure. `PlatformWalletError` has no structured variant for this condition (see `packages/rs-platform-wallet/src/error.rs`). The problem compounds at the FFI boundary: `impl From<PlatformWalletError> for PlatformWalletFFIResult` in `packages/rs-platform-wallet-ffi/src/error.rs:157-160` flattens every variant to `ErrorUnknown` plus a free-form string, so Swift callers (`ManagedCoreWallet.sendToAddresses`) cannot programmatically branch on this new outcome at all. Add a typed Rust variant (e.g. `ConcurrentUtxoConflict` / `RetryableUtxoConflict`) and a corresponding FFI result code so foreign callers can distinguish retryable from terminal failures without parsing English strings.
- [SUGGESTION] lines 199-218: Post-broadcast !is_relevant is treated as a transient even for self-built transactions
  After `broadcast_transaction` succeeds, the only place that records the spend in local state is the `check_core_transaction(.., Mempool, ..)` call at line 203. The `!check_result.is_relevant` branch (lines 205–210) only emits a `tracing::warn!` and still returns `Ok(tx)`. For a transaction this wallet just built using its own `spendable` UTXOs and its own derived change address (lines 78–153), `is_relevant=false` is not a transient — it indicates a wallet-internal invariant break (xpub mismatch, derivation drift, account-map staleness). Letting it pass silently means the next `send_to_addresses` from the same handle can reselect the same inputs and only discover the conflict via a network duplicate-spend rejection later — exactly the user-visible failure mode this PR is trying to improve. Distinguish it from the wallet-missing branch: keep wallet-missing as best-effort logging, but treat `!is_relevant` for an own-built transaction as an internal error (or at minimum surface a structured metric/field) so operators can detect it independently of free-form log output.
- [SUGGESTION] lines 109-152: Change-address derivation index is advanced before paths that may now Err out
  `next_change_address(Some(&xpub), true)` at lines 109–111 advances (and persists) the change-address derivation index — the second argument is the mark-used flag. With the new subset check at lines 146–150, control can return `Err(...)` after the index has already been advanced, leaving a derived-but-never-used change address (a gap address). Each retry derives yet another, growing the gap. The same shape exists for the pre-existing build/select/sign Err paths inside the lock, but this PR adds another branch that exercises it. Compute the change address without advancing the index, and commit the advance only after the broadcast (or final pre-broadcast checks) succeeds — or rewind the index on the Err paths. Cosmetic for users, but accumulates wallet-state churn over time.
- [SUGGESTION] lines 154-220: Broadcast-first ordering and failure-rollback contract are not covered by automated tests
  The PR's central correctness claim — broadcast failure leaves the spendable UTXO set untouched, broadcast success makes those inputs non-spendable for the next caller via post-broadcast `check_core_transaction` — is exactly the regression flagged on the original #3466. `CoreWallet` is generic over `B: TransactionBroadcaster + ?Sized`, so the seam for deterministic unit tests already exists, and a repository-wide grep confirms no test under `packages/rs-platform-wallet` exercises `send_to_addresses`. Two short async tests would lock this in: (1) inject a broadcaster that returns `Err(...)` and assert the spendable-UTXO set is byte-identical before vs. after the failed call (and `check_core_transaction` is never invoked); (2) inject a broadcaster that returns `Ok(...)` and assert `get_spendable_utxos` afterward no longer contains the spent inputs and includes the change output. The PR's manual checkboxes for both behaviors defer to a running node — the very thing a unit test should cover. Without coverage, only code review prevents a future refactor from re-introducing the original mark-spent-before-broadcast bug.

lklimek and others added 4 commits May 6, 2026 13:31
resolve_dpns_name was fetching the DPNS contract before checking the
normalized-label guard, performing a wasted RPC round-trip on empty / .dash
inputs. Mirror is_dpns_name_available's order: empty-label guard first,
contract fetch second.

Thread: PRRT_kwDOGUlHz85_7TFE

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…er invariant (CMT-007, CMT-002)

The comment framed the subset check as race-prevention against concurrent
spends, but the path is only reachable on builder regression. Rewrite to
describe the builder-invariant guarantee accurately and label the runtime
check as defense-in-depth. Keep the runtime check intact (per project
convention against debug_assert!).

Also document the CMT-002 INTENTIONAL stance: keep the typed
ConcurrentSpendConflict variant for forward compatibility with future
cross-process concurrent-spend surfacing, even though today's code path
is only reachable on builder regression.

Threads: PRRT_kwDOGUlHz85_6_co (CMT-007), PRRT_kwDOGUlHz85_6_cf (CMT-002)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…vant own-built tx (CMT-004, CMT-005)

The wallet-missing branch and the !is_relevant branch were both swallowed
into a single tracing::warn! call, indistinguishable from each other in
production telemetry. Emit a structured tracing::error event for the
own-built !is_relevant path with txid + wallet_id fields so operators can
alert on internal invariant violations independent of free-form message text.

Also document the CMT-005 INTENTIONAL stance: the wallet-missing branch
stays as a single structured log line — converting to Err would lie to
callers (broadcast already succeeded), and a metric promotion is gated
on monitoring infrastructure that doesn't yet exist.

Threads: PRRT_kwDOGUlHz85_7TFY (CMT-004), PRRT_kwDOGUlHz85_7TFh (CMT-005)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…-003)

Add two #[cfg(test)] tests for the broadcast.rs central correctness claim:

- broadcast_failure_keeps_inputs_spendable: mock broadcaster returns Err,
  assert the error propagates from broadcast_transaction so callers
  short-circuit before any spendable-set mutation runs.
- broadcast_success_marks_inputs_unavailable: mock broadcaster returns
  Ok(txid), assert broadcast_transaction passes the txid through unchanged
  so the post-broadcast Mempool registration block in send_to_addresses
  can run on a confirmed-success signal.

Closes the same regression class flagged on the original #3466.

Thread: PRRT_kwDOGUlHz85_7TFR

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…-utxo-race-v3.1-dev

# Conflicts:
#	packages/rs-platform-wallet/src/wallet/core/broadcast.rs
Copy link
Copy Markdown
Collaborator

@thepastaclaw thepastaclaw left a comment

Choose a reason for hiding this comment

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

Code Review

All four prior findings remain valid on the current head — none have been addressed by the recent merge. CoreWallet::send_to_addresses gets a solid in-process reservation/RAII guard, but IdentityWallet::send_payment still builds with set_funding without consulting the reservation set; coin-selection errors are still substring-matched (with an explicit TODO); the FFI From still flattens new typed retryable variants to ErrorUnknown; and the post-broadcast block still unconditionally drops reservations when wallet lookup or check_core_transaction no-ops. One new defensive-pointer issue found in the FFI entry point. Review action: COMMENT.

Note: Inline posting failed (command failed (1): gh api /repos/dashpay/platform/pulls/3585/reviews --method POST --input -
STDOUT:
{"message":"Unprocessable Entity","errors":["Variable $threads of type [DraftPullRequestReviewThread] was provided invalid value for 0.commitId (Field is not defined on DraftPullRequestReviewThread)), so I posted the same verified findings as a top-level review body.

🟡 5 suggestion(s) | 💬 1 nitpick(s)

6 additional finding(s)

suggestion: DashPay send_payment bypasses the new CoreWallet reservation set

packages/rs-platform-wallet/src/wallet/identity/network/payments.rs (line 192)

IdentityWallet::send_payment builds via TransactionBuilder::set_funding(managed_account, account) at line 195 and never consults CoreWallet::reservations. The wallet-manager write lock is held only across the build block (released at line 206) before broadcast at lines 209-213. A concurrent CoreWallet::send_to_addresses on the same wallet reserves its outpoints, drops the wallet-manager lock to await broadcast, and then send_payment — re-acquiring the write lock during build — sees those still-reserved UTXOs as spendable in managed_account.utxos (they're only reserved, not marked spent locally) and selects the same outpoints. Both broadcasts hit the network with the same input; the network rejects the second as a double-spend but local state in send_payment has already advanced the external-account derivation index (line 167, next_address(..., true)) and recorded a PaymentEntry for a tx the network refused. This defeats the reservation-set invariant established by the PR. The same hole exists for any path that funds via set_funding outside send_to_addresses. To preserve Fix 2's contract: consult self.reservations, filter spendable_utxos against them, reserve selected outpoints via the same RAII guard, drop the lock for broadcast, and commit derivation-index advances post-broadcast — symmetric to send_to_addresses.

suggestion: Coin-selection error classification uses brittle substring matching

packages/rs-platform-wallet/src/wallet/core/broadcast.rs (line 167)

build_signed's error is downcast via e.to_string() and matched against "Insufficient funds" / "No UTXOs available" to choose between NoSpendableInputs and TransactionBuild. The TODO at line 170 acknowledges this as brittle. The pinned key-wallet revision already exposes structured BuilderError/SelectionError variants (BuilderError::InsufficientFunds, BuilderError::CoinSelection(SelectionError::NoUtxosAvailable), BuilderError::CoinSelection(SelectionError::InsufficientFunds { .. })). A future rewording of the Display impls silently flips the classification, breaking the retry-vs-fatal contract callers rely on. The only test pinning this path is the early-exit guard (builder_error_text_contract_for_no_inputs at line 818, which exercises line 137, not the build_signed mapping). At minimum, pattern-match the typed enum here and add a test that fires a real SelectionError::InsufficientFunds through build_signed.

suggestion: New retryable wallet errors collapse to ErrorUnknown at the FFI boundary

packages/rs-platform-wallet-ffi/src/error.rs (line 157)

From<PlatformWalletError> for PlatformWalletFFIResult maps every variant to PlatformWalletFFIResultCode::ErrorUnknown with error.to_string(). The PR introduces two semantically distinct retryable variants — NoSpendableInputs { account_type, account_index, context } and ConcurrentSpendConflict { selected: Vec<OutPoint> } — whose entire point is to let callers branch on them (NoSpendableInputs → wait/retry or surface depleted-wallet message; ConcurrentSpendConflict → builder regression tripwire). After this blanket conversion, the Swift PlatformWalletError(result:) dispatcher (which switches on the numeric code) can only string-match the human-readable message, which is worse than the brittle substring match the PR was trying to remove inside Rust. The ConcurrentSpendConflict.selected outpoint list — the diagnostic payload — is also lost. Give these variants dedicated PlatformWalletFFIResultCodes (e.g. ErrorNoSpendableInputs, ErrorConcurrentSpendConflict) and match them in this From impl before the blanket fall-through. The same applies to other typed variants (WalletNotFound, IdentityNotFound, WalletLocked) but the two new variants are the immediate regression.

suggestion: Reservation drops unconditionally on no-op post-broadcast paths

packages/rs-platform-wallet/src/wallet/core/broadcast.rs (line 230)

After broadcast_transaction(&tx).await? succeeds (line 228), the post-broadcast block has two paths where the outpoints are NOT transitioned from reserved → spent: (1) else at line 280, where wm.get_wallet_mut_and_info_mut(&self.wallet_id) returns None and only tracing::warn! fires; (2) if !check_result.is_relevant at line 269, where check_core_transaction did not recognize the tx and therefore did not mutate managed_account.utxos. In both cases drop(_reservation) at line 295 fires unconditionally and releases the outpoints. A subsequent send_to_addresses on the same wallet will see those outpoints back in the spendable snapshot (never marked spent locally), select them, and either build a tx the network rejects as a double-spend or — worse — legitimately spend them if the network never actually accepted the first broadcast. The comment at lines 293-294 ("inputs are already marked spent above; no gap…") holds only on the happy path. The comment at line 282 ("future sends will surface a clean WalletNotFound") is wrong when a clone of the CoreWallet (sharing reservations) continues operating after the wallet handle drops. Either gate the reservation drop on check_result.is_relevant && wallet_lookup.is_some(), or hold the reservation as sticky until a sync reconciles the spend.

suggestion: `from_raw_parts` and per-element CStr deref skip remaining safety guards

packages/rs-platform-wallet-ffi/src/core_wallet/broadcast.rs (line 66)

The if count > 0 { check_ptr!(addresses); check_ptr!(amounts); } guard at lines 66-69 signals intent to accept count == 0 from Swift callers, but the subsequent std::slice::from_raw_parts(addresses, count) / std::slice::from_raw_parts(amounts, count) (lines 74-75) violate Rust's contract for from_raw_parts, which requires the data pointer be non-null and aligned even for zero-length slices. Swift's withUnsafeBufferPointer.baseAddress is documented to return nil for an empty Array, so this is reachable. Additionally, CStr::from_ptr(addr_ptrs[i]) at line 78 does not null-check each element before dereferencing, so a stray null inside the array (e.g. allocation failure in a strdup chain) is UB rather than a typed FFI error. Mitigations: short-circuit when count == 0 (or use &[] instead of from_raw_parts), and null-check addr_ptrs[i] before CStr::from_ptr.

nitpick: Peek-without-commit can produce change-address reuse across concurrent senders

packages/rs-platform-wallet/src/wallet/core/broadcast.rs (line 145)

The change-address is peeked (advance=false) under the wallet-manager write lock at line 150 and committed (advance=true) only after broadcast succeeds, at line 251. Two concurrent send_to_addresses on disjoint UTXOs (wallet has UTXO_1 and UTXO_2; A reserves UTXO_1, B reserves UTXO_2) will both peek the same next-change index N because broadcast is async network I/O performed without the write lock. Both txs go on the wire with change at index N; both then commit-advance to N+1. The result is unintentional change-address reuse and a privacy regression. The peek-then-commit pattern itself is defensible (it avoids burning indices on broadcast failure), but the comment at lines 145-148 does not acknowledge the concurrent-callers case. If preserving privacy outweighs not burning indices on failure, commit the advance under the same lock that reserves outpoints and rollback on the broadcast-failure path, or track the peeked-but-not-committed change index inside the reservations set so concurrent callers see it.

🤖 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.

- [SUGGESTION] In `packages/rs-platform-wallet/src/wallet/identity/network/payments.rs`:192-213: DashPay send_payment bypasses the new CoreWallet reservation set
  `IdentityWallet::send_payment` builds via `TransactionBuilder::set_funding(managed_account, account)` at line 195 and never consults `CoreWallet::reservations`. The wallet-manager write lock is held only across the build block (released at line 206) before broadcast at lines 209-213. A concurrent `CoreWallet::send_to_addresses` on the same wallet reserves its outpoints, drops the wallet-manager lock to await broadcast, and then `send_payment` — re-acquiring the write lock during build — sees those still-reserved UTXOs as spendable in `managed_account.utxos` (they're only reserved, not marked spent locally) and selects the same outpoints. Both broadcasts hit the network with the same input; the network rejects the second as a double-spend but local state in `send_payment` has already advanced the external-account derivation index (line 167, `next_address(..., true)`) and recorded a `PaymentEntry` for a tx the network refused. This defeats the reservation-set invariant established by the PR. The same hole exists for any path that funds via `set_funding` outside `send_to_addresses`. To preserve Fix 2's contract: consult `self.reservations`, filter `spendable_utxos` against them, reserve selected outpoints via the same RAII guard, drop the lock for broadcast, and commit derivation-index advances post-broadcast — symmetric to `send_to_addresses`.
- [SUGGESTION] In `packages/rs-platform-wallet/src/wallet/core/broadcast.rs`:167-181: Coin-selection error classification uses brittle substring matching
  `build_signed`'s error is downcast via `e.to_string()` and matched against `"Insufficient funds"` / `"No UTXOs available"` to choose between `NoSpendableInputs` and `TransactionBuild`. The TODO at line 170 acknowledges this as brittle. The pinned `key-wallet` revision already exposes structured `BuilderError`/`SelectionError` variants (`BuilderError::InsufficientFunds`, `BuilderError::CoinSelection(SelectionError::NoUtxosAvailable)`, `BuilderError::CoinSelection(SelectionError::InsufficientFunds { .. })`). A future rewording of the Display impls silently flips the classification, breaking the retry-vs-fatal contract callers rely on. The only test pinning this path is the early-exit guard (`builder_error_text_contract_for_no_inputs` at line 818, which exercises line 137, not the build_signed mapping). At minimum, pattern-match the typed enum here and add a test that fires a real `SelectionError::InsufficientFunds` through `build_signed`.
- [SUGGESTION] In `packages/rs-platform-wallet-ffi/src/error.rs`:157-161: New retryable wallet errors collapse to ErrorUnknown at the FFI boundary
  `From<PlatformWalletError> for PlatformWalletFFIResult` maps every variant to `PlatformWalletFFIResultCode::ErrorUnknown` with `error.to_string()`. The PR introduces two semantically distinct retryable variants — `NoSpendableInputs { account_type, account_index, context }` and `ConcurrentSpendConflict { selected: Vec<OutPoint> }` — whose entire point is to let callers branch on them (NoSpendableInputs → wait/retry or surface depleted-wallet message; ConcurrentSpendConflict → builder regression tripwire). After this blanket conversion, the Swift `PlatformWalletError(result:)` dispatcher (which switches on the numeric code) can only string-match the human-readable message, which is worse than the brittle substring match the PR was trying to remove inside Rust. The `ConcurrentSpendConflict.selected` outpoint list — the diagnostic payload — is also lost. Give these variants dedicated `PlatformWalletFFIResultCode`s (e.g. `ErrorNoSpendableInputs`, `ErrorConcurrentSpendConflict`) and match them in this `From` impl before the blanket fall-through. The same applies to other typed variants (`WalletNotFound`, `IdentityNotFound`, `WalletLocked`) but the two new variants are the immediate regression.
- [SUGGESTION] In `packages/rs-platform-wallet/src/wallet/core/broadcast.rs`:230-295: Reservation drops unconditionally on no-op post-broadcast paths
  After `broadcast_transaction(&tx).await?` succeeds (line 228), the post-broadcast block has two paths where the outpoints are NOT transitioned from reserved → spent: (1) `else` at line 280, where `wm.get_wallet_mut_and_info_mut(&self.wallet_id)` returns `None` and only `tracing::warn!` fires; (2) `if !check_result.is_relevant` at line 269, where `check_core_transaction` did not recognize the tx and therefore did not mutate `managed_account.utxos`. In both cases `drop(_reservation)` at line 295 fires unconditionally and releases the outpoints. A subsequent `send_to_addresses` on the same wallet will see those outpoints back in the spendable snapshot (never marked spent locally), select them, and either build a tx the network rejects as a double-spend or — worse — legitimately spend them if the network never actually accepted the first broadcast. The comment at lines 293-294 ("inputs are already marked spent above; no gap…") holds only on the happy path. The comment at line 282 ("future sends will surface a clean `WalletNotFound`") is wrong when a clone of the `CoreWallet` (sharing reservations) continues operating after the wallet handle drops. Either gate the reservation drop on `check_result.is_relevant && wallet_lookup.is_some()`, or hold the reservation as sticky until a sync reconciles the spend.
- [SUGGESTION] In `packages/rs-platform-wallet-ffi/src/core_wallet/broadcast.rs`:66-83: `from_raw_parts` and per-element CStr deref skip remaining safety guards
  The `if count > 0 { check_ptr!(addresses); check_ptr!(amounts); }` guard at lines 66-69 signals intent to accept `count == 0` from Swift callers, but the subsequent `std::slice::from_raw_parts(addresses, count)` / `std::slice::from_raw_parts(amounts, count)` (lines 74-75) violate Rust's contract for `from_raw_parts`, which requires the data pointer be non-null and aligned even for zero-length slices. Swift's `withUnsafeBufferPointer.baseAddress` is documented to return `nil` for an empty `Array`, so this is reachable. Additionally, `CStr::from_ptr(addr_ptrs[i])` at line 78 does not null-check each element before dereferencing, so a stray null inside the array (e.g. allocation failure in a `strdup` chain) is UB rather than a typed FFI error. Mitigations: short-circuit when `count == 0` (or use `&[]` instead of `from_raw_parts`), and null-check `addr_ptrs[i]` before `CStr::from_ptr`.
- [NITPICK] In `packages/rs-platform-wallet/src/wallet/core/broadcast.rs`:145-151: Peek-without-commit can produce change-address reuse across concurrent senders
  The change-address is peeked (`advance=false`) under the wallet-manager write lock at line 150 and committed (`advance=true`) only after broadcast succeeds, at line 251. Two concurrent `send_to_addresses` on disjoint UTXOs (wallet has UTXO_1 and UTXO_2; A reserves UTXO_1, B reserves UTXO_2) will both peek the same next-change index N because broadcast is async network I/O performed without the write lock. Both txs go on the wire with change at index N; both then commit-advance to N+1. The result is unintentional change-address reuse and a privacy regression. The peek-then-commit pattern itself is defensible (it avoids burning indices on broadcast failure), but the comment at lines 145-148 does not acknowledge the concurrent-callers case. If preserving privacy outweighs not burning indices on failure, commit the advance under the same lock that reserves outpoints and rollback on the broadcast-failure path, or track the peeked-but-not-committed change index inside the reservations set so concurrent callers see it.

lklimek and others added 2 commits May 21, 2026 15:35
…afe slice marshalling

CMT-002: map PlatformWalletError::NoSpendableInputs and
ConcurrentSpendConflict to dedicated PlatformWalletFFIResultCode
variants (30, 31) so Swift can branch on numeric codes instead of
substring-matching messages. Mirrors the new cases in
PlatformWalletResultCode + PlatformWalletError on the Swift side.
The ConcurrentSpendConflict.selected: Vec<OutPoint> payload is still
stringified into the message field (TODO: propagate structured payload
when a generic FFI sidecar exists).

CMT-004: harden core_wallet_send_to_addresses input marshalling.
slice::from_raw_parts requires a non-null aligned pointer even for
len == 0, and Swift's empty Array.withUnsafeBufferPointer.baseAddress
returns nil — guard the zero-count path. Also null-check every
addr_ptrs[i] before CStr::from_ptr instead of trusting the caller.

Tests cover both count==0 paths (null and non-null arrays) plus a
null-element-inside-array case.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…x and post-broadcast paths

- CMT-001: `IdentityWallet::send_payment` now consults the shared
  `OutpointReservations` set used by `CoreWallet::send_to_addresses`,
  reserving its selected outpoints + change address under the same write
  lock and releasing them only after `check_core_transaction` reconciles
  the broadcast into wallet state. A concurrent core send + DashPay send
  on the same wallet can no longer pick the same UTXO.
- CMT-003: `send_to_addresses` no longer releases the reservation
  unconditionally after a successful broadcast. If the post-broadcast
  reconcile path fails (wallet handle stale, or own-built tx not
  recognised by `check_core_transaction`), the reservation is leaked
  until a wallet restart / full sync; releasing it would let a concurrent
  caller re-select an already-broadcast outpoint and produce a
  double-spend the network would reject. Added a unit test
  (`unreconciled_broadcast_keeps_reservation_held`).
- CMT-006: peek-without-commit change-address reuse closed by committing
  the change-index advance inside the same write lock as the outpoint
  reservation, and skipping past any change address still pending from a
  concurrent in-flight send. A single index per failure is burned —
  acceptable; reuse is not.
- CMT-005: stronger deferral comment for the brittle coin-selection
  substring match (typed `BuilderError::CoinSelection(SelectionError::…)`
  upstream).

Refs: PR #3585.

Co-Authored-By: Bilby the Dev <842586+lklimek@users.noreply.github.com>
@Claudius-Maginificent
Copy link
Copy Markdown
Collaborator Author

Triage results for the 6 carry-forward findings at this PR's head — now c29b4ba6a4:

ID Disposition Where
DashPay send_payment bypasses reservation set (payments.rs:195) ✅ Fixed c29b4ba6a4IdentityWallet::send_payment now shares OutpointReservations with CoreWallet and mirrors the full reserve→broadcast→reconcile→release pattern. Single set_funding caller in the workspace, so no broader surgery needed.
New retryable wallet errors collapse to ErrorUnknown at FFI (error.rs:157) ✅ Fixed 25bb359678 — added ErrorNoSpendableInputs = 30 and ErrorConcurrentSpendConflict = 31 to PlatformWalletFFIResultCode; pattern-matched in From<PlatformWalletError> before the blanket fallback; mirrored in PlatformWalletResult.swift. The ConcurrentSpendConflict.selected payload is stringified into message for now (no generic FFI payload sidecar exists yet — TODO noted in enum docs).
Reservation drops unconditionally on no-op post-broadcast paths (broadcast.rs:230-295) ✅ Fixed c29b4ba6a4 — release gated on wallet_lookup.is_some() && check_result.is_relevant; non-reconciled successful broadcasts leak the reservation until process restart with a stable warn event. New unit test pins the behavior.
FFI from_raw_parts UB + missing per-element null-check (core_wallet/broadcast.rs:66-78) ✅ Fixed 25bb359678 — slice construction gated on count > 0; each addr_ptrs[i] null-checked before CStr::from_ptr. Three new tests cover zero-count + null pointers, zero-count + non-null, and a null element inside a count == 2 array.
Brittle substring matching for selection errors (broadcast.rs:167-181) ⏸ Deferred c29b4ba6a4 — TODO comment hardened with deferral rationale referencing the typed BuilderError::CoinSelection(SelectionError::…) variants in pinned key-wallet. Behavior unchanged; follow-up work to thread the typed variants through build_signed's error type.
Peek-without-commit change-address reuse (broadcast.rs:145,251) ✅ Fixed c29b4ba6a4 — change-address advance now committed inside the wallet write lock with peek-and-skip over an in-flight pending_change set tracked alongside outpoint reservations; reservation guard releases outpoints and change-address entry atomically. Trade-off: one burned change-index per broadcast failure (acceptable since gap-limit ≥ 20). Note: tracked by Address rather than index since key_wallet::managed_account doesn't expose an index accessor on the pool — same privacy property, slightly different mechanism.

Triage performed via Claudius the Magnificent AI Agent.

@lklimek lklimek requested a review from thepastaclaw May 21, 2026 13:51
Copy link
Copy Markdown
Collaborator

@thepastaclaw thepastaclaw left a comment

Choose a reason for hiding this comment

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

Code Review

Current head resolves three earlier issues in this area: DashPay now shares the core reservation set, pending change addresses are tracked to avoid reuse, and post-broadcast reconciliation no longer drops reservations on the success-but-unreconciled path. One carried-forward prior finding remains valid in send_to_addresses, and this delta adds two new suggestion-level issues: leak_until_sync makes leaked reservations permanent for the life of the process, and the duplicated DashPay send path still has no dedicated regression coverage.

🟡 3 suggestion(s)

3 additional finding(s)

suggestion: `send_to_addresses` still classifies retryable coin-selection failures by `Display` text

packages/rs-platform-wallet/src/wallet/core/broadcast.rs (line 190)

TransactionBuilder::build_signed returns a typed BuilderError, and the pinned key-wallet dependency exposes BuilderError::CoinSelection(SelectionError::InsufficientFunds|NoUtxosAvailable). This code discards that structure and decides whether to return PlatformWalletError::NoSpendableInputs by substring-matching e.to_string(). That makes the wallet's public retryable/non-retryable contract depend on upstream wording instead of the actual error variant. A wording change in key-wallet silently turns a retryable selection failure into TransactionBuild, and the nearby test explicitly notes that this mapping path is still unpinned.

suggestion: `leak_until_sync` permanently strands reservations with no in-process recovery path

packages/rs-platform-wallet/src/wallet/core/reservations.rs (line 148)

OutpointReservationGuard::leak_until_sync calls std::mem::forget(self), so the only owner capable of removing the reserved outpoints and pending change address never runs Drop. There is no API in OutpointReservations or any sync path in this crate that rebuilds or clears ReservationsInner afterward, and the test suite now pins the behavior as "held until process restart." If check_core_transaction returns a false negative or the wallet handle is missing after a successful broadcast, those outpoints and change addresses remain unavailable for the lifetime of the process, causing false NoSpendableInputs results and permanently skipped change indices until restart.

suggestion: The duplicated DashPay send path has no regression tests for its new reservation and reconciliation logic

packages/rs-platform-wallet/src/wallet/identity/network/payments.rs (line 93)

IdentityWallet::send_payment now duplicates the same lock/build/reserve/broadcast/reconcile flow as CoreWallet::send_to_addresses, including shared outpoint reservations, pending-change tracking, and the sticky reservation path on unreconciled broadcasts. There is no test coverage for send_payment itself: a repo-wide search finds only the implementation and docs, not a test that exercises concurrent send_payment vs. send_to_addresses, rollback on broadcast failure, or the leak_until_sync branch. Because this logic is duplicated instead of factored into a shared helper, the existing core-wallet tests do not protect the DashPay path from regressions.

🤖 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.

- [SUGGESTION] In `packages/rs-platform-wallet/src/wallet/core/broadcast.rs`:190-205: `send_to_addresses` still classifies retryable coin-selection failures by `Display` text
  `TransactionBuilder::build_signed` returns a typed `BuilderError`, and the pinned `key-wallet` dependency exposes `BuilderError::CoinSelection(SelectionError::InsufficientFunds|NoUtxosAvailable)`. This code discards that structure and decides whether to return `PlatformWalletError::NoSpendableInputs` by substring-matching `e.to_string()`. That makes the wallet's public retryable/non-retryable contract depend on upstream wording instead of the actual error variant. A wording change in `key-wallet` silently turns a retryable selection failure into `TransactionBuild`, and the nearby test explicitly notes that this mapping path is still unpinned.
- [SUGGESTION] In `packages/rs-platform-wallet/src/wallet/core/reservations.rs`:148-161: `leak_until_sync` permanently strands reservations with no in-process recovery path
  `OutpointReservationGuard::leak_until_sync` calls `std::mem::forget(self)`, so the only owner capable of removing the reserved outpoints and pending change address never runs `Drop`. There is no API in `OutpointReservations` or any sync path in this crate that rebuilds or clears `ReservationsInner` afterward, and the test suite now pins the behavior as "held until process restart." If `check_core_transaction` returns a false negative or the wallet handle is missing after a successful broadcast, those outpoints and change addresses remain unavailable for the lifetime of the process, causing false `NoSpendableInputs` results and permanently skipped change indices until restart.
- [SUGGESTION] In `packages/rs-platform-wallet/src/wallet/identity/network/payments.rs`:93-357: The duplicated DashPay send path has no regression tests for its new reservation and reconciliation logic
  `IdentityWallet::send_payment` now duplicates the same lock/build/reserve/broadcast/reconcile flow as `CoreWallet::send_to_addresses`, including shared outpoint reservations, pending-change tracking, and the sticky reservation path on unreconciled broadcasts. There is no test coverage for `send_payment` itself: a repo-wide search finds only the implementation and docs, not a test that exercises concurrent `send_payment` vs. `send_to_addresses`, rollback on broadcast failure, or the `leak_until_sync` branch. Because this logic is duplicated instead of factored into a shared helper, the existing core-wallet tests do not protect the DashPay path from regressions.

Inline posting hit GitHub HTTP 422, so I posted the same verified findings as a top-level review body.

Copy link
Copy Markdown
Collaborator

@thepastaclaw thepastaclaw left a comment

Choose a reason for hiding this comment

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

Code Review

I verified the wallet-related findings against b8043f9aabc26594f1bd796dc78dcaa4460a3b46. The three prior findings are still valid on this head, and there is one additional latest-delta regression in the duplicated DashPay send path: it does not preserve the new retryable NoSpendableInputs contract when transaction building fails.

🟡 4 suggestion(s)

4 additional finding(s)

suggestion: `send_to_addresses` still classifies coin-selection failures by matching `Display` text

packages/rs-platform-wallet/src/wallet/core/broadcast.rs (line 190)

send_to_addresses still converts build_signed failures into NoSpendableInputs by searching e.to_string() for "Insufficient funds" and "No UTXOs available". That is a brittle protocol boundary: the pinned key-wallet dependency exposes typed BuilderError::CoinSelection(SelectionError) and SelectionError::{NoUtxosAvailable, InsufficientFunds}, so this code is discarding stable enum variants and depending on human-readable wording instead. A harmless upstream wording change would silently reclassify the same retryable condition into TransactionBuild, breaking the API contract this PR introduced.

suggestion: `leak_until_sync` makes unreconciled reservations permanent for the lifetime of the process

packages/rs-platform-wallet/src/wallet/core/reservations.rs (line 148)

OutpointReservationGuard::leak_until_sync only calls std::mem::forget(self), so the only code that can remove the reserved outpoints and pending change address never runs. I could not find any compensating API on OutpointReservations, and the crate’s own test now pins this as "held until process restart", so the documented "full sync will reconcile" path does not exist in-process. If post-broadcast reconciliation returns a false negative or the wallet handle goes stale after broadcast, those UTXOs and change addresses remain locally blocked until restart, causing persistent NoSpendableInputs results and skipped change indices.

suggestion: The duplicated DashPay send path still has no focused regression coverage for reservation and reconciliation behavior

packages/rs-platform-wallet/src/wallet/identity/network/payments.rs (line 93)

IdentityWallet::send_payment duplicates the same reserve/build/broadcast/reconcile flow as CoreWallet::send_to_addresses, including shared outpoint reservations, pending change tracking, and the leak_until_sync branch. A repo-wide search at this SHA found no tests that exercise send_payment itself for the concurrency loser case, broadcast-failure rollback, or unreconciled-success behavior. Because this logic is duplicated instead of factored behind a shared helper, the existing broadcast.rs tests do not protect the DashPay path from diverging.

suggestion: `send_payment` collapses retryable coin-selection failures into generic `TransactionBuild` errors

packages/rs-platform-wallet/src/wallet/identity/network/payments.rs (line 257)

Unlike CoreWallet::send_to_addresses, the DashPay path maps every build_signed failure directly to PlatformWalletError::TransactionBuild(e.to_string()). That regresses the new wallet error contract for retryable depletion cases: a normal BuilderError::CoinSelection(SelectionError::InsufficientFunds | NoUtxosAvailable) now surfaces as a generic builder error instead of NoSpendableInputs. Callers of the two send paths therefore receive inconsistent error semantics for the same underlying failure and are forced back to string inspection on the DashPay path.

🤖 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.

- [SUGGESTION] In `packages/rs-platform-wallet/src/wallet/core/broadcast.rs`:190-205: `send_to_addresses` still classifies coin-selection failures by matching `Display` text
  `send_to_addresses` still converts `build_signed` failures into `NoSpendableInputs` by searching `e.to_string()` for `"Insufficient funds"` and `"No UTXOs available"`. That is a brittle protocol boundary: the pinned `key-wallet` dependency exposes typed `BuilderError::CoinSelection(SelectionError)` and `SelectionError::{NoUtxosAvailable, InsufficientFunds}`, so this code is discarding stable enum variants and depending on human-readable wording instead. A harmless upstream wording change would silently reclassify the same retryable condition into `TransactionBuild`, breaking the API contract this PR introduced.
- [SUGGESTION] In `packages/rs-platform-wallet/src/wallet/core/reservations.rs`:148-161: `leak_until_sync` makes unreconciled reservations permanent for the lifetime of the process
  `OutpointReservationGuard::leak_until_sync` only calls `std::mem::forget(self)`, so the only code that can remove the reserved outpoints and pending change address never runs. I could not find any compensating API on `OutpointReservations`, and the crate’s own test now pins this as `"held until process restart"`, so the documented `"full sync will reconcile"` path does not exist in-process. If post-broadcast reconciliation returns a false negative or the wallet handle goes stale after broadcast, those UTXOs and change addresses remain locally blocked until restart, causing persistent `NoSpendableInputs` results and skipped change indices.
- [SUGGESTION] In `packages/rs-platform-wallet/src/wallet/identity/network/payments.rs`:93-360: The duplicated DashPay send path still has no focused regression coverage for reservation and reconciliation behavior
  `IdentityWallet::send_payment` duplicates the same reserve/build/broadcast/reconcile flow as `CoreWallet::send_to_addresses`, including shared outpoint reservations, pending change tracking, and the `leak_until_sync` branch. A repo-wide search at this SHA found no tests that exercise `send_payment` itself for the concurrency loser case, broadcast-failure rollback, or unreconciled-success behavior. Because this logic is duplicated instead of factored behind a shared helper, the existing `broadcast.rs` tests do not protect the DashPay path from diverging.
- [SUGGESTION] In `packages/rs-platform-wallet/src/wallet/identity/network/payments.rs`:257-262: `send_payment` collapses retryable coin-selection failures into generic `TransactionBuild` errors
  Unlike `CoreWallet::send_to_addresses`, the DashPay path maps every `build_signed` failure directly to `PlatformWalletError::TransactionBuild(e.to_string())`. That regresses the new wallet error contract for retryable depletion cases: a normal `BuilderError::CoinSelection(SelectionError::InsufficientFunds | NoUtxosAvailable)` now surfaces as a generic builder error instead of `NoSpendableInputs`. Callers of the two send paths therefore receive inconsistent error semantics for the same underlying failure and are forced back to string inspection on the DashPay path.

Inline posting hit GitHub HTTP 422, so I posted the same verified findings as a top-level review body.

lklimek and others added 3 commits May 25, 2026 14:44
…per (CMT-001, CMT-004)

Replace brittle `e.to_string()` substring matches in `send_to_addresses`
and `send_payment` with a typed `classify_build_error` helper that
pattern-matches `BuilderError::InsufficientFunds` and
`BuilderError::CoinSelection(SelectionError::{NoUtxosAvailable,
InsufficientFunds})` into the retryable
`PlatformWalletError::NoSpendableInputs`. Every other build failure
falls through to fatal `PlatformWalletError::TransactionBuild` with the
upstream Display text preserved in `context`.

Unit-pinned with six assertions over the typed variants so a future
upstream rewording of the `Display` impl cannot silently downgrade
`NoSpendableInputs` back to `TransactionBuild`.

Drops the stale `TODO(CMT-005, #3585)` comment.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…pinned behavior (CMT-002)

The previous docstring claimed "a wallet restart or full sync will reconcile"
the leaked reservation, but no in-process reclaim API exists today —
`leak_until_sync` calls `mem::forget(self)` and the outpoints stay pinned
in `OutpointReservations` until the process exits. The unit test
`leak_until_sync_keeps_reservation_held` pins exactly that behavior.

Rewrite the doc to plainly state "held until process restart", keep the
safer-of-two-bad-outcomes rationale, and add a follow-up TODO referencing
@thepastaclaw's PR #3585 review for a future
`OutpointReservations::reclaim_confirmed` API hooked into periodic sync.

No behaviour change — docs only.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…Wallet/IdentityWallet (CMT-003)

Extract `broadcast_and_reconcile` — the broadcast → check_core_transaction →
release-or-leak decision tree shared by `CoreWallet::send_to_addresses`
and `IdentityWallet::send_payment`. The path-specific bits stay inlined
at each call site (UTXO snapshot/filter, change-address peek-burn-commit
loop, builder construction, post-broadcast reservation reconcile target
account layout) — the helper covers only the genuinely shared invariant
code where divergence would cause double-spend or leaked-reservation
bugs.

A `FnOnce(&mut PlatformWalletInfo, &Txid, bool /*reconciled*/)` hook
runs inside the post-broadcast write lock so call-site-specific
bookkeeping (e.g. `record_dashpay_payment`) sees the same locked
critical section as the reconcile.

Pin the helper's three invariants directly with new unit tests:

* `broadcast_and_reconcile_drops_reservation_on_broadcast_failure` —
  the hook is NOT invoked and the reservation is released so the
  caller can retry.
* `broadcast_and_reconcile_leaks_when_wallet_missing` — stale wallet
  handle leaks the reservation (no concurrent re-selection of an
  already-broadcast outpoint).
* `send_to_addresses_success_invokes_hook_and_releases_reservation` —
  end-to-end happy path through the shared helper.

The three pre-existing CMT-003 concurrency/rollback tests in
`broadcast.rs` (`concurrent_same_utxo_sends_resolve_via_reservation_set`,
`broadcast_failure_releases_reservation_for_retry`,
`unreconciled_broadcast_keeps_reservation_held`) continue to exercise
the same shared helper — no separate `send_payment` ports are needed
because the helper IS the shared seam those tests target.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@Claudius-Maginificent
Copy link
Copy Markdown
Collaborator Author

@thepastaclaw — addressing your 2026-05-25 review (the one posted as review body due to inline HTTP 422). Three commits on top of `b8043f9a`:

CMT-001 + CMT-004 — typed coin-selection mapper (`8de859a6`)
New `classify_build_error(err, account_type, account_index)` helper in `broadcast.rs`. Pattern-matches the three depleted-UTXO variants on the pinned key-wallet rev (`f569e7b`): `BuilderError::InsufficientFunds`, `BuilderError::CoinSelection(SelectionError::NoUtxosAvailable)`, `BuilderError::CoinSelection(SelectionError::InsufficientFunds { .. })` → `NoSpendableInputs`; else `TransactionBuild`. Used by both `send_to_addresses` and `send_payment`. Stale `TODO(CMT-005, #3585)` removed. 6 new unit tests pin each variant→mapping.

CMT-002 — `leak_until_sync` docstring corrected (`4d017b8a`)
Rewrote to plainly state "held until process restart — no in-process reclaim path exists today". Added `TODO(@thepastaclaw, PR #3585)` referencing the future `OutpointReservations::reclaim_confirmed` API. Behavior unchanged.

CMT-003 — shared broadcast/reconcile helper (`cd2ade5e`)
New `broadcast_and_reconcile<B, F>(wallet_manager, wallet_id, broadcaster, tx, reservation, on_reconcile)` covering broadcast → `check_core_transaction` → release-vs-leak. The `FnOnce` hook runs inside the post-broadcast write lock so call-site bookkeeping (e.g. `record_dashpay_payment`) shares the same critical section as the reconcile. The pre-broadcast filter/reserve flow stays inlined because the path-specific account-type lookups don't reduce cleanly to a callback. Three direct helper tests + the existing three concurrency/rollback tests in `broadcast.rs` now cover both `send_to_addresses` and `send_payment` through the shared seam.

Validations: `cargo fmt` clean, `clippy --tests -- -D warnings` clean, `cargo test --lib` 146 passed.

🤖 Co-authored by Claudius the Magnificent AI Agent

Copy link
Copy Markdown
Collaborator

@thepastaclaw thepastaclaw left a comment

Choose a reason for hiding this comment

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

Code Review

Two prior concerns remain on cd2ade5: unreconciled successful broadcasts still pin reservations until process restart, and send_payment still has no direct regression coverage for its DashPay-specific path after the helper refactor. The latest delta does fix the earlier typed-error-mapping and shared post-broadcast-tail issues, and I did not confirm the new claim that send_payment has a current reachable concurrent-spend bug from omitting the extra post-build recheck.

🟡 2 suggestion(s)

2 additional finding(s)

suggestion: `leak_until_sync` still leaves reservations stuck for the lifetime of the process

packages/rs-platform-wallet/src/wallet/core/reservations.rs (line 148)

This branch still calls mem::forget(self) with no matching in-process reclaim path. broadcast_and_reconcile reaches it after a successful broadcast whenever post-broadcast reconciliation fails or the wallet handle is missing, then returns Ok(txid) to the caller. At that point the reserved outpoints and pending change address remain pinned in OutpointReservations until the PlatformWalletManager is dropped, so later sends on the same wallet can keep failing with NoSpendableInputs or skip change indices even though the send already succeeded. The updated docstring and test now explicitly confirm that behavior instead of fixing it.

suggestion: `send_payment` still has no focused regression coverage for its own build, reconcile, and payment-recording path

packages/rs-platform-wallet/src/wallet/identity/network/payments.rs (line 93)

There are still no tests that call IdentityWallet::send_payment on this head. The new coverage in wallet/core/broadcast.rs exercises broadcast_and_reconcile directly and through CoreWallet::send_to_addresses, but it does not cover the DashPay-specific work that remains inside send_payment: external-account lookup, recipient address derivation, change-address handling, and the on_reconcile hook that records the outgoing PaymentEntry. A regression in that path would still pass the current suite.

🤖 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.

- [SUGGESTION] In `packages/rs-platform-wallet/src/wallet/core/reservations.rs`:148-167: `leak_until_sync` still leaves reservations stuck for the lifetime of the process
  This branch still calls `mem::forget(self)` with no matching in-process reclaim path. `broadcast_and_reconcile` reaches it after a successful broadcast whenever post-broadcast reconciliation fails or the wallet handle is missing, then returns `Ok(txid)` to the caller. At that point the reserved outpoints and pending change address remain pinned in `OutpointReservations` until the `PlatformWalletManager` is dropped, so later sends on the same wallet can keep failing with `NoSpendableInputs` or skip change indices even though the send already succeeded. The updated docstring and test now explicitly confirm that behavior instead of fixing it.
- [SUGGESTION] In `packages/rs-platform-wallet/src/wallet/identity/network/payments.rs`:93-327: `send_payment` still has no focused regression coverage for its own build, reconcile, and payment-recording path
  There are still no tests that call `IdentityWallet::send_payment` on this head. The new coverage in `wallet/core/broadcast.rs` exercises `broadcast_and_reconcile` directly and through `CoreWallet::send_to_addresses`, but it does not cover the DashPay-specific work that remains inside `send_payment`: external-account lookup, recipient address derivation, change-address handling, and the `on_reconcile` hook that records the outgoing `PaymentEntry`. A regression in that path would still pass the current suite.

Inline posting hit GitHub HTTP 422, so I posted the same verified findings as a top-level review body.

@thepastaclaw
Copy link
Copy Markdown
Collaborator

One remaining non-blocking concern I still see in the exact head:

When post-broadcast reconciliation fails, the code intentionally calls leak_until_sync(), but there is no in-process reclaim path yet. The relevant comment in packages/rs-platform-wallet/src/wallet/core/reservations.rs:148-167 says the outpoints stay pinned for the lifetime of the PlatformWalletManager, and broadcast.rs:130-146 takes that path when reconciled == false.

Why it matters: any false-negative check_core_transaction() result, or a wallet removal race after a successful broadcast, can permanently block the selected outpoints and pending change address until restart. That is safer than releasing them immediately and risking a double-spend attempt, but it still leaves the wallet unable to reuse confirmed inputs without a process restart.

Suggested follow-up: add a sync-driven reclaim path keyed by observed outpoints/txid, and cover the reconcile-failure branch with a regression test proving the wallet can recover without restart.

The main targeted fixes look good to me: case-insensitive .dash suffix handling is fixed, the shared reservation set closes the in-process same-UTXO race across core sends and DashPay payments, and broadcast failures release reservations for retry instead of committing post-broadcast state first.

lklimek and others added 3 commits May 26, 2026 14:35
…-utxo-race-v3.1-dev

# Conflicts:
#	packages/rs-platform-wallet-ffi/src/error.rs
#	packages/rs-platform-wallet/src/error.rs
#	packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletResult.swift
…g_mut` in tests

Post-merge interface drift: clippy 1.92's `manual_dangling_ptr` flags the
existing test-helper pattern. Swap to the idiom clippy now expects — same
non-null sentinel pointer, no behavior change.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…electableInputs (14)

Follow upstream #3651's umbrella intent: all three "can't-pick-UTXOs"
wallet variants — `NoSpendableInputs` (incl. race-loser path),
`OnlyOutputAddressesFunded`, `OnlyDustInputs` — now route to
`ErrorNoSelectableInputs (14)`. The dedicated `ErrorNoSpendableInputs
(30)` slot becomes dead and is removed from both the FFI enum and the
Swift mirror (case, switch arm, `errorDescription` arm). The typed
`PlatformWalletError::NoSpendableInputs` Display rendering (account_type
/ account_index / context + race-loser breadcrumb) still survives
verbatim in the result message so callers can distinguish the
underlying cause. `ErrorConcurrentSpendConflict (31)` is unchanged.

Pinned FFI test updated to assert all three variants collapse onto code
14; added a separate pin for `ConcurrentSpendConflict (31)` keeping its
dedicated code so future churn doesn't accidentally fold it into the
umbrella too.
@Claudius-Maginificent Claudius-Maginificent changed the title fix: case-insensitive .dash + atomic state on broadcast failure fix: case-insensitive .dash + concurrent UTXO race across send_to_addresses and send_payment May 26, 2026
Copy link
Copy Markdown
Collaborator

@thepastaclaw thepastaclaw left a comment

Choose a reason for hiding this comment

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

Code Review

The latest incremental push at 87d24e3 is limited to the FFI/Swift error-code remap and I did not find a new in-scope defect in that delta. Two prior wallet-core concerns from earlier commits in PR #3585 remain valid on this head and should still be carried forward: the post-broadcast leak path permanently pins reservations in-process, and the rewritten DashPay send_payment path still lacks focused regression coverage.

🟡 2 suggestion(s)

2 additional finding(s)

suggestion: Successful broadcasts can still leave UTXOs and change addresses reserved for the life of the process

packages/rs-platform-wallet/src/wallet/core/reservations.rs (line 148)

broadcast_and_reconcile() still sends every successful-but-unreconciled broadcast down the reservation.leak_until_sync() branch at packages/rs-platform-wallet/src/wallet/core/broadcast.rs:130-146, and leak_until_sync() still implements that by mem::forget(self) with an explicit note that there is no in-process reclaim path. That means this PR's shared send-flow can leave a transaction that was already accepted by the network permanently pinning its selected outpoints and pending change address inside OutpointReservations until the wallet process restarts. The branch avoids an immediate double-spend attempt, but the resulting stuck reservations can still make later sends fail with NoSpendableInputs or burn change indices indefinitely on a long-lived wallet handle.

suggestion: The rewritten `IdentityWallet::send_payment` path still has no direct regression test

packages/rs-platform-wallet/src/wallet/identity/network/payments.rs (line 93)

IdentityWallet::send_payment now contains substantial PR-specific logic of its own: contact external-account lookup, recipient address derivation, funding-account change-address advancement, reservation/filtering against concurrent sends, the call into broadcast_and_reconcile, and the on_reconcile hook that records the outgoing PaymentEntry. A repository-wide search on this head finds no test that calls send_payment directly; current coverage only exercises the shared helper and CoreWallet::send_to_addresses in wallet/core/broadcast.rs. Because of that gap, a regression in the DashPay-specific build/reconcile/payment-recording path introduced by this PR can still pass the suite.

🤖 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.

- [SUGGESTION] In `packages/rs-platform-wallet/src/wallet/core/reservations.rs`:148-172: Successful broadcasts can still leave UTXOs and change addresses reserved for the life of the process
  `broadcast_and_reconcile()` still sends every successful-but-unreconciled broadcast down the `reservation.leak_until_sync()` branch at `packages/rs-platform-wallet/src/wallet/core/broadcast.rs:130-146`, and `leak_until_sync()` still implements that by `mem::forget(self)` with an explicit note that there is no in-process reclaim path. That means this PR's shared send-flow can leave a transaction that was already accepted by the network permanently pinning its selected outpoints and pending change address inside `OutpointReservations` until the wallet process restarts. The branch avoids an immediate double-spend attempt, but the resulting stuck reservations can still make later sends fail with `NoSpendableInputs` or burn change indices indefinitely on a long-lived wallet handle.
- [SUGGESTION] In `packages/rs-platform-wallet/src/wallet/identity/network/payments.rs`:93-327: The rewritten `IdentityWallet::send_payment` path still has no direct regression test
  `IdentityWallet::send_payment` now contains substantial PR-specific logic of its own: contact external-account lookup, recipient address derivation, funding-account change-address advancement, reservation/filtering against concurrent sends, the call into `broadcast_and_reconcile`, and the `on_reconcile` hook that records the outgoing `PaymentEntry`. A repository-wide search on this head finds no test that calls `send_payment` directly; current coverage only exercises the shared helper and `CoreWallet::send_to_addresses` in `wallet/core/broadcast.rs`. Because of that gap, a regression in the DashPay-specific build/reconcile/payment-recording path introduced by this PR can still pass the suite.

Inline posting hit GitHub HTTP 422, so I posted the same verified findings as a top-level review body.

… (Found-026) [backport] (#3658)

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@Claudius-Maginificent Claudius-Maginificent changed the title fix: case-insensitive .dash + concurrent UTXO race across send_to_addresses and send_payment fix: case-insensitive .dash + wallet concurrency hardening (UTXO and receive-address races) May 26, 2026
Copy link
Copy Markdown
Collaborator

@thepastaclaw thepastaclaw left a comment

Choose a reason for hiding this comment

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

Code Review

The latest incremental patch at b9a3be72c7a0b5cc7c14e2338ca26d0335c90281 correctly fixes the receive-address handout race in PlatformAddressWallet and adds focused regression coverage for that path; I did not find a new defect in the two files changed by this last commit. In the cumulative PR range for #3585, however, both carried-forward prior findings remain valid: the post-broadcast unreconciled-success path still pins reservations for the rest of the process, and the rewritten DashPay send_payment path still has no direct regression test covering its call-site-specific behavior.

🟡 2 suggestion(s)

2 additional finding(s)

suggestion: Successful-but-unreconciled broadcasts still pin reservations until process restart

packages/rs-platform-wallet/src/wallet/core/reservations.rs (line 139)

ReservationGuard::leak_until_sync() still preserves reservations by skipping Drop with mem::forget(self), and the helper used by both send paths still takes that branch whenever broadcast succeeds but post-broadcast reconciliation does not (packages/rs-platform-wallet/src/wallet/core/broadcast.rs:130-146). That means send_to_addresses and send_payment can return success while leaving selected outpoints and pending change addresses reserved for the lifetime of the process if the wallet is missing or check_core_transaction(..) reports is_relevant = false. The double-spend risk is avoided, but the wallet can then degrade into persistent NoSpendableInputs or permanently burned change slots until restart because there is still no sync-time reclaim path.

suggestion: `IdentityWallet::send_payment` still lacks a direct regression test for its rewritten path

packages/rs-platform-wallet/src/wallet/identity/network/payments.rs (line 93)

A repository search on this head still finds no test that calls IdentityWallet::send_payment directly. The added coverage in wallet/core/broadcast.rs validates the shared broadcast_and_reconcile() helper, but it does not execute the DashPay-specific logic that this PR moved into send_payment: external-account lookup, recipient-address derivation, funding-account change-address advancement, reservation filtering against concurrent sends, and the on_reconcile hook that records the outgoing PaymentEntry. Because those behaviors remain untested end-to-end, a regression in the send_payment call site can still pass the current suite even though the shared helper is covered.

🤖 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.

- [SUGGESTION] In `packages/rs-platform-wallet/src/wallet/core/reservations.rs`:139-164: Successful-but-unreconciled broadcasts still pin reservations until process restart
  `ReservationGuard::leak_until_sync()` still preserves reservations by skipping `Drop` with `mem::forget(self)`, and the helper used by both send paths still takes that branch whenever broadcast succeeds but post-broadcast reconciliation does not (`packages/rs-platform-wallet/src/wallet/core/broadcast.rs:130-146`). That means `send_to_addresses` and `send_payment` can return success while leaving selected outpoints and pending change addresses reserved for the lifetime of the process if the wallet is missing or `check_core_transaction(..)` reports `is_relevant = false`. The double-spend risk is avoided, but the wallet can then degrade into persistent `NoSpendableInputs` or permanently burned change slots until restart because there is still no sync-time reclaim path.
- [SUGGESTION] In `packages/rs-platform-wallet/src/wallet/identity/network/payments.rs`:93-316: `IdentityWallet::send_payment` still lacks a direct regression test for its rewritten path
  A repository search on this head still finds no test that calls `IdentityWallet::send_payment` directly. The added coverage in `wallet/core/broadcast.rs` validates the shared `broadcast_and_reconcile()` helper, but it does not execute the DashPay-specific logic that this PR moved into `send_payment`: external-account lookup, recipient-address derivation, funding-account change-address advancement, reservation filtering against concurrent sends, and the `on_reconcile` hook that records the outgoing `PaymentEntry`. Because those behaviors remain untested end-to-end, a regression in the `send_payment` call site can still pass the current suite even though the shared helper is covered.

Inline posting hit GitHub HTTP 422, so I posted the same verified findings as a top-level review body.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ready for final review Ready for the final review. If AI was involved in producing this PR, it has already had a reviewer.

Projects

Status: In review / testing

Development

Successfully merging this pull request may close these issues.

4 participants