Skip to content

fix: case-insensitive .dash + atomic state on broadcast failure#3585

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

fix: case-insensitive .dash + atomic state on broadcast failure#3585
Claudius-Maginificent wants to merge 28 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

Two independent fixes:

  1. Sdk::resolve_dpns_name accepts mixed-case .dash suffixAlice.DASH now resolves the same way as alice.dash.
  2. CoreWallet::send_to_addresses closes the same-UTXO concurrent-selection race — outpoints selected by one caller are reserved under the write lock; a concurrent caller filters them out of its spendable snapshot and short-circuits with NoSpendableInputs before touching the network. Local state mutations are also gated on broadcast success.

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

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

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

Adds empty-label rejection: a bare .dash (no label) now returns an explicit error rather than querying with an empty key.

Fix 2 — Concurrent same-UTXO race + broadcast-failure atomicity

Behavior

CoreWallet::send_to_addresses performs:

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

Two new typed errors:

  • PlatformWalletError::NoSpendableInputs { account_type, account_index, context } — emitted when filtered spendable is empty (race-loser short-circuit, or genuinely-depleted wallet).
  • PlatformWalletError::ConcurrentSpendConflict { selected: Vec<OutPoint> } — builder-invariant tripwire: selected inputs are no longer spendable mid-flight. Currently unreachable; regression-pinned.

Concurrent-broadcast contract

When two callers race on the same wallet:

  • Both serialise on the wallet write lock for steps 1–5.
  • The first to hold the lock reserves its selected outpoints.
  • The second sees those outpoints in the reservation snapshot, filters them out, and either selects different inputs or short-circuits with NoSpendableInputs if the filtered set is empty.
  • No two concurrent broadcasts can include the same outpoint as input.

This adopts the stronger pattern from dash-evo-tool's SPV-payment path (backend_task/core/mod.rs) — mark-spent-under-lock + RAII rollback. Achievable on Platform because tokio::sync::RwLock is Send-safe across awaits. Validated by the concurrent_callers_get_no_spendable_inputs test in broadcast.rs.

Files changed

File Net Purpose
packages/rs-sdk/src/platform/dpns_usernames/mod.rs +100/-2 Fix 1 + tests
packages/rs-platform-wallet/src/wallet/core/broadcast.rs +612/-25 Fix 2 — reservation orchestration + concurrent-broadcast test
packages/rs-platform-wallet/src/wallet/core/reservations.rs +139 (NEW) OutpointReservations set + RAII release guard
packages/rs-platform-wallet/src/wallet/core/wallet.rs +7 CoreWallet::reservations field + Clone impl
packages/rs-platform-wallet/src/wallet/core/mod.rs +1 pub(crate) mod reservations
packages/rs-platform-wallet/src/error.rs +16 NoSpendableInputs + ConcurrentSpendConflict variants
packages/rs-platform-wallet/src/wallet/platform_addresses/wallet.rs +1/-5 Cosmetic rustfmt collapse of a multi-line set_address_credit_balance call — orthogonal to Fix 2, kept for now
packages/rs-platform-wallet-ffi/tests/integration_tests.rs -2 FFI signature match: upstream changed platform_wallet_info_create_from_mnemonic from 4 → 3 parameters

Test plan

  • cargo fmt --check green
  • cargo check --workspace green
  • cargo test -p dash-sdk --lib (117 passed)
  • cargo test -p platform-wallet --lib (129 passed including new concurrent_callers_get_no_spendable_inputs test)
  • cargo clippy --workspace --all-features -- -D warnings green for files this PR touches
  • Manual: trigger a broadcast failure (e.g., disconnect node, send malformed tx) and verify wallet state is unchanged afterward (deferred — needs running node)
  • Manual: confirm ConcurrentSpendConflict is reachable only via builder-invariant violation (regression-tripwire only) (deferred — needs running node)

Provenance

Backported from dash-evo-tool PRs #810 and #845. Supersedes #3466.

🤖 Co-authored by Claudius the Magnificent AI Agent

Summary by CodeRabbit

  • Bug Fixes

    • Improved wallet transaction broadcasting to handle concurrent spending attempts safely; transactions now fail gracefully with clear messaging when UTXOs are reserved by in-flight operations instead of hanging or causing conflicts.
    • Enhanced DPNS username validation and normalization to consistently handle domain suffix variations.
  • Refactor

    • Optimized internal wallet reservation handling for better concurrent operation safety.

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
Title check ✅ Passed The title "fix: case-insensitive .dash + atomic state on broadcast failure" accurately reflects the two main changes: DPNS case-insensitive .dash suffix handling and atomic wallet state mutation on broadcast failure.
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.

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

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

Tip

💬 Introducing Slack Agent: The best way for teams to turn conversations into code.

Slack Agent is built on CodeRabbit's deep understanding of your code, so your team can collaborate across the entire SDLC without losing context.

  • Generate code and open pull requests
  • Plan features and break down work
  • Investigate incidents and troubleshoot customer tickets together
  • Automate recurring tasks and respond to alerts with triggers
  • Summarize progress and report instantly

Built for teams:

  • Shared memory across your entire org—no repeating context
  • Per-thread sandboxes to safely plan and execute work
  • Governance built-in—scoped access, auditability, and budget controls

One agent for your entire SDLC. Right inside Slack.

👉 Get started


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
@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 e4cf6b3)

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
Comment thread packages/rs-platform-wallet/src/wallet/core/broadcast.rs
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: "a6f1b8611aeadb00baa7255255c77e50527ae11cb26213b98b0c5badb7f3d44c"
)

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>
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 substantive wallet behavior change in this SHA is directionally correct, but two review comments remain valid after checking the exact source. The new retryable concurrent-spend outcome does not survive the FFI boundary, and the added tests explicitly stop short of covering the send_to_addresses ordering contract this PR is meant to protect.

Reviewed commit: cc2104f

🟡 2 suggestion(s)

1 additional finding

🟡 suggestion: `ConcurrentSpendConflict` is not exposed across the FFI boundary

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

This PR adds PlatformWalletError::ConcurrentSpendConflict as a distinct retryable outcome in Rust, but impl From<PlatformWalletError> for PlatformWalletFFIResult still maps every wallet error to PlatformWalletFFIResultCode::ErrorUnknown. core_wallet_send_to_addresses returns through that conversion, and Swift only switches on the existing FFI result codes, so foreign callers still receive .errorUnknown / .unknown(...) and cannot branch on the new condition programmatically. That leaves the new variant usable only inside Rust and forces UI retry logic in Swift to parse English message text instead of a stable ABI contract.

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

In `packages/rs-platform-wallet-ffi/src/error.rs`:
- [SUGGESTION] lines 157-160: `ConcurrentSpendConflict` is not exposed across the FFI boundary
  This PR adds `PlatformWalletError::ConcurrentSpendConflict` as a distinct retryable outcome in Rust, but `impl From<PlatformWalletError> for PlatformWalletFFIResult` still maps every wallet error to `PlatformWalletFFIResultCode::ErrorUnknown`. `core_wallet_send_to_addresses` returns through that conversion, and Swift only switches on the existing FFI result codes, so foreign callers still receive `.errorUnknown` / `.unknown(...)` and cannot branch on the new condition programmatically. That leaves the new variant usable only inside Rust and forces UI retry logic in Swift to parse English message text instead of a stable ABI contract.

In `packages/rs-platform-wallet/src/wallet/core/broadcast.rs`:
- [SUGGESTION] lines 279-411: The new tests do not cover the `send_to_addresses` broadcast-first rollback behavior
  The behavioral change in this PR lives in `send_to_addresses`: it now builds under the wallet-manager lock, drops the lock, broadcasts, and only then re-acquires the manager to register the transaction locally. The added tests do not execute that path. They construct an empty `WalletManager`, call `broadcast_transaction` directly, and the module comment explicitly states that the `send_to_addresses` rollback contract is not covered. Because of that, a future refactor that moves local spend registration back above `self.broadcast_transaction(&tx).await?` would reintroduce the original bug while these tests continue to pass unchanged.

Comment thread packages/rs-platform-wallet/src/wallet/core/broadcast.rs
@lklimek lklimek changed the title fix: case-insensitive .dash suffix and UTXO double-spend prevention (re-target of #3466) fix: case-insensitive DPNS .dash + UTXO double-spend race May 8, 2026
@lklimek lklimek requested a review from shumkov May 8, 2026 09:45
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 functional changes in this PR look correct at 6aa4f42e5c5f26291ace40732fa2d203489265af: DPNS normalization is now case-insensitive, and send_to_addresses no longer marks inputs spent before a failed broadcast. The only review finding I could confirm is a coverage gap: the new tests explicitly avoid the send_to_addresses path that changed, so the rollback behavior this PR is protecting can still regress unnoticed.

Reviewed commit: 6aa4f42

🟡 1 suggestion(s)

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

In `packages/rs-platform-wallet/src/wallet/core/broadcast.rs`:
- [SUGGESTION] lines 279-411: The new tests do not exercise the `send_to_addresses` rollback contract this PR changes
  The behavior change in this PR is in `send_to_addresses`: it now builds under the wallet-manager lock, drops the lock, broadcasts, and only then re-acquires the manager to register the mempool spend and advance the change index. The added test module explicitly states that this contract is not covered, `make_core_wallet` constructs an empty `WalletManager::new(Network::Testnet)` with no funded account state, and both tests call `broadcast_transaction` directly instead of `send_to_addresses`. That means a future refactor that moves local spend registration back above `self.broadcast_transaction(&tx).await?` or otherwise breaks the post-broadcast `check_core_transaction(..., true, true)` path would leave these tests green while reintroducing the original wallet-state regression.

Comment thread packages/rs-platform-wallet/src/wallet/core/broadcast.rs
@Claudius-Maginificent Claudius-Maginificent changed the title fix: case-insensitive DPNS .dash + UTXO double-spend race fix: case-insensitive .dash + atomic state on broadcast failure May 8, 2026
…3622)

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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.

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

126-132: 🏗️ Heavy lift

Reserve the change-address slot alongside the UTXOs to prevent concurrent address reuse.

Line 131 only peeks the next change address with false, while line 228 later commits by calling next_change_address(..., true) without binding to the peeked address. When two concurrent send_to_addresses calls spend different UTXOs, both can peek the same change address before either commits: the first call completes and advances to the next slot, then the second call advances past it, leaving both transactions with the same change address and burning an extra gap slot.

Also applies to: 213–228

🤖 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 126 -
132, The peek of the change address using
change_account.next_change_address(Some(&xpub), false) allows races: two
concurrent send_to_addresses can both peek the same address and later one will
advance past it, causing duplicate reuse and burned gap slots; fix by atomically
reserving the change slot when you pick UTXOs—call next_change_address with the
reserve flag (true) at the time you bind UTXOs (or change the flow so the
initial peek returns and is the only reservation, i.e., store that address and
do not call next_change_address(..., true) again later). Update all occurrences
(the initial peek in send_to_addresses and the later commit path) so reservation
is done once and the same returned address is reused, referring to
next_change_address and change_account to locate the logic.
🤖 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.

Nitpick comments:
In `@packages/rs-platform-wallet/src/wallet/core/broadcast.rs`:
- Around line 126-132: The peek of the change address using
change_account.next_change_address(Some(&xpub), false) allows races: two
concurrent send_to_addresses can both peek the same address and later one will
advance past it, causing duplicate reuse and burned gap slots; fix by atomically
reserving the change slot when you pick UTXOs—call next_change_address with the
reserve flag (true) at the time you bind UTXOs (or change the flow so the
initial peek returns and is the only reservation, i.e., store that address and
do not call next_change_address(..., true) again later). Update all occurrences
(the initial peek in send_to_addresses and the later commit path) so reservation
is done once and the same returned address is reused, referring to
next_change_address and change_account to locate the logic.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: cbd885d6-daca-4053-ad9d-69d354c4120a

📥 Commits

Reviewing files that changed from the base of the PR and between cc2104f and 4dd55d2.

📒 Files selected for processing (5)
  • packages/rs-platform-wallet/src/error.rs
  • packages/rs-platform-wallet/src/wallet/core/broadcast.rs
  • packages/rs-platform-wallet/src/wallet/core/mod.rs
  • packages/rs-platform-wallet/src/wallet/core/reservations.rs
  • packages/rs-platform-wallet/src/wallet/core/wallet.rs
🚧 Files skipped from review as they are similar to previous changes (1)
  • packages/rs-platform-wallet/src/error.rs

lklimek and others added 3 commits May 8, 2026 15:10
…onflict

`ConcurrentSpendConflict` was unit-only — if the defense-in-depth subset
check ever fired, operators would have no diagnostic content. Carry the
selected outpoints in the variant so the construction site (and downstream
log lines) surface them automatically via `Display`.

Strip the `INTENTIONAL(CMT-002)` review-thread tag from the same site —
git history is the record for review provenance.

Refs PR #3585 review (F-001, F-004).

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

Strip the `CMT-004` review-thread prefix from the post-broadcast checker
comment in `send_to_addresses`. The surrounding prose already documents
the present-state semantics; the review-comment ID is git-history noise.

Refs PR #3585 review (F-001).

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

Align the post-broadcast wallet-missing `tracing::warn!` with its two
sibling sites in `send_to_addresses` by adding
`target: "platform_wallet::broadcast"` and
`event = "post_broadcast_wallet_missing"`. Operators alerting on stable
event names now catch all three post-broadcast observability paths
without parsing free-text.

Strip the `INTENTIONAL(CMT-005)` review-thread tag from the same site —
the rewritten present-state comment already explains why log-only is
sufficient on this path.

Refs PR #3585 review (F-001, F-003).

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

Three valid non-blocking findings on a PR that adds typed retryable wallet errors and structured tracing for the same-UTXO concurrent-selection race fix. The strongest signal (flagged by all three Claude agents independently) is that the new typed PlatformWalletError variants (NoSpendableInputs, ConcurrentSpendConflict) are flattened to ErrorUnknown across the FFI boundary, defeating their purpose for Swift retry logic. Two minor nits about per-handle reservation invariant and unnecessary BTreeSet allocations on the happy path.

Reviewed commit: 4d204cd

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

1 additional finding

🟡 suggestion: New typed retryable error variants are erased to ErrorUnknown across the FFI boundary

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

The blanket impl From<PlatformWalletError> for PlatformWalletFFIResult here maps every variant to PlatformWalletFFIResultCode::ErrorUnknown plus error.to_string(). This PR adds two typed variants in packages/rs-platform-wallet/src/error.rs whose entire purpose is to let callers branch on a retryable outcome:

  • NoSpendableInputs { context } (lines 70-74) is the canonical, expected return value of CoreWallet::send_to_addresses when concurrent in-flight reservations leave nothing spendable (packages/rs-platform-wallet/src/wallet/core/broadcast.rs:92-99) or when coin selection reports insufficient funds (broadcast.rs:165-172). The doc comment explicitly frames this as a retry signal ("retry once they confirm").
  • ConcurrentSpendConflict { selected: Vec<OutPoint> } (lines 64-68) is defense-in-depth today but carries structured outpoint context that is dropped at the boundary.

core_wallet_send_to_addresses routes through unwrap_result_or_return! and Swift reconstructs errors purely from the numeric PlatformWalletFFIResultCode. A grep confirms neither variant is referenced anywhere on the FFI side. Net effect: a Swift caller cannot programmatically distinguish a transient reservation conflict from a generic build failure without substring-matching the localization-unsafe English message — exactly the brittleness the typed Rust variants were added to eliminate.

Replace the blanket From with a per-variant match (or special-case NoSpendableInputs and ConcurrentSpendConflict ahead of the blanket arm) and add a dedicated #[repr(C)] PlatformWalletFFIResultCode discriminant such as ErrorRetryable / ErrorNoSpendableInputs. Adding a new C-ABI variant requires header regeneration and Swift consumer rebuilds, so it is best landed before any caller starts depending on the current ErrorUnknown shape.

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

In `packages/rs-platform-wallet-ffi/src/error.rs`:
- [SUGGESTION] lines 157-161: New typed retryable error variants are erased to ErrorUnknown across the FFI boundary
  The blanket `impl From<PlatformWalletError> for PlatformWalletFFIResult` here maps every variant to `PlatformWalletFFIResultCode::ErrorUnknown` plus `error.to_string()`. This PR adds two typed variants in `packages/rs-platform-wallet/src/error.rs` whose entire purpose is to let callers branch on a retryable outcome:

- `NoSpendableInputs { context }` (lines 70-74) is the canonical, expected return value of `CoreWallet::send_to_addresses` when concurrent in-flight reservations leave nothing spendable (`packages/rs-platform-wallet/src/wallet/core/broadcast.rs:92-99`) or when coin selection reports insufficient funds (`broadcast.rs:165-172`). The doc comment explicitly frames this as a retry signal ("retry once they confirm").
- `ConcurrentSpendConflict { selected: Vec<OutPoint> }` (lines 64-68) is defense-in-depth today but carries structured outpoint context that is dropped at the boundary.

`core_wallet_send_to_addresses` routes through `unwrap_result_or_return!` and Swift reconstructs errors purely from the numeric `PlatformWalletFFIResultCode`. A grep confirms neither variant is referenced anywhere on the FFI side. Net effect: a Swift caller cannot programmatically distinguish a transient reservation conflict from a generic build failure without substring-matching the localization-unsafe English message — exactly the brittleness the typed Rust variants were added to eliminate.

Replace the blanket `From` with a per-variant match (or special-case `NoSpendableInputs` and `ConcurrentSpendConflict` ahead of the blanket arm) and add a dedicated `#[repr(C)]` `PlatformWalletFFIResultCode` discriminant such as `ErrorRetryable` / `ErrorNoSpendableInputs`. Adding a new C-ABI variant requires header regeneration and Swift consumer rebuilds, so it is best landed before any caller starts depending on the current `ErrorUnknown` shape.

Comment thread packages/rs-platform-wallet/src/wallet/core/broadcast.rs
Comment thread packages/rs-platform-wallet/src/wallet/core/wallet.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

Reviewed current head 4d204cda. The DPNS normalization path and the new outpoint reservation guard are good progress, and the latest send_to_addresses tests now cover the same-UTXO concurrent sender and reservation-release-on-broadcast-failure cases.

I still found one correctness issue in the concurrent-send path: the outpoint reservation closes same-UTXO double selection, but two concurrent sends that select different UTXOs can still reuse the same change address.

Reviewed commit: 4d204cd

🔴 1 blocking issue

In packages/rs-platform-wallet/src/wallet/core/broadcast.rs:

  • send_to_addresses peeks the change address with next_change_address(..., false) while building, then only advances it after broadcast success. That avoids burning a gap slot on failed broadcast, but it leaves a race between different-UTXO concurrent sends: caller A peeks change index N and releases the wallet lock while broadcasting; caller B selects a different unreserved UTXO, also peeks index N, builds a second transaction to the same change address, and broadcasts. The later post-broadcast next_change_address(..., true) calls advance the wallet index, but both transactions already committed to the same change output address.

Please reserve the change slot at the same time the transaction binds its inputs, or otherwise add a reservation/commit protocol that makes the change address unique per in-flight transaction without burning an index on broadcast failure. The new same-UTXO tests won't catch this because the reservation set intentionally filters only selected outpoints.

🤖 Prompt for AI agents
Verify against current head `4d204cda`. In `packages/rs-platform-wallet/src/wallet/core/broadcast.rs`, fix the concurrent different-UTXO change-address reuse race in `CoreWallet::send_to_addresses`. Today the code peeks `change_account.next_change_address(Some(&xpub), false)` during build and commits `next_change_address(..., true)` only after successful broadcast; two concurrent sends that select different UTXOs can both build with the same peeked change address before either commits. Keep the no-gap-burn-on-broadcast-failure property if possible, but ensure each in-flight transaction gets a unique change address (e.g. reserve the change index/address under the wallet lock with rollback on broadcast failure, or add a dedicated change-address reservation guard). Add/update focused tests for two concurrent sends with different spendable UTXOs proving the change outputs differ and the loser/success paths do not burn/leak reservations.

…-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

PR #3585 closes the same-UTXO concurrent-selection race via an RAII reservation set and adds case-insensitive .dash DPNS suffix normalization. Implementation is solid: peek-then-commit on change-address derivation, broadcast-before-mutate ordering, and structured tracing on invariant violations. Validated findings are non-blocking — the strongest items are that newly-added typed retryable error variants are still flattened to ErrorUnknown across FFI, that NoSpendableInputs's message wrongly implies a transient reservation conflict even when the wallet is simply underfunded, and that builder_error_text_contract_for_no_inputs never actually reaches the brittle string-match branch it claims to pin. Remaining items are docstring/nit/perf.

Reviewed commit: 543a8dc

🟡 3 suggestion(s) | 💬 4 nitpick(s)

1 additional finding

🟡 suggestion: New typed retryable wallet errors are flattened to ErrorUnknown across FFI

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

The blanket impl From<PlatformWalletError> for PlatformWalletFFIResult maps every variant — including this PR's newly-introduced NoSpendableInputs { context } and ConcurrentSpendConflict { selected } (rs-platform-wallet/src/error.rs:64-74) — to PlatformWalletFFIResultCode::ErrorUnknown plus a free-form error.to_string(). The entire point of these new typed variants is to let foreign callers branch on a retryable outcome without parsing English. NoSpendableInputs is now an expected, reachable result of core_wallet_send_to_addresses (rs-platform-wallet/src/wallet/core/broadcast.rs:122-129 and :158-165), and its docstring frames it explicitly as a retry signal. Swift consumers (e.g. ManagedCoreWallet.sendToAddresses) reconstruct errors from the numeric PlatformWalletFFIResultCode, so today they only see .errorUnknown and would have to substring-match the localization-unsafe message string to drive a retry — exactly the brittleness the typed variants were added to eliminate. Replace the blanket impl with a per-variant match (or special-case these two ahead of the blanket arm) and add dedicated #[repr(C)] PlatformWalletFFIResultCode discriminants (e.g. ErrorNoSpendableInputs / ErrorConcurrentSpendConflict, or a single ErrorRetryable). Adding a new C-ABI discriminant requires header regeneration and Swift consumer rebuilds, so it is best landed before callers start depending on the current ErrorUnknown shape.

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

In `packages/rs-platform-wallet-ffi/src/error.rs`:
- [SUGGESTION] lines 157-161: New typed retryable wallet errors are flattened to ErrorUnknown across FFI
  The blanket `impl From<PlatformWalletError> for PlatformWalletFFIResult` maps every variant — including this PR's newly-introduced `NoSpendableInputs { context }` and `ConcurrentSpendConflict { selected }` (rs-platform-wallet/src/error.rs:64-74) — to `PlatformWalletFFIResultCode::ErrorUnknown` plus a free-form `error.to_string()`. The entire point of these new typed variants is to let foreign callers branch on a retryable outcome without parsing English. `NoSpendableInputs` is now an expected, reachable result of `core_wallet_send_to_addresses` (rs-platform-wallet/src/wallet/core/broadcast.rs:122-129 and :158-165), and its docstring frames it explicitly as a retry signal. Swift consumers (e.g. `ManagedCoreWallet.sendToAddresses`) reconstruct errors from the numeric `PlatformWalletFFIResultCode`, so today they only see `.errorUnknown` and would have to substring-match the localization-unsafe message string to drive a retry — exactly the brittleness the typed variants were added to eliminate. Replace the blanket impl with a per-variant match (or special-case these two ahead of the blanket arm) and add dedicated `#[repr(C)]` `PlatformWalletFFIResultCode` discriminants (e.g. `ErrorNoSpendableInputs` / `ErrorConcurrentSpendConflict`, or a single `ErrorRetryable`). Adding a new C-ABI discriminant requires header regeneration and Swift consumer rebuilds, so it is best landed before callers start depending on the current `ErrorUnknown` shape.

In `packages/rs-platform-wallet/src/error.rs`:
- [SUGGESTION] lines 70-74: NoSpendableInputs message implies a transient reservation conflict even for genuine insufficient-funds failures
  The `Display` impl unconditionally appends "(other in-flight transactions reserved the wallet's UTXOs; retry once they confirm)". But `send_to_addresses` now returns this variant in two semantically distinct paths in `wallet/core/broadcast.rs`: (1) the post-reservation `spendable.is_empty()` early-exit at lines 122-129, which fires for *any* empty spendable set — wallet with zero balance, only immature coins, or actual reservation contention; and (2) the coin-selection `map_err` at lines 158-165 that matches the builder text `Insufficient funds` / `No UTXOs available`, which fires for ordinary underfunding (overspend, fees, etc.) with no concurrency involved. Reporting all of these as "retry once they confirm" misleads users ("add funds" is the real fix) and will cause any future automatic retry logic keyed off this variant to spin on deterministic insufficient-funds failures. Either split into two variants (e.g. `Reserved` vs `InsufficientFunds`) or remove the "retry once they confirm" claim from the unconditional message and let the `context` field disambiguate. The early-exit `context` at broadcast.rs:124-127 also unconditionally says "all UTXOs reserved by in-flight transactions" — same misclassification.

In `packages/rs-platform-wallet/src/wallet/core/broadcast.rs`:
- [SUGGESTION] lines 682-715: builder_error_text_contract_for_no_inputs never reaches the string-match branch it claims to pin
  The test's purpose, per its name and the production comment at lines 154-156 ("String-match pinned by `builder_error_text_contract_for_no_inputs`"), is to guard the brittle `msg.contains("Insufficient funds") || msg.contains("No UTXOs available")` branch at line 158. But the test reserves the wallet's only outpoint via `core.reservations.reserve(vec![outpoint])` *before* calling `send_to_addresses`. Inside the call, `spendable` is filtered against the reservation set (lines 114-120), comes out empty, and the early-exit guard at lines 122-129 returns `NoSpendableInputs` before `build_signed` is ever invoked. The `Insufficient funds` / `No UTXOs available` matcher — the most fragile part of the new logic — is never executed. `key-wallet` could rephrase its coin-selection error string tomorrow and this test would still pass. To actually pin the contract, drive the builder path: e.g. set up a wallet with at least one spendable UTXO whose value is below the requested output+fee so the builder reaches `build_signed` and returns the upstream `Insufficient funds` text.

Comment thread packages/rs-platform-wallet/src/error.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
Comment on lines 35 to 56
@@ -47,6 +52,7 @@ impl<B: TransactionBroadcaster + ?Sized> CoreWallet<B> {
wallet_id,
broadcaster,
balance,
reservations: OutpointReservations::new(),
}
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

💬 Nitpick: OutpointReservations is per-CoreWallet handle; cross-handle race-safety relies on convention

CoreWallet::new at line 55 initializes a fresh OutpointReservations::new(), and Clone shares it across cloned handles. This is correct for the documented usage where higher layers keep one CoreWallet and clone it for concurrent tasks. But two independently-constructed CoreWallet::new(.., wallet_id, ..) instances pointing at the same wallet_id (same Arc<RwLock<WalletManager>>) would have disjoint reservation sets — reopening exactly the same-UTXO race this PR closes. WalletManager doesn't keep a per-wallet_id reservation registry to prevent this. Either (a) tighten the docstring on the reservations field to state explicitly that all CoreWallet instances for a given wallet_id MUST be derived via Clone, not constructed independently, or (b) move the reservation set into PlatformWalletInfo (already keyed by wallet_id inside WalletManager) so the invariant becomes structural rather than convention-based.

source: ['claude']

Comment thread 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

PR #3585 closes a same-UTXO concurrent-selection race with a reservation set and peek-then-commit pattern. The Rust changes are sound, but the new typed retryable error variants (NoSpendableInputs, ConcurrentSpendConflict) are flattened to ErrorUnknown at the FFI boundary — defeating the purpose of carrying structured fields. Secondary issues: NoSpendableInputs is overloaded across genuinely-distinct conditions, the brittle upstream string-match remains unpinned by tests, and the post-broadcast persistence contract is not directly asserted. No blocking issues.

Reviewed commit: 0188fa9

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

1 additional finding

🟡 suggestion: New typed retryable wallet errors are flattened to ErrorUnknown across FFI

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

The blanket impl From<PlatformWalletError> for PlatformWalletFFIResult at lines 157-161 maps every variant — including this PR's newly-added NoSpendableInputs { account_type, account_index, context } and ConcurrentSpendConflict { selected } (packages/rs-platform-wallet/src/error.rs:64-75) — to PlatformWalletFFIResultCode::ErrorUnknown plus a free-form error.to_string(). NoSpendableInputs is now an expected, reachable result of core_wallet_send_to_addresses (returned from broadcast.rs:122-128 and :152-166), and the inline comment at broadcast.rs:175-176 explicitly notes ConcurrentSpendConflict is kept for forward-compat retry signalling. Swift consumers reconstruct errors solely from the numeric PlatformWalletFFIResultCode (see packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletResult.swift), so they only see .errorUnknown and would have to substring-match the localization-unsafe English context to drive a retry — exactly the brittleness the typed variants were added to eliminate. Replace the blanket impl with a per-variant match (or special-case these two ahead of the blanket arm) and add dedicated #[repr(C)] PlatformWalletFFIResultCode discriminants (e.g. ErrorNoSpendableInputs / ErrorConcurrentSpendConflict, or a single ErrorRetryable). Adding a C-ABI discriminant requires header regeneration and Swift consumer rebuilds, so it is best landed before callers depend on the current ErrorUnknown shape.

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

In `packages/rs-platform-wallet-ffi/src/error.rs`:
- [SUGGESTION] lines 157-161: New typed retryable wallet errors are flattened to ErrorUnknown across FFI
  The blanket `impl From<PlatformWalletError> for PlatformWalletFFIResult` at lines 157-161 maps every variant — including this PR's newly-added `NoSpendableInputs { account_type, account_index, context }` and `ConcurrentSpendConflict { selected }` (`packages/rs-platform-wallet/src/error.rs:64-75`) — to `PlatformWalletFFIResultCode::ErrorUnknown` plus a free-form `error.to_string()`. `NoSpendableInputs` is now an expected, reachable result of `core_wallet_send_to_addresses` (returned from `broadcast.rs:122-128` and `:152-166`), and the inline comment at `broadcast.rs:175-176` explicitly notes `ConcurrentSpendConflict` is kept for forward-compat retry signalling. Swift consumers reconstruct errors solely from the numeric `PlatformWalletFFIResultCode` (see `packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletResult.swift`), so they only see `.errorUnknown` and would have to substring-match the localization-unsafe English `context` to drive a retry — exactly the brittleness the typed variants were added to eliminate. Replace the blanket impl with a per-variant match (or special-case these two ahead of the blanket arm) and add dedicated `#[repr(C)]` `PlatformWalletFFIResultCode` discriminants (e.g. `ErrorNoSpendableInputs` / `ErrorConcurrentSpendConflict`, or a single `ErrorRetryable`). Adding a C-ABI discriminant requires header regeneration and Swift consumer rebuilds, so it is best landed before callers depend on the current `ErrorUnknown` shape.

In `packages/rs-platform-wallet/src/wallet/core/broadcast.rs`:
- [SUGGESTION] lines 122-166: NoSpendableInputs is overloaded across reservation contention, empty wallet, and genuine underfunding
  `PlatformWalletError::NoSpendableInputs` is now returned from two semantically different sites with three failure modes hidden inside them: (a) lines 122-128 fire whenever `spendable.is_empty()` after subtracting reservations — this hits not only the actual reservation-contention case the message describes ("all UTXOs used or reserved by in-flight transactions") but also a brand-new wallet with no UTXOs and a wallet with only immature/coinbase-locked UTXOs at `current_height`; (b) lines 152-166 reclassify upstream `build_signed` failures matching `Insufficient funds` / `No UTXOs available` as the same variant, which conflates ordinary underfunding (output + fee > balance, not retryable) with reservation-contention (retryable). A retry loop keyed off `NoSpendableInputs` will spin forever on an unfunded wallet or on deterministic underfunding. Either disambiguate the early-exit context by counting `managed_account.spendable_utxos(current_height).len()` vs `spendable.len()` and emitting different contexts, or split into distinct variants (e.g. `Reserved` vs `WalletUnfunded` vs `InsufficientFunds`). This pairs with the FFI flattening above — without per-variant FFI codes the distinction never reaches consumers anyway.
- [SUGGESTION] lines 152-166: Brittle upstream coin-selection string-match is still unpinned by tests
  The `map_err` at lines 152-166 inspects `e.to_string()` for the substrings `"Insufficient funds"` and `"No UTXOs available"` to decide whether to emit `NoSpendableInputs` or `TransactionBuild`. The TODO at line 155 acknowledges the fragility, and `builder_error_text_contract_for_no_inputs` (lines 687-710) honestly admits in its docstring that it does not exercise this branch — it reserves the only outpoint and trips the early-exit guard at line 122 before ever entering the builder. A `key-wallet` refactor that changes either substring (e.g. lowercased `"insufficient funds"`, or `"no spendable utxos"`) will silently re-route what used to be `NoSpendableInputs` into a generic `TransactionBuild(String)`, breaking any retry/UX logic with no test failure. To pin the contract: build a wallet with at least one spendable UTXO whose value is below `output + fee` so the builder reaches `build_signed` and returns the genuine upstream `Insufficient funds` text, then assert the result variant is `NoSpendableInputs`.
- [SUGGESTION] lines 195-262: Post-broadcast persistence contract is not directly pinned by tests
  After a successful broadcast, the only path that records the spend in local state is `info.check_core_transaction(&tx, TransactionContext::Mempool, wallet, true, true)` at lines 231-233 (the second `true` is `update_state`). If a future refactor removes/reorders that call relative to `drop(_reservation)` at line 260, `ManagedWalletInfo` never sees the spend — once the reservation guard releases, the next `send_to_addresses` from the same handle will reselect the same outpoint and the network will reject the duplicate. None of the new tests exercise this contract: `concurrent_same_utxo_sends_resolve_via_reservation_set` (lines 562-635) asserts only `broadcaster.calls == 1` and that A's result `is_ok()` — it never issues a third sequential send after A completes to verify the spend was persisted, and `broadcast_failure_releases_reservation_for_retry` only covers the error branch. Add either a third sequential `send_to_addresses` to the existing test (asserting `NoSpendableInputs` after the reservation drops), or a standalone test with a successful broadcaster followed by a same-account send.
- [SUGGESTION] lines 234-260: is_relevant=false path silently surfaces spent UTXOs back to the reservation drop
  When `check_core_transaction` returns `is_relevant=false` at lines 234-244 (described inline as an internal-invariant violation), the function logs `tracing::error!` and falls through. The explicit `drop(_reservation)` at line 260 then releases the outpoints from the reservation set without them ever having been marked spent in `ManagedWalletInfo` — so the next `send_to_addresses` from the same handle will see those same outpoints in its spendable snapshot and re-select them. The transaction is already on the network, so the second send will be rejected as a duplicate spend at the network boundary, but only after rebuilding/signing/broadcasting. This is exactly the failure mode the reservation set was added to avoid (filter before reaching the network), now demoted to log-only. Returning `Ok(tx)` here is correct (broadcast already succeeded), but consider also keeping the reservation alive on this path until a sync reconciles, plus a counter/metric independent of log volume so the case is alertable in production telemetry.

Comment thread packages/rs-platform-wallet/src/wallet/core/broadcast.rs
Comment thread packages/rs-platform-wallet/src/wallet/core/broadcast.rs
Comment thread packages/rs-platform-wallet/src/wallet/core/broadcast.rs
Comment thread packages/rs-platform-wallet/src/wallet/core/broadcast.rs
Comment thread packages/rs-platform-wallet/src/wallet/core/wallet.rs
Comment thread 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

Two prior reviews converge on three substantive issues at 543a8dc: the new typed retry variants are erased to ErrorUnknown across FFI, the NoSpendableInputs variant's documented retry semantics now also cover genuine insufficient-funds failures, and builder_error_text_contract_for_no_inputs never reaches the upstream string-match path it claims to pin. Two nitpicks (defensive allocation cost on the happy path, per-handle reservation invariant) are also valid but non-blocking. No consensus-critical or blocking issues.

Reviewed commit: 543a8dc

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

2 additional findings

🟡 suggestion: New typed retry variants are flattened to `ErrorUnknown` across the FFI boundary

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

send_to_addresses now returns two new structured variants whose entire purpose is to let foreign callers branch on a retryable outcome without parsing English text: NoSpendableInputs (reachable from broadcast.rs:122-128 when in-flight reservations leave nothing spendable, and from broadcast.rs:153-167 for coin-selection failures) and ConcurrentSpendConflict { selected: Vec<OutPoint> }. But the blanket impl From<PlatformWalletError> for PlatformWalletFFIResult here maps every wallet error to PlatformWalletFFIResultCode::ErrorUnknown plus error.to_string(), and PlatformWalletFFIResultCode (lines 65-82) has no ErrorRetryable / ErrorNoSpendableInputs / ErrorConcurrentSpendConflict discriminant. core_wallet_send_to_addresses routes failures through unwrap_result_or_return!, so Swift consumers (e.g. ManagedCoreWallet.sendToAddresses) reconstruct everything as .unknown(...) and would have to substring-match localization-unsafe English to drive retry — exactly the brittleness the new typed variants are meant to eliminate. Special-case these two variants ahead of the blanket conversion (or replace it with a per-variant match) and add dedicated #[repr(C)] discriminants. Because adding a new enum variant is itself a C-ABI change requiring header regeneration and Swift consumer rebuilds, it is best landed before callers start depending on the current ErrorUnknown shape.

🟡 suggestion: `builder_error_text_contract_for_no_inputs` does not exercise the upstream-error-text contract it claims to pin

packages/rs-platform-wallet/src/wallet/core/broadcast.rs (lines 678-715)

The doc comment at lines 678-681 states this test exists so that "if key-wallet ever rephrases its coin-selection errors, this test breaks loudly so the matcher can be updated" — referring to the substring check at broadcast.rs:158. The body, however, reserves the wallet's only outpoint externally at line 705 and then calls send_to_addresses. With every outpoint reserved, the spendable filter at lines 114-120 returns an empty vec and the early-return at lines 122-128 fires before TransactionBuilder is ever constructed; the .map_err closure at lines 153-167 — the only consumer of the upstream error strings — is never reached. The body itself acknowledges this at lines 698-699. Net effect: if key-wallet renames "Insufficient funds", the matcher silently stops mapping that case to NoSpendableInputs and falls through to TransactionBuild(msg) — but this test stays green. Either rename and rescope this test (e.g. empty_spendable_short_circuits_to_no_spendable_inputs) and add a separate test that forces build_signed to fail with the upstream message (e.g. add a UTXO smaller than fee + dust threshold so coin selection emits "Insufficient funds"), or restructure the body to drive that exact 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.

In `packages/rs-platform-wallet-ffi/src/error.rs`:
- [SUGGESTION] lines 157-161: New typed retry variants are flattened to `ErrorUnknown` across the FFI boundary
  `send_to_addresses` now returns two new structured variants whose entire purpose is to let foreign callers branch on a retryable outcome without parsing English text: `NoSpendableInputs` (reachable from `broadcast.rs:122-128` when in-flight reservations leave nothing spendable, and from `broadcast.rs:153-167` for coin-selection failures) and `ConcurrentSpendConflict { selected: Vec<OutPoint> }`. But the blanket `impl From<PlatformWalletError> for PlatformWalletFFIResult` here maps every wallet error to `PlatformWalletFFIResultCode::ErrorUnknown` plus `error.to_string()`, and `PlatformWalletFFIResultCode` (lines 65-82) has no `ErrorRetryable` / `ErrorNoSpendableInputs` / `ErrorConcurrentSpendConflict` discriminant. `core_wallet_send_to_addresses` routes failures through `unwrap_result_or_return!`, so Swift consumers (e.g. `ManagedCoreWallet.sendToAddresses`) reconstruct everything as `.unknown(...)` and would have to substring-match localization-unsafe English to drive retry — exactly the brittleness the new typed variants are meant to eliminate. Special-case these two variants ahead of the blanket conversion (or replace it with a per-variant match) and add dedicated `#[repr(C)]` discriminants. Because adding a new enum variant is itself a C-ABI change requiring header regeneration and Swift consumer rebuilds, it is best landed before callers start depending on the current `ErrorUnknown` shape.

In `packages/rs-platform-wallet/src/wallet/core/broadcast.rs`:
- [SUGGESTION] lines 153-168: `NoSpendableInputs` conflates a retryable reservation conflict with permanent insufficient balance
  This branch maps both `"No UTXOs available"` and `"Insufficient funds"` from the builder into `PlatformWalletError::NoSpendableInputs`. That variant's contract in `packages/rs-platform-wallet/src/error.rs:70-74` explicitly tells callers the wallet's UTXOs are reserved by *other in-flight transactions* and that they should *retry once those confirm*. A normal low-balance send on an otherwise-idle wallet also produces `"Insufficient funds"`, so today the wallet tells callers to retry a request that simply cannot succeed without additional funds. This is a user-visible behavior regression and it also makes the typed variant unreliable for any future host-side branching, including the FFI discriminants suggested above. Either keep `"Insufficient funds"` mapped to the existing build/insufficient-funds path, or split the new variant into a distinct `InsufficientFunds` and a reservation-specific `NoSpendableInputs` so the retry contract stays trustworthy.
- [SUGGESTION] lines 678-715: `builder_error_text_contract_for_no_inputs` does not exercise the upstream-error-text contract it claims to pin
  The doc comment at lines 678-681 states this test exists so that "if `key-wallet` ever rephrases its coin-selection errors, this test breaks loudly so the matcher can be updated" — referring to the substring check at `broadcast.rs:158`. The body, however, reserves the wallet's only outpoint externally at line 705 and then calls `send_to_addresses`. With every outpoint reserved, the spendable filter at lines 114-120 returns an empty vec and the early-return at lines 122-128 fires before `TransactionBuilder` is ever constructed; the `.map_err` closure at lines 153-167 — the only consumer of the upstream error strings — is never reached. The body itself acknowledges this at lines 698-699. Net effect: if `key-wallet` renames `"Insufficient funds"`, the matcher silently stops mapping that case to `NoSpendableInputs` and falls through to `TransactionBuild(msg)` — but this test stays green. Either rename and rescope this test (e.g. `empty_spendable_short_circuits_to_no_spendable_inputs`) and add a separate test that forces `build_signed` to fail with the upstream message (e.g. add a UTXO smaller than fee + dust threshold so coin selection emits `"Insufficient funds"`), or restructure the body to drive that exact path.

Comment thread packages/rs-platform-wallet/src/wallet/core/broadcast.rs
Comment thread packages/rs-platform-wallet/src/wallet/core/broadcast.rs
Comment thread packages/rs-platform-wallet/src/wallet/core/wallet.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

Reviewed current head 9902cbd6 after the latest follow-ups. The DPNS .dash normalization path looks good, and the same-UTXO reservation set closes the original double-spend race for one wallet handle. I still see three actionable issues in the wallet broadcast/error surface that should be addressed before merge:

  1. Retryable wallet errors are still flattened to ErrorUnknown at the FFI boundary. PlatformWalletError::NoSpendableInputs and ConcurrentSpendConflict remain typed in Rust, but impl From<PlatformWalletError> for PlatformWalletFFIResult still maps every wallet error to PlatformWalletFFIResultCode::ErrorUnknown. Swift/FFI callers therefore still have to parse English strings to distinguish retryable cases.

  2. NoSpendableInputs still conflates retryable reservation contention with permanent insufficient balance. The builder mapping still converts both "No UTXOs available" and "Insufficient funds" into NoSpendableInputs. That makes an ordinary low-balance send look like a retryable in-flight-reservation conflict.

  3. Concurrent sends that select different UTXOs can reuse the same change address. The initial path peeks next_change_address(..., false) and only commits with next_change_address(..., true) after broadcast. Because UTXO reservations do not reserve the change slot, two in-flight sends with disjoint UTXOs can both build transactions to the same peeked change address before either post-broadcast commit runs. Fixing this likely needs a change-address reservation/rollback mechanism, or a single atomic “reserve this exact change address” flow that does not advance a second time later.

Validation I ran locally:

  • git diff --check origin/v3.1-dev...HEAD
  • cargo fmt -p platform-wallet -p platform-wallet-ffi -p dash-sdk -- --check
  • cargo test -p dash-sdk --lib platform::dpns_usernames:: — 5 passed / 0 failed / 6 ignored
  • cargo test -p platform-wallet --lib wallet::core::broadcast::tests:: — 5 passed
  • cargo check -p platform-wallet --lib
  • cargo check -p platform-wallet-ffi --tests

QA-001 (LOW) from Marvin's #3585 merge audit: #3585's `OutpointReservations`
pre-build filter closes the in-process concurrent-caller race entirely, and
the existing post-build `selected.is_subset(&spendable_outpoints)` check
catches builder regressions. But that check re-uses the SAME `spendable`
snapshot captured BEFORE `build_signed`, so a future mutator that touches
UTXOs outside the wallet write lock (mempool listener, chain reorg
subsystem, cross-process spend) would slip through.

Restore the defense-in-depth pattern from the obsolete commit `603b444425`:
after `build_signed` returns, re-call `managed_account.spendable_utxos`
within the same lock-acquisition block and confirm every selected outpoint
is still present in the fresh view. If not, surface
`PlatformWalletError::ConcurrentSpendConflict` (the typed retryable variant
#3585 introduced) carrying the missing outpoints — semantically correct
post-build, distinct from the pre-build `NoSpendableInputs` failure.

Today no code path mutates UTXOs without holding the wallet write lock we
hold across build, so this is unreachable by construction. The reservations
guard remains the primary in-process race defense; this is the cross-
subsystem / future-proofing net. Without it, someone introducing a parallel
UTXO mutator later would re-open the race silently.

No unit test: injecting a UTXO disappearance between `build_signed` and the
fresh re-fetch requires test-only plumbing inside the same lock-acquisition
block (no clean seam to mock). The two #3585 concurrency tests
(`concurrent_same_utxo_sends_resolve_via_reservation_set`,
`broadcast_failure_releases_reservation_for_retry`) still pass — semantics
of the reservation path are unchanged.

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

Defensive UTXO revalidation added (371e2c3742)

While preparing PR #3549 for merge, Marvin's audit of this PR's tree (after we integrated it locally into #3549 via git merge) flagged a defense-in-depth gap:

The reservation system fully eliminates the concurrent-caller race. But the post-build selected.is_subset(&spendable_outpoints) check at broadcast.rs:168-180 only re-uses the same spendable_outpoints snapshot captured BEFORE build — it catches builder regressions, but NOT external UTXO invalidation if a future code path mutates UTXOs without taking the wallet write lock. The earlier 603b444425 commit (which this PR's broadcast.rs rewrite supersedes) did a fresh get_spendable_utxos() re-fetch for that purpose; this PR's tree dropped it.

Marvin classified the finding as LOW (not load-bearing today) because no current code path mutates UTXOs without the wallet write lock — the reservation system + the existing same-snapshot subset check already cover everything that can actually happen today.

Per @lklimek's direction, I added the defensive re-fetch back as future-proofing. The change (371e2c3742):

  • Re-fetches managed_account.spendable_utxos(current_height) after build_signed() succeeds, inside the same wallet write lock acquisition (no extra lock-acquire — the existing write lock is held continuously from before build_signed through the reservation reserve() call).
  • Computes selected.difference(&fresh_spendable_outpoints); if non-empty, returns Err(PlatformWalletError::ConcurrentSpendConflict { selected: <missing-vec> }) — same typed retryable variant this PR introduced for pre-build conflicts, just emitted from the post-build verification site.
  • Adds a 2-3 line comment explaining the rationale (reservation = primary defense, this = defense-in-depth) to deter future "simplify" rewrites that strip it back out.

The existing is_subset check is kept — it pins the same-snapshot builder invariant cheaply, complementary to the fresh re-fetch.

Quality gates: cargo fmt, cargo check, cargo clippy --tests -- -D warnings clean. cargo test --lib: 129 passed (including this PR's concurrent_same_utxo_sends_resolve_via_reservation_set and broadcast_failure_releases_reservation_for_retry — semantics intact).

Side observation flagged but NOT fixed by me: this branch has pre-existing clippy errors at rs-platform-wallet-ffi/src/tokens/group_info.rs:97,123 (on base 9902cbd6da). Those were independently fixed by PR #3554's f19a9fe718 (chore: use Result::is_err). When this PR rebases on v3.1-dev post-#3554-merge, the lints clear naturally. If this PR is going to merge before #3554, you may want a cherry-pick of f19a9fe718 to clear -D warnings.

If the defense-in-depth addition is unwelcome, the commit is trivially revertable — happy to drop it on signal.

🤖 Co-authored by Claudius the Magnificent AI Agent

lklimek and others added 2 commits May 13, 2026 13:24
Pre-existing `matches!(result, Err(_))` patterns trip
`clippy::redundant_pattern_matching` under the workspace's `-D warnings`
gate. Swap to `result.is_err()` so the clippy step stays green for the
crates this PR touches.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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

None yet

Development

Successfully merging this pull request may close these issues.

4 participants