fix: case-insensitive .dash + atomic state on broadcast failure#3585
fix: case-insensitive .dash + atomic state on broadcast failure#3585Claudius-Maginificent wants to merge 28 commits into
Conversation
`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
|
Note Reviews pausedIt 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 Use the following commands to manage reviews:
Use the checkboxes below for quick actions:
📝 WalkthroughWalkthroughThis 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 ChangesWallet Broadcast UTXO Revalidation & Mempool Registration
DPNS Case-Insensitive Suffix Parsing
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
Estimated code review effort🎯 4 (Complex) | ⏱️ ~45 minutes Poem
🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches🧪 Generate unit tests (beta)
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.
Built for teams:
One agent for your entire SDLC. Right inside Slack. 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. Comment |
|
✅ Review complete (commit e4cf6b3) |
There was a problem hiding this comment.
♻️ Duplicate comments (1)
packages/rs-platform-wallet/src/wallet/core/broadcast.rs (1)
177-186:⚠️ Potential issue | 🟠 Major | ⚡ Quick winDon'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 ofsend_to_addresses. Please also verify thatcheck_core_transactionis truly infallible here; if it returns a status orResult, 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 winConsider 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
Sdkor 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
📒 Files selected for processing (2)
packages/rs-platform-wallet/src/wallet/core/broadcast.rspackages/rs-sdk/src/platform/dpns_usernames/mod.rs
thepastaclaw
left a comment
There was a problem hiding this comment.
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.
|
Opened draft follow-up PR to address the review feedback here: It covers the intentional ambiguous broadcast-error comment, makes post-broadcast wallet bookkeeping best-effort with warnings, binds/checks |
thepastaclaw
left a comment
There was a problem hiding this comment.
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.
Co-authored-by: PastaClaw <thepastaclaw@users.noreply.github.com>
thepastaclaw
left a comment
There was a problem hiding this comment.
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.
…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>
thepastaclaw
left a comment
There was a problem hiding this comment.
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).
|
✅ 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:
|
thepastaclaw
left a comment
There was a problem hiding this comment.
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...HEADcargo check -p platform-wallet --libcargo clippy -p platform-wallet --lib -- -D warningscargo 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.
thepastaclaw
left a comment
There was a problem hiding this comment.
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.
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>
thepastaclaw
left a comment
There was a problem hiding this comment.
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.
thepastaclaw
left a comment
There was a problem hiding this comment.
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.
…3622) Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
🧹 Nitpick comments (1)
packages/rs-platform-wallet/src/wallet/core/broadcast.rs (1)
126-132: 🏗️ Heavy liftReserve 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 callingnext_change_address(..., true)without binding to the peeked address. When two concurrentsend_to_addressescalls 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
📒 Files selected for processing (5)
packages/rs-platform-wallet/src/error.rspackages/rs-platform-wallet/src/wallet/core/broadcast.rspackages/rs-platform-wallet/src/wallet/core/mod.rspackages/rs-platform-wallet/src/wallet/core/reservations.rspackages/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
…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>
thepastaclaw
left a comment
There was a problem hiding this comment.
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 ofCoreWallet::send_to_addresseswhen 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.
thepastaclaw
left a comment
There was a problem hiding this comment.
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_addressespeeks the change address withnext_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-broadcastnext_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
thepastaclaw
left a comment
There was a problem hiding this comment.
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.
| @@ -47,6 +52,7 @@ impl<B: TransactionBroadcaster + ?Sized> CoreWallet<B> { | |||
| wallet_id, | |||
| broadcaster, | |||
| balance, | |||
| reservations: OutpointReservations::new(), | |||
| } | |||
There was a problem hiding this comment.
💬 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']
thepastaclaw
left a comment
There was a problem hiding this comment.
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.
thepastaclaw
left a comment
There was a problem hiding this comment.
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.
thepastaclaw
left a comment
There was a problem hiding this comment.
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:
-
Retryable wallet errors are still flattened to
ErrorUnknownat the FFI boundary.PlatformWalletError::NoSpendableInputsandConcurrentSpendConflictremain typed in Rust, butimpl From<PlatformWalletError> for PlatformWalletFFIResultstill maps every wallet error toPlatformWalletFFIResultCode::ErrorUnknown. Swift/FFI callers therefore still have to parse English strings to distinguish retryable cases. -
NoSpendableInputsstill conflates retryable reservation contention with permanent insufficient balance. The builder mapping still converts both"No UTXOs available"and"Insufficient funds"intoNoSpendableInputs. That makes an ordinary low-balance send look like a retryable in-flight-reservation conflict. -
Concurrent sends that select different UTXOs can reuse the same change address. The initial path peeks
next_change_address(..., false)and only commits withnext_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...HEADcargo fmt -p platform-wallet -p platform-wallet-ffi -p dash-sdk -- --checkcargo test -p dash-sdk --lib platform::dpns_usernames::— 5 passed / 0 failed / 6 ignoredcargo test -p platform-wallet --lib wallet::core::broadcast::tests::— 5 passedcargo check -p platform-wallet --libcargo 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>
Defensive UTXO revalidation added (
|
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>
…-utxo-race-v3.1-dev
Summary
Two independent fixes:
Sdk::resolve_dpns_nameaccepts mixed-case.dashsuffix —Alice.DASHnow resolves the same way asalice.dash.CoreWallet::send_to_addressescloses 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 withNoSpendableInputsbefore touching the network. Local state mutations are also gated on broadcast success.Fix 1 — Case-insensitive
.dashsuffix in DPNS resolutionSdk::resolve_dpns_namestripped the.dashsuffix with an exact-match. Inputs likeAlice.DASHfell into the else branch — the entire string was treated as a label, DPNS lookup missed.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_addressesperforms:ManagedWalletInfo.OutpointReservationsset; filter spendable UTXOs against it.NoSpendableInputs(don't broadcast).OutpointReservations(RAII guard).check_core_transaction(.., Mempool, .., update_state=true)— transitions outpoints from "reserved" to "spent" atomically.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:
NoSpendableInputsif the filtered set is empty.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 becausetokio::sync::RwLockisSend-safe across awaits. Validated by theconcurrent_callers_get_no_spendable_inputstest inbroadcast.rs.Files changed
packages/rs-sdk/src/platform/dpns_usernames/mod.rspackages/rs-platform-wallet/src/wallet/core/broadcast.rspackages/rs-platform-wallet/src/wallet/core/reservations.rsOutpointReservationsset + RAII release guardpackages/rs-platform-wallet/src/wallet/core/wallet.rsCoreWallet::reservationsfield +Cloneimplpackages/rs-platform-wallet/src/wallet/core/mod.rspub(crate) mod reservationspackages/rs-platform-wallet/src/error.rsNoSpendableInputs+ConcurrentSpendConflictvariantspackages/rs-platform-wallet/src/wallet/platform_addresses/wallet.rsset_address_credit_balancecall — orthogonal to Fix 2, kept for nowpackages/rs-platform-wallet-ffi/tests/integration_tests.rsplatform_wallet_info_create_from_mnemonicfrom 4 → 3 parametersTest plan
cargo fmt --checkgreencargo check --workspacegreencargo test -p dash-sdk --lib(117 passed)cargo test -p platform-wallet --lib(129 passed including newconcurrent_callers_get_no_spendable_inputstest)cargo clippy --workspace --all-features -- -D warningsgreen for files this PR touchesConcurrentSpendConflictis 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
Refactor