fix: case-insensitive .dash suffix and UTXO double-spend prevention (re-target of #3466)#3585
fix: case-insensitive .dash suffix and UTXO double-spend prevention (re-target of #3466)#3585Claudius-Maginificent wants to merge 5 commits intov3.1-devfrom
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
|
Warning Rate limit exceeded
To continue reviewing without waiting, purchase usage credits in the billing tab. ⌛ How to resolve this issue?After the wait time has elapsed, a review can be triggered using the We recommend that you space out your commits to avoid hitting the rate limit. 🚦 How do rate limits work?CodeRabbit enforces hourly rate limits for each developer per organization. Our paid plans have higher rate limits than the trial, open-source and free plans. In all cases, we re-allow further reviews after a brief timeout. Please see our FAQ for further information. ℹ️ Review info⚙️ Run configurationConfiguration used: defaults Review profile: CHILL Plan: Pro Run ID: 📒 Files selected for processing (2)
📝 WalkthroughWalkthroughThis PR adds UTXO revalidation during transaction broadcasting to prevent spending already-consumed outputs in concurrent scenarios, and refines the post-broadcast flow to explicitly register the transaction with the mempool. Additionally, DPNS name resolution now performs case-insensitive ChangesWallet Broadcast UTXO Revalidation & Mempool Registration
DPNS Case-Insensitive Suffix Parsing
Estimated code review effort🎯 3 (Moderate) | ⏱️ ~25 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 |
|
|
||
| // Broadcast first; if the network rejects we leave wallet state | ||
| // untouched so the caller can retry without manual sync repair. | ||
| self.broadcast_transaction(&tx).await?; |
There was a problem hiding this comment.
Returning error here allows the system to be in an unstable state. We have no idea if the tx was really broadcasted or not; it means we have no idea if it's ok to reuse affected UTXOs.
Document this as INTENTIONAL - worst case scenario, subsequent calls will be rejected on the drive level.
There was a problem hiding this comment.
Verified on HEAD 0d17a63d25 — the comment block above the ? propagation on line 167 currently reads "Broadcast first; if the network rejects we leave wallet state untouched so the caller can retry without manual sync repair." That covers the network-rejection path but does NOT call out the genuinely ambiguous broadcast-result case as INTENTIONAL, nor reference drive-level dedup as the safety net. Per your own self-note (r3188490456), this still needs to be documented inline. Follow-up: PR #3595.
🤖 Co-authored by Claudius the Magnificent AI Agent
There was a problem hiding this comment.
Verified on HEAD 1bd306a057 (commit #3595 just landed). The "Broadcast first;" block at lines 155-198 now explicitly documents the ambiguous case as INTENTIONAL:
This is intentional even if the remote accepted the transaction but the broadcast path returned an error: in that ambiguous case later attempts may reuse the same inputs locally, but the network rejects the duplicate spend instead of us marking UTXOs spent for a transaction that might not have propagated.
Followed by a "Broadcast-first semantics" recovery section that walks through both transient cases — your worst-case scenario (subsequent calls rejected at the network level) is the documented escape hatch. Concern addressed.
|
🔍 Review in progress — actively reviewing now (commit 4616cba) |
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.
| ) | ||
| .map_err(|e| PlatformWalletError::TransactionBuild(e.to_string()))?; | ||
|
|
||
| builder | ||
| let tx = builder | ||
| .build() | ||
| .map_err(|e| PlatformWalletError::TransactionBuild(e.to_string()))? | ||
| .map_err(|e| PlatformWalletError::TransactionBuild(e.to_string()))?; | ||
|
|
||
| // Re-validate the selected outpoints are still spendable while | ||
| // we still hold the write lock. The lock makes our build atomic | ||
| // against other callers on this handle, but external mempool / | ||
| // block events processed before we acquired the lock may have | ||
| // invalidated UTXOs that were still in the spendable set when | ||
| // `select_inputs` ran. | ||
| // | ||
| // We deliberately do NOT mark the inputs as spent here — that | ||
| // happens after a successful broadcast (see #3466 review). A | ||
| // failed broadcast must not leave UTXOs falsely marked spent. | ||
| let selected: BTreeSet<OutPoint> = | ||
| tx.input.iter().map(|txin| txin.previous_output).collect(); | ||
| let still_spendable: BTreeSet<OutPoint> = info | ||
| .get_spendable_utxos() | ||
| .into_iter() | ||
| .map(|utxo| utxo.outpoint) | ||
| .collect(); | ||
| if !selected.is_subset(&still_spendable) { | ||
| return Err(PlatformWalletError::TransactionBuild( | ||
| "Selected UTXOs are no longer available (concurrent transaction). \ | ||
| Please retry." | ||
| .to_string(), | ||
| )); | ||
| } | ||
|
|
||
| tx | ||
| }; | ||
|
|
||
| // Broadcast first; if the network rejects we leave wallet state | ||
| // untouched so the caller can retry without manual sync repair. | ||
| self.broadcast_transaction(&tx).await?; | ||
|
|
||
| // Now that the tx is in flight, register it as a mempool transaction | ||
| // so subsequent callers see the inputs as spent and don't reselect | ||
| // them. The trade-off is that two callers racing between the lock | ||
| // drop above and the broadcast can both pick the same UTXOs; the | ||
| // network resolves that race exactly as it does on `v3.1-dev` | ||
| // today, but neither caller corrupts local state on a transient | ||
| // broadcast failure. | ||
| { | ||
| 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; | ||
| } | ||
|
|
||
| Ok(tx) | ||
| } |
There was a problem hiding this comment.
🟡 Suggestion: 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:
- On broadcast
Err, no UTXO state is mutated — i.e.,check_core_transactionis never invoked. This is the core regression-prevention guarantee for fix: case-insensitive .dash suffix and UTXO double-spend prevention (backport from DET) #3466. - If a selected outpoint is independently marked spent between selection and the post-build revalidation,
send_to_addressesreturnsPlatformWalletError::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.
source: ['claude']
🤖 Fix this 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 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.
There was a problem hiding this comment.
Verified on HEAD 0d17a63d25 — no #[cfg(test)] mod tests block in broadcast.rs, and no new test file exercising send_to_addresses against a mocked TransactionBroadcaster. Neither contract (broadcast-Err leaves UTXO state unmutated; selected outpoint marked spent between selection and revalidation produces TransactionBuild("Selected UTXOs are no longer available...") without reaching the broadcaster) is currently asserted by automated tests. Follow-up: PR #3595.
🤖 Co-authored by Claudius the Magnificent AI Agent
|
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.
| // Re-validate the selected outpoints are still spendable while | ||
| // we still hold the write lock. The lock makes our build atomic | ||
| // against other callers on this handle, but external mempool / | ||
| // block events processed before we acquired the lock may have | ||
| // invalidated UTXOs that were still in the spendable set when | ||
| // `select_inputs` ran. | ||
| // | ||
| // We deliberately do NOT mark the inputs as spent here — that | ||
| // happens after a successful broadcast (see #3466 review). A | ||
| // failed broadcast must not leave UTXOs falsely marked spent. | ||
| let selected: BTreeSet<OutPoint> = | ||
| tx.input.iter().map(|txin| txin.previous_output).collect(); | ||
| let still_spendable: BTreeSet<OutPoint> = info | ||
| .get_spendable_utxos() | ||
| .into_iter() | ||
| .map(|utxo| utxo.outpoint) | ||
| .collect(); | ||
| if !selected.is_subset(&still_spendable) { | ||
| return Err(PlatformWalletError::TransactionBuild( | ||
| "Selected UTXOs are no longer available (concurrent transaction). \ | ||
| Please retry." | ||
| .to_string(), | ||
| )); | ||
| } |
There was a problem hiding this comment.
💬 Nitpick: In-lock re-validation comment is misleading; check is mostly redundant
The same wallet_manager.write() guard acquired at line 50 is still held when this check runs at line 149, so no other writer could have mutated the UTXO set since select_inputs saw spendable at line 78 — any external mempool/block event the comment claims to guard against would already be reflected in that snapshot. The only window where another writer can race is the gap between this lock dropping at line 163 and the second lock acquisition at line 177, which is correctly acknowledged in the trade-off comment below. There's also a subtle false-positive risk: account.spendable_utxos(current_height) filters per-account at synced_height() while info.get_spendable_utxos() is a global filter that may use last_processed_height(), so an honestly-built tx can be rejected to the caller if those filters diverge. Either drop the check, narrow it to a debug_assert!, or rewrite the comment so future readers understand its actual scope.
source: ['claude']
| ) | ||
| .map_err(|e| PlatformWalletError::TransactionBuild(e.to_string()))?; | ||
|
|
||
| builder | ||
| let tx = builder | ||
| .build() | ||
| .map_err(|e| PlatformWalletError::TransactionBuild(e.to_string()))? | ||
| .map_err(|e| PlatformWalletError::TransactionBuild(e.to_string()))?; | ||
|
|
||
| // Re-validate the selected outpoints are still spendable while | ||
| // we still hold the write lock. The lock makes our build atomic | ||
| // against other callers on this handle, but external mempool / | ||
| // block events processed before we acquired the lock may have | ||
| // invalidated UTXOs that were still in the spendable set when | ||
| // `select_inputs` ran. | ||
| // | ||
| // We deliberately do NOT mark the inputs as spent here — that | ||
| // happens after a successful broadcast (see #3466 review). A | ||
| // failed broadcast must not leave UTXOs falsely marked spent. | ||
| let selected: BTreeSet<OutPoint> = | ||
| tx.input.iter().map(|txin| txin.previous_output).collect(); | ||
| let still_spendable: BTreeSet<OutPoint> = info | ||
| .get_spendable_utxos() | ||
| .into_iter() | ||
| .map(|utxo| utxo.outpoint) | ||
| .collect(); | ||
| if !selected.is_subset(&still_spendable) { | ||
| return Err(PlatformWalletError::TransactionBuild( | ||
| "Selected UTXOs are no longer available (concurrent transaction). \ | ||
| Please retry." | ||
| .to_string(), | ||
| )); | ||
| } |
There was a problem hiding this comment.
🟡 Suggestion: 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.
source: ['claude']
🤖 Fix this 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 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.
| if !selected.is_subset(&still_spendable) { | ||
| return Err(PlatformWalletError::TransactionBuild( | ||
| "Selected UTXOs are no longer available (concurrent transaction). \ | ||
| Please retry." | ||
| .to_string(), | ||
| )); |
There was a problem hiding this comment.
🟡 Suggestion: 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.
source: ['codex']
🤖 Fix this 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 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.
| // Re-validate the selected outpoints are still spendable while | ||
| // we still hold the write lock. The lock makes our build atomic | ||
| // against other callers on this handle, but external mempool / | ||
| // block events processed before we acquired the lock may have | ||
| // invalidated UTXOs that were still in the spendable set when | ||
| // `select_inputs` ran. | ||
| // | ||
| // We deliberately do NOT mark the inputs as spent here — that | ||
| // happens after a successful broadcast (see #3466 review). A | ||
| // failed broadcast must not leave UTXOs falsely marked spent. | ||
| let selected: BTreeSet<OutPoint> = | ||
| tx.input.iter().map(|txin| txin.previous_output).collect(); | ||
| let still_spendable: BTreeSet<OutPoint> = info | ||
| .get_spendable_utxos() | ||
| .into_iter() | ||
| .map(|utxo| utxo.outpoint) | ||
| .collect(); | ||
| if !selected.is_subset(&still_spendable) { | ||
| return Err(PlatformWalletError::TransactionBuild( | ||
| "Selected UTXOs are no longer available (concurrent transaction). \ | ||
| Please retry." | ||
| .to_string(), | ||
| )); | ||
| } | ||
|
|
||
| tx | ||
| }; | ||
|
|
||
| // Broadcast first; if the network rejects we leave wallet state | ||
| // untouched so the caller can retry without manual sync repair. | ||
| self.broadcast_transaction(&tx).await?; | ||
|
|
||
| // Now that the tx is in flight, register it as a mempool transaction | ||
| // so subsequent callers see the inputs as spent and don't reselect | ||
| // them. The trade-off is that two callers racing between the lock | ||
| // drop above and the broadcast can both pick the same UTXOs; the | ||
| // network resolves that race exactly as it does on `v3.1-dev` | ||
| // today, but neither caller corrupts local state on a transient | ||
| // broadcast failure. | ||
| { | ||
| 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; | ||
| } |
There was a problem hiding this comment.
🟡 Suggestion: 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:
- 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 fix: case-insensitive .dash suffix and UTXO double-spend prevention (backport from DET) #3466).
- Two interleaved
send_to_addressescalls against a single-UTXO wallet must produce the documented retry error rather than corrupted state. - 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.
source: ['claude', 'codex']
🤖 Fix this 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 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`.
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.
| let dpns_contract = self.fetch_dpns_contract().await?; | ||
|
|
||
| // Extract label from full name if needed | ||
| // Handle both "alice" and "alice.dash" formats | ||
| let label = if let Some(dot_pos) = name.rfind('.') { | ||
| let (label_part, suffix) = name.split_at(dot_pos); | ||
| // Only strip the suffix if it's exactly ".dash" | ||
| if suffix == ".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 normalized_label = normalize_dpns_label(name); | ||
|
|
||
| // Validate the label before proceeding | ||
| if label.is_empty() { | ||
| // Validate the normalized label before proceeding | ||
| if normalized_label.is_empty() { | ||
| return Ok(None); | ||
| } |
There was a problem hiding this comment.
💬 Nitpick: resolve_dpns_name fetches the DPNS contract before the empty-label early return
is_dpns_name_available now normalizes and returns Ok(false) for empty inputs before calling self.fetch_dpns_contract().await? (lines 402-411). resolve_dpns_name does the opposite at lines 456-463: it fetches the contract first, then returns Ok(None) on empty normalization. So resolve_dpns_name(""), resolve_dpns_name(".dash"), or resolve_dpns_name(".DASH") performs a network round-trip that gets discarded. The PR description calls out that both APIs should agree on malformed input — this is the easy half of that contract. Move fetch_dpns_contract below the empty-label guard to mirror is_dpns_name_available.
source: ['claude']
| // Sanity-check that the builder only selected outpoints from | ||
| // the same height-aware spendable set we handed to input | ||
| // selection. We deliberately do NOT mark the inputs as spent here | ||
| // — that happens after a successful broadcast (see #3466 review). | ||
| // A failed broadcast must not leave UTXOs falsely marked spent. | ||
| let selected: BTreeSet<OutPoint> = | ||
| tx.input.iter().map(|txin| txin.previous_output).collect(); | ||
| let spendable_outpoints: BTreeSet<OutPoint> = | ||
| spendable.iter().map(|utxo| utxo.outpoint).collect(); | ||
| if !selected.is_subset(&spendable_outpoints) { | ||
| return Err(PlatformWalletError::TransactionBuild( | ||
| "Transaction builder selected an unavailable UTXO. Please retry.".to_string(), | ||
| )); | ||
| } |
There was a problem hiding this comment.
💬 Nitpick: Builder-vs-spendable subset check is effectively unreachable but framed as a user-retryable error
select_inputs(&spendable, ...) on lines 115-131 hands TransactionBuilder the only input source it has access to in this code path; by the builder's contract tx.input outpoints must be a subset of spendable. The branch on line 146 is only reachable if the upstream key-wallet builder violates its own invariant — a bug, not a transient. The user-facing message "Transaction builder selected an unavailable UTXO. Please retry." then misleads callers into a retry that will hit the same broken builder, and pays for two BTreeSet allocations on the happy path. Prefer a debug_assert! or a distinct InternalInvariant error variant rather than runtime Err framed as retryable.
source: ['claude']
| if !selected.is_subset(&spendable_outpoints) { | ||
| return Err(PlatformWalletError::TransactionBuild( | ||
| "Transaction builder selected an unavailable UTXO. Please retry.".to_string(), | ||
| )); |
There was a problem hiding this comment.
🟡 Suggestion: 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.
source: ['codex']
🤖 Fix this 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.
|
|
||
| // Broadcast first; if the network rejects we leave wallet state | ||
| // untouched so the caller can retry without manual sync repair. | ||
| // This is intentional even if the remote accepted the transaction | ||
| // but the broadcast path returned an error: in that ambiguous case | ||
| // later attempts may reuse the same inputs locally, but the network | ||
| // rejects the duplicate spend instead of us marking UTXOs spent for | ||
| // a transaction that might not have propagated. | ||
| self.broadcast_transaction(&tx).await?; | ||
|
|
||
| // Now that the tx is in flight, register it as a mempool transaction | ||
| // so subsequent callers see the inputs as spent and don't reselect | ||
| // them. The trade-off is that two callers racing between the lock | ||
| // drop above and the broadcast can both pick the same UTXOs; the | ||
| // network resolves that race exactly as it does on `v3.1-dev` | ||
| // today, but neither caller corrupts local state on a transient | ||
| // broadcast failure. | ||
| // | ||
| // Broadcast-first semantics: by the time we get here the network has | ||
| // already accepted the transaction, so the two warning paths below | ||
| // intentionally do NOT convert into a post-success `Err`. They | ||
| // simply mean local wallet state did not get updated to reflect the | ||
| // mempool spend / change output. Recovery in both cases: | ||
| // | ||
| // * The next `send_to_addresses` from the same handle may reselect | ||
| // the same UTXOs because they still look spendable locally. That | ||
| // follow-up transaction will be rejected by the network as a | ||
| // duplicate spend (the broadcaster surfaces that as an error to | ||
| // the caller), so funds are never double-spent on-chain. | ||
| // * Once mempool/block sync catches up, the wallet will see the | ||
| // original transaction and reconcile its UTXO set, after which | ||
| // subsequent sends pick up the correct change outputs. | ||
| // | ||
| // The two cases differ in what they imply: | ||
| // | ||
| // * `!check_result.is_relevant` is the expected transient: the | ||
| // wallet just hasn't ingested the tx yet (or some derivation | ||
| // path/script is unrecognised), and a later sync will fix it. | ||
| // * The `else` branch (wallet missing in the manager) is NOT a | ||
| // normal transient — the broadcast succeeded against a | ||
| // `CoreWallet` handle whose underlying wallet entry is gone | ||
| // from the manager. That is a broken/inconsistent local handle | ||
| // and the warning exists so operators can spot it; future | ||
| // sends through the same handle will keep failing the lookup | ||
| // above and surface a clean `WalletNotFound` error. | ||
| { | ||
| let mut wm = self.wallet_manager.write().await; | ||
| if let Some((wallet, info)) = wm.get_wallet_mut_and_info_mut(&self.wallet_id) { | ||
| let check_result = info | ||
| .check_core_transaction(&tx, TransactionContext::Mempool, wallet, true, true) | ||
| .await; | ||
| if !check_result.is_relevant { | ||
| tracing::warn!( | ||
| txid = %tx.txid(), | ||
| "broadcast transaction was not relevant during post-broadcast wallet registration" | ||
| ); | ||
| } | ||
| } else { | ||
| tracing::warn!( | ||
| wallet_id = %hex::encode(self.wallet_id), | ||
| txid = %tx.txid(), | ||
| "wallet missing during post-broadcast transaction registration" | ||
| ); | ||
| } | ||
| } | ||
|
|
||
| Ok(tx) |
There was a problem hiding this comment.
🟡 Suggestion: 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:
- 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).
- 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.
source: ['claude', 'codex']
🤖 Fix this 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 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.
| { | ||
| let mut wm = self.wallet_manager.write().await; | ||
| if let Some((wallet, info)) = wm.get_wallet_mut_and_info_mut(&self.wallet_id) { | ||
| let check_result = info | ||
| .check_core_transaction(&tx, TransactionContext::Mempool, wallet, true, true) | ||
| .await; | ||
| if !check_result.is_relevant { | ||
| tracing::warn!( | ||
| txid = %tx.txid(), | ||
| "broadcast transaction was not relevant during post-broadcast wallet registration" | ||
| ); | ||
| } | ||
| } else { | ||
| tracing::warn!( | ||
| wallet_id = %hex::encode(self.wallet_id), | ||
| txid = %tx.txid(), | ||
| "wallet missing during post-broadcast transaction registration" | ||
| ); | ||
| } |
There was a problem hiding this comment.
🟡 Suggestion: 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.
source: ['codex']
🤖 Fix this 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 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.
| { | ||
| let mut wm = self.wallet_manager.write().await; | ||
| if let Some((wallet, info)) = wm.get_wallet_mut_and_info_mut(&self.wallet_id) { | ||
| let check_result = info | ||
| .check_core_transaction(&tx, TransactionContext::Mempool, wallet, true, true) | ||
| .await; | ||
| if !check_result.is_relevant { | ||
| tracing::warn!( | ||
| txid = %tx.txid(), | ||
| "broadcast transaction was not relevant during post-broadcast wallet registration" | ||
| ); | ||
| } | ||
| } else { | ||
| tracing::warn!( | ||
| wallet_id = %hex::encode(self.wallet_id), | ||
| txid = %tx.txid(), | ||
| "wallet missing during post-broadcast transaction registration" | ||
| ); | ||
| } | ||
| } |
There was a problem hiding this comment.
💬 Nitpick: Wallet-missing-after-successful-broadcast branch is observable only as a single log line
The else branch on lines 211-217 logs a warning and returns Ok(tx) when the wallet's underlying entry has been removed from the manager between the lock drop on line 153 and the lock reacquisition on line 200. The PR comment correctly explains why this is not converted to Err (broadcast already succeeded), but there is no metric, no counter, and the function returns Ok(tx) indistinguishably from the happy path. If this is broken-handle territory as the comment states, the next operation through the handle will hit WalletNotFound anyway, so local state has only one chance to be reconciled by mempool/block sync. A counter or a structured tracing field (e.g. wallet manager's known wallet count) would make the inconsistency observable in production telemetry.
source: ['claude']
| }; | ||
|
|
||
| // Broadcast first; if the network rejects we leave wallet state | ||
| // untouched so the caller can retry without manual sync repair. | ||
| // This is intentional even if the remote accepted the transaction | ||
| // but the broadcast path returned an error: in that ambiguous case | ||
| // later attempts may reuse the same inputs locally, but the network | ||
| // rejects the duplicate spend instead of us marking UTXOs spent for | ||
| // a transaction that might not have propagated. | ||
| self.broadcast_transaction(&tx).await?; | ||
|
|
||
| // Now that the tx is in flight, register it as a mempool transaction | ||
| // so subsequent callers see the inputs as spent and don't reselect | ||
| // them. The trade-off is that two callers racing between the lock | ||
| // drop above and the broadcast can both pick the same UTXOs; the | ||
| // network resolves that race exactly as it does on `v3.1-dev` | ||
| // today, but neither caller corrupts local state on a transient | ||
| // broadcast failure. | ||
| // | ||
| // Broadcast-first semantics: by the time we get here the network has | ||
| // already accepted the transaction, so the two warning paths below | ||
| // intentionally do NOT convert into a post-success `Err`. They | ||
| // simply mean local wallet state did not get updated to reflect the | ||
| // mempool spend / change output. Recovery in both cases: | ||
| // | ||
| // * The next `send_to_addresses` from the same handle may reselect | ||
| // the same UTXOs because they still look spendable locally. That | ||
| // follow-up transaction will be rejected by the network as a | ||
| // duplicate spend (the broadcaster surfaces that as an error to | ||
| // the caller), so funds are never double-spent on-chain. | ||
| // * Once mempool/block sync catches up, the wallet will see the | ||
| // original transaction and reconcile its UTXO set, after which | ||
| // subsequent sends pick up the correct change outputs. | ||
| // | ||
| // The two cases differ in what they imply: | ||
| // | ||
| // * `!check_result.is_relevant` is the expected transient: the | ||
| // wallet just hasn't ingested the tx yet (or some derivation | ||
| // path/script is unrecognised), and a later sync will fix it. | ||
| // * The `else` branch (wallet missing in the manager) is NOT a | ||
| // normal transient — the broadcast succeeded against a | ||
| // `CoreWallet` handle whose underlying wallet entry is gone | ||
| // from the manager. That is a broken/inconsistent local handle | ||
| // and the warning exists so operators can spot it; future | ||
| // sends through the same handle will keep failing the lookup | ||
| // above and surface a clean `WalletNotFound` error. | ||
| { | ||
| let mut wm = self.wallet_manager.write().await; | ||
| if let Some((wallet, info)) = wm.get_wallet_mut_and_info_mut(&self.wallet_id) { | ||
| let check_result = info | ||
| .check_core_transaction(&tx, TransactionContext::Mempool, wallet, true, true) | ||
| .await; | ||
| if !check_result.is_relevant { | ||
| tracing::warn!( | ||
| txid = %tx.txid(), | ||
| "broadcast transaction was not relevant during post-broadcast wallet registration" | ||
| ); | ||
| } | ||
| } else { | ||
| tracing::warn!( | ||
| wallet_id = %hex::encode(self.wallet_id), | ||
| txid = %tx.txid(), | ||
| "wallet missing during post-broadcast transaction registration" | ||
| ); | ||
| } | ||
| } |
There was a problem hiding this comment.
💬 Nitpick: Lock-drop window between selection and post-broadcast registration permits same-owner UTXO double-pick
The write lock is dropped at line 153 and re-acquired at line 200; during that window a concurrent task with the same CoreWallet handle still sees the just-spent UTXOs as spendable and can pick them. There is no exploitable vector: only the wallet owner's own async tasks can race, and the network rejects the loser, so on-chain double-spend is impossible. The PR explicitly accepts this trade-off. Worth flagging only as defense-in-depth: if a future broadcaster ever runs in a context where ordering is not network-arbitrated (private mempool / sequencer), the local race becomes the only backstop. A cheap hardening would be an in-memory BTreeSet of pending-broadcast outpoints that selection skips. No action required for merge.
source: ['claude']
Summary
Re-implementation of PR #3466 against the current
v3.1-dev. The original PR's base branch (feat/platform-wallet) is defunct after the platform-wallet refactor merge; rather than rebase that PR through a tangle of merge commits, this is a fresh start.Both fixes were originally backported from dash-evo-tool. Triage at the time of authoring confirmed both bugs are still present on
v3.1-dev.Fix 1 — Case-insensitive
.dashsuffix in DPNS resolutionSdk::resolve_dpns_namestripped the.dashsuffix using exact-match. Inputs like"Alice.DASH"fell into the else branch — the entire string was treated as a label and DPNS lookup missed.Verbatim port from PR #3466 fix 1 / dash-evo-tool PR #810.
Fix 2 — UTXO double-spend prevention (Option A: mark-spent after broadcast)
CoreWallet::send_to_addresseshad a TOCTOU window between dropping the write lock (after build/select/sign) and broadcasting. Concurrent callers could both select the same UTXOs; the loser got an opaque network rejection.Adapted from PR #3466's original
send_transactionpatch because the wallet was rewritten aroundTransactionBuilderpost-#3466 — same bug, different call site, same optimistic-validation cure.Addresses thepastaclaw's blocking comment from PR #3466 about state corruption on broadcast failure. We deliberately mark UTXOs spent only after successful broadcast: re-validate selected outpoints under the write lock, drop the lock, broadcast, then re-acquire the lock and call
check_core_transaction(.., Mempool, .., update_state=true)only on broadcast success. A transient broadcast failure now leaves wallet state untouched instead of falsely marking UTXOs spent until manual sync.Trade-off documented in the code comment: two callers racing between drop-lock and broadcast can both pick the same UTXOs — the network resolves that race exactly as it does on
v3.1-devtoday, but at least neither caller corrupts local state on transient failure.Companion
Supersedes #3466 (which was against the defunct
feat/platform-walletbase). Old PR will be closed as superseded once this opens.Test plan
cargo fmt --checkgreencargo check -p platform-wallet -p dash-sdk --testsgreencargo test -p dash-sdk --lib(117 passed)cargo test -p platform-wallet --lib(115 passed)cargo clippy -p dash-sdk -p platform-wallet --tests -- -D warnings— clean for files this PR touches; 3 pre-existing warnings inpackages/rs-platform-wallet/src/manager/accessors.rs(manual_unwrap_or_default,unnecessary_cast,redundant_closure) are already onv3.1-devand need a separate cleanup PR🤖 Co-authored by Claudius the Magnificent AI Agent
Summary by CodeRabbit
Bug Fixes
.dashsuffix in any letter case.Improvements